Files
XQKqueue/server/internal/httpapi/project_delete_integration_test.go

304 lines
12 KiB
Go

package httpapi
import (
"bytes"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"calllinesystem/server/internal/config"
"calllinesystem/server/internal/database"
"calllinesystem/server/internal/model"
"calllinesystem/server/internal/security"
"github.com/google/uuid"
"gorm.io/gorm"
)
func TestDeleteProjectRouteRequiresAdminAuthentication(t *testing.T) {
server := &Server{
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
now: func() time.Time { return time.Date(2026, 8, 13, 0, 0, 0, 0, time.UTC) },
}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodDelete, "/api/admin/projects/11111111-1111-4111-8111-111111111111", nil)
server.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("delete project without admin session status = %d, want 401; body = %s", recorder.Code, recorder.Body.String())
}
}
func TestDeleteProjectPostgresIntegration(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("TEST_DATABASE_URL"))
if dsn == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
ctx := t.Context()
db, err := database.Open(ctx, dsn, logger)
if err != nil {
t.Fatal(err)
}
defer database.Close(db)
sqlDB, err := database.SQLDB(db)
if err != nil {
t.Fatal(err)
}
if err := database.Migrate(ctx, sqlDB, logger); err != nil {
t.Fatal(err)
}
server, err := New(db, config.Config{
EncryptionKey: bytes.Repeat([]byte{0x61}, 32),
PhoneHMACKey: bytes.Repeat([]byte{0x62}, 32),
SessionCookieName: "queue_session",
SessionTTL: time.Hour,
}, logger)
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC)
server.now = func() time.Time { return now }
password := "AdminPassword123!"
passwordHash, err := security.HashPassword(password)
if err != nil {
t.Fatal(err)
}
suffix := strings.ReplaceAll(uuid.NewString(), "-", "")[:10]
admin := model.User{
ID: uuid.NewString(), Username: "delete_admin_" + suffix, PasswordHash: passwordHash,
Role: model.RoleAdmin, Active: true, CreatedAt: now, UpdatedAt: now,
}
if err := db.Create(&admin).Error; err != nil {
t.Fatal(err)
}
newProject := func(code, name string) model.Project {
return model.Project{
ID: uuid.NewString(), Code: code, Name: name, Status: model.ProjectNotOpen,
Timezone: "Asia/Shanghai", TicketPrefix: "A", CallBatchSize: 1,
CallMode: model.CallModeBoth, MaxCallTicketCount: 100,
DefaultCallPeopleCount: 1, MaxCallPeopleCount: 100, MinPartySize: 1, MaxPartySize: 10,
GracePeriodMinutes: 5, ETAMode: model.ETAFixedBatch, AverageBatchIntervalSeconds: 300,
ContinuousRatePerMinute: 1, ETAIntervalSeconds: 60, VisitorNotice: model.DefaultVisitorNotice,
DeviceSimulationMode: "DISABLED", CreatedAt: now, UpdatedAt: now,
}
}
unused := newProject("DEL"+strings.ToUpper(suffix), "unused deletion project")
history := newProject("HIS"+strings.ToUpper(suffix), "history deletion project")
history.Status = model.ProjectEnded
notEnded := newProject("RUN"+strings.ToUpper(suffix), "running history project")
notEnded.Status = model.ProjectRunning
if err := db.Create(&unused).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&history).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&notEnded).Error; err != nil {
t.Fatal(err)
}
grant := model.UserProject{UserID: admin.ID, ProjectID: unused.ID, CreatedAt: now}
if err := db.Create(&grant).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.UserProject{UserID: admin.ID, ProjectID: history.ID, CreatedAt: now}).Error; err != nil {
t.Fatal(err)
}
createdAudit := func(projectID string) model.AuditEntry {
return model.AuditEntry{
ID: uuid.NewString(), ProjectID: &projectID, ActorUserID: &admin.ID,
Action: "PROJECT_CREATED", EntityType: "PROJECT", EntityID: &projectID,
Details: []byte(`{"source":"integration-test"}`), RequestID: "integration-create",
UserAgent: "integration-test", RetainUntil: now.AddDate(1, 0, 0), CreatedAt: now,
}
}
unusedCreatedAudit := createdAudit(unused.ID)
historyCreatedAudit := createdAudit(history.ID)
if err := db.Create(&unusedCreatedAudit).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&historyCreatedAudit).Error; err != nil {
t.Fatal(err)
}
queueSession := model.QueueSession{
ID: uuid.NewString(), ProjectID: history.ID, BusinessDate: now,
Status: "ENDED", NextTicketNumber: 1, Revision: 0, OpenedAt: now,
CreatedAt: now, UpdatedAt: now,
}
if err := db.Create(&queueSession).Error; err != nil {
t.Fatal(err)
}
notEndedSession := queueSession
notEndedSession.ID = uuid.NewString()
notEndedSession.ProjectID = notEnded.ID
if err := db.Create(&notEndedSession).Error; err != nil {
t.Fatal(err)
}
loginRecorder := httptest.NewRecorder()
loginRequest := httptest.NewRequest(http.MethodPost, "/api/admin/auth/login",
strings.NewReader(`{"username":"`+admin.Username+`","password":"`+password+`"}`))
server.Handler().ServeHTTP(loginRecorder, loginRequest)
if loginRecorder.Code != http.StatusOK {
t.Fatalf("admin login status = %d, want 200; body = %s", loginRecorder.Code, loginRecorder.Body.String())
}
var adminCookie *http.Cookie
for _, cookie := range loginRecorder.Result().Cookies() {
if cookie.Name == server.authCookieName(model.RoleAdmin) {
adminCookie = cookie
break
}
}
if adminCookie == nil {
t.Fatal("admin login did not set the admin session cookie")
}
deleteRequest := func(projectID string) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodDelete, "/api/admin/projects/"+projectID, nil)
request.AddCookie(adminCookie)
server.Handler().ServeHTTP(recorder, request)
return recorder
}
t.Run("unused project is deleted while audit history is retained", func(t *testing.T) {
response := deleteRequest(unused.ID)
if response.Code != http.StatusNoContent {
t.Fatalf("delete unused project status = %d, want 204; body = %s", response.Code, response.Body.String())
}
if response.Body.Len() != 0 {
t.Fatalf("delete unused project body = %q, want empty", response.Body.String())
}
if err := db.First(&model.Project{}, "id = ?", unused.ID).Error; !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("deleted project lookup error = %v, want record not found", err)
}
var grantCount int64
if err := db.Model(&model.UserProject{}).Where("project_id = ?", unused.ID).Count(&grantCount).Error; err != nil {
t.Fatal(err)
}
if grantCount != 0 {
t.Fatalf("project grants after deletion = %d, want 0", grantCount)
}
var retained model.AuditEntry
if err := db.First(&retained, "id = ?", unusedCreatedAudit.ID).Error; err != nil {
t.Fatalf("load retained PROJECT_CREATED audit: %v", err)
}
if retained.ProjectID != nil {
t.Fatalf("retained PROJECT_CREATED project_id = %v, want nil", *retained.ProjectID)
}
var deletedAudit model.AuditEntry
if err := db.Where("action = ? AND entity_type = ? AND entity_id = ?", "PROJECT_DELETED", "PROJECT", unused.ID).
First(&deletedAudit).Error; err != nil {
t.Fatalf("load PROJECT_DELETED audit: %v", err)
}
if deletedAudit.ProjectID != nil {
t.Fatalf("PROJECT_DELETED project_id = %v, want nil", *deletedAudit.ProjectID)
}
if deletedAudit.EntityID == nil || *deletedAudit.EntityID != unused.ID {
t.Fatalf("PROJECT_DELETED entity_id = %v, want %s", deletedAudit.EntityID, unused.ID)
}
if !bytes.Contains(deletedAudit.Details, []byte(unused.Code)) {
t.Fatalf("PROJECT_DELETED details = %s, want deleted project snapshot", deletedAudit.Details)
}
})
t.Run("ended project with queue history is archived intact", func(t *testing.T) {
response := deleteRequest(history.ID)
if response.Code != http.StatusNoContent {
t.Fatalf("archive history project status = %d, want 204; body = %s", response.Code, response.Body.String())
}
var archived model.Project
if err := db.First(&archived, "id = ?", history.ID).Error; err != nil {
t.Fatalf("history project was not retained: %v", err)
}
if archived.ArchivedAt == nil || !archived.ArchivedAt.Equal(now) {
t.Fatalf("archived_at = %v, want %v", archived.ArchivedAt, now)
}
var archivedGrantCount int64
if err := db.Model(&model.UserProject{}).Where("project_id = ?", history.ID).Count(&archivedGrantCount).Error; err != nil {
t.Fatal(err)
}
if archivedGrantCount != 0 {
t.Fatalf("archived project grants = %d, want 0", archivedGrantCount)
}
if err := db.First(&model.QueueSession{}, "id = ?", queueSession.ID).Error; err != nil {
t.Fatalf("queue session was not retained: %v", err)
}
var retained model.AuditEntry
if err := db.First(&retained, "id = ?", historyCreatedAudit.ID).Error; err != nil {
t.Fatalf("history audit was not retained: %v", err)
}
if retained.ProjectID == nil || *retained.ProjectID != history.ID {
t.Fatalf("history audit project_id = %v, want %s", retained.ProjectID, history.ID)
}
var archivedAudit model.AuditEntry
if err := db.Where("action = ? AND entity_id = ?", "PROJECT_ARCHIVED", history.ID).First(&archivedAudit).Error; err != nil {
t.Fatalf("load PROJECT_ARCHIVED audit: %v", err)
}
if archivedAudit.ProjectID == nil || *archivedAudit.ProjectID != history.ID {
t.Fatalf("PROJECT_ARCHIVED project_id = %v, want %s", archivedAudit.ProjectID, history.ID)
}
second := deleteRequest(history.ID)
if second.Code != http.StatusNotFound {
t.Fatalf("second archive status = %d, want 404; body = %s", second.Code, second.Body.String())
}
requestAdmin := func(method, path, body string) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(method, path, strings.NewReader(body))
request.AddCookie(adminCookie)
server.Handler().ServeHTTP(recorder, request)
return recorder
}
profile := requestAdmin(http.MethodPut, "/api/admin/projects/"+history.ID,
`{"name":"archived","code":"`+history.Code+`","timezone":"Asia/Shanghai","ticket_prefix":"A"}`)
if profile.Code != http.StatusNotFound {
t.Fatalf("update archived project status = %d, want 404; body = %s", profile.Code, profile.Body.String())
}
settings := requestAdmin(http.MethodPut, "/api/admin/projects/"+history.ID+"/settings", `{"status":"ENDED"}`)
if settings.Code != http.StatusNotFound {
t.Fatalf("update archived settings status = %d, want 404; body = %s", settings.Code, settings.Body.String())
}
createUser := requestAdmin(http.MethodPost, "/api/admin/users",
`{"username":"archived_grant_`+suffix+`","password":"Password123!","role":"STAFF","project_ids":["`+history.ID+`"]}`)
if createUser.Code != http.StatusUnprocessableEntity || !strings.Contains(createUser.Body.String(), `"code":"INVALID_PROJECT"`) {
t.Fatalf("grant archived project status = %d, body = %s; want 422 INVALID_PROJECT", createUser.Code, createUser.Body.String())
}
adminUsers := requestAdmin(http.MethodGet, "/api/admin/users", "")
if adminUsers.Code != http.StatusOK {
t.Fatalf("admin users status = %d, want 200; body = %s", adminUsers.Code, adminUsers.Body.String())
}
if strings.Contains(adminUsers.Body.String(), history.ID) {
t.Fatalf("admin users leaked archived project id: %s", adminUsers.Body.String())
}
})
t.Run("project with history must be ended before archival", func(t *testing.T) {
response := deleteRequest(notEnded.ID)
if response.Code != http.StatusConflict || !strings.Contains(response.Body.String(), `"code":"PROJECT_MUST_BE_ENDED"`) {
t.Fatalf("archive running project status = %d, body = %s; want 409 PROJECT_MUST_BE_ENDED", response.Code, response.Body.String())
}
var retained model.Project
if err := db.First(&retained, "id = ?", notEnded.ID).Error; err != nil {
t.Fatal(err)
}
if retained.ArchivedAt != nil {
t.Fatalf("running project archived_at = %v, want nil", retained.ArchivedAt)
}
})
}