package mysql import ( "context" "crypto/sha256" "database/sql" "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)) } for _, id := range ids { if _, err := db.ExecContext(ctx, `UPDATE pulse_tasks SET state='FAILED',completed_at=UTC_TIMESTAMP(6)-INTERVAL 2 DAY,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=UNHEX(?)`, boot.NamespaceID, id); 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)]) } t.Logf("bounded retention growth sample inserted %d terminal tasks; 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 }