| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | |
| 3 | package secrets_test |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "crypto/rand" |
| 8 | "encoding/base64" |
| 9 | "errors" |
| 10 | "io" |
| 11 | "log/slog" |
| 12 | "testing" |
| 13 | |
| 14 | "github.com/jackc/pgx/v5/pgtype" |
| 15 | |
| 16 | "github.com/tenseleyFlow/shithub/internal/actions/secrets" |
| 17 | "github.com/tenseleyFlow/shithub/internal/auth/secretbox" |
| 18 | reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc" |
| 19 | "github.com/tenseleyFlow/shithub/internal/testing/dbtest" |
| 20 | usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc" |
| 21 | ) |
| 22 | |
| 23 | const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" + |
| 24 | "AAAAAAAAAAAAAAAA$" + |
| 25 | "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" |
| 26 | |
| 27 | // freshKey returns a random base64 32-byte key suitable for |
| 28 | // secretbox.FromBase64. We mint one per-test so test failures don't |
| 29 | // contaminate the next run's key material. |
| 30 | func freshKey(t *testing.T) string { |
| 31 | t.Helper() |
| 32 | b := make([]byte, 32) |
| 33 | if _, err := rand.Read(b); err != nil { |
| 34 | t.Fatalf("rand: %v", err) |
| 35 | } |
| 36 | return base64.StdEncoding.EncodeToString(b) |
| 37 | } |
| 38 | |
| 39 | type fx struct { |
| 40 | deps secrets.Deps |
| 41 | repoID int64 |
| 42 | userID int64 |
| 43 | } |
| 44 | |
| 45 | func setup(t *testing.T) fx { |
| 46 | t.Helper() |
| 47 | pool := dbtest.NewTestDB(t) |
| 48 | ctx := context.Background() |
| 49 | |
| 50 | user, err := usersdb.New().CreateUser(ctx, pool, usersdb.CreateUserParams{ |
| 51 | Username: "alice", DisplayName: "Alice", PasswordHash: fixtureHash, |
| 52 | }) |
| 53 | if err != nil { |
| 54 | t.Fatalf("CreateUser: %v", err) |
| 55 | } |
| 56 | repo, err := reposdb.New().CreateRepo(ctx, pool, reposdb.CreateRepoParams{ |
| 57 | OwnerUserID: pgtype.Int8{Int64: user.ID, Valid: true}, |
| 58 | Name: "demo", |
| 59 | DefaultBranch: "trunk", |
| 60 | Visibility: reposdb.RepoVisibilityPublic, |
| 61 | }) |
| 62 | if err != nil { |
| 63 | t.Fatalf("CreateRepo: %v", err) |
| 64 | } |
| 65 | box, err := secretbox.FromBase64(freshKey(t)) |
| 66 | if err != nil { |
| 67 | t.Fatalf("secretbox: %v", err) |
| 68 | } |
| 69 | return fx{ |
| 70 | deps: secrets.Deps{ |
| 71 | Pool: pool, |
| 72 | Box: box, |
| 73 | Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), |
| 74 | }, |
| 75 | repoID: repo.ID, |
| 76 | userID: user.ID, |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | func TestSet_RoundTripsThroughSecretbox(t *testing.T) { |
| 81 | f := setup(t) |
| 82 | ctx := context.Background() |
| 83 | scope := secrets.RepoScope(f.repoID) |
| 84 | if err := f.deps.Set(ctx, scope, "MY_SECRET", []byte("hunter2"), f.userID); err != nil { |
| 85 | t.Fatalf("Set: %v", err) |
| 86 | } |
| 87 | plain, err := f.deps.Get(ctx, scope, "MY_SECRET") |
| 88 | if err != nil { |
| 89 | t.Fatalf("Get: %v", err) |
| 90 | } |
| 91 | if string(plain) != "hunter2" { |
| 92 | t.Errorf("got %q want hunter2", string(plain)) |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | func TestSet_OverwriteOnSameName(t *testing.T) { |
| 97 | // Set twice with the same name → second value wins (UPSERT). |
| 98 | f := setup(t) |
| 99 | ctx := context.Background() |
| 100 | scope := secrets.RepoScope(f.repoID) |
| 101 | if err := f.deps.Set(ctx, scope, "API_KEY", []byte("v1"), f.userID); err != nil { |
| 102 | t.Fatalf("Set v1: %v", err) |
| 103 | } |
| 104 | if err := f.deps.Set(ctx, scope, "API_KEY", []byte("v2-rotated"), f.userID); err != nil { |
| 105 | t.Fatalf("Set v2: %v", err) |
| 106 | } |
| 107 | plain, err := f.deps.Get(ctx, scope, "API_KEY") |
| 108 | if err != nil { |
| 109 | t.Fatalf("Get: %v", err) |
| 110 | } |
| 111 | if string(plain) != "v2-rotated" { |
| 112 | t.Errorf("got %q want v2-rotated", string(plain)) |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | func TestSet_InvalidNameRejected(t *testing.T) { |
| 117 | f := setup(t) |
| 118 | ctx := context.Background() |
| 119 | scope := secrets.RepoScope(f.repoID) |
| 120 | for _, name := range []string{"", "1leading-digit", "has space", "has-dash", "café"} { |
| 121 | if err := f.deps.Set(ctx, scope, name, []byte("v"), f.userID); !errors.Is(err, secrets.ErrInvalidName) { |
| 122 | t.Errorf("Set name=%q: expected ErrInvalidName, got %v", name, err) |
| 123 | } |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | func TestSet_EmptyValueRejected(t *testing.T) { |
| 128 | f := setup(t) |
| 129 | ctx := context.Background() |
| 130 | scope := secrets.RepoScope(f.repoID) |
| 131 | if err := f.deps.Set(ctx, scope, "OK", nil, f.userID); !errors.Is(err, secrets.ErrEmptyValue) { |
| 132 | t.Errorf("Set empty: expected ErrEmptyValue, got %v", err) |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | func TestSet_InvalidScopeRejected(t *testing.T) { |
| 137 | f := setup(t) |
| 138 | ctx := context.Background() |
| 139 | for _, sc := range []secrets.Scope{ |
| 140 | {}, // both zero |
| 141 | {RepoID: 1, OrgID: 2}, // both set |
| 142 | } { |
| 143 | if err := f.deps.Set(ctx, sc, "K", []byte("v"), 0); !errors.Is(err, secrets.ErrInvalidScope) { |
| 144 | t.Errorf("Set scope=%+v: expected ErrInvalidScope, got %v", sc, err) |
| 145 | } |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | func TestList_NamesAndMetadataOnly(t *testing.T) { |
| 150 | // Pin the security-load-bearing invariant: List returns no plaintext |
| 151 | // or ciphertext. The web UI consumes Meta; the leak surface should be |
| 152 | // zero by construction. |
| 153 | f := setup(t) |
| 154 | ctx := context.Background() |
| 155 | scope := secrets.RepoScope(f.repoID) |
| 156 | for _, n := range []string{"AAA", "BBB", "ccc"} { |
| 157 | if err := f.deps.Set(ctx, scope, n, []byte("v-"+n), f.userID); err != nil { |
| 158 | t.Fatalf("Set %s: %v", n, err) |
| 159 | } |
| 160 | } |
| 161 | metas, err := f.deps.List(ctx, scope) |
| 162 | if err != nil { |
| 163 | t.Fatalf("List: %v", err) |
| 164 | } |
| 165 | if len(metas) != 3 { |
| 166 | t.Fatalf("got %d metas, want 3", len(metas)) |
| 167 | } |
| 168 | // Sorted alphabetical (case-insensitive citext): AAA < BBB < ccc. |
| 169 | want := []string{"AAA", "BBB", "ccc"} |
| 170 | for i, w := range want { |
| 171 | if metas[i].Name != w { |
| 172 | t.Errorf("metas[%d].Name = %q, want %q", i, metas[i].Name, w) |
| 173 | } |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | func TestDelete_RemovesRow(t *testing.T) { |
| 178 | f := setup(t) |
| 179 | ctx := context.Background() |
| 180 | scope := secrets.RepoScope(f.repoID) |
| 181 | if err := f.deps.Set(ctx, scope, "TOKEN", []byte("v"), f.userID); err != nil { |
| 182 | t.Fatalf("Set: %v", err) |
| 183 | } |
| 184 | if err := f.deps.Delete(ctx, scope, "TOKEN"); err != nil { |
| 185 | t.Fatalf("Delete: %v", err) |
| 186 | } |
| 187 | if _, err := f.deps.Get(ctx, scope, "TOKEN"); !errors.Is(err, secrets.ErrNotFound) { |
| 188 | t.Errorf("Get after Delete: expected ErrNotFound, got %v", err) |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | func TestDelete_MissingIsIdempotent(t *testing.T) { |
| 193 | // DELETE WHERE doesn't error on zero rows; the store keeps that |
| 194 | // surface so callers can call Delete blindly during cleanup. |
| 195 | f := setup(t) |
| 196 | ctx := context.Background() |
| 197 | scope := secrets.RepoScope(f.repoID) |
| 198 | if err := f.deps.Delete(ctx, scope, "NEVER_EXISTED"); err != nil { |
| 199 | t.Errorf("Delete missing: expected nil, got %v", err) |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | func TestGet_CitextNameIsCaseInsensitive(t *testing.T) { |
| 204 | // citext column means MY_SECRET and my_secret collide. Pin it. |
| 205 | f := setup(t) |
| 206 | ctx := context.Background() |
| 207 | scope := secrets.RepoScope(f.repoID) |
| 208 | if err := f.deps.Set(ctx, scope, "MY_TOKEN", []byte("v"), f.userID); err != nil { |
| 209 | t.Fatalf("Set: %v", err) |
| 210 | } |
| 211 | plain, err := f.deps.Get(ctx, scope, "my_token") |
| 212 | if err != nil { |
| 213 | t.Fatalf("Get lowercased: %v", err) |
| 214 | } |
| 215 | if string(plain) != "v" { |
| 216 | t.Errorf("got %q want v", string(plain)) |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | // TestCiphertext_IsActuallyEncryptedInDB is the load-bearing pin |
| 221 | // the spec called out: verify via psql that the ciphertext column |
| 222 | // is bytea, not plaintext. |
| 223 | func TestCiphertext_IsActuallyEncryptedInDB(t *testing.T) { |
| 224 | f := setup(t) |
| 225 | ctx := context.Background() |
| 226 | scope := secrets.RepoScope(f.repoID) |
| 227 | plaintext := []byte("the-quick-brown-fox") |
| 228 | if err := f.deps.Set(ctx, scope, "RAW_PROBE", plaintext, f.userID); err != nil { |
| 229 | t.Fatalf("Set: %v", err) |
| 230 | } |
| 231 | var ct []byte |
| 232 | if err := f.deps.Pool.QueryRow(ctx, |
| 233 | `SELECT ciphertext FROM workflow_secrets WHERE repo_id = $1 AND name = $2`, |
| 234 | f.repoID, "RAW_PROBE").Scan(&ct); err != nil { |
| 235 | t.Fatalf("query: %v", err) |
| 236 | } |
| 237 | if len(ct) == 0 { |
| 238 | t.Fatal("ciphertext is empty") |
| 239 | } |
| 240 | for i := 0; i+len(plaintext) <= len(ct); i++ { |
| 241 | if string(ct[i:i+len(plaintext)]) == string(plaintext) { |
| 242 | t.Fatal("plaintext appears verbatim in ciphertext bytea — encryption broken") |
| 243 | } |
| 244 | } |
| 245 | } |
| 246 |