package management import ( "bytes" "context" "database/sql" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "testing" "time" ) func testServer(t *testing.T, apply bool) (*Server, *Store) { t.Helper() store, err := NewMemoryStore() if err != nil { t.Fatal(err) } cfg := DefaultConfig() cfg.MockCellApply = apply server := NewServer(cfg, store) if err := store.SeedDemo(context.Background()); err != nil { store.Close() t.Fatal(err) } t.Cleanup(func() { store.Close() }) return server, store } func request(t *testing.T, server *Server, method, path, token, requestID, ifMatch string, body any) *http.Response { t.Helper() var data []byte if body != nil { data, _ = json.Marshal(body) } req := httptest.NewRequest(method, path, bytes.NewReader(data)) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } if requestID != "" { req.Header.Set("X-Request-ID", requestID) } if ifMatch != "" { req.Header.Set("If-Match", ifMatch) } if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := server.App().Test(req) if err != nil { t.Fatal(err) } return resp } func responseJSON(t *testing.T, resp *http.Response) map[string]any { t.Helper() defer resp.Body.Close() var body map[string]any if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { t.Fatal(err) } return body } func TestAuthorizationAndIdempotency(t *testing.T) { server, store := testServer(t, true) resp := request(t, server, http.MethodGet, "/admin/v1/trunks", "dev-saas-token", "", "", nil) if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("saas token on admin API: %d", resp.StatusCode) } resp = request(t, server, http.MethodGet, "/readonly/v1/sip/trunks", "dev-saas-token", "", "", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("readonly API: %d", resp.StatusCode) } resp = request(t, server, http.MethodGet, "/admin/v1/trunks/demo-trunk", "dev-admin-token", "", "", nil) body := responseJSON(t, resp) latest := int(body["latest_revision"].(float64)) input := TrunkConfig{ProviderID: "demo-provider", DisplayName: "更新线路", Enabled: true, Sip: SipConfig{Host: "198.51.100.21", Port: 5060, Transport: "udp", AuthMode: "ip"}, CodecProfile: CodecProfile{Allowed: []string{"PCMA"}, Preferred: "PCMA"}, CallerIDs: []string{"BD93205882"}, DialPrefix: "7089", EgressPoolID: "egress-main", MaxConcurrency: 100, MaxCPS: 5} resp = request(t, server, http.MethodPut, "/admin/v1/trunks/demo-trunk", "dev-config-token", "trunk-update-1", strconvI(latest), input) if resp.StatusCode != http.StatusOK { t.Fatalf("put trunk: %d %s", resp.StatusCode, readBody(resp)) } first := responseJSON(t, resp) resp = request(t, server, http.MethodPut, "/admin/v1/trunks/demo-trunk", "dev-config-token", "trunk-update-1", strconvI(latest), input) if resp.StatusCode != http.StatusOK { t.Fatalf("idempotent retry: %d", resp.StatusCode) } second := responseJSON(t, resp) if first["trunk"].(map[string]any)["latest_revision"] != second["trunk"].(map[string]any)["latest_revision"] { t.Fatal("retry created a second revision") } if _, err := store.db.Exec("UPDATE operations SET expires_at=? WHERE request_id=?", utcString(time.Now().Add(-time.Hour)), "trunk-update-1"); err != nil { t.Fatal(err) } resp = request(t, server, http.MethodPut, "/admin/v1/trunks/demo-trunk", "dev-config-token", "trunk-update-1", strconvI(latest), input) if resp.StatusCode != http.StatusConflict { t.Fatalf("expired idempotency status: %d", resp.StatusCode) } if code := responseJSON(t, resp)["error"].(map[string]any)["code"]; code != "IDEMPOTENCY_EXPIRED" { t.Fatalf("expired idempotency code=%v", code) } input.DisplayName = "different payload" resp = request(t, server, http.MethodPut, "/admin/v1/trunks/demo-trunk", "dev-config-token", "trunk-update-1", strconvI(latest), input) if resp.StatusCode != http.StatusConflict { t.Fatalf("idempotency conflict: %d", resp.StatusCode) } resp = request(t, server, http.MethodGet, "/admin/v1/trunks/demo-trunk", "dev-admin-token", "", "", nil) decoded := responseJSON(t, resp) latestView := decoded["latest"].(map[string]any) if _, ok := latestView["credential_ref"]; ok { t.Fatal("credential reference leaked from read response") } } func TestPublicationBarrierAndRecovery(t *testing.T) { server, store := testServer(t, false) cfg, err := store.getTrunkConfig(context.Background(), "demo-trunk", 1) if err != nil { t.Fatal(err) } cfg.DisplayName = "待发布线路" if _, _, err := store.PutTrunk(context.Background(), "demo-trunk", cfg, 1, "fixture:test", "fixture-draft"); err != nil { t.Fatal(err) } resp := request(t, server, http.MethodPost, "/admin/v1/trunks/demo-trunk/publish", "dev-publisher-token", "publish-intent-1", "2", nil) if resp.StatusCode != http.StatusConflict { t.Fatalf("intent-only publish status: %d", resp.StatusCode) } if code := responseJSON(t, resp)["error"].(map[string]any)["code"]; code != "PUBLISH_PENDING" { t.Fatalf("error code=%v", code) } trunk, _ := store.GetTrunk(context.Background(), "demo-trunk", ModeMock) if trunk.ActiveRevision != 1 { t.Fatalf("pending publication changed active revision: %d", trunk.ActiveRevision) } server.cfg.MockCellApply = true resp = request(t, server, http.MethodPost, "/admin/v1/trunks/demo-trunk/publish", "dev-publisher-token", "publish-retry-1", "2", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("recovered publish: %d %s", resp.StatusCode, readBody(resp)) } trunk, _ = store.GetTrunk(context.Background(), "demo-trunk", ModeMock) if trunk.ActiveRevision != 2 || trunk.Status != "published" { t.Fatalf("publication did not finalize: %+v", trunk) } } func TestStatisticsSemantics(t *testing.T) { server, _ := testServer(t, true) resp := request(t, server, http.MethodGet, "/admin/v1/statistics/outbound/summary", "dev-admin-token", "", "", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("summary: %d %s", resp.StatusCode, readBody(resp)) } body := responseJSON(t, resp) metrics := body["metrics"].(map[string]any) for field, want := range map[string]float64{"attempts_total": 6, "started_confirmed": 6, "answered": 2, "terminal_not_answered": 3, "rejected": 1, "pending_not_answered": 1} { if got := metrics[field].(float64); got != want { t.Fatalf("%s=%v want %v", field, got, want) } } if complete := body["complete"].(bool); !complete { t.Fatal("rejected admission was incorrectly marked incomplete") } talk := metrics["talk_duration_seconds"].(map[string]any) if got := talk["total"].(float64); got != 180 { t.Fatalf("talk total=%v want 180", got) } if got := metrics["acd_seconds"].(float64); got != 90 { t.Fatalf("acd=%v want 90", got) } realtime := body["realtime"].(map[string]any) if _, ok := realtime["current_answered"]; !ok { t.Fatal("realtime current_answered missing") } if _, ok := realtime["current_cps"]; !ok { t.Fatal("realtime current_cps missing") } if body["definition_version"] != "sip-statistics.v1" || body["timezone"] != "UTC" { t.Fatalf("statistics metadata missing: %#v", body) } resp = request(t, server, http.MethodGet, "/admin/v1/statistics/outbound/timeseries?granularity=minute", "dev-admin-token", "", "", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("timeseries: %d", resp.StatusCode) } series := responseJSON(t, resp) if series["definition_version"] != "sip-statistics.v1" { t.Fatalf("timeseries definition version = %#v", series["definition_version"]) } } func TestObservationBootAndSequence(t *testing.T) { _, store := testServer(t, true) ctx := context.Background() now := time.Now().UTC() if err := store.AdvanceCellBoot(ctx, "cell-a", "boot-next"); err != nil { t.Fatal(err) } obs := ObservationInput{ObservationID: "obs-new", CellID: "cell-a", BootID: "boot-next", Sequence: 1, ObservedAt: now, ReceivedAt: now, Source: "mock", States: map[string]any{"cell_agent": "healthy"}} if err := store.InsertObservation(ctx, obs); err != nil { t.Fatal(err) } obs.ObservationID = "obs-zero" obs.Sequence = 0 if err := store.InsertObservation(ctx, obs); err == nil { t.Fatal("zero sequence accepted") } else if appErr, ok := err.(*AppError); !ok || appErr.Code != "INVALID_OBSERVATION" { t.Fatalf("wrong invalid observation error: %v", err) } obs.Sequence = 1 obs.ObservationID = "obs-old" if err := store.InsertObservation(ctx, obs); err == nil { t.Fatal("stale sequence accepted") } else if appErr, ok := err.(*AppError); !ok || appErr.Code != "STALE_OBSERVATION" { t.Fatalf("wrong stale error: %v", err) } obs.BootID = "old-boot" obs.Sequence = 2 if err := store.InsertObservation(ctx, obs); err == nil { t.Fatal("old boot accepted") } } func TestStatusRejectsFutureObservationClock(t *testing.T) { server, store := testServer(t, true) ctx := context.Background() if err := store.AdvanceCellBoot(ctx, "cell-a", "future-boot"); err != nil { t.Fatal(err) } future := time.Now().UTC().Add(2 * time.Minute) if err := store.InsertObservation(ctx, ObservationInput{ObservationID: "future-observation", CellID: "cell-a", BootID: "future-boot", Sequence: 1, ObservedAt: future, ReceivedAt: time.Now().UTC(), Source: "mock", States: map[string]any{"cell_agent": "healthy", "asterisk": "healthy", "ari": "healthy", "registration": "registered", "media": "healthy"}}); err != nil { t.Fatal(err) } resp := request(t, server, http.MethodGet, "/admin/v1/cells/cell-a/sip-status", "dev-admin-token", "future-status", "", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("future status=%d %s", resp.StatusCode, readBody(resp)) } body := responseJSON(t, resp) if body["availability"] != "unknown" || body["complete"] != false || body["clock_skew"] != true { t.Fatalf("future observation was treated as healthy: %#v", body) } } func TestReadonlyKeepsPublishedRevisionWhileDraftExists(t *testing.T) { server, store := testServer(t, true) cfg, err := store.getTrunkConfig(context.Background(), "demo-trunk", 1) if err != nil { t.Fatal(err) } cfg.DisplayName = "draft-only" if _, _, err := store.PutTrunk(context.Background(), "demo-trunk", cfg, 1, "fixture:test", "draft-only"); err != nil { t.Fatal(err) } resp := request(t, server, http.MethodGet, "/readonly/v1/sip/trunks", "dev-saas-token", "", "", nil) body := responseJSON(t, resp) trunks := body["trunks"].([]any) if len(trunks) != 1 { t.Fatalf("published trunks=%d", len(trunks)) } if trunks[0].(map[string]any)["revision"].(float64) != 1 { t.Fatal("readonly view followed unpublished latest revision") } } func TestPartialPublicationRetainsBarrierAndRetriesFailedCell(t *testing.T) { server, store := testServer(t, true) cfg, err := store.getTrunkConfig(context.Background(), "demo-trunk", 1) if err != nil { t.Fatal(err) } cfg.DisplayName = "partial" if _, _, err := store.PutTrunk(context.Background(), "demo-trunk", cfg, 1, "fixture:test", "partial-draft"); err != nil { t.Fatal(err) } server.cfg.MockCellFailures = map[string]string{"cell-b": "CELL_DOWN"} resp := request(t, server, http.MethodPost, "/admin/v1/trunks/demo-trunk/publish", "dev-publisher-token", "partial-1", "2", nil) if resp.StatusCode != http.StatusConflict { t.Fatalf("partial publish=%d", resp.StatusCode) } _ = responseJSON(t, resp) trunk, _ := store.GetTrunk(context.Background(), "demo-trunk", ModeMock) if trunk.ActiveRevision != 1 { t.Fatal("partial publication activated revision") } if allowed, reason, err := store.AdmissionAllowed(context.Background(), "demo-trunk"); err != nil || allowed || reason != "admission_barrier" { t.Fatalf("admission after partial: %v %s", err, reason) } server.cfg.MockCellFailures = map[string]string{} resp = request(t, server, http.MethodPost, "/admin/v1/trunks/demo-trunk/publish", "dev-publisher-token", "partial-2", "2", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("retry partial=%d %s", resp.StatusCode, readBody(resp)) } trunk, _ = store.GetTrunk(context.Background(), "demo-trunk", ModeMock) if trunk.ActiveRevision != 2 { t.Fatal("retry did not activate revision") } } func TestRollbackMissingRevisionDoesNotLeaveBarrier(t *testing.T) { server, store := testServer(t, true) resp := request(t, server, http.MethodPost, "/admin/v1/trunks/demo-trunk/rollback", "dev-publisher-token", "rollback-missing", "1", map[string]any{"target_revision": 99}) if resp.StatusCode != http.StatusConflict { t.Fatalf("missing rollback status=%d %s", resp.StatusCode, readBody(resp)) } if code := responseJSON(t, resp)["error"].(map[string]any)["code"]; code != "VERSION_NOT_FOUND" { t.Fatalf("missing rollback code=%v", code) } allowed, reason, err := store.AdmissionAllowed(context.Background(), "demo-trunk") if err != nil || !allowed || reason != "ok" { t.Fatalf("missing rollback left barrier: allowed=%v reason=%s err=%v", allowed, reason, err) } } func TestRealPublicationNeedsVerification(t *testing.T) { server, store := testServer(t, true) server.cfg.Mode = ModeReal cfg, err := store.getTrunkConfig(context.Background(), "demo-trunk", 1) if err != nil { t.Fatal(err) } cfg.DisplayName = "real-draft" if _, _, err := store.PutTrunk(context.Background(), "demo-trunk", cfg, 1, "fixture:test", "real-draft"); err != nil { t.Fatal(err) } resp := request(t, server, http.MethodPost, "/admin/v1/trunks/demo-trunk/publish", "dev-publisher-token", "real-publish", "2", nil) if resp.StatusCode != http.StatusConflict { t.Fatalf("real verification status=%d", resp.StatusCode) } if code := responseJSON(t, resp)["error"].(map[string]any)["code"]; code != "REAL_VERIFICATION_INCOMPLETE" { t.Fatalf("real verification code=%v", code) } } func TestScopedResourcesAndCORS(t *testing.T) { server, _ := testServer(t, true) cfg := server.cfg cfg.Tokens = map[string]TokenSpec{"scoped": {Principal: "operator:scoped", Realm: "admin", Scopes: []string{"sip.provider.read", "sip.trunk.read", "sip.status.read", "sip.statistics.read", "sip.calls.read"}, Resources: map[string][]string{"provider": {"other-provider"}, "trunk": {"other-trunk"}, "cell": {"other-cell"}}}} server.cfg = cfg resp := request(t, server, http.MethodGet, "/admin/v1/providers", "scoped", "", "", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("scoped list=%d", resp.StatusCode) } if len(responseJSON(t, resp)["providers"].([]any)) != 0 { t.Fatal("scoped provider leaked") } req := httptest.NewRequest(http.MethodOptions, "/healthz/live", nil) req.Header.Set("Origin", "https://evil.example") resp, err := server.App().Test(req) if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusForbidden { t.Fatalf("evil origin=%d", resp.StatusCode) } } func strconvI(v int) string { return fmt.Sprintf("%d", v) } func readBody(resp *http.Response) string { b, _ := io.ReadAll(resp.Body) resp.Body.Close() return string(b) } func TestAttemptEventsAreIdempotentAndVersioned(t *testing.T) { _, store := testServer(t, true) ctx := context.Background() started := time.Now().UTC().Add(-2 * time.Minute) if err := store.UpsertAttempt(ctx, Attempt{AttemptID: "event-attempt", Mode: ModeMock, OriginStatus: "confirmed", AttemptStartedAt: &started, Source: "mock", FactVersion: 1}); err != nil { t.Fatal(err) } answered := started.Add(30 * time.Second) if err := store.RecordAttemptEvent(ctx, "event-answered", "event-attempt", "answered", answered, time.Now().UTC(), "cell-agent", 2, nil); err != nil { t.Fatal(err) } if err := store.RecordAttemptEvent(ctx, "event-answered", "event-attempt", "answered", started.Add(5*time.Second), time.Now().UTC(), "cell-agent", 1, nil); err != nil { t.Fatal(err) } ended := answered.Add(time.Minute) if err := store.RecordAttemptEvent(ctx, "event-ended", "event-attempt", "ended", ended, time.Now().UTC(), "cell-agent", 2, map[string]any{"termination_reason": "completed"}); err != nil { t.Fatal(err) } attempts, err := store.ListAttempts(ctx, AttemptFilter{Limit: 20}) if err != nil { t.Fatal(err) } var found *Attempt for i := range attempts { if attempts[i].AttemptID == "event-attempt" { found = &attempts[i] } } if found == nil || found.AnsweredAt == nil || !found.AnsweredAt.Equal(answered) || found.EndedAt == nil || !found.EndedAt.Equal(ended) || found.FactVersion != 2 { t.Fatalf("stale event regressed fact: %#v", found) } if err := store.UpsertAttempt(ctx, Attempt{AttemptID: "event-attempt", Mode: ModeMock, OriginStatus: "confirmed", AttemptStartedAt: &started, AnsweredAt: &started, Source: "late-source", FactVersion: 1}); err != nil { t.Fatal(err) } attempts, err = store.ListAttempts(ctx, AttemptFilter{Limit: 20}) if err != nil { t.Fatal(err) } for i := range attempts { if attempts[i].AttemptID == "event-attempt" && (attempts[i].AnsweredAt == nil || !attempts[i].AnsweredAt.Equal(answered) || attempts[i].FactVersion != 2) { t.Fatalf("stale upsert regressed fact: %#v", attempts[i]) } } var eventCount int if err := store.db.QueryRow("SELECT count(*) FROM attempt_events WHERE event_id IN ('event-answered','event-ended')").Scan(&eventCount); err != nil { t.Fatal(err) } if eventCount != 2 { t.Fatalf("event dedup count = %d", eventCount) } } func TestNewStoreUpgradesLegacyEgressPoolSchema(t *testing.T) { path := t.TempDir() + "/legacy.db" legacy, err := sql.Open("sqlite", path) if err != nil { t.Fatal(err) } _, err = legacy.Exec(`CREATE TABLE egress_pools ( egress_pool_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, fixed_ips_json TEXT NOT NULL DEFAULT '[]', whitelist_status TEXT NOT NULL DEFAULT 'unknown', whitelist_checked_at TEXT, source TEXT NOT NULL DEFAULT 'mock', updated_at TEXT NOT NULL )`) if err != nil { legacy.Close() t.Fatal(err) } if err := legacy.Close(); err != nil { t.Fatal(err) } store, err := NewStore(path) if err != nil { t.Fatal(err) } defer store.Close() var providerID string if err := store.db.QueryRow("SELECT provider_id FROM egress_pools").Scan(&providerID); err != sql.ErrNoRows { t.Fatalf("expected upgraded empty table, got %v", err) } var columnCount int if err := store.db.QueryRow("SELECT count(*) FROM pragma_table_info('egress_pools') WHERE name='provider_id'").Scan(&columnCount); err != nil { t.Fatal(err) } if columnCount != 1 { t.Fatalf("provider_id column count = %d", columnCount) } } var _ = sql.ErrNoRows