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
3// The account-level object-storage entitlement doors (issue #381). These
4// tests pin the authority boundary first: only an account owner may apply,
5// while decisions belong to a deployment operator who need not be a member
6// of the account. They then pin the record transitions and the operator queue.
7
8import (
9 "context"
10 "errors"
11 "net/http"
12 "strings"
13 "testing"
14 "time"
15
16 "github.com/carlosframework/platform/internal/objstore"
17 "github.com/carlosframework/platform/internal/release"
18 "github.com/carlosframework/platform/internal/svcrole"
19)
20
21func entitlementApplyURL(fx *consoleFixture, account string) string {
22 return fx.ts.URL + "/api/cli/accounts/" + account + "/store/apply"
23}
24
25func entitlementDecisionURL(fx *consoleFixture, account, decision string) string {
26 return fx.ts.URL + "/api/cli/accounts/" + account + "/store/" + decision
27}
28
29func entitlementApprovalsURL(fx *consoleFixture) string {
30 return fx.ts.URL + "/api/cli/store/approvals"
31}
32
33func storedEntitlement(t *testing.T, fx *consoleFixture, account string) *objstore.Entitlement {
34 t.Helper()
35 e, _, err := objstore.GetEntitlement(context.Background(), fx.rel, account)
36 if err != nil {
37 t.Fatalf("read entitlement for %s: %v", account, err)
38 }
39 return e
40}
41
42func putEntitlement(t *testing.T, fx *consoleFixture, account string, e *objstore.Entitlement) {
43 t.Helper()
44 if _, err := objstore.PutEntitlement(context.Background(), fx.rel, account, e, ""); err != nil {
45 t.Fatalf("seed entitlement for %s: %v", account, err)
46 }
47}
48
49func entitlementOperatorToken(t *testing.T, fx *consoleFixture, address, code string) string {
50 t.Helper()
51 if err := fx.st.SetOperator(address, true); err != nil {
52 t.Fatal(err)
53 }
54 fx.srv.cfg.StoreOperators = []string{address}
55 return cliToken(t, fx, address, code)
56}
57
58func entitlementBlock(t *testing.T, out map[string]any) map[string]any {
59 t.Helper()
60 return block(t, out, "entitlement")
61}
62
63func TestAPIStoreApplyNeedsAnOwner(t *testing.T) {
64 fx := newConsoleFixture(t)
65 if err := fx.st.AddMember(OpsAccountID, "member@keymail.dev", RoleMember, time.Now()); err != nil {
66 t.Fatal(err)
67 }
68 token := cliToken(t, fx, "member@keymail.dev", "code-store-apply-member")
69
70 status, _ := postJSON(t, entitlementApplyURL(fx, fx.sqid), map[string]any{"note": "uploads"}, token)
71 if status != http.StatusForbidden {
72 t.Fatalf("apply by a non-owner = %d, want 403", status)
73 }
74 if got := storedEntitlement(t, fx, fx.sqid); got != nil {
75 t.Fatalf("refused apply wrote %+v", got)
76 }
77}
78
79func TestAPIStoreApplyHidesUnknownAndOtherAccounts(t *testing.T) {
80 fx := newConsoleFixture(t)
81 eventoken := cliToken(t, fx, "eve@keymail.dev", "code-store-apply-stranger")
82 owner := cliToken(t, fx, "paul@keymail.dev", "code-store-apply-unknown")
83
84 statusA, bodyA := postRaw(t, entitlementApplyURL(fx, fx.sqid), map[string]any{}, eventoken)
85 statusB, bodyB := postRaw(t, entitlementApplyURL(fx, "zzzz"), map[string]any{}, owner)
86 if statusA != http.StatusNotFound || statusB != http.StatusNotFound {
87 t.Fatalf("apply probes = %d / %d, want 404 for both", statusA, statusB)
88 }
89 if bodyA != bodyB || bodyA != "404 page not found\n" {
90 t.Fatalf("apply probe bodies = %q / %q, want byte-identical stdlib 404", bodyA, bodyB)
91 }
92}
93
94func TestAPIStoreApplyWritesAndRepliesWithTheEntitlement(t *testing.T) {
95 fx := newConsoleFixture(t)
96 token := cliToken(t, fx, "paul@keymail.dev", "code-store-apply")
97
98 status, out := postJSON(t, entitlementApplyURL(fx, fx.sqid), map[string]any{"note": "customer uploads"}, token)
99 if status != http.StatusOK {
100 t.Fatalf("apply = %d %v, want 200", status, out)
101 }
102 if str(t, out, "account") != fx.sqid {
103 t.Fatalf("account = %v, want %s", out["account"], fx.sqid)
104 }
105 e := entitlementBlock(t, out)
106 applied := block(t, e, "applied")
107 if applied["by"] != "paul@keymail.dev" || applied["note"] != "customer uploads" {
108 t.Fatalf("applied = %v", applied)
109 }
110 if e["decision"] != nil {
111 t.Fatalf("decision = %v, want null while pending", e["decision"])
112 }
113 got := storedEntitlement(t, fx, fx.sqid)
114 if got == nil || !got.Pending() || got.Applied.By != "paul@keymail.dev" || got.Applied.Note != "customer uploads" {
115 t.Fatalf("stored entitlement = %+v, want the pending application", got)
116 }
117 auditedBy(t, fx, "", "store-apply", "paul@keymail.dev", "customer uploads")
118}
119
120func TestAPIStoreApplyReopensADeclineUsingCAS(t *testing.T) {
121 fx := newConsoleFixture(t)
122 oldApplied := time.Now().Add(-time.Hour).UTC()
123 putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{
124 Applied: &objstore.Applied{By: "paul@keymail.dev", At: oldApplied, Note: "first ask"},
125 Decision: &objstore.Decision{Approved: false, By: "ops@keymail.dev", At: time.Now().Add(-time.Minute), Reason: "more detail needed"},
126 })
127 token := cliToken(t, fx, "paul@keymail.dev", "code-store-reapply")
128
129 status, out := postJSON(t, entitlementApplyURL(fx, fx.sqid), map[string]any{"note": "expanded plan"}, token)
130 if status != http.StatusOK {
131 t.Fatalf("re-apply = %d %v, want 200", status, out)
132 }
133 got := storedEntitlement(t, fx, fx.sqid)
134 if got == nil || !got.Pending() || got.Decision != nil {
135 t.Fatalf("re-applied entitlement = %+v, want pending with the decision cleared", got)
136 }
137 if got.Applied.Note != "expanded plan" || !got.Applied.At.After(oldApplied) {
138 t.Fatalf("re-applied block = %+v, want a fresh application", got.Applied)
139 }
140}
141
142func TestAPIStoreApplySurfacesACASConflictWithoutOverwritingTheWinner(t *testing.T) {
143 fx := newConsoleFixture(t)
144 putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{
145 Applied: &objstore.Applied{By: "paul@keymail.dev", At: time.Now().Add(-time.Hour), Note: "first ask"},
146 Decision: &objstore.Decision{Approved: false, By: "ops@keymail.dev", At: time.Now().Add(-time.Minute), Reason: "revise"},
147 })
148 racer := &racingStore{Store: fx.rel, raceKeyOnce: objstore.EntitlementKey(fx.sqid)}
149 racer.race = func(inner release.Store) {
150 winner, etag, err := objstore.GetEntitlement(context.Background(), inner, fx.sqid)
151 if err != nil {
152 t.Fatal(err)
153 }
154 winner.Applied.Note = "the concurrent winner"
155 if _, err := objstore.PutEntitlement(context.Background(), inner, fx.sqid, winner, etag); err != nil {
156 t.Fatal(err)
157 }
158 }
159 fx.srv.cfg.Releases = racer
160 token := cliToken(t, fx, "paul@keymail.dev", "code-store-apply-cas")
161
162 status, _ := postJSON(t, entitlementApplyURL(fx, fx.sqid), map[string]any{"note": "the losing write"}, token)
163 if status != http.StatusConflict {
164 t.Fatalf("apply losing its CAS = %d, want 409", status)
165 }
166 got := storedEntitlement(t, fx, fx.sqid)
167 if got.Applied.Note != "the concurrent winner" {
168 t.Fatalf("application after conflict = %+v, want the concurrent winner preserved", got.Applied)
169 }
170}
171
172func TestAPIStoreApplyUsesTheSharedWriteBudget(t *testing.T) {
173 fx := newConsoleFixture(t)
174 fx.srv.cli.write = newRateLimiter(1, time.Minute)
175 token := cliToken(t, fx, "paul@keymail.dev", "code-store-apply-limit")
176
177 if status, out := postJSON(t, entitlementApplyURL(fx, fx.sqid), map[string]any{}, token); status != http.StatusOK {
178 t.Fatalf("first apply = %d %v, want 200", status, out)
179 }
180 if status, _ := postJSON(t, entitlementApplyURL(fx, fx.sqid), map[string]any{}, token); status != http.StatusTooManyRequests {
181 t.Fatalf("second apply on an exhausted budget = %d, want 429", status)
182 }
183}
184
185func TestAPIStoreApplyToAnApprovedAccountIsANoOp(t *testing.T) {
186 fx := newConsoleFixture(t)
187 want := &objstore.Entitlement{
188 Applied: &objstore.Applied{By: "paul@keymail.dev", At: time.Now().Add(-time.Hour).UTC(), Note: "original"},
189 Decision: &objstore.Decision{Approved: true, By: "ops@keymail.dev", At: time.Now().Add(-time.Minute).UTC()},
190 }
191 putEntitlement(t, fx, fx.sqid, want)
192 token := cliToken(t, fx, "paul@keymail.dev", "code-store-apply-approved")
193
194 status, out := postJSON(t, entitlementApplyURL(fx, fx.sqid), map[string]any{"note": "must not replace approval"}, token)
195 if status != http.StatusOK {
196 t.Fatalf("apply when approved = %d %v, want 200", status, out)
197 }
198 got := storedEntitlement(t, fx, fx.sqid)
199 if got == nil || !got.Approved() || got.Decision.By != want.Decision.By || !got.Decision.At.Equal(want.Decision.At) {
200 t.Fatalf("approved decision changed: got %+v, want %+v", got, want)
201 }
202 if got.Applied.Note != "original" || !got.Applied.At.Equal(want.Applied.At) {
203 t.Fatalf("approved application changed: got %+v, want %+v", got.Applied, want.Applied)
204 }
205}
206
207func TestAPIStoreDecisionNeedsBothOperatorAuthorities(t *testing.T) {
208 tests := []struct {
209 name string
210 fleet bool
211 allowed bool
212 }{
213 {"ordinary user on allowlist", false, true},
214 {"fleet operator absent from allowlist", true, false},
215 }
216 for _, tt := range tests {
217 t.Run(tt.name, func(t *testing.T) {
218 fx := newConsoleFixture(t)
219 putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{Applied: &objstore.Applied{By: "paul@keymail.dev", At: time.Now()}})
220 const address = "candidate@keymail.dev"
221 if tt.fleet {
222 if err := fx.st.SetOperator(address, true); err != nil {
223 t.Fatal(err)
224 }
225 }
226 if tt.allowed {
227 fx.srv.cfg.StoreOperators = []string{address}
228 }
229 token := cliToken(t, fx, address, "code-store-authority-"+tt.name)
230
231 status, body := postRaw(t, entitlementDecisionURL(fx, fx.sqid, "approve"), map[string]any{}, token)
232 if status != http.StatusNotFound || body != "404 page not found\n" {
233 t.Fatalf("approve with only one authority bit = %d %q, want bare 404", status, body)
234 }
235 status, body = getRaw(t, entitlementApprovalsURL(fx), token)
236 if status != http.StatusNotFound || body != "404 page not found\n" {
237 t.Fatalf("approvals with only one authority bit = %d %q, want bare 404", status, body)
238 }
239 if !storedEntitlement(t, fx, fx.sqid).Pending() {
240 t.Fatal("refused operator changed the pending entitlement")
241 }
242 })
243 }
244}
245
246func TestAPIStoreOperatorMatchingIsExact(t *testing.T) {
247 fx := newConsoleFixture(t)
248 const lookalike = "owner@example.teſt"
249 if err := fx.st.SetOperator(lookalike, true); err != nil {
250 t.Fatal(err)
251 }
252 fx.srv.cfg.StoreOperators = []string{"owner@example.test"}
253 token := cliToken(t, fx, lookalike, "code-store-long-s")
254
255 status, body := getRaw(t, entitlementApprovalsURL(fx), token)
256 if status != http.StatusNotFound || body != "404 page not found\n" {
257 t.Fatalf("lookalike operator = %d %q, want bare 404", status, body)
258 }
259}
260
261func TestAPIStoreNonMemberOperatorCanApprove(t *testing.T) {
262 fx := newConsoleFixture(t)
263 putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{Applied: &objstore.Applied{By: "paul@keymail.dev", At: time.Now(), Note: "backups"}})
264 token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-approve-nonmember")
265 role, err := fx.st.Role(OpsAccountID, "ops@keymail.dev")
266 if err != nil {
267 t.Fatal(err)
268 }
269 if role != "" {
270 t.Fatalf("operator role in target account = %q, want none", role)
271 }
272
273 status, out := postJSON(t, entitlementDecisionURL(fx, fx.sqid, "approve"), map[string]any{}, token)
274 if status != http.StatusOK {
275 t.Fatalf("approve by non-member operator = %d %v, want 200", status, out)
276 }
277 if str(t, out, "account") != fx.sqid {
278 t.Fatalf("account = %v, want %s", out["account"], fx.sqid)
279 }
280 got := storedEntitlement(t, fx, fx.sqid)
281 if got == nil || !got.Approved() || got.Decision.By != "ops@keymail.dev" || got.Decision.Reason != "" {
282 t.Fatalf("approved entitlement = %+v", got)
283 }
284 auditedBy(t, fx, "", "store-approve", "ops@keymail.dev", "approved")
285}
286
287func TestAPIStoreOperatorDecisionsHideUnknownAccounts(t *testing.T) {
288 fx := newConsoleFixture(t)
289 token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-decision-unknown")
290
291 for _, decision := range []string{"approve", "decline"} {
292 status, body := postRaw(t, entitlementDecisionURL(fx, "zzzz", decision), map[string]any{"reason": "irrelevant"}, token)
293 if status != http.StatusNotFound || body != "404 page not found\n" {
294 t.Errorf("%s unknown account = %d %q, want bare 404", decision, status, body)
295 }
296 }
297}
298
299func TestAPIStoreApproveNeedsAnApplication(t *testing.T) {
300 fx := newConsoleFixture(t)
301 token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-approve-empty")
302
303 status, _ := postJSON(t, entitlementDecisionURL(fx, fx.sqid, "approve"), map[string]any{}, token)
304 if status != http.StatusConflict {
305 t.Fatalf("approve without an application = %d, want 409", status)
306 }
307 if got := storedEntitlement(t, fx, fx.sqid); got != nil {
308 t.Fatalf("approve without an application wrote %+v", got)
309 }
310}
311
312func TestAPIStoreDeclineRequiresAReasonAndMayReplaceApproval(t *testing.T) {
313 fx := newConsoleFixture(t)
314 putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{
315 Applied: &objstore.Applied{By: "paul@keymail.dev", At: time.Now().Add(-time.Hour)},
316 Decision: &objstore.Decision{Approved: true, By: "first-op@keymail.dev", At: time.Now().Add(-time.Minute)},
317 })
318 token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-decline")
319
320 status, _ := postJSON(t, entitlementDecisionURL(fx, fx.sqid, "decline"), map[string]any{}, token)
321 if status != http.StatusBadRequest {
322 t.Fatalf("decline without a reason = %d, want 400", status)
323 }
324 if !storedEntitlement(t, fx, fx.sqid).Approved() {
325 t.Fatal("a refused decline changed the approval")
326 }
327
328 status, out := postJSON(t, entitlementDecisionURL(fx, fx.sqid, "decline"), map[string]any{"reason": "capacity review"}, token)
329 if status != http.StatusOK {
330 t.Fatalf("decline = %d %v, want 200", status, out)
331 }
332 got := storedEntitlement(t, fx, fx.sqid)
333 if got == nil || !got.Declined() || got.Decision.By != "ops@keymail.dev" || got.Decision.Reason != "capacity review" {
334 t.Fatalf("declined entitlement = %+v", got)
335 }
336 auditedBy(t, fx, "", "store-decline", "ops@keymail.dev", "capacity review")
337}
338
339func TestAPIStoreDecisionSurfacesACASConflictWithoutOverwritingTheWinner(t *testing.T) {
340 fx := newConsoleFixture(t)
341 putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{
342 Applied: &objstore.Applied{By: "paul@keymail.dev", At: time.Now().Add(-time.Hour)},
343 })
344 racer := &racingStore{Store: fx.rel, raceKeyOnce: objstore.EntitlementKey(fx.sqid)}
345 racer.race = func(inner release.Store) {
346 winner, etag, err := objstore.GetEntitlement(context.Background(), inner, fx.sqid)
347 if err != nil {
348 t.Fatal(err)
349 }
350 winner.Decision = &objstore.Decision{Approved: false, By: "winner@keymail.dev", At: time.Now(), Reason: "winner's decision"}
351 if _, err := objstore.PutEntitlement(context.Background(), inner, fx.sqid, winner, etag); err != nil {
352 t.Fatal(err)
353 }
354 }
355 fx.srv.cfg.Releases = racer
356 token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-decision-cas")
357
358 status, _ := postJSON(t, entitlementDecisionURL(fx, fx.sqid, "approve"), map[string]any{}, token)
359 if status != http.StatusConflict {
360 t.Fatalf("decision losing its CAS = %d, want 409", status)
361 }
362 got := storedEntitlement(t, fx, fx.sqid)
363 if got == nil || !got.Declined() || got.Decision.By != "winner@keymail.dev" {
364 t.Fatalf("entitlement after conflict = %+v, want the concurrent winner preserved", got)
365 }
366}
367
368func TestAPIStoreApprovalsListsPendingOldestFirst(t *testing.T) {
369 fx := newConsoleFixture(t)
370 now := time.Now().UTC()
371 const secondID int64 = 4242
372 secondSqid := accountSqid(secondID)
373 if err := fx.st.CreateAccount(secondID, secondSqid, "Second", "second@keymail.dev", now); err != nil {
374 t.Fatal(err)
375 }
376 putEntitlement(t, fx, secondSqid, &objstore.Entitlement{Applied: &objstore.Applied{By: "second@keymail.dev", At: now.Add(-2 * time.Hour), Note: "oldest"}})
377 putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{Applied: &objstore.Applied{By: "paul@keymail.dev", At: now.Add(-time.Hour), Note: "newer"}})
378 token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-approvals-order")
379
380 status, out := getJSON(t, entitlementApprovalsURL(fx), token)
381 if status != http.StatusOK {
382 t.Fatalf("approvals = %d %v, want 200", status, out)
383 }
384 raw, ok := out["applications"].([]any)
385 if !ok || len(raw) != 2 {
386 t.Fatalf("applications = %#v, want two entries", out["applications"])
387 }
388 first, ok := raw[0].(map[string]any)
389 if !ok {
390 t.Fatalf("applications[0] = %#v, want an object", raw[0])
391 }
392 second, ok := raw[1].(map[string]any)
393 if !ok {
394 t.Fatalf("applications[1] = %#v, want an object", raw[1])
395 }
396 if first["account"] != secondSqid || second["account"] != fx.sqid {
397 t.Fatalf("application order = %v then %v, want %s then %s", first["account"], second["account"], secondSqid, fx.sqid)
398 }
399 if _, ok := first["applied"].(map[string]any); !ok {
400 t.Fatalf("first application = %#v, want an entitlement object", first)
401 }
402}
403
404func TestAPIStoreApprovalsFiltersDecidedDeletedAndUnknownAccounts(t *testing.T) {
405 fx := newConsoleFixture(t)
406 now := time.Now().UTC()
407
408 const deletedID int64 = 4343
409 deletedSqid := accountSqid(deletedID)
410 if err := fx.st.CreateAccount(deletedID, deletedSqid, "Deleted", "deleted@keymail.dev", now); err != nil {
411 t.Fatal(err)
412 }
413 if err := fx.st.DeleteAccount(deletedID, now.Add(time.Minute)); err != nil {
414 t.Fatal(err)
415 }
416 putEntitlement(t, fx, deletedSqid, &objstore.Entitlement{Applied: &objstore.Applied{By: "deleted@keymail.dev", At: now.Add(-3 * time.Hour)}})
417 putEntitlement(t, fx, "bde", &objstore.Entitlement{Applied: &objstore.Applied{By: "orphan@keymail.dev", At: now.Add(-2 * time.Hour)}})
418 putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{
419 Applied: &objstore.Applied{By: "paul@keymail.dev", At: now.Add(-time.Hour)},
420 Decision: &objstore.Decision{Approved: false, By: "ops@keymail.dev", At: now, Reason: "decided"},
421 })
422 token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-approvals-filter")
423
424 status, out := getJSON(t, entitlementApprovalsURL(fx), token)
425 if status != http.StatusOK {
426 t.Fatalf("approvals = %d %v, want 200", status, out)
427 }
428 raw, ok := out["applications"].([]any)
429 if !ok {
430 t.Fatalf("applications = %#v, want a list", out["applications"])
431 }
432 if len(raw) != 0 {
433 t.Fatalf("applications = %#v, want decided, deleted, and unknown accounts filtered", raw)
434 }
435}
436
437type entitlementListErrorStore struct{ release.Store }
438
439func (s entitlementListErrorStore) List(ctx context.Context, prefix string) ([]string, error) {
440 if prefix == "store/entitlements/" {
441 return nil, errors.New("list unavailable")
442 }
443 return s.Store.List(ctx, prefix)
444}
445
446func TestAPIStoreApprovalsFailsWhenTheQueueCannotBeListed(t *testing.T) {
447 fx := newConsoleFixture(t)
448 fx.srv.cfg.Releases = entitlementListErrorStore{Store: fx.rel}
449 token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-approvals-list-error")
450
451 status, _ := getJSON(t, entitlementApprovalsURL(fx), token)
452 if status != http.StatusBadGateway {
453 t.Fatalf("approvals with a failed list = %d, want 502", status)
454 }
455}
456
457func TestAPIStoreApprovalsFailsOnAMalformedEntitlement(t *testing.T) {
458 fx := newConsoleFixture(t)
459 if _, err := fx.rel.Put(context.Background(), objstore.EntitlementKey(fx.sqid), []byte("not json"), ""); err != nil {
460 t.Fatal(err)
461 }
462 token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-approvals-malformed")
463
464 status, _ := getJSON(t, entitlementApprovalsURL(fx), token)
465 if status != http.StatusBadGateway {
466 t.Fatalf("approvals with malformed entitlement = %d, want 502", status)
467 }
468}
469
470func TestAPIStoreApprovalsIgnoresNonCanonicalKeys(t *testing.T) {
471 fx := newConsoleFixture(t)
472 if _, err := fx.rel.Put(context.Background(), "store/entitlements/nested/bab.json", []byte("not json"), ""); err != nil {
473 t.Fatal(err)
474 }
475 if _, err := fx.rel.Put(context.Background(), "store/entitlements/readme", []byte("not json"), ""); err != nil {
476 t.Fatal(err)
477 }
478 token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-approvals-keys")
479
480 status, out := getJSON(t, entitlementApprovalsURL(fx), token)
481 if status != http.StatusOK {
482 t.Fatalf("approvals with unrelated keys = %d %v, want 200", status, out)
483 }
484 if raw, ok := out["applications"].([]any); !ok || len(raw) != 0 {
485 t.Fatalf("applications = %#v, want an empty list", out["applications"])
486 }
487}
488
489func TestAPIStoreApprovalsTrustsTheKeyForTheAccount(t *testing.T) {
490 fx := newConsoleFixture(t)
491 now := time.Now().UTC()
492 putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{Applied: &objstore.Applied{By: "paul@keymail.dev", At: now}})
493 raw, etag, err := fx.rel.Get(context.Background(), objstore.EntitlementKey(fx.sqid))
494 if err != nil {
495 t.Fatal(err)
496 }
497 raw = []byte(strings.Replace(string(raw), `"account": "`+fx.sqid+`"`, `"account": "zzz"`, 1))
498 if _, err := fx.rel.Put(context.Background(), objstore.EntitlementKey(fx.sqid), raw, etag); err != nil {
499 t.Fatal(err)
500 }
501 token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-approvals-account")
502
503 status, out := getJSON(t, entitlementApprovalsURL(fx), token)
504 if status != http.StatusOK {
505 t.Fatalf("approvals = %d %v, want 200", status, out)
506 }
507 applications := out["applications"].([]any)
508 got := applications[0].(map[string]any)["account"]
509 if got != fx.sqid {
510 t.Fatalf("application account = %v, want key account %s", got, fx.sqid)
511 }
512}
513
514// The projection tests (§4 versus §8, resolved 2026-09-08: a person may read
515// why they were declined, a machine may not, and the deciding operator's
516// address travels to neither). These read the STORE door rather than the apply
517// door because that is the one an app's own service credential can reach.
518
519func declinedStoreFixture(t *testing.T, code string) *consoleFixture {
520 t.Helper()
521 fx, _ := storeFixture(t, code)
522 now := time.Now()
523 putRecord(t, fx, "titogo", &objstore.Record{
524 Declared: &objstore.Declared{EnvPrefix: "CARLOS_STORE", At: now, By: "alice@keymail.dev"},
525 })
526 putEntitlement(t, fx, release.OpsAccount, &objstore.Entitlement{
527 Applied: &objstore.Applied{By: "alice@keymail.dev", At: now, Note: "customer uploads"},
528 Decision: &objstore.Decision{
529 Approved: false, By: "paul@keymail.dev", At: now,
530 Reason: "not until the account is on a card",
531 },
532 })
533 return fx
534}
535
536func TestAPIStoreReplyGivesAMemberTheReasonButNotTheOperator(t *testing.T) {
537 fx := declinedStoreFixture(t, "code-store-project-person")
538 token := cliToken(t, fx, "alice@keymail.dev", "code-store-project-person-2")
539
540 status, out := getJSON(t, storeURL(fx, "titogo"), token)
541 if status != http.StatusOK {
542 t.Fatalf("status = %d %v, want 200", status, out)
543 }
544 e := entitlementBlock(t, out)
545 d := block(t, e, "decision")
546 if d["reason"] != "not until the account is on a card" {
547 // The whole point of requiring a reason is that the account learns it.
548 t.Fatalf("decision.reason = %v, want the reason a person was given", d["reason"])
549 }
550 if _, present := d["by"]; present {
551 t.Fatalf("decision.by = %v, want the deciding operator's address withheld from the tenant", d["by"])
552 }
553 if applied := block(t, e, "applied"); applied["note"] != "customer uploads" {
554 t.Fatalf("applied.note = %v, want the applicant's own text returned to them", applied["note"])
555 }
556}
557
558func TestAPIStoreReplyWithholdsTheReasonFromAServiceCredential(t *testing.T) {
559 fx := declinedStoreFixture(t, "code-store-project-service")
560 svc := serviceToken(t, fx.st, OpsAccountID, "uploader", "titogo", svcrole.Admin)
561
562 status, out := getJSON(t, storeURL(fx, "titogo"), svc)
563 if status != http.StatusOK {
564 t.Fatalf("status = %d %v, want 200", status, out)
565 }
566 e := entitlementBlock(t, out)
567 d := block(t, e, "decision")
568 // The state still has to arrive — a machine reading "not approved" is the
569 // whole reason the block travels at all.
570 if d["approved"] != false {
571 t.Fatalf("decision.approved = %v, want false", d["approved"])
572 }
573 for _, k := range []string{"reason", "by"} {
574 if v, present := d[k]; present {
575 t.Fatalf("decision.%s = %v, want it withheld from a service credential", k, v)
576 }
577 }
578 applied := block(t, e, "applied")
579 for _, k := range []string{"note", "by"} {
580 if v, present := applied[k]; present {
581 t.Fatalf("applied.%s = %v, want it withheld from a service credential", k, v)
582 }
583 }
584}
585