问题:移动网络共享出口 IP 会造成游客取号被误限流。 实现:移除公开取号 IP 限制,改用项目总量与手机号 HMAC 限流,并支持 Retry-After 倒计时。
141 lines
5.3 KiB
Go
141 lines
5.3 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"calllinesystem/server/internal/config"
|
|
"calllinesystem/server/internal/model"
|
|
)
|
|
|
|
func TestDisplayBatchDTOCannotSerializePersonalFields(t *testing.T) {
|
|
batch := model.CallBatch{BatchSequence: 7, Status: "CALLED", CalledAt: time.Unix(100, 0).UTC()}
|
|
view := newDisplayBatchDTO(batch, []displayTicketDTO{{
|
|
TicketNumber: "00042", DisplayNumber: "00042", Status: model.TicketCalled,
|
|
}})
|
|
body, err := json.Marshal(view)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
encoded := string(body)
|
|
for _, forbidden := range []string{"honorific", "phone", "phone_last4", "last_name", "ticket_id", "joined_at", "created_by"} {
|
|
if strings.Contains(encoded, forbidden) {
|
|
t.Fatalf("public display DTO leaked forbidden field %q: %s", forbidden, encoded)
|
|
}
|
|
}
|
|
if !strings.Contains(encoded, `"ticket_number":"00042"`) {
|
|
t.Fatalf("public ticket number missing: %s", encoded)
|
|
}
|
|
}
|
|
|
|
func TestPublicDisplayProjectViewOnlySerializesDisplayFields(t *testing.T) {
|
|
view := publicDisplayProjectView(map[string]any{
|
|
"id": "project-1", "name": "东门观光车", "status": model.ProjectRunning,
|
|
"waiting_count": 3, "waiting_ticket_count": 3, "waiting_people_count": 7,
|
|
"issued_ticket_count": 15, "latest_ticket_number": "00015", "experienced_people": 12,
|
|
"current_batch": map[string]any{"tickets": []map[string]any{{"ticket_number": "00013"}}},
|
|
"estimated_wait": map[string]any{"available": true}, "last_updated_at": time.Unix(100, 0).UTC(),
|
|
"phone": "13800138000", "last_name": "张", "visitor_notice": "internal",
|
|
"device_status": map[string]any{"status": "FAILURE"}, "display_token_hash": "secret",
|
|
})
|
|
body, err := json.Marshal(view)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
encoded := string(body)
|
|
for _, forbidden := range []string{"phone", "last_name", "visitor_notice", "device_status", "display_token_hash", "13800138000"} {
|
|
if strings.Contains(encoded, forbidden) {
|
|
t.Fatalf("public display overview leaked forbidden field %q: %s", forbidden, encoded)
|
|
}
|
|
}
|
|
for _, required := range []string{`"name":"东门观光车"`, `"ticket_number":"00013"`, `"waiting_people_count":7`} {
|
|
if !strings.Contains(encoded, required) {
|
|
t.Fatalf("public display overview missing field %q: %s", required, encoded)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNormalizeDisplayProjectCode(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
ok bool
|
|
}{
|
|
{input: "raft", want: "RAFT", ok: true},
|
|
{input: " east-ride ", want: "EAST-RIDE", ok: true},
|
|
{input: "A", ok: false},
|
|
{input: "bad code", ok: false},
|
|
{input: strings.Repeat("a", 40), ok: false},
|
|
}
|
|
for _, test := range tests {
|
|
got, ok := normalizeDisplayProjectCode(test.input)
|
|
if got != test.want || ok != test.ok {
|
|
t.Fatalf("normalizeDisplayProjectCode(%q) = %q, %v; want %q, %v", test.input, got, ok, test.want, test.ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPublicPhoneLookupIsDisabledInProduction(t *testing.T) {
|
|
server := &Server{config: config.Config{Environment: "production"}}
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest("POST", "/api/public/status/search", strings.NewReader(`{"phone":"13800138000"}`))
|
|
|
|
server.publicStatusByPhone(recorder, request)
|
|
|
|
if recorder.Code != 404 {
|
|
t.Fatalf("production phone lookup status = %d, want 404", recorder.Code)
|
|
}
|
|
}
|
|
|
|
func TestPublicPhoneLookupRejectsInvalidPhone(t *testing.T) {
|
|
server := &Server{config: config.Config{Environment: "development"}}
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest("POST", "/api/public/status/search", strings.NewReader(`{"phone":"123"}`))
|
|
|
|
server.publicStatusByPhone(recorder, request)
|
|
|
|
if recorder.Code != 422 {
|
|
t.Fatalf("invalid phone lookup status = %d, want 422", recorder.Code)
|
|
}
|
|
}
|
|
|
|
func TestPublicTicketLimitsUseProjectAndPhoneDigest(t *testing.T) {
|
|
now := time.Unix(100, 0)
|
|
server := &Server{
|
|
publicTicketProjectLimiter: newQueryLimiter(func() time.Time { return now }, 10, time.Minute),
|
|
publicTicketPhoneLimiter: newQueryLimiter(func() time.Time { return now }, 1, time.Minute),
|
|
}
|
|
projectA := "11111111-1111-4111-8111-111111111111"
|
|
projectB := "22222222-2222-4222-8222-222222222222"
|
|
phoneDigest := strings.Repeat("a", 64)
|
|
|
|
if allowed, _ := server.allowPublicTicket(projectA, phoneDigest); !allowed {
|
|
t.Fatal("first project/phone request should be allowed")
|
|
}
|
|
if allowed, retry := server.allowPublicTicket(projectA, phoneDigest); allowed || retry <= 0 {
|
|
t.Fatalf("second request should be phone-limited, got allowed=%v retry=%s", allowed, retry)
|
|
}
|
|
if allowed, _ := server.allowPublicTicket(projectB, phoneDigest); !allowed {
|
|
t.Fatal("the same phone digest should have an independent limit in another project")
|
|
}
|
|
}
|
|
|
|
func TestPublicTicketLimitsEnforceProjectTotalAcrossPhones(t *testing.T) {
|
|
now := time.Unix(100, 0)
|
|
server := &Server{
|
|
publicTicketProjectLimiter: newQueryLimiter(func() time.Time { return now }, 1, time.Minute),
|
|
publicTicketPhoneLimiter: newQueryLimiter(func() time.Time { return now }, 10, time.Minute),
|
|
}
|
|
projectID := "11111111-1111-4111-8111-111111111111"
|
|
|
|
if allowed, _ := server.allowPublicTicket(projectID, strings.Repeat("a", 64)); !allowed {
|
|
t.Fatal("first project request should be allowed")
|
|
}
|
|
if allowed, retry := server.allowPublicTicket(projectID, strings.Repeat("b", 64)); allowed || retry <= 0 {
|
|
t.Fatalf("second phone should be limited by project total, got allowed=%v retry=%s", allowed, retry)
|
|
}
|
|
}
|