package mysql import ( "context" "crypto/sha256" "database/sql" "encoding/base64" "encoding/hex" "encoding/json" "os" "strings" "testing" "time" "git.yuntuo.hk/k2safe/orbit/internal/fault" "git.yuntuo.hk/k2safe/orbit/internal/retention" "git.yuntuo.hk/k2safe/orbit/platform/auth" pulse "git.yuntuo.hk/k2safe/orbit/pulse/model" "git.yuntuo.hk/k2safe/orbit/pulse/service" ) func TestIntegrationRetentionPolicyIsSharedAuditedAndIdempotent(t *testing.T) { dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN") if dsn == "" { t.Skip("set ORBIT_TEST_MYSQL_DSN to a disposable database, or run make integration") } ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() db, err := Open(ctx, dsn, 8, 2) if err != nil { t.Fatal(err) } defer db.Close() if err := Migrate(ctx, db); err != nil { t.Fatal(err) } var boot BootstrapResult app := testID(t) if err := Bootstrap(ctx, db, BootstrapInput{ Application: string(app), Namespace: "default", Environment: "test", Queue: "admin", ConsoleUsername: "admin-" + string(app), ConsolePassword: "a-strong-password-123", }, func(v BootstrapResult) error { boot = v return nil }); err != nil { t.Fatal(err) } login, err := NewConsoleAuthStore(db).Login(ctx, "admin-"+string(app), "a-strong-password-123", "retention-test", "127.0.0.1") if err != nil { t.Fatal(err) } token, err := auth.Parse(login.Token) if err != nil { t.Fatal(err) } defaults := retention.Defaults(boot.NamespaceID) store := NewRetentionPolicyStore(db, defaults) got, err := store.GetRetentionPolicy(ctx, boot.NamespaceID) if err != nil || got.Source != "default" || got.Revision != 0 || got.TaskHistoryDays != 365 { t.Fatalf("default policy: %+v %v", got, err) } in := retention.UpdateInput{ ExpectedRevision: 0, Enabled: true, IdempotencyDays: 120, TaskHistoryDays: 730, WorkflowHistoryDays: 730, EventLogDays: 365, DeliveryHistoryDays: 120, InboxHistoryDays: 120, AuditLogDays: 2555, Reason: "production retention policy review", } updated, err := store.UpdateRetentionPolicy(ctx, token, "retention-change-1", in) if err != nil { t.Fatal(err) } if updated.Source != "database" || updated.Revision != 1 || !updated.Enabled || updated.TaskHistoryDays != 730 { t.Fatalf("updated policy: %+v", updated) } // A second store represents another Orbit node; it must read the same MySQL policy. other := NewRetentionPolicyStore(db, retention.Defaults(boot.NamespaceID)) shared, err := other.GetRetentionPolicy(ctx, boot.NamespaceID) if err != nil || shared.Revision != 1 || shared.TaskHistoryDays != 730 || !shared.Enabled { t.Fatalf("cross-node policy read: %+v %v", shared, err) } replayed, err := store.UpdateRetentionPolicy(ctx, token, "retention-change-1", in) if err != nil || replayed.Revision != updated.Revision || replayed.TaskHistoryDays != updated.TaskHistoryDays { t.Fatalf("idempotent replay: %+v %v", replayed, err) } conflict := in conflict.ExpectedRevision = 0 conflict.TaskHistoryDays = 900 if _, err := store.UpdateRetentionPolicy(ctx, token, "retention-change-2", conflict); faultCode(err) != "STATE_CONFLICT" { t.Fatalf("stale revision accepted: %v", err) } var audits int if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_audit_events WHERE namespace_id=? AND action='retention.policy.update' AND resource_kind='retention_policy'`, boot.NamespaceID).Scan(&audits); err != nil { t.Fatal(err) } if audits != 1 { t.Fatalf("audit rows=%d want 1 after idempotent replay", audits) } var actor, reason string if err := db.QueryRowContext(ctx, "SELECT actor_id,reason FROM orbit_audit_events WHERE namespace_id=? AND action='retention.policy.update'", boot.NamespaceID).Scan(&actor, &reason); err != nil { t.Fatal(err) } if actor == "" || reason != in.Reason || actor == "mysql:database" { t.Fatalf("unexpected retention audit identity: actor=%q reason=%q", actor, reason) } t.Logf("retention policy revision %d is shared across stores, audited once, and safe on replay", updated.Revision) } func faultCode(err error) string { if err == nil { return "" } return fault.Code(err) } func TestIntegrationRetentionDiskGrowthAndPreview(t *testing.T) { dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN") if dsn == "" { t.Skip("set ORBIT_TEST_MYSQL_DSN to a disposable database, or run make integration") } ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() db, err := Open(ctx, dsn, 12, 4) if err != nil { t.Fatal(err) } defer db.Close() if err := Migrate(ctx, db); err != nil { t.Fatal(err) } var boot BootstrapResult app := testID(t) if err := Bootstrap(ctx, db, BootstrapInput{ Application: string(app), Namespace: "growth", Environment: "test", Queue: "growth", ConsoleUsername: "growth-admin-" + string(app), ConsolePassword: "a-strong-password-123", }, func(v BootstrapResult) error { boot = v; return nil }); err != nil { t.Fatal(err) } apiToken, err := auth.Parse(boot.Token) if err != nil { t.Fatal(err) } login, err := NewConsoleAuthStore(db).Login(ctx, "growth-admin-"+string(app), "a-strong-password-123", "retention-growth-test", "127.0.0.1") if err != nil { t.Fatal(err) } consoleToken, err := auth.Parse(login.Token) if err != nil { t.Fatal(err) } if _, err := db.ExecContext(ctx, "ANALYZE TABLE pulse_tasks, orbit_events, orbit_audit_events"); err != nil { t.Fatal(err) } before := retentionTableBytes(t, ctx, db) serviceStore := service.New(NewTaskStore(db)) // 256 rows × 32 KiB is deliberately larger than one InnoDB allocation page, // so the information_schema sample must observe physical table growth. payload := json.RawMessage(`{"blob":"` + strings.Repeat("x", 32768) + `"}`) ids := make([]string, 0, 256) for i := 0; i < 256; i++ { out, err := serviceStore.Submit(ctx, apiToken, "growth-"+string(app)+"-"+string(rune('a'+i%26))+"-"+string(testID(t)), service.SubmitInput{ QueueID: boot.QueueID, Handler: "retention.growth", HandlerVersion: "v1", PayloadVersion: "v1", Payload: payload, TaskTimeoutMS: 60000, RecoverySafety: pulse.RecoveryReconcileOnly, Retry: pulse.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 30000}, }) if err != nil { t.Fatal(err) } ids = append(ids, string(out.Task.ID)) } completedAt := time.Now().UTC().Add(-48 * time.Hour).Truncate(time.Microsecond) for _, id := range ids { if _, err := db.ExecContext(ctx, `UPDATE pulse_tasks SET state='FAILED',completed_at=?,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=UNHEX(?)`, completedAt, boot.NamespaceID, id); err != nil { t.Fatal(err) } } // 同一完成时间验证复合游标;未知效果和活跃租约不能因时间足够旧而被导出。 if _, err := db.ExecContext(ctx, "UPDATE pulse_tasks SET state='NEEDS_REVIEW',completed_at=NULL,effect_uncertain=TRUE WHERE namespace_id=? AND id=UNHEX(?)", boot.NamespaceID, ids[0]); err != nil { t.Fatal(err) } if _, err := db.ExecContext(ctx, "UPDATE pulse_tasks SET state='RUNNING',completed_at=NULL,current_attempt_id=UNHEX('00000000000000000000000000000001'),lease_until=UTC_TIMESTAMP(6)+INTERVAL 1 HOUR WHERE namespace_id=? AND id=UNHEX(?)", boot.NamespaceID, ids[1]); err != nil { t.Fatal(err) } if _, err := db.ExecContext(ctx, "ANALYZE TABLE pulse_tasks, orbit_events, orbit_audit_events"); err != nil { t.Fatal(err) } after := retentionTableBytes(t, ctx, db) if after <= before { t.Fatalf("retention table bytes did not grow during bounded growth sample: before=%d after=%d", before, after) } store := NewRetentionPolicyStore(db, retention.Defaults(boot.NamespaceID)) _, err = store.UpdateRetentionPolicy(ctx, consoleToken, "growth-retention-policy", retention.UpdateInput{ ExpectedRevision: 0, Enabled: true, IdempotencyDays: 90, TaskHistoryDays: 1, WorkflowHistoryDays: 30, EventLogDays: 30, DeliveryHistoryDays: 30, InboxHistoryDays: 30, AuditLogDays: 365, Reason: "bounded disk growth rehearsal", }) if err != nil { t.Fatal(err) } preview, err := store.PreviewRetention(ctx, consoleToken) if err != nil { t.Fatal(err) } var taskSample retention.CandidatePage for _, item := range preview.Items { if item.Kind == "tasks" { taskSample = item } } if taskSample.Count == 0 { t.Fatalf("backdated terminal tasks were not visible as retention candidates: %+v", preview.Items) } snapshot, err := store.ExportRetention(ctx, consoleToken, retention.ArchiveInput{Kind: "tasks", Limit: 1, ExpectedRevision: 1}) if err != nil { t.Fatal(err) } if snapshot.FormatVersion != 1 || snapshot.Content == "" { t.Fatalf("invalid retention snapshot envelope: %+v", snapshot) } sum := sha256.Sum256([]byte(snapshot.Content)) if snapshot.SHA256 != hex.EncodeToString(sum[:]) || !json.Valid([]byte(snapshot.Content)) || !strings.Contains(snapshot.Content, `"source_retained":true`) { t.Fatalf("retention snapshot integrity/source marker failed: sha=%s content=%s", snapshot.SHA256, snapshot.Content[:min(len(snapshot.Content), 200)]) } var first archiveData if err := json.Unmarshal([]byte(snapshot.Content), &first); err != nil { t.Fatal(err) } if first.NextCursor == "" { t.Fatal("first page lost continuation cursor") } seen := map[string]bool{} page := first batches := 0 for { batches++ if page.Cutoff != first.Cutoff || page.Policy.Revision != 1 || !page.SourceRetained { t.Fatalf("batch changed boundary: %+v", page.Policy) } if page.RootCount != len(page.Tables["pulse_tasks"]) { t.Fatal("root count mismatch") } for _, row := range page.Tables["pulse_tasks"] { id, ok := row["id"].(string) if !ok || seen[id] || id == ids[0] || id == ids[1] { t.Fatalf("duplicate/unsafe root: %v", row["id"]) } seen[id] = true } if page.NextCursor == "" { break } if batches > 10 { t.Fatal("cursor did not make bounded progress") } next, err := store.ExportRetention(ctx, consoleToken, retention.ArchiveInput{Kind: "tasks", Limit: 37, Cursor: page.NextCursor, ExpectedRevision: 1}) if err != nil { t.Fatal(err) } digest := sha256.Sum256([]byte(next.Content)) if next.SHA256 != hex.EncodeToString(digest[:]) { t.Fatal("batch digest mismatch") } if err := json.Unmarshal([]byte(next.Content), &page); err != nil { t.Fatal(err) } } if len(seen) != len(ids)-2 { t.Fatalf("exported %d roots; want %d", len(seen), len(ids)-2) } var retained int if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM pulse_tasks WHERE namespace_id=?", boot.NamespaceID).Scan(&retained); err != nil || retained != len(ids) { t.Fatalf("source data changed: count=%d err=%v", retained, err) } for _, in := range []retention.ArchiveInput{ {Kind: "audit", Cursor: first.NextCursor, ExpectedRevision: 1}, {Kind: "tasks", Cursor: "not-a-cursor", ExpectedRevision: 1}, {Kind: "tasks", Limit: 101, ExpectedRevision: 1}, } { if _, err := store.ExportRetention(ctx, consoleToken, in); err == nil { t.Fatalf("invalid export accepted: %+v", in) } } raw, err := base64.RawURLEncoding.DecodeString(first.NextCursor) if err != nil { t.Fatal(err) } var foreign archiveCursor if err := json.Unmarshal(raw, &foreign); err != nil { t.Fatal(err) } foreign.Namespace = testID(t) raw, err = json.Marshal(foreign) if err != nil { t.Fatal(err) } if _, err := store.ExportRetention(ctx, consoleToken, retention.ArchiveInput{Kind: "tasks", ExpectedRevision: 1, Cursor: base64.RawURLEncoding.EncodeToString(raw)}); err == nil { t.Fatal("foreign namespace cursor accepted") } if _, err := store.ExportRetention(ctx, apiToken, retention.ArchiveInput{Kind: "tasks", ExpectedRevision: 1}); err == nil { t.Fatal("business bearer token accepted as console admin") } policyChange := retention.UpdateInput{ExpectedRevision: 1, Enabled: true, IdempotencyDays: 90, TaskHistoryDays: 2, WorkflowHistoryDays: 30, EventLogDays: 30, DeliveryHistoryDays: 30, InboxHistoryDays: 30, AuditLogDays: 365, Reason: "verify frozen cursor boundary"} if _, err := store.UpdateRetentionPolicy(ctx, consoleToken, "growth-retention-revise", policyChange); err != nil { t.Fatal(err) } if _, err := store.ExportRetention(ctx, consoleToken, retention.ArchiveInput{Kind: "tasks", ExpectedRevision: 1, Cursor: first.NextCursor}); faultCode(err) != "STATE_CONFLICT" { t.Fatalf("stale policy allowed: %v", err) } if _, err := store.ExportRetention(ctx, consoleToken, retention.ArchiveInput{Kind: "tasks", ExpectedRevision: 2, Cursor: first.NextCursor}); err == nil { t.Fatal("old cursor accepted with new policy") } policyChange.ExpectedRevision, policyChange.Enabled = 2, false if _, err := store.UpdateRetentionPolicy(ctx, consoleToken, "growth-retention-disable", policyChange); err != nil { t.Fatal(err) } if _, err := store.ExportRetention(ctx, consoleToken, retention.ArchiveInput{Kind: "tasks", ExpectedRevision: 3}); faultCode(err) != "STATE_CONFLICT" { t.Fatalf("disabled policy allowed: %v", err) } t.Logf("archive pagination: batches=%d unique_roots=%d protected_roots=2 source_rows=%d; digest, namespace, kind, revision, disabled policy and console-only checks passed", batches, len(seen), retained) t.Logf("bounded retention growth sample inserted %d tasks (254 terminal, 2 protected); bytes before=%d after=%d delta=%d; preview tasks=%d more=%v", len(ids), before, after, after-before, taskSample.Count, taskSample.More) } func retentionTableBytes(t *testing.T, ctx context.Context, db interface { QueryRowContext(context.Context, string, ...any) *sql.Row }) int64 { t.Helper() var bytes int64 if err := db.QueryRowContext(ctx, `SELECT COALESCE(SUM(data_length+index_length),0) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('pulse_tasks','orbit_events','orbit_audit_events')`).Scan(&bytes); err != nil { t.Fatal(err) } return bytes }