Files
XQKqueue/server/internal/httpapi/personal_test.go
2026-07-12 15:53:24 +08:00

148 lines
4.4 KiB
Go

package httpapi
import (
"bytes"
"encoding/json"
"strings"
"testing"
"time"
"calllinesystem/server/internal/model"
"calllinesystem/server/internal/security"
)
func TestAuthorizedTicketDTOsContainDecryptedPersonalFields(t *testing.T) {
server, ticket := encryptedTicketFixture(t)
staffView, err := server.staffTicketView(ticket)
if err != nil {
t.Fatal(err)
}
assertPersonalTicketView(t, staffView, "+8613812345678", "林", "女士")
batchView, err := server.staffCallBatchView(model.CallBatch{ID: "batch-a"}, []model.QueueTicket{ticket})
if err != nil {
t.Fatal(err)
}
batchTickets, ok := batchView["tickets"].([]map[string]any)
if !ok || len(batchTickets) != 1 {
t.Fatalf("staff current batch tickets = %#v, want one ticket", batchView["tickets"])
}
assertPersonalTicketView(t, batchTickets[0], "+8613812345678", "林", "女士")
adminView, err := server.adminActiveTicketView(ticket, "云岭漂流")
if err != nil {
t.Fatal(err)
}
assertPersonalTicketView(t, adminView, "+8613812345678", "林", "女士")
if got := adminView["project_name"]; got != "云岭漂流" {
t.Fatalf("admin project_name = %v, want 云岭漂流", got)
}
body, err := json.Marshal(map[string]any{"staff": staffView, "admin": adminView})
if err != nil {
t.Fatal(err)
}
encoded := string(body)
for _, forbidden := range []string{"phone_ciphertext", "phone_nonce", "phone_hmac", "last_name_ciphertext", "last_name_nonce"} {
if strings.Contains(encoded, forbidden) {
t.Fatalf("authorized DTO leaked storage field %q: %s", forbidden, encoded)
}
}
}
func TestPhoneLast4ReturnsOnlyFinalFourDigits(t *testing.T) {
server, ticket := encryptedTicketFixture(t)
got, err := server.ticketPhoneLast4(ticket)
if err != nil {
t.Fatal(err)
}
if got != "5678" {
t.Fatalf("encrypted ticket phone_last4 = %q, want 5678", got)
}
for _, test := range []struct {
phone string
want string
}{
{phone: "+8613812345678", want: "5678"},
{phone: "(138) 1234-0099", want: "0099"},
} {
got, err := phoneLast4(test.phone)
if err != nil {
t.Fatalf("phoneLast4(%q): %v", test.phone, err)
}
if got != test.want {
t.Fatalf("phoneLast4(%q) = %q, want %q", test.phone, got, test.want)
}
}
if _, err := phoneLast4("123"); err == nil {
t.Fatal("phoneLast4 should reject fewer than four digits")
}
}
func TestPurgedTicketPhoneLast4IsEmptyWithoutError(t *testing.T) {
server, ticket := encryptedTicketFixture(t)
ticket.PhoneCiphertext = nil
ticket.PhoneNonce = nil
ticket.PhoneHMAC = nil
got, err := server.ticketPhoneLast4(ticket)
if err != nil {
t.Fatal(err)
}
if got != "" {
t.Fatalf("purged ticket phone_last4 = %q, want empty", got)
}
}
func TestStaffTicketResponseAssemblyRejectsCorruptCiphertext(t *testing.T) {
server, ticket := encryptedTicketFixture(t)
ticket.PhoneCiphertext[0] ^= 0xff
response, err := server.staffTicketView(ticket)
if err == nil {
t.Fatalf("corrupt ciphertext produced a response: %#v", response)
}
if response != nil {
t.Fatalf("failed response assembly must not expose a partial DTO: %#v", response)
}
if !strings.Contains(err.Error(), "encrypted field authentication failed") {
t.Fatalf("unexpected corrupt ciphertext error: %v", err)
}
}
func encryptedTicketFixture(t *testing.T) (*Server, model.QueueTicket) {
t.Helper()
cipher, err := security.NewCipher(bytes.Repeat([]byte{0x11}, 32), bytes.Repeat([]byte{0x22}, 32))
if err != nil {
t.Fatal(err)
}
projectID := "project-a"
phoneCiphertext, phoneNonce, err := cipher.Encrypt("+8613812345678", []byte("phone:"+projectID))
if err != nil {
t.Fatal(err)
}
lastNameCiphertext, lastNameNonce, err := cipher.Encrypt("林", []byte("last_name:"+projectID))
if err != nil {
t.Fatal(err)
}
now := time.Unix(100, 0).UTC()
phoneHMAC := cipher.Digest("+8613812345678")
return &Server{cipher: cipher}, model.QueueTicket{
ID: "ticket-a", ProjectID: projectID, DisplayNumber: "00042", Status: model.TicketWaiting,
PhoneCiphertext: phoneCiphertext, PhoneNonce: phoneNonce,
PhoneHMAC: &phoneHMAC,
LastNameCiphertext: lastNameCiphertext, LastNameNonce: lastNameNonce,
Honorific: "女士", JoinedAt: now, CreatedAt: now,
}
}
func assertPersonalTicketView(t *testing.T, view map[string]any, phone, lastName, honorific string) {
t.Helper()
for field, want := range map[string]string{"phone": phone, "last_name": lastName, "honorific": honorific} {
if got := view[field]; got != want {
t.Fatalf("%s = %v, want %q", field, got, want)
}
}
}