239 lines
8.6 KiB
Go
239 lines
8.6 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")
|
|
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)
|
|
}
|
|
})
|
|
}
|