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 "errors"
5 "fmt"
6 "net/http"
7 "net/http/httptest"
8 "reflect"
9 "sort"
10 "strings"
11 "testing"
12 "time"
13)
14
15func TestPendingChangeQuorum(t *testing.T) {
16 s := openTestStore(t)
17 now := time.Now()
18 accountID := testAccountID()
19
20 // Console-proposed (proposerConfirmed=true): the proposer's confirmation
21 // is recorded atomically with the change, but that alone must not meet a
22 // quorum of 2.
23 pc, err := s.ProposeChange(accountID, "kass", "promote", []byte(`{"v":1}`), 2, "paul@keymail.dev", true, now)
24 if err != nil {
25 t.Fatal(err)
26 }
27 if pc.ID == "" {
28 t.Fatal("ProposeChange: empty ID")
29 }
30 if !reflect.DeepEqual(pc.Confirms, []string{"paul@keymail.dev"}) {
31 t.Fatalf("ProposeChange Confirms = %v, want [paul@keymail.dev]", pc.Confirms)
32 }
33 if pc.Quorum != 2 || pc.AccountID != accountID || pc.App != "kass" || pc.Kind != "promote" {
34 t.Fatalf("ProposeChange fields wrong: %+v", pc)
35 }
36 if string(pc.Payload) != `{"v":1}` {
37 t.Fatalf("ProposeChange Payload = %s", pc.Payload)
38 }
39 if !pc.ExpiresAt.Equal(pc.CreatedAt.Add(PendingChangeTTL)) {
40 t.Fatalf("ExpiresAt = %v, want CreatedAt+%v = %v", pc.ExpiresAt, PendingChangeTTL, pc.CreatedAt.Add(PendingChangeTTL))
41 }
42
43 // Re-confirming as the SAME proposer is idempotent: still one confirm,
44 // not met.
45 got, met, err := s.ConfirmChange(pc.ID, "paul@keymail.dev", now)
46 if err != nil {
47 t.Fatal(err)
48 }
49 if met {
50 t.Fatal("ConfirmChange: met=true after idempotent re-confirm by proposer, want false")
51 }
52 if !reflect.DeepEqual(got.Confirms, []string{"paul@keymail.dev"}) {
53 t.Fatalf("Confirms after re-confirm = %v, want [paul@keymail.dev]", got.Confirms)
54 }
55
56 // A second, distinct member's confirmation reaches quorum.
57 got, met, err = s.ConfirmChange(pc.ID, "bob@keymail.dev", now)
58 if err != nil {
59 t.Fatal(err)
60 }
61 if !met {
62 t.Fatal("ConfirmChange: met=false after quorum reached, want true")
63 }
64 wantConfirms := []string{"bob@keymail.dev", "paul@keymail.dev"}
65 gotConfirms := append([]string(nil), got.Confirms...)
66 sort.Strings(gotConfirms)
67 if !reflect.DeepEqual(gotConfirms, wantConfirms) {
68 t.Fatalf("Confirms at quorum = %v, want %v", gotConfirms, wantConfirms)
69 }
70
71 // Already-met quorum still reports met on a further read.
72 fetched, err := s.PendingChange(pc.ID, now)
73 if err != nil {
74 t.Fatal(err)
75 }
76 if fetched == nil {
77 t.Fatal("PendingChange returned nil for a live change")
78 }
79 if len(fetched.Confirms) != 2 {
80 t.Fatalf("PendingChange Confirms = %v, want 2 entries", fetched.Confirms)
81 }
82}
83
84func TestPendingChangeCLIProposalStartsAtZero(t *testing.T) {
85 s := openTestStore(t)
86 now := time.Now()
87 accountID := testAccountID()
88
89 // CLI bearer-token proposal (proposerConfirmed=false): a bearer token
90 // must never count toward a passkey quorum, so this starts at zero
91 // confirmations even though quorum is only 1.
92 pc, err := s.ProposeChange(accountID, "aria", "policy", []byte(`{"v":2}`), 1, "cli@keymail.dev", false, now)
93 if err != nil {
94 t.Fatal(err)
95 }
96 if len(pc.Confirms) != 0 {
97 t.Fatalf("ProposeChange(proposerConfirmed=false) Confirms = %v, want empty", pc.Confirms)
98 }
99
100 list, err := s.PendingChangesFor(accountID, "aria", now)
101 if err != nil {
102 t.Fatal(err)
103 }
104 if len(list) != 1 || len(list[0].Confirms) != 0 {
105 t.Fatalf("PendingChangesFor = %+v, want one change with zero confirms", list)
106 }
107
108 // One console confirmation — even by the proposer themself — meets
109 // quorum of 1.
110 got, met, err := s.ConfirmChange(pc.ID, "cli@keymail.dev", now)
111 if err != nil {
112 t.Fatal(err)
113 }
114 if !met {
115 t.Fatal("ConfirmChange: met=false after single confirmation on quorum-1 CLI proposal, want true")
116 }
117 if !reflect.DeepEqual(got.Confirms, []string{"cli@keymail.dev"}) {
118 t.Fatalf("Confirms = %v, want [cli@keymail.dev]", got.Confirms)
119 }
120}
121
122func TestPendingChangeExpiryAndExecution(t *testing.T) {
123 s := openTestStore(t)
124 now := time.Now()
125 accountID := testAccountID()
126
127 // --- expiry ---
128 pc, err := s.ProposeChange(accountID, "kass", "fasttrack", []byte(`{"v":3}`), 2, "paul@keymail.dev", true, now)
129 if err != nil {
130 t.Fatal(err)
131 }
132 past := pc.ExpiresAt.Add(time.Second) // now >= ExpiresAt
133 _, _, err = s.ConfirmChange(pc.ID, "bob@keymail.dev", past)
134 if err == nil || !strings.Contains(err.Error(), "expired") {
135 t.Fatalf("ConfirmChange past expiry: err = %v, want an error mentioning 'expired'", err)
136 }
137
138 list, err := s.PendingChangesFor(accountID, "kass", past)
139 if err != nil {
140 t.Fatal(err)
141 }
142 if len(list) != 0 {
143 t.Fatalf("PendingChangesFor at/after expiry = %v, want empty", list)
144 }
145 if got, err := s.PendingChange(pc.ID, past); err != nil || got != nil {
146 t.Fatalf("PendingChange at/after expiry = %v, %v, want nil, nil", got, err)
147 }
148
149 // --- execution ---
150 pc2, err := s.ProposeChange(accountID, "kass", "promote", []byte(`{"v":4}`), 1, "paul@keymail.dev", true, now)
151 if err != nil {
152 t.Fatal(err)
153 }
154 if err := s.MarkExecuted(pc2.ID, now); err != nil {
155 t.Fatal(err)
156 }
157 _, _, err = s.ConfirmChange(pc2.ID, "bob@keymail.dev", now)
158 if err == nil {
159 t.Fatal("ConfirmChange on an executed change: want error, got nil")
160 }
161
162 // pc (above) is still genuinely pending at real `now` — only `past` made
163 // it look expired — so it legitimately remains in this account/app's
164 // listing; what must be true is that the newly-executed pc2 does not.
165 list2, err := s.PendingChangesFor(accountID, "kass", now)
166 if err != nil {
167 t.Fatal(err)
168 }
169 for _, c := range list2 {
170 if c.ID == pc2.ID {
171 t.Fatalf("PendingChangesFor after execution still lists executed change %s: %+v", pc2.ID, list2)
172 }
173 }
174 if got, err := s.PendingChange(pc2.ID, now); err != nil || got != nil {
175 t.Fatalf("PendingChange after execution = %v, %v, want nil, nil", got, err)
176 }
177
178 // --- delete ---
179 pc3, err := s.ProposeChange(accountID, "kass", "promote", []byte(`{"v":5}`), 1, "paul@keymail.dev", true, now)
180 if err != nil {
181 t.Fatal(err)
182 }
183 if err := s.DeleteChange(pc3.ID); err != nil {
184 t.Fatal(err)
185 }
186 if got, err := s.PendingChange(pc3.ID, now); err != nil || got != nil {
187 t.Fatalf("PendingChange after DeleteChange = %v, %v, want nil, nil", got, err)
188 }
189}
190
191// TestConfirmChangeRefusalsAreSentinels: ConfirmChange's three refusals are
192// values a caller can test for, not prose it has to grep. The HTTP mapping
193// (refuseConfirm) turns them into 404 / 410 / 409, and a reworded message
194// must not be able to silently downgrade any of those to a 500 that tells a
195// member to "try again" about something that can never succeed.
196func TestConfirmChangeRefusalsAreSentinels(t *testing.T) {
197 s := openTestStore(t)
198 now := time.Now()
199 accountID := testAccountID()
200
201 if _, _, err := s.ConfirmChange("nosuchid", "paul@keymail.dev", now); !errors.Is(err, ErrNoPendingChange) {
202 t.Errorf("ConfirmChange on an unknown id = %v, want ErrNoPendingChange", err)
203 }
204
205 expiring, err := s.ProposeChange(accountID, "kass", "promote", []byte(`{}`), 2, "paul@keymail.dev", true, now)
206 if err != nil {
207 t.Fatal(err)
208 }
209 _, _, err = s.ConfirmChange(expiring.ID, "bob@keymail.dev", expiring.ExpiresAt.Add(time.Second))
210 if !errors.Is(err, ErrPendingChangeExpired) {
211 t.Errorf("ConfirmChange past expiry = %v, want ErrPendingChangeExpired", err)
212 }
213 // The id stays in the message: a sentinel is for the caller, the text is
214 // for the log.
215 if err == nil || !strings.Contains(err.Error(), expiring.ID) {
216 t.Errorf("expiry error %v does not name the change", err)
217 }
218
219 done, err := s.ProposeChange(accountID, "kass", "promote", []byte(`{}`), 2, "paul@keymail.dev", true, now)
220 if err != nil {
221 t.Fatal(err)
222 }
223 if err := s.MarkExecuted(done.ID, now); err != nil {
224 t.Fatal(err)
225 }
226 if _, _, err := s.ConfirmChange(done.ID, "bob@keymail.dev", now); !errors.Is(err, ErrPendingChangeExecuted) {
227 t.Errorf("ConfirmChange on an executed change = %v, want ErrPendingChangeExecuted", err)
228 }
229}
230
231// TestRefuseConfirmMapsSentinels: the status each sentinel earns, pinned
232// against errors.Is rather than the wording — the wording here is deliberately
233// nothing like the store's, which is exactly the case substring matching used
234// to get wrong.
235func TestRefuseConfirmMapsSentinels(t *testing.T) {
236 fx := newConsoleFixture(t)
237 for _, tc := range []struct {
238 name string
239 err error
240 want int
241 }{
242 {"unknown", fmt.Errorf("wrapped in something else entirely: %w", ErrNoPendingChange), http.StatusNotFound},
243 {"expired", fmt.Errorf("wrapped in something else entirely: %w", ErrPendingChangeExpired), http.StatusGone},
244 {"executed", fmt.Errorf("wrapped in something else entirely: %w", ErrPendingChangeExecuted), http.StatusConflict},
245 {"other", errors.New("database is locked"), http.StatusInternalServerError},
246 } {
247 rec := httptest.NewRecorder()
248 req := httptest.NewRequest(http.MethodPost, "/a/bab/apps/widget/pending/x/confirm", nil)
249 fx.srv.refuseConfirm(rec, req, tc.err)
250 if rec.Code != tc.want {
251 t.Errorf("refuseConfirm(%s) = %d, want %d", tc.name, rec.Code, tc.want)
252 }
253 }
254}
255