fix: support safe project deletion
This commit is contained in:
238
server/internal/httpapi/project_delete_integration_test.go
Normal file
238
server/internal/httpapi/project_delete_integration_test.go
Normal file
@@ -0,0 +1,238 @@
|
||||
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")
|
||||
if err := db.Create(&unused).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&history).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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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("project with queue history is rejected intact", func(t *testing.T) {
|
||||
response := deleteRequest(history.ID)
|
||||
if response.Code != http.StatusConflict {
|
||||
t.Fatalf("delete history project status = %d, want 409; body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), `"code":"PROJECT_HAS_HISTORY"`) {
|
||||
t.Fatalf("delete history project body = %s, want PROJECT_HAS_HISTORY", response.Body.String())
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "不能删除") || !strings.Contains(response.Body.String(), "结束") {
|
||||
t.Fatalf("delete history project message is not actionable Chinese: %s", response.Body.String())
|
||||
}
|
||||
if err := db.First(&model.Project{}, "id = ?", history.ID).Error; err != nil {
|
||||
t.Fatalf("history project was not retained: %v", err)
|
||||
}
|
||||
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 deletedAuditCount int64
|
||||
if err := db.Model(&model.AuditEntry{}).
|
||||
Where("action = ? AND entity_id = ?", "PROJECT_DELETED", history.ID).
|
||||
Count(&deletedAuditCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deletedAuditCount != 0 {
|
||||
t.Fatalf("PROJECT_DELETED audit count for retained project = %d, want 0", deletedAuditCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user