Go · 7269 bytes Raw Blame History
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