Initial commit
This commit is contained in:
147
server/internal/httpapi/personal_test.go
Normal file
147
server/internal/httpapi/personal_test.go
Normal file
@@ -0,0 +1,147 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user