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