From 0d5cf2d8b4cc208ed7cf99319bfc3c5afa7bb257 Mon Sep 17 00:00:00 2001 From: Allisson Azevedo Date: Thu, 28 May 2026 15:44:32 -0300 Subject: [PATCH 1/3] refactor(cli): collapse six retention-sweep commands into one deep module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The purge-* and clean-* commands each re-typed the same shape — a Result struct, ToText/ToJSON, and a Run* with validate → log → track → output — diverging only in noun, metric label, and one usecase method. Their ToJSON was byte-identical across all six. Introduce RunRetentionSweep + SweepSpec, a single deep module that owns the sweep machinery. Each command supplies a SweepSpec (verb, subject, metric labels, dry-run support, and a Sweep closure adapting its usecase method). The differently-named usecase methods (PurgeDeleted, CleanupExpired, DeleteOlderThan, PurgeExpiredAndRevoked) are adapted by closure, so no shared interface is needed. - audit-log cleanup now flows through metrics.Track like every other sweep (previously untracked — drift, now fixed). - auth-token purge keeps its no-dry-run notice via SupportsDryRun=false. - user-facing output is preserved via per-spec verb/subject strings. The six per-command test files collapse into one table-driven test on RunRetentionSweep. Records the concept in CONTEXT.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTEXT.md | 25 ++ cmd/app/auth_commands.go | 28 ++- cmd/app/commands/clean_audit_logs.go | 80 ------ cmd/app/commands/clean_audit_logs_test.go | 51 ---- cmd/app/commands/clean_expired_tokens.go | 90 ------- cmd/app/commands/clean_expired_tokens_test.go | 79 ------ cmd/app/commands/purge_auth_tokens.go | 106 -------- cmd/app/commands/purge_auth_tokens_test.go | 77 ------ cmd/app/commands/purge_secrets.go | 86 ------- cmd/app/commands/purge_secrets_test.go | 166 ------------ cmd/app/commands/purge_tokenization_keys.go | 90 ------- .../commands/purge_tokenization_keys_test.go | 178 ------------- cmd/app/commands/purge_transit_keys.go | 86 ------- cmd/app/commands/purge_transit_keys_test.go | 166 ------------ cmd/app/commands/retention_sweep.go | 141 +++++++++++ cmd/app/commands/retention_sweep_test.go | 238 ++++++++++++++++++ cmd/app/system_commands.go | 61 ++++- 17 files changed, 481 insertions(+), 1267 deletions(-) delete mode 100644 cmd/app/commands/clean_audit_logs.go delete mode 100644 cmd/app/commands/clean_audit_logs_test.go delete mode 100644 cmd/app/commands/clean_expired_tokens.go delete mode 100644 cmd/app/commands/clean_expired_tokens_test.go delete mode 100644 cmd/app/commands/purge_auth_tokens.go delete mode 100644 cmd/app/commands/purge_auth_tokens_test.go delete mode 100644 cmd/app/commands/purge_secrets.go delete mode 100644 cmd/app/commands/purge_secrets_test.go delete mode 100644 cmd/app/commands/purge_tokenization_keys.go delete mode 100644 cmd/app/commands/purge_tokenization_keys_test.go delete mode 100644 cmd/app/commands/purge_transit_keys.go delete mode 100644 cmd/app/commands/purge_transit_keys_test.go create mode 100644 cmd/app/commands/retention_sweep.go create mode 100644 cmd/app/commands/retention_sweep_test.go diff --git a/CONTEXT.md b/CONTEXT.md index 120fd6d..15c203b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -132,3 +132,28 @@ inside a `database.TxManager` transaction propagated via `context.Context` (per [ADR-0005](docs/adr/0005-context-based-transaction-management.md)). `Keyring.Encrypt` and `Keyring.AllocateDek` join the caller's transaction when one is present. + +## Operations + +### Retention sweep +The age-based deletion shared by six CLI commands (`purge-secrets`, +`purge-transit-keys`, `purge-tokenization-keys`, `clean-expired-tokens`, +`clean-audit-logs`, `purge-auth-tokens`). Each deletes rows older than a +`--days` threshold. The umbrella term covers both the soft-delete *purges* +and the expiry-based *cleans*; use "retention sweep" for the shared concept +and keep the per-command verb (`purge` / `clean`) in user-facing text. + +A single deep module, `RunRetentionSweep` in `cmd/app/commands`, owns the +shape: validate `days` → log → `metrics.Track(module, op)` → run the +feature's sweep func (dry-run aware where supported) → format output as +text or JSON. Each command supplies a `SweepSpec`: + +- `Verb` / `Subject` — the wording for output (e.g. `purge` / + `"expired/revoked authentication token(s)"`). +- `MetricModule` / `MetricOp` — the `metrics.Track` labels. +- `SupportsDryRun` — `false` only for the auth-token sweep, whose + `TokenUseCase.PurgeExpiredAndRevoked` takes no `dryRun`; the module then + emits a "dry-run not supported" notice and deletes nothing. +- `Sweep` — a closure adapting the feature usecase's sweep method + (`PurgeDeleted`, `CleanupExpired`, `DeleteOlderThan`, + `PurgeExpiredAndRevoked`), which have no shared interface. diff --git a/cmd/app/auth_commands.go b/cmd/app/auth_commands.go index c6f84e0..ed7dcbc 100644 --- a/cmd/app/auth_commands.go +++ b/cmd/app/auth_commands.go @@ -49,9 +49,19 @@ func getAuthCommands() []*cli.Command { return err } - return commands.RunPurgeAuthTokens( + return commands.RunRetentionSweep( ctx, - tokenUseCase, + commands.SweepSpec{ + Verb: "purge", + VerbPast: "purged", + Subject: "expired/revoked authentication token(s)", + MetricModule: "auth", + MetricOp: "token_purge", + SupportsDryRun: false, + Sweep: func(c context.Context, days int, _ bool) (int64, error) { + return tokenUseCase.PurgeExpiredAndRevoked(c, days) + }, + }, bm, container.Logger(), commands.DefaultIO().Writer, @@ -99,9 +109,19 @@ func getAuthCommands() []*cli.Command { return err } - return commands.RunCleanExpiredTokens( + return commands.RunRetentionSweep( ctx, - tokenizationUseCase, + commands.SweepSpec{ + Verb: "delete", + VerbPast: "deleted", + Subject: "expired token(s)", + MetricModule: "tokenization", + MetricOp: "tokenize_cleanup_expired", + SupportsDryRun: true, + Sweep: func(c context.Context, days int, dryRun bool) (int64, error) { + return tokenizationUseCase.CleanupExpired(c, days, dryRun) + }, + }, bm, container.Logger(), commands.DefaultIO().Writer, diff --git a/cmd/app/commands/clean_audit_logs.go b/cmd/app/commands/clean_audit_logs.go deleted file mode 100644 index 86976a8..0000000 --- a/cmd/app/commands/clean_audit_logs.go +++ /dev/null @@ -1,80 +0,0 @@ -package commands - -import ( - "context" - "encoding/json" - "fmt" - "io" - "log/slog" - - authUseCase "github.com/allisson/secrets/internal/auth/usecase" -) - -// CleanAuditLogsResult holds the result of the audit log cleanup operation. -type CleanAuditLogsResult struct { - Count int64 `json:"count"` - Days int `json:"days"` - DryRun bool `json:"dry_run"` -} - -// ToText returns a human-readable representation of the cleanup result. -func (r *CleanAuditLogsResult) ToText() string { - if r.DryRun { - return fmt.Sprintf( - "Dry-run mode: Would delete %d audit log(s) older than %d day(s)", - r.Count, - r.Days, - ) - } - return fmt.Sprintf("Successfully deleted %d audit log(s) older than %d day(s)", r.Count, r.Days) -} - -// ToJSON returns a JSON representation of the cleanup result. -func (r *CleanAuditLogsResult) ToJSON() string { - jsonBytes, _ := json.MarshalIndent(r, "", " ") - return string(jsonBytes) -} - -// RunCleanAuditLogs deletes audit logs older than the specified number of days. -// Supports dry-run mode and multiple output formats. -func RunCleanAuditLogs( - ctx context.Context, - auditLogUseCase authUseCase.AuditLogUseCase, - logger *slog.Logger, - writer io.Writer, - days int, - dryRun bool, - format string, -) error { - // Validate days parameter - if days < 0 { - return fmt.Errorf("days must be a positive number, got: %d", days) - } - - logger.Info("cleaning audit logs", - slog.Int("days", days), - slog.Bool("dry_run", dryRun), - ) - - // Execute deletion or count operation - count, err := auditLogUseCase.DeleteOlderThan(ctx, days, dryRun) - if err != nil { - return fmt.Errorf("failed to delete audit logs: %w", err) - } - - // Output result - result := &CleanAuditLogsResult{ - Count: count, - Days: days, - DryRun: dryRun, - } - WriteOutput(writer, format, result) - - logger.Info("cleanup completed", - slog.Int64("count", count), - slog.Int("days", days), - slog.Bool("dry_run", dryRun), - ) - - return nil -} diff --git a/cmd/app/commands/clean_audit_logs_test.go b/cmd/app/commands/clean_audit_logs_test.go deleted file mode 100644 index e1262ee..0000000 --- a/cmd/app/commands/clean_audit_logs_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package commands - -import ( - "bytes" - "context" - "log/slog" - "testing" - - "github.com/stretchr/testify/require" - - authMocks "github.com/allisson/secrets/internal/auth/usecase/mocks" -) - -func TestRunCleanAuditLogs(t *testing.T) { - ctx := context.Background() - logger := slog.Default() - days := 30 - - t.Run("text-output", func(t *testing.T) { - mockUseCase := &authMocks.MockAuditLogUseCase{} - mockUseCase.On("DeleteOlderThan", ctx, days, false).Return(int64(100), nil) - - var out bytes.Buffer - err := RunCleanAuditLogs(ctx, mockUseCase, logger, &out, days, false, "text") - - require.NoError(t, err) - require.Contains(t, out.String(), "Successfully deleted 100 audit log(s)") - mockUseCase.AssertExpectations(t) - }) - - t.Run("json-output", func(t *testing.T) { - mockUseCase := &authMocks.MockAuditLogUseCase{} - mockUseCase.On("DeleteOlderThan", ctx, days, true).Return(int64(50), nil) - - var out bytes.Buffer - err := RunCleanAuditLogs(ctx, mockUseCase, logger, &out, days, true, "json") - - require.NoError(t, err) - require.Contains(t, out.String(), `"count": 50`) - require.Contains(t, out.String(), `"dry_run": true`) - mockUseCase.AssertExpectations(t) - }) - - t.Run("invalid-days", func(t *testing.T) { - mockUseCase := &authMocks.MockAuditLogUseCase{} - err := RunCleanAuditLogs(ctx, mockUseCase, logger, &bytes.Buffer{}, -1, false, "text") - - require.Error(t, err) - require.Contains(t, err.Error(), "days must be a positive number") - }) -} diff --git a/cmd/app/commands/clean_expired_tokens.go b/cmd/app/commands/clean_expired_tokens.go deleted file mode 100644 index f9c40a5..0000000 --- a/cmd/app/commands/clean_expired_tokens.go +++ /dev/null @@ -1,90 +0,0 @@ -package commands - -import ( - "context" - "encoding/json" - "fmt" - "io" - "log/slog" - - "github.com/allisson/secrets/internal/metrics" - tokenizationUseCase "github.com/allisson/secrets/internal/tokenization/usecase" -) - -// CleanExpiredTokensResult holds the result of the expired token cleanup operation. -type CleanExpiredTokensResult struct { - Count int64 `json:"count"` - Days int `json:"days"` - DryRun bool `json:"dry_run"` -} - -// ToText returns a human-readable representation of the cleanup result. -func (r *CleanExpiredTokensResult) ToText() string { - if r.DryRun { - return fmt.Sprintf( - "Dry-run mode: Would delete %d expired token(s) older than %d day(s)", - r.Count, - r.Days, - ) - } - return fmt.Sprintf( - "Successfully deleted %d expired token(s) older than %d day(s)", - r.Count, - r.Days, - ) -} - -// ToJSON returns a JSON representation of the cleanup result. -func (r *CleanExpiredTokensResult) ToJSON() string { - jsonBytes, _ := json.MarshalIndent(r, "", " ") - return string(jsonBytes) -} - -// RunCleanExpiredTokens deletes expired tokens older than the specified number of days. -// Supports dry-run mode and multiple output formats. -func RunCleanExpiredTokens( - ctx context.Context, - tokenizationUseCase tokenizationUseCase.TokenizationUseCase, - bm metrics.BusinessMetrics, - logger *slog.Logger, - writer io.Writer, - days int, - dryRun bool, - format string, -) error { - // Validate days parameter - if days < 0 { - return fmt.Errorf("days must be a positive number, got: %d", days) - } - - logger.Info("cleaning expired tokens", - slog.Int("days", days), - slog.Bool("dry_run", dryRun), - ) - - // Execute deletion or count operation - var count int64 - if err := metrics.Track(ctx, bm, "tokenization", "tokenize_cleanup_expired", func() error { - var e error - count, e = tokenizationUseCase.CleanupExpired(ctx, days, dryRun) - return e - }); err != nil { - return fmt.Errorf("failed to cleanup expired tokens: %w", err) - } - - // Output result - result := &CleanExpiredTokensResult{ - Count: count, - Days: days, - DryRun: dryRun, - } - WriteOutput(writer, format, result) - - logger.Info("cleanup completed", - slog.Int64("count", count), - slog.Int("days", days), - slog.Bool("dry_run", dryRun), - ) - - return nil -} diff --git a/cmd/app/commands/clean_expired_tokens_test.go b/cmd/app/commands/clean_expired_tokens_test.go deleted file mode 100644 index 58d5a82..0000000 --- a/cmd/app/commands/clean_expired_tokens_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package commands - -import ( - "bytes" - "context" - "log/slog" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/allisson/secrets/internal/metrics" - tokenizationMocks "github.com/allisson/secrets/internal/tokenization/usecase/mocks" -) - -func TestRunCleanExpiredTokens(t *testing.T) { - ctx := context.Background() - logger := slog.Default() - days := 30 - - t.Run("text-output", func(t *testing.T) { - mockUseCase := &tokenizationMocks.MockTokenizationUseCase{} - mockUseCase.On("CleanupExpired", ctx, days, false).Return(int64(100), nil) - - var out bytes.Buffer - err := RunCleanExpiredTokens( - ctx, - mockUseCase, - metrics.NewNopBusinessMetrics(), - logger, - &out, - days, - false, - "text", - ) - - require.NoError(t, err) - require.Contains(t, out.String(), "Successfully deleted 100 expired token(s)") - mockUseCase.AssertExpectations(t) - }) - - t.Run("json-output", func(t *testing.T) { - mockUseCase := &tokenizationMocks.MockTokenizationUseCase{} - mockUseCase.On("CleanupExpired", ctx, days, true).Return(int64(50), nil) - - var out bytes.Buffer - err := RunCleanExpiredTokens( - ctx, - mockUseCase, - metrics.NewNopBusinessMetrics(), - logger, - &out, - days, - true, - "json", - ) - - require.NoError(t, err) - require.Contains(t, out.String(), `"count": 50`) - require.Contains(t, out.String(), `"dry_run": true`) - mockUseCase.AssertExpectations(t) - }) - - t.Run("invalid-days", func(t *testing.T) { - mockUseCase := &tokenizationMocks.MockTokenizationUseCase{} - err := RunCleanExpiredTokens( - ctx, - mockUseCase, - metrics.NewNopBusinessMetrics(), - logger, - &bytes.Buffer{}, - -1, - false, - "text", - ) - - require.Error(t, err) - require.Contains(t, err.Error(), "days must be a positive number") - }) -} diff --git a/cmd/app/commands/purge_auth_tokens.go b/cmd/app/commands/purge_auth_tokens.go deleted file mode 100644 index d8ebc99..0000000 --- a/cmd/app/commands/purge_auth_tokens.go +++ /dev/null @@ -1,106 +0,0 @@ -package commands - -import ( - "context" - "encoding/json" - "fmt" - "io" - "log/slog" - - "github.com/allisson/secrets/internal/auth/usecase" - "github.com/allisson/secrets/internal/metrics" -) - -// PurgeAuthTokensResult holds the result of the authentication token purge operation. -type PurgeAuthTokensResult struct { - Count int64 `json:"count"` - Days int `json:"days"` - DryRun bool `json:"dry_run"` -} - -// ToText returns a human-readable representation of the purge result. -func (r *PurgeAuthTokensResult) ToText() string { - if r.DryRun { - return fmt.Sprintf( - "Dry-run mode: Would purge %d expired/revoked authentication token(s) older than %d day(s)", - r.Count, - r.Days, - ) - } - return fmt.Sprintf( - "Successfully purged %d expired/revoked authentication token(s) older than %d day(s)", - r.Count, - r.Days, - ) -} - -// ToJSON returns a JSON representation of the purge result. -func (r *PurgeAuthTokensResult) ToJSON() string { - jsonBytes, _ := json.MarshalIndent(r, "", " ") - return string(jsonBytes) -} - -// RunPurgeAuthTokens deletes expired and revoked authentication tokens older than the specified number of days. -// Supports dry-run mode (if implemented in usecase) and multiple output formats. -func RunPurgeAuthTokens( - ctx context.Context, - tokenUseCase usecase.TokenUseCase, - bm metrics.BusinessMetrics, - logger *slog.Logger, - writer io.Writer, - days int, - dryRun bool, - format string, -) error { - // Validate days parameter - if days < 0 { - return fmt.Errorf("days must be a non-negative number, got: %d", days) - } - - logger.Info("purging authentication tokens", - slog.Int("days", days), - slog.Bool("dry_run", dryRun), - ) - - // Note: dryRun is not yet supported in TokenUseCase.PurgeExpiredAndRevoked - // For now, we will only proceed if dryRun is false or inform that it's not supported. - if dryRun { - result := &PurgeAuthTokensResult{ - Count: 0, - Days: days, - DryRun: dryRun, - } - _, _ = fmt.Fprintln( - writer, - "Notice: Dry-run is not yet implemented for auth token purging. No tokens were deleted.", - ) - WriteOutput(writer, format, result) - return nil - } - - // Execute purge - var count int64 - if err := metrics.Track(ctx, bm, "auth", "token_purge", func() error { - var e error - count, e = tokenUseCase.PurgeExpiredAndRevoked(ctx, days) - return e - }); err != nil { - return fmt.Errorf("failed to purge authentication tokens: %w", err) - } - - // Output result - result := &PurgeAuthTokensResult{ - Count: count, - Days: days, - DryRun: dryRun, - } - WriteOutput(writer, format, result) - - logger.Info("purge completed", - slog.Int64("count", count), - slog.Int("days", days), - slog.Bool("dry_run", dryRun), - ) - - return nil -} diff --git a/cmd/app/commands/purge_auth_tokens_test.go b/cmd/app/commands/purge_auth_tokens_test.go deleted file mode 100644 index ecad0ce..0000000 --- a/cmd/app/commands/purge_auth_tokens_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package commands - -import ( - "bytes" - "context" - "log/slog" - "testing" - - "github.com/stretchr/testify/require" - - usecaseMocks "github.com/allisson/secrets/internal/auth/usecase/mocks" - "github.com/allisson/secrets/internal/metrics" -) - -func TestRunPurgeAuthTokens(t *testing.T) { - ctx := context.Background() - logger := slog.Default() - days := 30 - - t.Run("text-output", func(t *testing.T) { - mockUseCase := usecaseMocks.NewMockTokenUseCase(t) - mockUseCase.EXPECT().PurgeExpiredAndRevoked(ctx, days).Return(int64(100), nil).Once() - - var out bytes.Buffer - err := RunPurgeAuthTokens( - ctx, - mockUseCase, - metrics.NewNopBusinessMetrics(), - logger, - &out, - days, - false, - "text", - ) - - require.NoError(t, err) - require.Contains(t, out.String(), "Successfully purged 100 expired/revoked authentication token(s)") - }) - - t.Run("json-output", func(t *testing.T) { - mockUseCase := usecaseMocks.NewMockTokenUseCase(t) - mockUseCase.EXPECT().PurgeExpiredAndRevoked(ctx, days).Return(int64(50), nil).Once() - - var out bytes.Buffer - err := RunPurgeAuthTokens( - ctx, - mockUseCase, - metrics.NewNopBusinessMetrics(), - logger, - &out, - days, - false, - "json", - ) - - require.NoError(t, err) - require.Contains(t, out.String(), `"count": 50`) - require.Contains(t, out.String(), `"dry_run": false`) - }) - - t.Run("invalid-days", func(t *testing.T) { - mockUseCase := usecaseMocks.NewMockTokenUseCase(t) - err := RunPurgeAuthTokens( - ctx, - mockUseCase, - metrics.NewNopBusinessMetrics(), - logger, - &bytes.Buffer{}, - -1, - false, - "text", - ) - - require.Error(t, err) - require.Contains(t, err.Error(), "days must be a non-negative number") - }) -} diff --git a/cmd/app/commands/purge_secrets.go b/cmd/app/commands/purge_secrets.go deleted file mode 100644 index c23ad4d..0000000 --- a/cmd/app/commands/purge_secrets.go +++ /dev/null @@ -1,86 +0,0 @@ -package commands - -import ( - "context" - "encoding/json" - "fmt" - "io" - "log/slog" - - "github.com/allisson/secrets/internal/metrics" - secretsUseCase "github.com/allisson/secrets/internal/secrets/usecase" -) - -// PurgeSecretsResult holds the result of the secret purge operation. -type PurgeSecretsResult struct { - Count int64 `json:"count"` - Days int `json:"days"` - DryRun bool `json:"dry_run"` -} - -// ToText returns a human-readable representation of the purge result. -func (r *PurgeSecretsResult) ToText() string { - if r.DryRun { - return fmt.Sprintf( - "Dry-run mode: Would delete %d secret(s) older than %d day(s)", - r.Count, - r.Days, - ) - } - return fmt.Sprintf("Successfully deleted %d secret(s) older than %d day(s)", r.Count, r.Days) -} - -// ToJSON returns a JSON representation of the purge result. -func (r *PurgeSecretsResult) ToJSON() string { - jsonBytes, _ := json.MarshalIndent(r, "", " ") - return string(jsonBytes) -} - -// RunPurgeSecrets permanently deletes soft-deleted secrets older than the specified number of days. -// Supports dry-run mode and multiple output formats. -func RunPurgeSecrets( - ctx context.Context, - secretUseCase secretsUseCase.SecretUseCase, - bm metrics.BusinessMetrics, - logger *slog.Logger, - writer io.Writer, - days int, - dryRun bool, - format string, -) error { - // Validate days parameter - if days < 0 { - return fmt.Errorf("days must be a positive number, got: %d", days) - } - - logger.Info("purging deleted secrets", - slog.Int("days", days), - slog.Bool("dry_run", dryRun), - ) - - // Execute purge operation - var count int64 - if err := metrics.Track(ctx, bm, "secrets", "secret_purge_deleted", func() error { - var e error - count, e = secretUseCase.PurgeDeleted(ctx, days, dryRun) - return e - }); err != nil { - return fmt.Errorf("failed to purge secrets: %w", err) - } - - // Output result - result := &PurgeSecretsResult{ - Count: count, - Days: days, - DryRun: dryRun, - } - WriteOutput(writer, format, result) - - logger.Info("purge completed", - slog.Int64("count", count), - slog.Int("days", days), - slog.Bool("dry_run", dryRun), - ) - - return nil -} diff --git a/cmd/app/commands/purge_secrets_test.go b/cmd/app/commands/purge_secrets_test.go deleted file mode 100644 index 0ba5781..0000000 --- a/cmd/app/commands/purge_secrets_test.go +++ /dev/null @@ -1,166 +0,0 @@ -package commands - -import ( - "bytes" - "context" - "log/slog" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/allisson/secrets/internal/metrics" - secretsMocks "github.com/allisson/secrets/internal/secrets/usecase/mocks" -) - -func TestRunPurgeSecrets(t *testing.T) { - ctx := context.Background() - logger := slog.Default() - days := 30 - - t.Run("text-output", func(t *testing.T) { - mockUseCase := &secretsMocks.MockSecretUseCase{} - mockUseCase.On("PurgeDeleted", ctx, days, false).Return(int64(100), nil) - - var out bytes.Buffer - err := RunPurgeSecrets( - ctx, - mockUseCase, - metrics.NewNopBusinessMetrics(), - logger, - &out, - days, - false, - "text", - ) - - require.NoError(t, err) - require.Contains(t, out.String(), "Successfully deleted 100 secret(s) older than 30 day(s)") - mockUseCase.AssertExpectations(t) - }) - - t.Run("text-output-dry-run", func(t *testing.T) { - mockUseCase := &secretsMocks.MockSecretUseCase{} - mockUseCase.On("PurgeDeleted", ctx, days, true).Return(int64(75), nil) - - var out bytes.Buffer - err := RunPurgeSecrets( - ctx, - mockUseCase, - metrics.NewNopBusinessMetrics(), - logger, - &out, - days, - true, - "text", - ) - - require.NoError(t, err) - require.Contains(t, out.String(), "Dry-run mode: Would delete 75 secret(s) older than 30 day(s)") - mockUseCase.AssertExpectations(t) - }) - - t.Run("json-output", func(t *testing.T) { - mockUseCase := &secretsMocks.MockSecretUseCase{} - mockUseCase.On("PurgeDeleted", ctx, days, true).Return(int64(50), nil) - - var out bytes.Buffer - err := RunPurgeSecrets( - ctx, - mockUseCase, - metrics.NewNopBusinessMetrics(), - logger, - &out, - days, - true, - "json", - ) - - require.NoError(t, err) - require.Contains(t, out.String(), `"count": 50`) - require.Contains(t, out.String(), `"days": 30`) - require.Contains(t, out.String(), `"dry_run": true`) - mockUseCase.AssertExpectations(t) - }) - - t.Run("json-output-no-dry-run", func(t *testing.T) { - mockUseCase := &secretsMocks.MockSecretUseCase{} - mockUseCase.On("PurgeDeleted", ctx, days, false).Return(int64(25), nil) - - var out bytes.Buffer - err := RunPurgeSecrets( - ctx, - mockUseCase, - metrics.NewNopBusinessMetrics(), - logger, - &out, - days, - false, - "json", - ) - - require.NoError(t, err) - require.Contains(t, out.String(), `"count": 25`) - require.Contains(t, out.String(), `"days": 30`) - require.Contains(t, out.String(), `"dry_run": false`) - mockUseCase.AssertExpectations(t) - }) - - t.Run("invalid-days-negative", func(t *testing.T) { - mockUseCase := &secretsMocks.MockSecretUseCase{} - err := RunPurgeSecrets( - ctx, - mockUseCase, - metrics.NewNopBusinessMetrics(), - logger, - &bytes.Buffer{}, - -1, - false, - "text", - ) - - require.Error(t, err) - require.Contains(t, err.Error(), "days must be a positive number") - }) - - t.Run("zero-days-allowed", func(t *testing.T) { - mockUseCase := &secretsMocks.MockSecretUseCase{} - mockUseCase.On("PurgeDeleted", ctx, 0, false).Return(int64(10), nil) - - var out bytes.Buffer - err := RunPurgeSecrets( - ctx, - mockUseCase, - metrics.NewNopBusinessMetrics(), - logger, - &out, - 0, - false, - "text", - ) - - require.NoError(t, err) - require.Contains(t, out.String(), "Successfully deleted 10 secret(s) older than 0 day(s)") - mockUseCase.AssertExpectations(t) - }) - - t.Run("no-secrets-to-delete", func(t *testing.T) { - mockUseCase := &secretsMocks.MockSecretUseCase{} - mockUseCase.On("PurgeDeleted", ctx, days, false).Return(int64(0), nil) - - var out bytes.Buffer - err := RunPurgeSecrets( - ctx, - mockUseCase, - metrics.NewNopBusinessMetrics(), - logger, - &out, - days, - false, - "text", - ) - - require.NoError(t, err) - require.Contains(t, out.String(), "Successfully deleted 0 secret(s)") - mockUseCase.AssertExpectations(t) - }) -} diff --git a/cmd/app/commands/purge_tokenization_keys.go b/cmd/app/commands/purge_tokenization_keys.go deleted file mode 100644 index 8f8532f..0000000 --- a/cmd/app/commands/purge_tokenization_keys.go +++ /dev/null @@ -1,90 +0,0 @@ -package commands - -import ( - "context" - "encoding/json" - "fmt" - "io" - "log/slog" - - "github.com/allisson/secrets/internal/metrics" - tokenizationUseCase "github.com/allisson/secrets/internal/tokenization/usecase" -) - -// PurgeTokenizationKeysResult holds the result of the tokenization key purge operation. -type PurgeTokenizationKeysResult struct { - Count int64 `json:"count"` - Days int `json:"days"` - DryRun bool `json:"dry_run"` -} - -// ToText returns a human-readable representation of the purge result. -func (r *PurgeTokenizationKeysResult) ToText() string { - if r.DryRun { - return fmt.Sprintf( - "Dry-run mode: Would delete %d tokenization key(s) (and associated tokens) older than %d day(s)", - r.Count, - r.Days, - ) - } - return fmt.Sprintf( - "Successfully deleted %d tokenization key(s) (and associated tokens) older than %d day(s)", - r.Count, - r.Days, - ) -} - -// ToJSON returns a JSON representation of the purge result. -func (r *PurgeTokenizationKeysResult) ToJSON() string { - jsonBytes, _ := json.MarshalIndent(r, "", " ") - return string(jsonBytes) -} - -// RunPurgeTokenizationKeys permanently deletes soft-deleted tokenization keys and their tokens older than the specified number of days. -// Supports dry-run mode and multiple output formats. -func RunPurgeTokenizationKeys( - ctx context.Context, - tokenizationUseCase tokenizationUseCase.TokenizationKeyUseCase, - bm metrics.BusinessMetrics, - logger *slog.Logger, - writer io.Writer, - days int, - dryRun bool, - format string, -) error { - // Validate days parameter - if days < 0 { - return fmt.Errorf("days must be a positive number, got: %d", days) - } - - logger.Info("purging deleted tokenization keys", - slog.Int("days", days), - slog.Bool("dry_run", dryRun), - ) - - // Execute purge operation - var count int64 - if err := metrics.Track(ctx, bm, "tokenization", "tokenization_key_purge_deleted", func() error { - var e error - count, e = tokenizationUseCase.PurgeDeleted(ctx, days, dryRun) - return e - }); err != nil { - return fmt.Errorf("failed to purge tokenization keys: %w", err) - } - - // Output result - result := &PurgeTokenizationKeysResult{ - Count: count, - Days: days, - DryRun: dryRun, - } - WriteOutput(writer, format, result) - - logger.Info("purge completed", - slog.Int64("count", count), - slog.Int("days", days), - slog.Bool("dry_run", dryRun), - ) - - return nil -} diff --git a/cmd/app/commands/purge_tokenization_keys_test.go b/cmd/app/commands/purge_tokenization_keys_test.go deleted file mode 100644 index 93f74cb..0000000 --- a/cmd/app/commands/purge_tokenization_keys_test.go +++ /dev/null @@ -1,178 +0,0 @@ -package commands - -import ( - "bytes" - "context" - "log/slog" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/allisson/secrets/internal/metrics" - tokenizationMocks "github.com/allisson/secrets/internal/tokenization/usecase/mocks" -) - -func TestRunPurgeTokenizationKeys(t *testing.T) { - ctx := context.Background() - logger := slog.Default() - days := 30 - - t.Run("text-output", func(t *testing.T) { - mockUseCase := &tokenizationMocks.MockTokenizationKeyUseCase{} - mockUseCase.On("PurgeDeleted", ctx, days, false).Return(int64(100), nil) - - var out bytes.Buffer - err := RunPurgeTokenizationKeys( - ctx, - mockUseCase, - metrics.NewNopBusinessMetrics(), - logger, - &out, - days, - false, - "text", - ) - - require.NoError(t, err) - require.Contains( - t, - out.String(), - "Successfully deleted 100 tokenization key(s) (and associated tokens) older than 30 day(s)", - ) - mockUseCase.AssertExpectations(t) - }) - - t.Run("text-output-dry-run", func(t *testing.T) { - mockUseCase := &tokenizationMocks.MockTokenizationKeyUseCase{} - mockUseCase.On("PurgeDeleted", ctx, days, true).Return(int64(75), nil) - - var out bytes.Buffer - err := RunPurgeTokenizationKeys( - ctx, - mockUseCase, - metrics.NewNopBusinessMetrics(), - logger, - &out, - days, - true, - "text", - ) - - require.NoError(t, err) - require.Contains( - t, - out.String(), - "Dry-run mode: Would delete 75 tokenization key(s) (and associated tokens) older than 30 day(s)", - ) - mockUseCase.AssertExpectations(t) - }) - - t.Run("json-output", func(t *testing.T) { - mockUseCase := &tokenizationMocks.MockTokenizationKeyUseCase{} - mockUseCase.On("PurgeDeleted", ctx, days, true).Return(int64(50), nil) - - var out bytes.Buffer - err := RunPurgeTokenizationKeys( - ctx, - mockUseCase, - metrics.NewNopBusinessMetrics(), - logger, - &out, - days, - true, - "json", - ) - - require.NoError(t, err) - require.Contains(t, out.String(), `"count": 50`) - require.Contains(t, out.String(), `"days": 30`) - require.Contains(t, out.String(), `"dry_run": true`) - mockUseCase.AssertExpectations(t) - }) - - t.Run("json-output-no-dry-run", func(t *testing.T) { - mockUseCase := &tokenizationMocks.MockTokenizationKeyUseCase{} - mockUseCase.On("PurgeDeleted", ctx, days, false).Return(int64(25), nil) - - var out bytes.Buffer - err := RunPurgeTokenizationKeys( - ctx, - mockUseCase, - metrics.NewNopBusinessMetrics(), - logger, - &out, - days, - false, - "json", - ) - - require.NoError(t, err) - require.Contains(t, out.String(), `"count": 25`) - require.Contains(t, out.String(), `"days": 30`) - require.Contains(t, out.String(), `"dry_run": false`) - mockUseCase.AssertExpectations(t) - }) - - t.Run("invalid-days-negative", func(t *testing.T) { - mockUseCase := &tokenizationMocks.MockTokenizationKeyUseCase{} - err := RunPurgeTokenizationKeys( - ctx, - mockUseCase, - metrics.NewNopBusinessMetrics(), - logger, - &bytes.Buffer{}, - -1, - false, - "text", - ) - - require.Error(t, err) - require.Contains(t, err.Error(), "days must be a positive number") - }) - - t.Run("zero-days-allowed", func(t *testing.T) { - mockUseCase := &tokenizationMocks.MockTokenizationKeyUseCase{} - mockUseCase.On("PurgeDeleted", ctx, 0, false).Return(int64(10), nil) - - var out bytes.Buffer - err := RunPurgeTokenizationKeys( - ctx, - mockUseCase, - metrics.NewNopBusinessMetrics(), - logger, - &out, - 0, - false, - "text", - ) - - require.NoError(t, err) - require.Contains( - t, - out.String(), - "Successfully deleted 10 tokenization key(s) (and associated tokens) older than 0 day(s)", - ) - mockUseCase.AssertExpectations(t) - }) - - t.Run("no-keys-to-delete", func(t *testing.T) { - mockUseCase := &tokenizationMocks.MockTokenizationKeyUseCase{} - mockUseCase.On("PurgeDeleted", ctx, days, false).Return(int64(0), nil) - - var out bytes.Buffer - err := RunPurgeTokenizationKeys( - ctx, - mockUseCase, - metrics.NewNopBusinessMetrics(), - logger, - &out, - days, - false, - "text", - ) - - require.NoError(t, err) - require.Contains(t, out.String(), "Successfully deleted 0 tokenization key(s)") - mockUseCase.AssertExpectations(t) - }) -} diff --git a/cmd/app/commands/purge_transit_keys.go b/cmd/app/commands/purge_transit_keys.go deleted file mode 100644 index 637df03..0000000 --- a/cmd/app/commands/purge_transit_keys.go +++ /dev/null @@ -1,86 +0,0 @@ -package commands - -import ( - "context" - "encoding/json" - "fmt" - "io" - "log/slog" - - "github.com/allisson/secrets/internal/metrics" - transitUseCase "github.com/allisson/secrets/internal/transit/usecase" -) - -// PurgeTransitKeysResult holds the result of the transit key purge operation. -type PurgeTransitKeysResult struct { - Count int64 `json:"count"` - Days int `json:"days"` - DryRun bool `json:"dry_run"` -} - -// ToText returns a human-readable representation of the purge result. -func (r *PurgeTransitKeysResult) ToText() string { - if r.DryRun { - return fmt.Sprintf( - "Dry-run mode: Would delete %d transit key(s) older than %d day(s)", - r.Count, - r.Days, - ) - } - return fmt.Sprintf("Successfully deleted %d transit key(s) older than %d day(s)", r.Count, r.Days) -} - -// ToJSON returns a JSON representation of the purge result. -func (r *PurgeTransitKeysResult) ToJSON() string { - jsonBytes, _ := json.MarshalIndent(r, "", " ") - return string(jsonBytes) -} - -// RunPurgeTransitKeys permanently deletes soft-deleted transit keys older than the specified number of days. -// Supports dry-run mode and multiple output formats. -func RunPurgeTransitKeys( - ctx context.Context, - transitUseCase transitUseCase.TransitKeyUseCase, - bm metrics.BusinessMetrics, - logger *slog.Logger, - writer io.Writer, - days int, - dryRun bool, - format string, -) error { - // Validate days parameter - if days < 0 { - return fmt.Errorf("days must be a positive number, got: %d", days) - } - - logger.Info("purging deleted transit keys", - slog.Int("days", days), - slog.Bool("dry_run", dryRun), - ) - - // Execute purge operation - var count int64 - if err := metrics.Track(ctx, bm, "transit", "transit_key_purge_deleted", func() error { - var e error - count, e = transitUseCase.PurgeDeleted(ctx, days, dryRun) - return e - }); err != nil { - return fmt.Errorf("failed to purge transit keys: %w", err) - } - - // Output result - result := &PurgeTransitKeysResult{ - Count: count, - Days: days, - DryRun: dryRun, - } - WriteOutput(writer, format, result) - - logger.Info("purge completed", - slog.Int64("count", count), - slog.Int("days", days), - slog.Bool("dry_run", dryRun), - ) - - return nil -} diff --git a/cmd/app/commands/purge_transit_keys_test.go b/cmd/app/commands/purge_transit_keys_test.go deleted file mode 100644 index e6b2945..0000000 --- a/cmd/app/commands/purge_transit_keys_test.go +++ /dev/null @@ -1,166 +0,0 @@ -package commands - -import ( - "bytes" - "context" - "log/slog" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/allisson/secrets/internal/metrics" - transitMocks "github.com/allisson/secrets/internal/transit/usecase/mocks" -) - -func TestRunPurgeTransitKeys(t *testing.T) { - ctx := context.Background() - logger := slog.Default() - days := 30 - - t.Run("text-output", func(t *testing.T) { - mockUseCase := &transitMocks.MockTransitKeyUseCase{} - mockUseCase.On("PurgeDeleted", ctx, days, false).Return(int64(100), nil) - - var out bytes.Buffer - err := RunPurgeTransitKeys( - ctx, - mockUseCase, - metrics.NewNopBusinessMetrics(), - logger, - &out, - days, - false, - "text", - ) - - require.NoError(t, err) - require.Contains(t, out.String(), "Successfully deleted 100 transit key(s) older than 30 day(s)") - mockUseCase.AssertExpectations(t) - }) - - t.Run("text-output-dry-run", func(t *testing.T) { - mockUseCase := &transitMocks.MockTransitKeyUseCase{} - mockUseCase.On("PurgeDeleted", ctx, days, true).Return(int64(75), nil) - - var out bytes.Buffer - err := RunPurgeTransitKeys( - ctx, - mockUseCase, - metrics.NewNopBusinessMetrics(), - logger, - &out, - days, - true, - "text", - ) - - require.NoError(t, err) - require.Contains(t, out.String(), "Dry-run mode: Would delete 75 transit key(s) older than 30 day(s)") - mockUseCase.AssertExpectations(t) - }) - - t.Run("json-output", func(t *testing.T) { - mockUseCase := &transitMocks.MockTransitKeyUseCase{} - mockUseCase.On("PurgeDeleted", ctx, days, true).Return(int64(50), nil) - - var out bytes.Buffer - err := RunPurgeTransitKeys( - ctx, - mockUseCase, - metrics.NewNopBusinessMetrics(), - logger, - &out, - days, - true, - "json", - ) - - require.NoError(t, err) - require.Contains(t, out.String(), `"count": 50`) - require.Contains(t, out.String(), `"days": 30`) - require.Contains(t, out.String(), `"dry_run": true`) - mockUseCase.AssertExpectations(t) - }) - - t.Run("json-output-no-dry-run", func(t *testing.T) { - mockUseCase := &transitMocks.MockTransitKeyUseCase{} - mockUseCase.On("PurgeDeleted", ctx, days, false).Return(int64(25), nil) - - var out bytes.Buffer - err := RunPurgeTransitKeys( - ctx, - mockUseCase, - metrics.NewNopBusinessMetrics(), - logger, - &out, - days, - false, - "json", - ) - - require.NoError(t, err) - require.Contains(t, out.String(), `"count": 25`) - require.Contains(t, out.String(), `"days": 30`) - require.Contains(t, out.String(), `"dry_run": false`) - mockUseCase.AssertExpectations(t) - }) - - t.Run("invalid-days-negative", func(t *testing.T) { - mockUseCase := &transitMocks.MockTransitKeyUseCase{} - err := RunPurgeTransitKeys( - ctx, - mockUseCase, - metrics.NewNopBusinessMetrics(), - logger, - &bytes.Buffer{}, - -1, - false, - "text", - ) - - require.Error(t, err) - require.Contains(t, err.Error(), "days must be a positive number") - }) - - t.Run("zero-days-allowed", func(t *testing.T) { - mockUseCase := &transitMocks.MockTransitKeyUseCase{} - mockUseCase.On("PurgeDeleted", ctx, 0, false).Return(int64(10), nil) - - var out bytes.Buffer - err := RunPurgeTransitKeys( - ctx, - mockUseCase, - metrics.NewNopBusinessMetrics(), - logger, - &out, - 0, - false, - "text", - ) - - require.NoError(t, err) - require.Contains(t, out.String(), "Successfully deleted 10 transit key(s) older than 0 day(s)") - mockUseCase.AssertExpectations(t) - }) - - t.Run("no-keys-to-delete", func(t *testing.T) { - mockUseCase := &transitMocks.MockTransitKeyUseCase{} - mockUseCase.On("PurgeDeleted", ctx, days, false).Return(int64(0), nil) - - var out bytes.Buffer - err := RunPurgeTransitKeys( - ctx, - mockUseCase, - metrics.NewNopBusinessMetrics(), - logger, - &out, - days, - false, - "text", - ) - - require.NoError(t, err) - require.Contains(t, out.String(), "Successfully deleted 0 transit key(s)") - mockUseCase.AssertExpectations(t) - }) -} diff --git a/cmd/app/commands/retention_sweep.go b/cmd/app/commands/retention_sweep.go new file mode 100644 index 0000000..d1b1c25 --- /dev/null +++ b/cmd/app/commands/retention_sweep.go @@ -0,0 +1,141 @@ +package commands + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + + "github.com/allisson/secrets/internal/metrics" +) + +// SweepSpec describes a single retention sweep: a CLI command that deletes +// rows older than a --days threshold. See the "Retention sweep" entry in +// CONTEXT.md. The six sweep commands differ only in the fields below; the +// shared validate -> log -> track -> output machinery lives in +// RunRetentionSweep. +type SweepSpec struct { + // Verb and VerbPast are the action words used in output, e.g. "delete" + // and "deleted", or "purge" and "purged". + Verb string + VerbPast string + // Subject is the noun phrase describing what is swept, e.g. "secret(s)" + // or "expired/revoked authentication token(s)". + Subject string + // MetricModule and MetricOp are the metrics.Track labels for the sweep. + MetricModule string + MetricOp string + // SupportsDryRun is false only for sweeps whose underlying usecase cannot + // count without deleting (auth-token purge). When false and a dry run is + // requested, the sweep emits a notice and deletes nothing. + SupportsDryRun bool + // Sweep deletes rows older than days and returns the affected count. + // dryRun is honored only when SupportsDryRun is true. + Sweep func(ctx context.Context, days int, dryRun bool) (int64, error) +} + +// SweepResult holds the outcome of a retention sweep. The verb/subject fields +// are unexported so JSON output stays {count, days, dry_run}. +type SweepResult struct { + verb string + verbPast string + subject string + + Count int64 `json:"count"` + Days int `json:"days"` + DryRun bool `json:"dry_run"` +} + +// ToText returns a human-readable representation of the sweep result. +func (r *SweepResult) ToText() string { + if r.DryRun { + return fmt.Sprintf( + "Dry-run mode: Would %s %d %s older than %d day(s)", + r.verb, + r.Count, + r.subject, + r.Days, + ) + } + return fmt.Sprintf( + "Successfully %s %d %s older than %d day(s)", + r.verbPast, + r.Count, + r.subject, + r.Days, + ) +} + +// ToJSON returns a JSON representation of the sweep result. +func (r *SweepResult) ToJSON() string { + jsonBytes, _ := json.MarshalIndent(r, "", " ") + return string(jsonBytes) +} + +// RunRetentionSweep executes a retention sweep described by spec: it validates +// days, tracks the sweep under the spec's metric labels, and writes the result +// in the requested format. A dry run against a spec that does not support one +// emits a notice and deletes nothing. +func RunRetentionSweep( + ctx context.Context, + spec SweepSpec, + bm metrics.BusinessMetrics, + logger *slog.Logger, + writer io.Writer, + days int, + dryRun bool, + format string, +) error { + if days < 0 { + return fmt.Errorf("days must be a non-negative number, got: %d", days) + } + + logger.Info("running retention sweep", + slog.String("subject", spec.Subject), + slog.Int("days", days), + slog.Bool("dry_run", dryRun), + ) + + if dryRun && !spec.SupportsDryRun { + _, _ = fmt.Fprintf( + writer, + "Notice: Dry-run is not supported for %s. No rows were deleted.\n", + spec.Subject, + ) + WriteOutput(writer, format, &SweepResult{ + verb: spec.Verb, + verbPast: spec.VerbPast, + subject: spec.Subject, + Days: days, + DryRun: dryRun, + }) + return nil + } + + var count int64 + if err := metrics.Track(ctx, bm, spec.MetricModule, spec.MetricOp, func() error { + var e error + count, e = spec.Sweep(ctx, days, dryRun) + return e + }); err != nil { + return fmt.Errorf("failed to %s %s: %w", spec.Verb, spec.Subject, err) + } + + WriteOutput(writer, format, &SweepResult{ + verb: spec.Verb, + verbPast: spec.VerbPast, + subject: spec.Subject, + Count: count, + Days: days, + DryRun: dryRun, + }) + + logger.Info("retention sweep completed", + slog.Int64("count", count), + slog.Int("days", days), + slog.Bool("dry_run", dryRun), + ) + + return nil +} diff --git a/cmd/app/commands/retention_sweep_test.go b/cmd/app/commands/retention_sweep_test.go new file mode 100644 index 0000000..b346f8c --- /dev/null +++ b/cmd/app/commands/retention_sweep_test.go @@ -0,0 +1,238 @@ +package commands + +import ( + "bytes" + "context" + "errors" + "log/slog" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/allisson/secrets/internal/metrics" +) + +// recordingMetrics captures the (domain, operation, status) labels passed to +// RecordOperation so tests can assert that a sweep was tracked. +type recordingMetrics struct { + operations []recordedOp +} + +type recordedOp struct { + domain, operation, status string +} + +func (m *recordingMetrics) RecordOperation(_ context.Context, domain, operation, status string) { + m.operations = append(m.operations, recordedOp{domain, operation, status}) +} + +func (m *recordingMetrics) RecordDuration(_ context.Context, _, _ string, _ time.Duration, _ string) { +} + +func deleteSecretsSpec(sweep func(context.Context, int, bool) (int64, error)) SweepSpec { + return SweepSpec{ + Verb: "delete", + VerbPast: "deleted", + Subject: "secret(s)", + MetricModule: "secrets", + MetricOp: "secret_purge_deleted", + SupportsDryRun: true, + Sweep: sweep, + } +} + +func TestRunRetentionSweep(t *testing.T) { + ctx := context.Background() + logger := slog.Default() + days := 30 + + t.Run("text-output", func(t *testing.T) { + spec := deleteSecretsSpec(func(_ context.Context, _ int, _ bool) (int64, error) { + return 100, nil + }) + + var out bytes.Buffer + err := RunRetentionSweep( + ctx, + spec, + metrics.NewNopBusinessMetrics(), + logger, + &out, + days, + false, + "text", + ) + + require.NoError(t, err) + require.Contains(t, out.String(), "Successfully deleted 100 secret(s) older than 30 day(s)") + }) + + t.Run("text-output-dry-run", func(t *testing.T) { + spec := deleteSecretsSpec(func(_ context.Context, _ int, dryRun bool) (int64, error) { + require.True(t, dryRun) + return 75, nil + }) + + var out bytes.Buffer + err := RunRetentionSweep(ctx, spec, metrics.NewNopBusinessMetrics(), logger, &out, days, true, "text") + + require.NoError(t, err) + require.Contains(t, out.String(), "Dry-run mode: Would delete 75 secret(s) older than 30 day(s)") + }) + + t.Run("json-output", func(t *testing.T) { + spec := deleteSecretsSpec(func(_ context.Context, _ int, _ bool) (int64, error) { + return 50, nil + }) + + var out bytes.Buffer + err := RunRetentionSweep(ctx, spec, metrics.NewNopBusinessMetrics(), logger, &out, days, true, "json") + + require.NoError(t, err) + require.Contains(t, out.String(), `"count": 50`) + require.Contains(t, out.String(), `"days": 30`) + require.Contains(t, out.String(), `"dry_run": true`) + }) + + t.Run("custom-verb-and-subject", func(t *testing.T) { + // The auth-token sweep purges rather than deletes and carries a richer subject. + spec := SweepSpec{ + Verb: "purge", + VerbPast: "purged", + Subject: "expired/revoked authentication token(s)", + MetricModule: "auth", + MetricOp: "token_purge", + SupportsDryRun: false, + Sweep: func(_ context.Context, _ int, _ bool) (int64, error) { + return 7, nil + }, + } + + var out bytes.Buffer + err := RunRetentionSweep( + ctx, + spec, + metrics.NewNopBusinessMetrics(), + logger, + &out, + days, + false, + "text", + ) + + require.NoError(t, err) + require.Contains(t, + out.String(), + "Successfully purged 7 expired/revoked authentication token(s) older than 30 day(s)", + ) + }) + + t.Run("invalid-days-negative", func(t *testing.T) { + called := false + spec := deleteSecretsSpec(func(_ context.Context, _ int, _ bool) (int64, error) { + called = true + return 0, nil + }) + + err := RunRetentionSweep( + ctx, + spec, + metrics.NewNopBusinessMetrics(), + logger, + &bytes.Buffer{}, + -1, + false, + "text", + ) + + require.Error(t, err) + require.Contains(t, err.Error(), "days must be a non-negative number") + require.False(t, called, "sweep must not run for invalid days") + }) + + t.Run("zero-days-allowed", func(t *testing.T) { + spec := deleteSecretsSpec(func(_ context.Context, d int, _ bool) (int64, error) { + require.Equal(t, 0, d) + return 10, nil + }) + + var out bytes.Buffer + err := RunRetentionSweep(ctx, spec, metrics.NewNopBusinessMetrics(), logger, &out, 0, false, "text") + + require.NoError(t, err) + require.Contains(t, out.String(), "Successfully deleted 10 secret(s) older than 0 day(s)") + }) + + t.Run("sweep-error-wrapped", func(t *testing.T) { + spec := deleteSecretsSpec(func(_ context.Context, _ int, _ bool) (int64, error) { + return 0, errors.New("db down") + }) + + err := RunRetentionSweep( + ctx, + spec, + metrics.NewNopBusinessMetrics(), + logger, + &bytes.Buffer{}, + days, + false, + "text", + ) + + require.Error(t, err) + require.Contains(t, err.Error(), "failed to delete secret(s)") + require.Contains(t, err.Error(), "db down") + }) + + t.Run("dry-run-unsupported-emits-notice-and-skips", func(t *testing.T) { + called := false + spec := SweepSpec{ + Verb: "purge", + VerbPast: "purged", + Subject: "expired/revoked authentication token(s)", + MetricModule: "auth", + MetricOp: "token_purge", + SupportsDryRun: false, + Sweep: func(_ context.Context, _ int, _ bool) (int64, error) { + called = true + return 99, nil + }, + } + + var out bytes.Buffer + err := RunRetentionSweep(ctx, spec, metrics.NewNopBusinessMetrics(), logger, &out, days, true, "text") + + require.NoError(t, err) + require.False(t, called, "sweep must not run on an unsupported dry run") + require.Contains( + t, + out.String(), + "Dry-run is not supported for expired/revoked authentication token(s)", + ) + require.Contains(t, out.String(), "Would purge 0 expired/revoked authentication token(s)") + }) + + t.Run("every-sweep-is-tracked", func(t *testing.T) { + // Includes audit-log cleanup, which previously ran without metrics. + rec := &recordingMetrics{} + spec := SweepSpec{ + Verb: "delete", + VerbPast: "deleted", + Subject: "audit log(s)", + MetricModule: "auth", + MetricOp: "audit_log_clean", + SupportsDryRun: true, + Sweep: func(_ context.Context, _ int, _ bool) (int64, error) { + return 3, nil + }, + } + + err := RunRetentionSweep(ctx, spec, rec, logger, &bytes.Buffer{}, days, false, "text") + + require.NoError(t, err) + require.Equal(t, []recordedOp{{"auth", "audit_log_clean", "success"}}, rec.operations) + }) +} + +var _ metrics.BusinessMetrics = (*recordingMetrics)(nil) diff --git a/cmd/app/system_commands.go b/cmd/app/system_commands.go index b57def7..e66b09e 100644 --- a/cmd/app/system_commands.go +++ b/cmd/app/system_commands.go @@ -92,10 +92,25 @@ func getSystemCommands(version string) []*cli.Command { if err != nil { return err } + bm, err := container.BusinessMetrics(ctx) + if err != nil { + return err + } - return commands.RunCleanAuditLogs( + return commands.RunRetentionSweep( ctx, - auditLogUseCase, + commands.SweepSpec{ + Verb: "delete", + VerbPast: "deleted", + Subject: "audit log(s)", + MetricModule: "auth", + MetricOp: "audit_log_clean", + SupportsDryRun: true, + Sweep: func(c context.Context, days int, dryRun bool) (int64, error) { + return auditLogUseCase.DeleteOlderThan(c, days, dryRun) + }, + }, + bm, container.Logger(), commands.DefaultIO().Writer, int(cmd.Int("days")), @@ -142,9 +157,19 @@ func getSystemCommands(version string) []*cli.Command { return err } - return commands.RunPurgeSecrets( + return commands.RunRetentionSweep( ctx, - secretUseCase, + commands.SweepSpec{ + Verb: "delete", + VerbPast: "deleted", + Subject: "secret(s)", + MetricModule: "secrets", + MetricOp: "secret_purge_deleted", + SupportsDryRun: true, + Sweep: func(c context.Context, days int, dryRun bool) (int64, error) { + return secretUseCase.PurgeDeleted(c, days, dryRun) + }, + }, bm, container.Logger(), commands.DefaultIO().Writer, @@ -192,9 +217,19 @@ func getSystemCommands(version string) []*cli.Command { return err } - return commands.RunPurgeTransitKeys( + return commands.RunRetentionSweep( ctx, - transitUseCase, + commands.SweepSpec{ + Verb: "delete", + VerbPast: "deleted", + Subject: "transit key(s)", + MetricModule: "transit", + MetricOp: "transit_key_purge_deleted", + SupportsDryRun: true, + Sweep: func(c context.Context, days int, dryRun bool) (int64, error) { + return transitUseCase.PurgeDeleted(c, days, dryRun) + }, + }, bm, container.Logger(), commands.DefaultIO().Writer, @@ -242,9 +277,19 @@ func getSystemCommands(version string) []*cli.Command { return err } - return commands.RunPurgeTokenizationKeys( + return commands.RunRetentionSweep( ctx, - tokenizationUseCase, + commands.SweepSpec{ + Verb: "delete", + VerbPast: "deleted", + Subject: "tokenization key(s) (and associated tokens)", + MetricModule: "tokenization", + MetricOp: "tokenization_key_purge_deleted", + SupportsDryRun: true, + Sweep: func(c context.Context, days int, dryRun bool) (int64, error) { + return tokenizationUseCase.PurgeDeleted(c, days, dryRun) + }, + }, bm, container.Logger(), commands.DefaultIO().Writer, From 9744eb1177d84aee8f13a584fd3f0a76263f0066 Mon Sep 17 00:00:00 2001 From: Allisson Azevedo Date: Thu, 28 May 2026 15:44:40 -0300 Subject: [PATCH 2/3] refactor(cli): move policy-prompt module into commands, delete internal/ui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interactive policy-prompt functions (PromptForPolicies, PromptForPoliciesUpdate, ParseCapabilities) lived in internal/ui — an infrastructure package doing terminal I/O and importing auth/domain, yet used only by the create-client and update-client CLI commands. Relocate them to cmd/app/commands (package commands) beside their only callers and delete the now-empty internal/ui package. Pure move: same logic, same tests, same behaviour. The seam now lives where the behaviour does, and the infra→domain import direction is gone. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/app/commands/create_client.go | 3 +-- internal/ui/policies.go => cmd/app/commands/policy_prompt.go | 3 +-- .../policies_test.go => cmd/app/commands/policy_prompt_test.go | 2 +- cmd/app/commands/update_client.go | 3 +-- 4 files changed, 4 insertions(+), 7 deletions(-) rename internal/ui/policies.go => cmd/app/commands/policy_prompt.go (97%) rename internal/ui/policies_test.go => cmd/app/commands/policy_prompt_test.go (99%) diff --git a/cmd/app/commands/create_client.go b/cmd/app/commands/create_client.go index 715e9dd..ff04c43 100644 --- a/cmd/app/commands/create_client.go +++ b/cmd/app/commands/create_client.go @@ -8,7 +8,6 @@ import ( authDomain "github.com/allisson/secrets/internal/auth/domain" authUseCase "github.com/allisson/secrets/internal/auth/usecase" - "github.com/allisson/secrets/internal/ui" ) // CreateClientResult holds the result of the client creation operation. @@ -57,7 +56,7 @@ func RunCreateClient( if policiesJSON == "" { // Interactive mode - policies, err = ui.PromptForPolicies(io.Reader, io.Writer) + policies, err = PromptForPolicies(io.Reader, io.Writer) if err != nil { return fmt.Errorf("failed to get policies: %w", err) } diff --git a/internal/ui/policies.go b/cmd/app/commands/policy_prompt.go similarity index 97% rename from internal/ui/policies.go rename to cmd/app/commands/policy_prompt.go index 087cacd..0843d4e 100644 --- a/internal/ui/policies.go +++ b/cmd/app/commands/policy_prompt.go @@ -1,5 +1,4 @@ -// Package ui provides interactive CLI components and input validation for the application. -package ui +package commands import ( "bufio" diff --git a/internal/ui/policies_test.go b/cmd/app/commands/policy_prompt_test.go similarity index 99% rename from internal/ui/policies_test.go rename to cmd/app/commands/policy_prompt_test.go index 1e9b6ad..58f09b1 100644 --- a/internal/ui/policies_test.go +++ b/cmd/app/commands/policy_prompt_test.go @@ -1,4 +1,4 @@ -package ui +package commands import ( "bytes" diff --git a/cmd/app/commands/update_client.go b/cmd/app/commands/update_client.go index 7f0caa7..71d4e0f 100644 --- a/cmd/app/commands/update_client.go +++ b/cmd/app/commands/update_client.go @@ -10,7 +10,6 @@ import ( authDomain "github.com/allisson/secrets/internal/auth/domain" authUseCase "github.com/allisson/secrets/internal/auth/usecase" - "github.com/allisson/secrets/internal/ui" ) // UpdateClientResult holds the result of the client update operation. @@ -68,7 +67,7 @@ func RunUpdateClient( if policiesJSON == "" { // Interactive mode - show current policies and prompt for new ones - policies, err = ui.PromptForPoliciesUpdate(io.Reader, io.Writer, existingClient.Policies) + policies, err = PromptForPoliciesUpdate(io.Reader, io.Writer, existingClient.Policies) if err != nil { return fmt.Errorf("failed to get policies: %w", err) } From 4ac53f3140fc2a252dcafdba7e4c7f56e9cac4bb Mon Sep 17 00:00:00 2001 From: Allisson Azevedo Date: Thu, 28 May 2026 15:46:21 -0300 Subject: [PATCH 3/3] docs(changelog): note retention-sweep consolidation and audit-log metric fix Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index febd4db..34e1a31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Internal refactors with no API change: consolidated the six retention-sweep CLI commands (`purge-secrets`, `purge-transit-keys`, `purge-tokenization-keys`, `clean-expired-tokens`, `clean-audit-logs`, `purge-auth-tokens`) behind a single `RunRetentionSweep` module, and relocated the interactive policy-prompt helpers from `internal/ui` into the CLI commands package (#141). + +### Fixed + +- `clean-audit-logs` now records operation metrics like the other retention-sweep commands; audit-log cleanup was previously unmetered (#141). + ## [0.29.0] - 2026-05-27 ### Removed