201 lines
8.2 KiB
Go
201 lines
8.2 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"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"
|
|
)
|
|
|
|
func TestArchivedProjectIsHiddenFromOrdinaryEntryPointsButRetainedInHistoryPostgresIntegration(t *testing.T) {
|
|
dsn := strings.TrimSpace(os.Getenv("TEST_DATABASE_URL"))
|
|
if dsn == "" {
|
|
t.Skip("TEST_DATABASE_URL is not set")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
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{
|
|
Environment: "development",
|
|
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 }
|
|
|
|
passwordHash, err := security.HashPassword("unused-integration-password")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
suffix := strings.ReplaceAll(uuid.NewString(), "-", "")[:10]
|
|
admin := model.User{ID: uuid.NewString(), Username: "archive_admin_" + suffix, PasswordHash: passwordHash, Role: model.RoleAdmin, Active: true, CreatedAt: now, UpdatedAt: now}
|
|
staff := model.User{ID: uuid.NewString(), Username: "archive_staff_" + suffix, PasswordHash: passwordHash, Role: model.RoleStaff, Active: true, CreatedAt: now, UpdatedAt: now}
|
|
if err := db.Create(&admin).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Create(&staff).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
displayToken := "archive-display-token-" + uuid.NewString()
|
|
displayTokenHash := security.HashToken(displayToken)
|
|
project := model.Project{
|
|
ID: uuid.NewString(), Code: "ARC" + strings.ToUpper(suffix), Name: "archived visibility project", Status: model.ProjectEnded,
|
|
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,
|
|
DisplayTokenHash: &displayTokenHash, DeviceSimulationMode: "DISABLED", CreatedAt: now, UpdatedAt: now,
|
|
}
|
|
if err := db.Create(&project).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Exec("UPDATE projects SET archived_at = ? WHERE id = ?", now, project.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Create(&model.UserProject{UserID: staff.ID, ProjectID: project.ID, CreatedAt: now}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
session := model.QueueSession{
|
|
ID: uuid.NewString(), ProjectID: project.ID, BusinessDate: now, Status: "ENDED", NextTicketNumber: 2, Revision: 1,
|
|
OpenedAt: now.Add(-time.Hour), ClosedAt: &now, CreatedAt: now.Add(-time.Hour), UpdatedAt: now,
|
|
}
|
|
if err := db.Create(&session).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
publicTicketToken := "archive-ticket-token-" + uuid.NewString()
|
|
phone := "+8613800138000"
|
|
phoneCiphertext, phoneNonce, err := server.cipher.Encrypt(phone, []byte("phone:"+project.ID))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
phoneHMAC := server.cipher.Digest(phone)
|
|
ticket := model.QueueTicket{
|
|
ID: uuid.NewString(), ProjectID: project.ID, QueueSessionID: session.ID, TicketNumber: 1, DisplayNumber: "00001", PartySize: 1,
|
|
PublicTokenHash: security.HashToken(publicTicketToken), PhoneCiphertext: phoneCiphertext, PhoneNonce: phoneNonce, PhoneHMAC: &phoneHMAC,
|
|
Honorific: "游客", Status: model.TicketCompleted, JoinedAt: now.Add(-time.Hour), PersonalDataPurgeAt: now.AddDate(0, 1, 0),
|
|
CreatedBy: admin.ID, CreatedAt: now.Add(-time.Hour), UpdatedAt: now,
|
|
}
|
|
if err := db.Create(&ticket).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
staffToken := "archive-staff-session-" + uuid.NewString()
|
|
adminToken := "archive-admin-session-" + uuid.NewString()
|
|
for _, authSession := range []model.AuthSession{
|
|
{ID: uuid.NewString(), UserID: staff.ID, TokenHash: security.HashToken(staffToken), ExpiresAt: now.Add(time.Hour), LastSeenAt: now, UserAgent: "integration-test", CreatedAt: now},
|
|
{ID: uuid.NewString(), UserID: admin.ID, TokenHash: security.HashToken(adminToken), ExpiresAt: now.Add(time.Hour), LastSeenAt: now, UserAgent: "integration-test", CreatedAt: now},
|
|
} {
|
|
if err := db.Create(&authSession).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
staffCookie := &http.Cookie{Name: server.authCookieName(model.RoleStaff), Value: staffToken}
|
|
adminCookie := &http.Cookie{Name: server.authCookieName(model.RoleAdmin), Value: adminToken}
|
|
|
|
request := func(method, path string, cookie *http.Cookie) *httptest.ResponseRecorder {
|
|
recorder := httptest.NewRecorder()
|
|
req := httptest.NewRequest(method, path, nil)
|
|
if cookie != nil {
|
|
req.AddCookie(cookie)
|
|
}
|
|
server.Handler().ServeHTTP(recorder, req)
|
|
return recorder
|
|
}
|
|
assertProjectAbsent := func(t *testing.T, response *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body = %s", response.Code, response.Body.String())
|
|
}
|
|
if strings.Contains(response.Body.String(), project.ID) || strings.Contains(response.Body.String(), project.Name) {
|
|
t.Fatalf("archived project leaked in response: %s", response.Body.String())
|
|
}
|
|
}
|
|
|
|
t.Run("staff me and project list hide archived project even if a stale grant exists", func(t *testing.T) {
|
|
assertProjectAbsent(t, request(http.MethodGet, "/api/staff/auth/me", staffCookie))
|
|
assertProjectAbsent(t, request(http.MethodGet, "/api/staff/projects", staffCookie))
|
|
})
|
|
|
|
t.Run("administrator me project list hides archived project", func(t *testing.T) {
|
|
assertProjectAbsent(t, request(http.MethodGet, "/api/admin/auth/me", adminCookie))
|
|
})
|
|
|
|
t.Run("public and display overview lists hide archived project", func(t *testing.T) {
|
|
assertProjectAbsent(t, request(http.MethodGet, "/api/public/projects", nil))
|
|
assertProjectAbsent(t, request(http.MethodGet, "/api/display/overview", nil))
|
|
})
|
|
|
|
t.Run("display project code and token no longer resolve", func(t *testing.T) {
|
|
for _, identifier := range []string{strings.ToLower(project.Code), displayToken} {
|
|
response := request(http.MethodGet, "/api/display/"+identifier+"/snapshot", nil)
|
|
if response.Code != http.StatusNotFound || !strings.Contains(response.Body.String(), `"code":"DISPLAY_NOT_FOUND"`) {
|
|
t.Fatalf("display snapshot %q status = %d, body = %s; want DISPLAY_NOT_FOUND", identifier, response.Code, response.Body.String())
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("staff operational entry is rejected despite a stale grant", func(t *testing.T) {
|
|
response := request(http.MethodGet, "/api/staff/projects/"+project.ID+"/queue", staffCookie)
|
|
if response.Code != http.StatusForbidden && response.Code != http.StatusNotFound {
|
|
t.Fatalf("staff queue status = %d, body = %s; want 403 or 404", response.Code, response.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("administrator history still includes archived project", func(t *testing.T) {
|
|
response := request(http.MethodGet, "/api/admin/history/tickets?from=2026-08-13&to=2026-08-13&project_id="+project.ID, adminCookie)
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("history status = %d, want 200; body = %s", response.Code, response.Body.String())
|
|
}
|
|
var body struct {
|
|
Items []map[string]any `json:"items"`
|
|
}
|
|
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(body.Items) != 1 || body.Items[0]["project_id"] != project.ID {
|
|
t.Fatalf("history items = %#v, want archived project ticket", body.Items)
|
|
}
|
|
})
|
|
|
|
t.Run("private terminal ticket status remains available", func(t *testing.T) {
|
|
response := request(http.MethodGet, "/api/public/status/"+publicTicketToken, nil)
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("private ticket status = %d, want 200; body = %s", response.Code, response.Body.String())
|
|
}
|
|
})
|
|
}
|