tenseleyflow/shithub / a9b76c0

Browse files

api/actions_secrets+variables: integration tests

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
a9b76c02717d21398b65b3dbaad5969ca041577f
Parents
57152ef
Tree
ad2e58a

2 changed files

StatusFile+-
A internal/web/handlers/api/actions_secrets_test.go 287 0
A internal/web/handlers/api/actions_variables_test.go 111 0
internal/web/handlers/api/actions_secrets_test.goadded
@@ -0,0 +1,287 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package api_test
4
+
5
+import (
6
+	"bytes"
7
+	"context"
8
+	"crypto/rand"
9
+	"encoding/base64"
10
+	"encoding/json"
11
+	"io"
12
+	"log/slog"
13
+	"net/http"
14
+	"net/http/httptest"
15
+	"testing"
16
+
17
+	"github.com/go-chi/chi/v5"
18
+	"github.com/jackc/pgx/v5/pgtype"
19
+	"github.com/jackc/pgx/v5/pgxpool"
20
+	"golang.org/x/crypto/nacl/box"
21
+
22
+	"github.com/tenseleyFlow/shithub/internal/actions/secrets"
23
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
24
+	"github.com/tenseleyFlow/shithub/internal/auth/pat"
25
+	"github.com/tenseleyFlow/shithub/internal/auth/sealbox"
26
+	"github.com/tenseleyFlow/shithub/internal/auth/secretbox"
27
+	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
28
+	"github.com/tenseleyFlow/shithub/internal/infra/storage"
29
+	"github.com/tenseleyFlow/shithub/internal/ratelimit"
30
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
31
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
32
+	apih "github.com/tenseleyFlow/shithub/internal/web/handlers/api"
33
+	"github.com/tenseleyFlow/shithub/internal/web/handlers/api/apilimit"
34
+)
35
+
36
+type apiSecretsPublicKey struct {
37
+	KeyID string `json:"key_id"`
38
+	Key   string `json:"key"`
39
+}
40
+
41
+// secretsTestEnv stands up an api router with both the storage AEAD
42
+// box and the sealed-box keypair wired in. The returned `secretBox`
43
+// is the storage AEAD, exposed so tests can decrypt directly and
44
+// assert the round-trip lands the actual plaintext on disk.
45
+type secretsTestEnv struct {
46
+	pool      *pgxpool.Pool
47
+	router    http.Handler
48
+	secretBox *secretbox.Box
49
+	repoID    int64
50
+	userID    int64
51
+	owner     string
52
+	repoName  string
53
+	tokenRO   string
54
+	tokenRW   string
55
+}
56
+
57
+func newSecretsTestEnv(t *testing.T) *secretsTestEnv {
58
+	t.Helper()
59
+	pool := dbtest.NewTestDB(t)
60
+	rfs, err := storage.NewRepoFS(t.TempDir())
61
+	if err != nil {
62
+		t.Fatalf("NewRepoFS: %v", err)
63
+	}
64
+	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
65
+
66
+	storageKey, err := secretbox.GenerateKey()
67
+	if err != nil {
68
+		t.Fatalf("secretbox key: %v", err)
69
+	}
70
+	sBox, err := secretbox.FromBytes(storageKey)
71
+	if err != nil {
72
+		t.Fatalf("secretbox: %v", err)
73
+	}
74
+	pkBox, err := sealbox.New()
75
+	if err != nil {
76
+		t.Fatalf("sealbox: %v", err)
77
+	}
78
+
79
+	h, err := apih.New(apih.Deps{
80
+		Pool:        pool,
81
+		Logger:      logger,
82
+		RepoFS:      rfs,
83
+		SecretBox:   sBox,
84
+		SecretsBox:  pkBox,
85
+		Audit:       audit.NewRecorder(),
86
+		Throttle:    throttle.NewLimiter(),
87
+		RateLimiter: ratelimit.New(pool),
88
+		BaseURL:     "https://shithub.test",
89
+		APILimit: apilimit.Config{
90
+			AuthedPerHour: 5000,
91
+			AnonPerHour:   60,
92
+			Logger:        logger,
93
+		},
94
+	})
95
+	if err != nil {
96
+		t.Fatalf("apih.New: %v", err)
97
+	}
98
+	r := chi.NewRouter()
99
+	h.Mount(r)
100
+
101
+	userID := seedRepoCreatorUser(t, pool, "alice")
102
+	owner, repoName := "alice", "demo"
103
+	row, err := reposdb.New().CreateRepo(context.Background(), pool, reposdb.CreateRepoParams{
104
+		Name:          repoName,
105
+		OwnerUserID:   pgtype.Int8{Int64: userID, Valid: true},
106
+		Visibility:    reposdb.RepoVisibilityPublic,
107
+		DefaultBranch: "trunk",
108
+	})
109
+	if err != nil {
110
+		t.Fatalf("CreateRepo: %v", err)
111
+	}
112
+	return &secretsTestEnv{
113
+		pool:      pool,
114
+		router:    r,
115
+		secretBox: sBox,
116
+		repoID:    row.ID,
117
+		userID:    userID,
118
+		owner:     owner,
119
+		repoName:  repoName,
120
+		tokenRO:   mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeRepoRead)),
121
+		tokenRW:   mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeRepoWrite)),
122
+	}
123
+}
124
+
125
+func TestActionsSecrets_PublicKeyEndpoint(t *testing.T) {
126
+	env := newSecretsTestEnv(t)
127
+
128
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/actions/secrets/public-key", nil)
129
+	req.Header.Set("Authorization", "Bearer "+env.tokenRO)
130
+	rr := httptest.NewRecorder()
131
+	env.router.ServeHTTP(rr, req)
132
+	if rr.Code != http.StatusOK {
133
+		t.Fatalf("status: got %d; body=%s", rr.Code, rr.Body.String())
134
+	}
135
+	var got apiSecretsPublicKey
136
+	if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
137
+		t.Fatalf("decode: %v", err)
138
+	}
139
+	if got.KeyID == "" || got.Key == "" {
140
+		t.Errorf("missing fields: %+v", got)
141
+	}
142
+	// Public key must base64-decode to 32 bytes.
143
+	raw, err := base64.StdEncoding.DecodeString(got.Key)
144
+	if err != nil {
145
+		t.Fatalf("decode key: %v", err)
146
+	}
147
+	if len(raw) != 32 {
148
+		t.Errorf("key length: got %d, want 32", len(raw))
149
+	}
150
+}
151
+
152
+func TestActionsSecrets_PutGetDeleteRoundTrip(t *testing.T) {
153
+	env := newSecretsTestEnv(t)
154
+
155
+	pubReq := httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/actions/secrets/public-key", nil)
156
+	pubReq.Header.Set("Authorization", "Bearer "+env.tokenRO)
157
+	pubRR := httptest.NewRecorder()
158
+	env.router.ServeHTTP(pubRR, pubReq)
159
+	var pk apiSecretsPublicKey
160
+	_ = json.Unmarshal(pubRR.Body.Bytes(), &pk)
161
+
162
+	pubBytes, _ := base64.StdEncoding.DecodeString(pk.Key)
163
+	var pubKey [32]byte
164
+	copy(pubKey[:], pubBytes)
165
+
166
+	plaintext := []byte("supersecret-value")
167
+	sealed, err := box.SealAnonymous(nil, plaintext, &pubKey, rand.Reader)
168
+	if err != nil {
169
+		t.Fatalf("SealAnonymous: %v", err)
170
+	}
171
+	body, _ := json.Marshal(map[string]string{
172
+		"encrypted_value": base64.StdEncoding.EncodeToString(sealed),
173
+		"key_id":          pk.KeyID,
174
+	})
175
+
176
+	putReq := httptest.NewRequest(http.MethodPut, "/api/v1/repos/alice/demo/actions/secrets/MY_TOKEN", bytes.NewReader(body))
177
+	putReq.Header.Set("Authorization", "Bearer "+env.tokenRW)
178
+	putReq.Header.Set("Content-Type", "application/json")
179
+	putRR := httptest.NewRecorder()
180
+	env.router.ServeHTTP(putRR, putReq)
181
+	if putRR.Code != http.StatusNoContent {
182
+		t.Fatalf("PUT status: got %d; body=%s", putRR.Code, putRR.Body.String())
183
+	}
184
+
185
+	listReq := httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/actions/secrets", nil)
186
+	listReq.Header.Set("Authorization", "Bearer "+env.tokenRO)
187
+	listRR := httptest.NewRecorder()
188
+	env.router.ServeHTTP(listRR, listReq)
189
+	if listRR.Code != http.StatusOK {
190
+		t.Fatalf("LIST status: got %d; body=%s", listRR.Code, listRR.Body.String())
191
+	}
192
+	var listed []map[string]any
193
+	_ = json.Unmarshal(listRR.Body.Bytes(), &listed)
194
+	if len(listed) != 1 {
195
+		t.Fatalf("expected 1 secret; got %+v", listed)
196
+	}
197
+	if name, _ := listed[0]["name"].(string); name != "MY_TOKEN" {
198
+		t.Errorf("secret name: %+v", listed[0])
199
+	}
200
+	if _, leaked := listed[0]["value"]; leaked {
201
+		t.Errorf("plaintext leaked into list response: %+v", listed[0])
202
+	}
203
+
204
+	// Runner-side decryption confirms the round-trip lands the actual
205
+	// plaintext at rest in workflow_secrets.
206
+	dec, err := secrets.Deps{Pool: env.pool, Box: env.secretBox}.Get(
207
+		context.Background(), secrets.RepoScope(env.repoID), "MY_TOKEN")
208
+	if err != nil {
209
+		t.Fatalf("orchestrator Get: %v", err)
210
+	}
211
+	if string(dec) != string(plaintext) {
212
+		t.Errorf("plaintext round-trip: got %q, want %q", dec, plaintext)
213
+	}
214
+
215
+	delReq := httptest.NewRequest(http.MethodDelete, "/api/v1/repos/alice/demo/actions/secrets/MY_TOKEN", nil)
216
+	delReq.Header.Set("Authorization", "Bearer "+env.tokenRW)
217
+	delRR := httptest.NewRecorder()
218
+	env.router.ServeHTTP(delRR, delReq)
219
+	if delRR.Code != http.StatusNoContent {
220
+		t.Fatalf("DELETE status: got %d; body=%s", delRR.Code, delRR.Body.String())
221
+	}
222
+}
223
+
224
+func TestActionsSecrets_StaleKeyIDRejected(t *testing.T) {
225
+	env := newSecretsTestEnv(t)
226
+	writeToken := env.tokenRW
227
+	router := env.router
228
+
229
+	body, _ := json.Marshal(map[string]string{
230
+		"encrypted_value": base64.StdEncoding.EncodeToString([]byte("doesnt-matter")),
231
+		"key_id":          "stale-key-id-1234",
232
+	})
233
+	req := httptest.NewRequest(http.MethodPut, "/api/v1/repos/alice/demo/actions/secrets/X", bytes.NewReader(body))
234
+	req.Header.Set("Authorization", "Bearer "+writeToken)
235
+	req.Header.Set("Content-Type", "application/json")
236
+	rr := httptest.NewRecorder()
237
+	router.ServeHTTP(rr, req)
238
+	if rr.Code != http.StatusUnprocessableEntity {
239
+		t.Fatalf("status: got %d, want 422; body=%s", rr.Code, rr.Body.String())
240
+	}
241
+}
242
+
243
+func TestActionsSecrets_PutRejectsBadCiphertext(t *testing.T) {
244
+	env := newSecretsTestEnv(t)
245
+	writeToken := env.tokenRW
246
+	router := env.router
247
+
248
+	body, _ := json.Marshal(map[string]string{
249
+		"encrypted_value": base64.StdEncoding.EncodeToString([]byte("too-short")),
250
+	})
251
+	req := httptest.NewRequest(http.MethodPut, "/api/v1/repos/alice/demo/actions/secrets/X", bytes.NewReader(body))
252
+	req.Header.Set("Authorization", "Bearer "+writeToken)
253
+	req.Header.Set("Content-Type", "application/json")
254
+	rr := httptest.NewRecorder()
255
+	router.ServeHTTP(rr, req)
256
+	if rr.Code != http.StatusUnprocessableEntity {
257
+		t.Fatalf("status: got %d, want 422; body=%s", rr.Code, rr.Body.String())
258
+	}
259
+}
260
+
261
+func TestActionsSecrets_GetUnknown404(t *testing.T) {
262
+	env := newSecretsTestEnv(t)
263
+	token := env.tokenRO
264
+	router := env.router
265
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/actions/secrets/MISSING", nil)
266
+	req.Header.Set("Authorization", "Bearer "+token)
267
+	rr := httptest.NewRecorder()
268
+	router.ServeHTTP(rr, req)
269
+	if rr.Code != http.StatusNotFound {
270
+		t.Fatalf("status: got %d, want 404; body=%s", rr.Code, rr.Body.String())
271
+	}
272
+}
273
+
274
+func TestActionsSecrets_PutRequiresRepoWrite(t *testing.T) {
275
+	env := newSecretsTestEnv(t)
276
+	token := env.tokenRO
277
+	router := env.router
278
+	body, _ := json.Marshal(map[string]string{"encrypted_value": base64.StdEncoding.EncodeToString([]byte("xx"))})
279
+	req := httptest.NewRequest(http.MethodPut, "/api/v1/repos/alice/demo/actions/secrets/X", bytes.NewReader(body))
280
+	req.Header.Set("Authorization", "Bearer "+token) // repo:read only
281
+	req.Header.Set("Content-Type", "application/json")
282
+	rr := httptest.NewRecorder()
283
+	router.ServeHTTP(rr, req)
284
+	if rr.Code != http.StatusForbidden {
285
+		t.Fatalf("status: got %d, want 403; body=%s", rr.Code, rr.Body.String())
286
+	}
287
+}
internal/web/handlers/api/actions_variables_test.goadded
@@ -0,0 +1,111 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package api_test
4
+
5
+import (
6
+	"bytes"
7
+	"encoding/json"
8
+	"net/http"
9
+	"net/http/httptest"
10
+	"testing"
11
+)
12
+
13
+func TestActionsVariables_CreateListGetUpdateDelete(t *testing.T) {
14
+	env := newSecretsTestEnv(t)
15
+
16
+	// CREATE
17
+	createBody, _ := json.Marshal(map[string]string{"name": "API_URL", "value": "https://api.example"})
18
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/actions/variables", bytes.NewReader(createBody))
19
+	req.Header.Set("Authorization", "Bearer "+env.tokenRW)
20
+	req.Header.Set("Content-Type", "application/json")
21
+	rr := httptest.NewRecorder()
22
+	env.router.ServeHTTP(rr, req)
23
+	if rr.Code != http.StatusCreated {
24
+		t.Fatalf("create: got %d; body=%s", rr.Code, rr.Body.String())
25
+	}
26
+
27
+	// LIST
28
+	req = httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/actions/variables", nil)
29
+	req.Header.Set("Authorization", "Bearer "+env.tokenRO)
30
+	rr = httptest.NewRecorder()
31
+	env.router.ServeHTTP(rr, req)
32
+	if rr.Code != http.StatusOK {
33
+		t.Fatalf("list: got %d; body=%s", rr.Code, rr.Body.String())
34
+	}
35
+	var listed []map[string]any
36
+	_ = json.Unmarshal(rr.Body.Bytes(), &listed)
37
+	if len(listed) != 1 || listed[0]["name"] != "API_URL" || listed[0]["value"] != "https://api.example" {
38
+		t.Errorf("list shape: %+v", listed)
39
+	}
40
+
41
+	// GET single
42
+	req = httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/actions/variables/API_URL", nil)
43
+	req.Header.Set("Authorization", "Bearer "+env.tokenRO)
44
+	rr = httptest.NewRecorder()
45
+	env.router.ServeHTTP(rr, req)
46
+	if rr.Code != http.StatusOK {
47
+		t.Fatalf("get single: got %d; body=%s", rr.Code, rr.Body.String())
48
+	}
49
+
50
+	// PATCH (update value)
51
+	updBody, _ := json.Marshal(map[string]string{"value": "https://api.example/v2"})
52
+	req = httptest.NewRequest(http.MethodPatch, "/api/v1/repos/alice/demo/actions/variables/API_URL", bytes.NewReader(updBody))
53
+	req.Header.Set("Authorization", "Bearer "+env.tokenRW)
54
+	req.Header.Set("Content-Type", "application/json")
55
+	rr = httptest.NewRecorder()
56
+	env.router.ServeHTTP(rr, req)
57
+	if rr.Code != http.StatusOK {
58
+		t.Fatalf("patch: got %d; body=%s", rr.Code, rr.Body.String())
59
+	}
60
+	var updated map[string]any
61
+	_ = json.Unmarshal(rr.Body.Bytes(), &updated)
62
+	if updated["value"] != "https://api.example/v2" {
63
+		t.Errorf("patched value: %+v", updated)
64
+	}
65
+
66
+	// DELETE
67
+	req = httptest.NewRequest(http.MethodDelete, "/api/v1/repos/alice/demo/actions/variables/API_URL", nil)
68
+	req.Header.Set("Authorization", "Bearer "+env.tokenRW)
69
+	rr = httptest.NewRecorder()
70
+	env.router.ServeHTTP(rr, req)
71
+	if rr.Code != http.StatusNoContent {
72
+		t.Fatalf("delete: got %d; body=%s", rr.Code, rr.Body.String())
73
+	}
74
+}
75
+
76
+func TestActionsVariables_CreateRejectsBadName(t *testing.T) {
77
+	env := newSecretsTestEnv(t)
78
+	body, _ := json.Marshal(map[string]string{"name": "1bad-name", "value": "x"})
79
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/actions/variables", bytes.NewReader(body))
80
+	req.Header.Set("Authorization", "Bearer "+env.tokenRW)
81
+	req.Header.Set("Content-Type", "application/json")
82
+	rr := httptest.NewRecorder()
83
+	env.router.ServeHTTP(rr, req)
84
+	if rr.Code != http.StatusUnprocessableEntity {
85
+		t.Fatalf("status: got %d, want 422; body=%s", rr.Code, rr.Body.String())
86
+	}
87
+}
88
+
89
+func TestActionsVariables_GetUnknown404(t *testing.T) {
90
+	env := newSecretsTestEnv(t)
91
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/actions/variables/MISSING", nil)
92
+	req.Header.Set("Authorization", "Bearer "+env.tokenRO)
93
+	rr := httptest.NewRecorder()
94
+	env.router.ServeHTTP(rr, req)
95
+	if rr.Code != http.StatusNotFound {
96
+		t.Fatalf("status: got %d, want 404; body=%s", rr.Code, rr.Body.String())
97
+	}
98
+}
99
+
100
+func TestActionsVariables_CreateRequiresRepoWrite(t *testing.T) {
101
+	env := newSecretsTestEnv(t)
102
+	body, _ := json.Marshal(map[string]string{"name": "X", "value": "y"})
103
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/actions/variables", bytes.NewReader(body))
104
+	req.Header.Set("Authorization", "Bearer "+env.tokenRO)
105
+	req.Header.Set("Content-Type", "application/json")
106
+	rr := httptest.NewRecorder()
107
+	env.router.ServeHTTP(rr, req)
108
+	if rr.Code != http.StatusForbidden {
109
+		t.Fatalf("status: got %d, want 403; body=%s", rr.Code, rr.Body.String())
110
+	}
111
+}