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) } } }