carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "net/http"
6 "strings"
7 "sync/atomic"
8 "testing"
9 "time"
10
11 "github.com/carlosframework/platform/internal/appconfig"
12 "github.com/carlosframework/platform/internal/objstore"
13 "github.com/carlosframework/platform/internal/release"
14)
15
16const testWebhookMaster = "test-current-webhook-master"
17
18func grantScanning(t *testing.T, fx *consoleFixture, token, app string) {
19 t.Helper()
20 if s, o := postJSON(t, scanURL(fx, app), map[string]any{"encrypted": false}, token); s != http.StatusOK {
21 t.Fatalf("scan request %s = %d %v", app, s, o)
22 }
23 if s, o := postJSON(t, scanURL(fx, app)+"/grant", map[string]any{}, token); s != http.StatusOK {
24 t.Fatalf("scan grant %s = %d %v", app, s, o)
25 }
26}
27
28func callbackSecret(t *testing.T, fx *consoleFixture, priv, app string) string {
29 t.Helper()
30 v, ok := appVars(t, fx, app)[objstore.DefaultEnvPrefix+"_SCAN_CALLBACK_SECRET"]
31 if !ok {
32 t.Fatal("callback secret was not delivered")
33 }
34 return openSecret(t, priv, v)
35}
36
37func TestGrantDeliversWebhookKey(t *testing.T) {
38 fx, _, token, priv := storeArmed(t, "code-webhook-grant")
39 grantStore(t, fx, token)
40 fx.srv.cfg.StoreScanner = objstore.NewMemScanner()
41 enableDeploymentScanning(t, fx)
42 grantScanning(t, fx, token, "titogo")
43
44 rec := storedRecord(t, fx, "titogo")
45 if rec.Scanning.WebhookGen <= 0 || rec.Scanning.WebhookGen >= 1<<53 {
46 t.Fatalf("webhook_gen = %d, want a positive JSON-safe generation", rec.Scanning.WebhookGen)
47 }
48 want := string(fx.srv.cfg.CallbackSigner.DerivedKey(fx.sqid, "titogo", rec.Scanning.WebhookGen))
49 if got := callbackSecret(t, fx, priv, "titogo"); got != want {
50 t.Fatalf("callback secret = %q, want derived app key %q", got, want)
51 }
52 if rec.Scanning.WebhookKeyID != fx.srv.cfg.CallbackSigner.CurrentKeyID() {
53 t.Fatalf("webhook_key_id = %q, want current key id", rec.Scanning.WebhookKeyID)
54 }
55}
56
57type callbackPutCounter struct {
58 release.Store
59 configPuts atomic.Int64
60 cancel context.CancelFunc
61}
62
63func (s *callbackPutCounter) Put(ctx context.Context, key string, body []byte, etag string) (string, error) {
64 got, err := s.Store.Put(ctx, key, body, etag)
65 if err == nil && strings.HasSuffix(key, "/config.json") {
66 s.configPuts.Add(1)
67 if s.cancel != nil {
68 s.cancel()
69 }
70 }
71 return got, err
72}
73
74func TestRotateWebhookKeySkipsUnchangedApps(t *testing.T) {
75 fx, _, token, _ := storeArmed(t, "code-webhook-skip")
76 grantStore(t, fx, token)
77 fx.srv.cfg.StoreScanner = objstore.NewMemScanner()
78 enableDeploymentScanning(t, fx)
79 grantScanning(t, fx, token, "titogo")
80
81 counter := &callbackPutCounter{Store: fx.rel}
82 fx.srv.cfg.Releases = counter
83 pub, err := appconfig.ParsePublicKey(fx.srv.cfg.SecretsPub, "test")
84 if err != nil {
85 t.Fatal(err)
86 }
87 remaining, changed, err := fx.srv.rotateWebhookBudget(context.Background(), pub)
88 if err != nil {
89 t.Fatal(err)
90 }
91 if remaining != 0 || changed != 0 || counter.configPuts.Load() != 0 {
92 t.Fatalf("unchanged rotation = remaining %d, changed %d, config puts %d; want all zero", remaining, changed, counter.configPuts.Load())
93 }
94}
95
96func TestRotateWebhookKeyDoesNotDropPrevious(t *testing.T) {
97 fx, _, token, priv := storeArmed(t, "code-webhook-previous")
98 grantStore(t, fx, token)
99 fx.srv.cfg.StoreScanner = objstore.NewMemScanner()
100 enableDeploymentScanning(t, fx)
101 grantScanning(t, fx, token, "titogo")
102 fx.srv.cfg.CallbackSigner = callbackTestSigner(t, "new-master", testWebhookMaster)
103 pub, _ := appconfig.ParsePublicKey(fx.srv.cfg.SecretsPub, "test")
104 if _, _, err := fx.srv.rotateWebhookBudget(context.Background(), pub); err != nil {
105 t.Fatal(err)
106 }
107 if fx.srv.cfg.CallbackSigner.previous == nil || fx.srv.cfg.CallbackSigner.previous.id == "" {
108 t.Fatal("rotation dropped the previous callback master")
109 }
110 rec := storedRecord(t, fx, "titogo")
111 want := string(fx.srv.cfg.CallbackSigner.DerivedKey(fx.sqid, "titogo", rec.Scanning.WebhookGen))
112 if got := callbackSecret(t, fx, priv, "titogo"); got != want {
113 t.Fatal("rotation did not deliver the current master")
114 }
115}
116
117func TestGrantFoldsBothSecretsIntoOneApply(t *testing.T) {
118 fx, _, token, _ := storeArmed(t, "code-webhook-one-apply")
119 grantStore(t, fx, token)
120 fx.srv.cfg.StoreScanner = objstore.NewMemScanner()
121 enableDeploymentScanning(t, fx)
122 requestScanning(t, fx, token, false)
123 counter := &callbackPutCounter{Store: fx.rel}
124 fx.srv.cfg.Releases = counter
125
126 if s, o := postJSON(t, scanURL(fx, "titogo")+"/grant", map[string]any{}, token); s != http.StatusOK {
127 t.Fatalf("scan grant = %d %v", s, o)
128 }
129 if got := counter.configPuts.Load(); got != 1 {
130 t.Fatalf("scan grant wrote config %d times, want one Apply", got)
131 }
132}
133
134func TestRevokeUnsetsTheCallbackSecret(t *testing.T) {
135 fx, _, token, priv := storeArmed(t, "code-webhook-revoke")
136 grantStore(t, fx, token)
137 fx.srv.cfg.StoreScanner = objstore.NewMemScanner()
138 enableDeploymentScanning(t, fx)
139 grantScanning(t, fx, token, "titogo")
140 _ = callbackSecret(t, fx, priv, "titogo")
141 if s, o := postJSON(t, scanURL(fx, "titogo")+"/revoke", map[string]any{}, token); s != http.StatusOK {
142 t.Fatalf("scan revoke = %d %v", s, o)
143 }
144 if _, ok := appVars(t, fx, "titogo")[objstore.DefaultEnvPrefix+"_SCAN_CALLBACK_SECRET"]; ok {
145 t.Fatal("revoke left the callback secret in app config")
146 }
147}
148
149func TestRotateIsBudgetedAndResumable(t *testing.T) {
150 fx, _, token, _ := storeArmed(t, "code-webhook-budget")
151 grantStore(t, fx, token)
152 fx.srv.cfg.StoreScanner = objstore.NewMemScanner()
153 enableDeploymentScanning(t, fx)
154 grantScanning(t, fx, token, "titogo")
155 if _, err := fx.st.CreateAppIn(OpsAccountID, "second", "paul@keymail.dev", time.Now()); err != nil {
156 t.Fatal(err)
157 }
158 if s, o := postJSON(t, storeURL(fx, "second"), map[string]any{}, token); s != http.StatusOK {
159 t.Fatalf("declare second = %d %v", s, o)
160 }
161 if s, o := postJSON(t, storeURL(fx, "second")+"/grant", map[string]any{}, token); s != http.StatusOK {
162 t.Fatalf("store grant second = %d %v", s, o)
163 }
164 grantScanning(t, fx, token, "second")
165 fx.srv.cfg.CallbackSigner = callbackTestSigner(t, "replacement-master", testWebhookMaster)
166 pub, _ := appconfig.ParsePublicKey(fx.srv.cfg.SecretsPub, "test")
167 ctx, cancel := context.WithCancel(context.Background())
168 counter := &callbackPutCounter{Store: fx.rel, cancel: cancel}
169 fx.srv.cfg.Releases = counter
170 remaining, changed, err := fx.srv.rotateWebhookBudget(ctx, pub)
171 if err != nil {
172 t.Fatal(err)
173 }
174 if changed != 1 || remaining != 1 {
175 t.Fatalf("bounded rotation = changed %d remaining %d, want 1/1", changed, remaining)
176 }
177 counter.cancel = nil
178 remaining, changed, err = fx.srv.rotateWebhookBudget(context.Background(), pub)
179 if err != nil {
180 t.Fatal(err)
181 }
182 if changed != 1 || remaining != 0 {
183 t.Fatalf("resumed rotation = changed %d remaining %d, want 1/0", changed, remaining)
184 }
185}
186
187func TestNoMasterRefusesACallbackPath(t *testing.T) {
188 fx, token := enqueueArmed(t, "code-webhook-no-master")
189 fx.srv.cfg.CallbackSigner = nil
190 status, out := postJSON(t, enqueueURL(fx, "titogo"), map[string]any{"key": "uploads/x", "callback_path": "/scan-result"}, token)
191 if status != http.StatusServiceUnavailable || out["reason"] != "callbacks_not_configured" {
192 t.Fatalf("enqueue callback without master = %d %v, want 503 callbacks_not_configured", status, out)
193 }
194 if got := queuedKeys(t, fx, "titogo"); len(got) != 0 {
195 t.Fatalf("refused callback still queued work: %v", got)
196 }
197}
198
199func TestPerAppKeyGenerationRotatesOneApp(t *testing.T) {
200 fx, _, token, priv := storeArmed(t, "code-webhook-one-app")
201 grantStore(t, fx, token)
202 fx.srv.cfg.StoreScanner = objstore.NewMemScanner()
203 enableDeploymentScanning(t, fx)
204 grantScanning(t, fx, token, "titogo")
205 before := storedRecord(t, fx, "titogo").Scanning.WebhookGen
206 secretBefore := callbackSecret(t, fx, priv, "titogo")
207 if s, o := postJSON(t, scanURL(fx, "titogo")+"/callback-key/rotate", map[string]any{}, token); s != http.StatusOK {
208 t.Fatalf("per-app rotate = %d %v", s, o)
209 }
210 after := storedRecord(t, fx, "titogo").Scanning.WebhookGen
211 if after == before || after <= 0 || after >= 1<<53 {
212 t.Fatalf("webhook_gen did not rotate safely: %d -> %d", before, after)
213 }
214 if got := callbackSecret(t, fx, priv, "titogo"); got == secretBefore {
215 t.Fatal("per-app rotation left the delivered callback secret unchanged")
216 }
217}
218
219func TestReclaimedAppNameGetsADifferentKey(t *testing.T) {
220 fx, _, token, priv := storeArmed(t, "code-webhook-reclaim")
221 grantStore(t, fx, token)
222 fx.srv.cfg.StoreScanner = objstore.NewMemScanner()
223 enableDeploymentScanning(t, fx)
224 grantScanning(t, fx, token, "titogo")
225 old := callbackSecret(t, fx, priv, "titogo")
226
227 keys, err := fx.rel.List(context.Background(), release.AppPrefix(fx.sqid, "titogo"))
228 if err != nil {
229 t.Fatal(err)
230 }
231 for _, key := range keys {
232 if err := fx.rel.Delete(context.Background(), key); err != nil {
233 t.Fatal(err)
234 }
235 }
236 declareStore(t, fx, token)
237 if s, o := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); s != http.StatusOK {
238 t.Fatalf("reclaimed store grant = %d %v", s, o)
239 }
240 grantScanning(t, fx, token, "titogo")
241 if got := callbackSecret(t, fx, priv, "titogo"); got == old {
242 t.Fatal("reclaimed app name received the deleted app's callback key")
243 }
244}
245