package secret_test import ( "context" "fmt" "io" "log/slog" "os" "testing" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" "git.fabledsword.com/bvandeusen/minstrel/internal/secret" ) func newPool(t *testing.T) *pgxpool.Pool { t.Helper() if testing.Short() { t.Skip("skipping integration test in -short mode") } dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") if dsn == "" { t.Skip("MINSTREL_TEST_DATABASE_URL not set") } if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { t.Fatalf("migrate: %v", err) } pool, err := pgxpool.New(context.Background(), dsn) if err != nil { t.Fatalf("pool: %v", err) } t.Cleanup(pool.Close) dbtest.ResetDB(t, pool) return pool } // Every listed column exists, so a renamed or dropped column fails here // rather than leaving a credential unsealed. func TestColumnsExistInTheSchema_Integration(t *testing.T) { pool := newPool(t) for _, c := range secret.Columns { var n int err := pool.QueryRow(context.Background(), `SELECT count(*) FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 AND column_name IN ($2, $3)`, c.Table, c.Column, c.KeyColumn).Scan(&n) if err != nil || n != 2 { t.Errorf("%s (key %s): found %d of 2 columns, err %v", c.Purpose(), c.KeyColumn, n, err) } } } // A database written before sealing existed is sealed in place at startup, // every value opens to what it held, and a second pass has nothing to do. func TestSealStoredSealsWhatIsInTheClear_Integration(t *testing.T) { pool := newPool(t) ctx := context.Background() q := dbq.New(pool) u, err := q.CreateUser(ctx, dbq.CreateUserParams{ Username: dbtest.TestUserPrefix + "sealer", PasswordHash: "x", ApiTokenHash: "sealer", IsAdmin: false, }) if err != nil { t.Fatalf("user: %v", err) } if _, err := q.CreateAcquisitionService(ctx, dbq.CreateAcquisitionServiceParams{ Code: "seal-test", Kind: "search", Protocol: "prowlarr", Label: "Seal test", Category: "", TimeoutMs: 1000, SearchesPerHour: 0, }); err != nil { t.Fatalf("acquisition row: %v", err) } seed := []string{ `UPDATE acquisition_services SET api_key = 'acq-key', password = 'acq-pw' WHERE code = 'seal-test'`, `UPDATE acoustid_settings SET api_key = 'acoustid-key' WHERE id = true`, `UPDATE smtp_config SET password = 'smtp-pw' WHERE id = true`, fmt.Sprintf(`UPDATE users SET listenbrainz_token = 'lb-token', subsonic_password = 'sub-pw' WHERE id = '%s'`, u.ID.String()), `INSERT INTO cover_art_provider_settings (provider_id, enabled, display_order, api_key) VALUES ('seal-test', false, 99, 'cover-key') ON CONFLICT (provider_id) DO UPDATE SET api_key = EXCLUDED.api_key`, `INSERT INTO tag_provider_settings (provider_id, enabled, display_order, api_key) VALUES ('seal-test', false, 99, 'tag-key') ON CONFLICT (provider_id) DO UPDATE SET api_key = EXCLUDED.api_key`, `UPDATE backup_settings SET secret_key = 'bucket-secret' WHERE id = true`, } for _, s := range seed { if _, err := pool.Exec(ctx, s); err != nil { t.Fatalf("seed %q: %v", s, err) } } t.Cleanup(func() { for _, s := range []string{ `DELETE FROM cover_art_provider_settings WHERE provider_id = 'seal-test'`, `DELETE FROM tag_provider_settings WHERE provider_id = 'seal-test'`, `DELETE FROM acquisition_services WHERE code = 'seal-test'`, `UPDATE acoustid_settings SET api_key = NULL WHERE id = true`, `UPDATE smtp_config SET password = '' WHERE id = true`, `UPDATE backup_settings SET secret_key = '' WHERE id = true`, } { _, _ = pool.Exec(context.Background(), s) } }) want := []struct { col secret.Column key string plain string }{ {secret.AcquisitionAPIKey, "seal-test", "acq-key"}, {secret.AcquisitionPassword, "seal-test", "acq-pw"}, {secret.AcoustIDAPIKey, "true", "acoustid-key"}, {secret.SMTPPassword, "true", "smtp-pw"}, {secret.ListenBrainzToken, u.ID.String(), "lb-token"}, {secret.SubsonicPassword, u.ID.String(), "sub-pw"}, {secret.CoverArtAPIKey, "seal-test", "cover-key"}, {secret.TagProviderAPIKey, "seal-test", "tag-key"}, {secret.BackupSecretKey, "true", "bucket-secret"}, } if len(want) != len(secret.Columns) { t.Fatalf("the test covers %d columns, secret.Columns lists %d", len(want), len(secret.Columns)) } n, err := secret.SealStored(ctx, pool) if err != nil { t.Fatalf("SealStored: %v", err) } if n < len(want) { t.Errorf("sealed %d values, want at least %d", n, len(want)) } for _, w := range want { var stored string query := fmt.Sprintf(`SELECT %s FROM %s WHERE %s::text = $1`, pgx.Identifier{w.col.Column}.Sanitize(), pgx.Identifier{w.col.Table}.Sanitize(), pgx.Identifier{w.col.KeyColumn}.Sanitize()) if err := pool.QueryRow(ctx, query, w.key).Scan(&stored); err != nil { t.Errorf("%s: read: %v", w.col.Purpose(), err) continue } if !secret.IsSealed(stored) { t.Errorf("%s = %q, want it sealed", w.col.Purpose(), stored) continue } got, err := secret.Open(w.col.Purpose(), w.col.Owner(w.key), stored) if err != nil || got != w.plain { t.Errorf("%s opens to %q (err %v), want %q", w.col.Purpose(), got, err, w.plain) } } again, err := secret.SealStored(ctx, pool) if err != nil || again != 0 { t.Errorf("second pass sealed %d (err %v), want 0", again, err) } }