tenseleyflow/shithub / a2030db

Browse files

actions/secrets: store test suite — 10 cases covering encryption + scope + citext (S41c)

- TestSet_RoundTripsThroughSecretbox: set → get → plaintext matches.
- TestSet_OverwriteOnSameName: UPSERT semantics.
- TestSet_InvalidNameRejected: regex enforcement (5 bad names).
- TestSet_EmptyValueRejected: nil/empty plaintext.
- TestSet_InvalidScopeRejected: zero AND both-set scope.
- TestList_NamesAndMetadataOnly: load-bearing — listing has no
plaintext or ciphertext exposed; the public surface can't leak.
- TestDelete_RemovesRow + TestDelete_MissingIsIdempotent.
- TestGet_CitextNameIsCaseInsensitive: pins citext semantics.
- TestCiphertext_IsActuallyEncryptedInDB: the spec called this out
explicitly. Reads the bytea column directly via SQL and asserts
the plaintext substring doesn't appear anywhere — would catch a
silent regression to plaintext-storage.
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
a2030db0275e169ed93dc15c7869c0f701249276
Parents
5d52b2e
Tree
5e4a49e

1 changed file

StatusFile+-
A internal/actions/secrets/store_test.go 246 0
internal/actions/secrets/store_test.goadded
@@ -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
+}