package console import ( "errors" "fmt" "net/http" "net/http/httptest" "reflect" "sort" "strings" "testing" "time" ) func TestPendingChangeQuorum(t *testing.T) { s := openTestStore(t) now := time.Now() accountID := testAccountID() // Console-proposed (proposerConfirmed=true): the proposer's confirmation // is recorded atomically with the change, but that alone must not meet a // quorum of 2. pc, err := s.ProposeChange(accountID, "kass", "promote", []byte(`{"v":1}`), 2, "paul@keymail.dev", true, now) if err != nil { t.Fatal(err) } if pc.ID == "" { t.Fatal("ProposeChange: empty ID") } if !reflect.DeepEqual(pc.Confirms, []string{"paul@keymail.dev"}) { t.Fatalf("ProposeChange Confirms = %v, want [paul@keymail.dev]", pc.Confirms) } if pc.Quorum != 2 || pc.AccountID != accountID || pc.App != "kass" || pc.Kind != "promote" { t.Fatalf("ProposeChange fields wrong: %+v", pc) } if string(pc.Payload) != `{"v":1}` { t.Fatalf("ProposeChange Payload = %s", pc.Payload) } if !pc.ExpiresAt.Equal(pc.CreatedAt.Add(PendingChangeTTL)) { t.Fatalf("ExpiresAt = %v, want CreatedAt+%v = %v", pc.ExpiresAt, PendingChangeTTL, pc.CreatedAt.Add(PendingChangeTTL)) } // Re-confirming as the SAME proposer is idempotent: still one confirm, // not met. got, met, err := s.ConfirmChange(pc.ID, "paul@keymail.dev", now) if err != nil { t.Fatal(err) } if met { t.Fatal("ConfirmChange: met=true after idempotent re-confirm by proposer, want false") } if !reflect.DeepEqual(got.Confirms, []string{"paul@keymail.dev"}) { t.Fatalf("Confirms after re-confirm = %v, want [paul@keymail.dev]", got.Confirms) } // A second, distinct member's confirmation reaches quorum. got, met, err = s.ConfirmChange(pc.ID, "bob@keymail.dev", now) if err != nil { t.Fatal(err) } if !met { t.Fatal("ConfirmChange: met=false after quorum reached, want true") } wantConfirms := []string{"bob@keymail.dev", "paul@keymail.dev"} gotConfirms := append([]string(nil), got.Confirms...) sort.Strings(gotConfirms) if !reflect.DeepEqual(gotConfirms, wantConfirms) { t.Fatalf("Confirms at quorum = %v, want %v", gotConfirms, wantConfirms) } // Already-met quorum still reports met on a further read. fetched, err := s.PendingChange(pc.ID, now) if err != nil { t.Fatal(err) } if fetched == nil { t.Fatal("PendingChange returned nil for a live change") } if len(fetched.Confirms) != 2 { t.Fatalf("PendingChange Confirms = %v, want 2 entries", fetched.Confirms) } } func TestPendingChangeCLIProposalStartsAtZero(t *testing.T) { s := openTestStore(t) now := time.Now() accountID := testAccountID() // CLI bearer-token proposal (proposerConfirmed=false): a bearer token // must never count toward a passkey quorum, so this starts at zero // confirmations even though quorum is only 1. pc, err := s.ProposeChange(accountID, "aria", "policy", []byte(`{"v":2}`), 1, "cli@keymail.dev", false, now) if err != nil { t.Fatal(err) } if len(pc.Confirms) != 0 { t.Fatalf("ProposeChange(proposerConfirmed=false) Confirms = %v, want empty", pc.Confirms) } list, err := s.PendingChangesFor(accountID, "aria", now) if err != nil { t.Fatal(err) } if len(list) != 1 || len(list[0].Confirms) != 0 { t.Fatalf("PendingChangesFor = %+v, want one change with zero confirms", list) } // One console confirmation — even by the proposer themself — meets // quorum of 1. got, met, err := s.ConfirmChange(pc.ID, "cli@keymail.dev", now) if err != nil { t.Fatal(err) } if !met { t.Fatal("ConfirmChange: met=false after single confirmation on quorum-1 CLI proposal, want true") } if !reflect.DeepEqual(got.Confirms, []string{"cli@keymail.dev"}) { t.Fatalf("Confirms = %v, want [cli@keymail.dev]", got.Confirms) } } func TestPendingChangeExpiryAndExecution(t *testing.T) { s := openTestStore(t) now := time.Now() accountID := testAccountID() // --- expiry --- pc, err := s.ProposeChange(accountID, "kass", "fasttrack", []byte(`{"v":3}`), 2, "paul@keymail.dev", true, now) if err != nil { t.Fatal(err) } past := pc.ExpiresAt.Add(time.Second) // now >= ExpiresAt _, _, err = s.ConfirmChange(pc.ID, "bob@keymail.dev", past) if err == nil || !strings.Contains(err.Error(), "expired") { t.Fatalf("ConfirmChange past expiry: err = %v, want an error mentioning 'expired'", err) } list, err := s.PendingChangesFor(accountID, "kass", past) if err != nil { t.Fatal(err) } if len(list) != 0 { t.Fatalf("PendingChangesFor at/after expiry = %v, want empty", list) } if got, err := s.PendingChange(pc.ID, past); err != nil || got != nil { t.Fatalf("PendingChange at/after expiry = %v, %v, want nil, nil", got, err) } // --- execution --- pc2, err := s.ProposeChange(accountID, "kass", "promote", []byte(`{"v":4}`), 1, "paul@keymail.dev", true, now) if err != nil { t.Fatal(err) } if err := s.MarkExecuted(pc2.ID, now); err != nil { t.Fatal(err) } _, _, err = s.ConfirmChange(pc2.ID, "bob@keymail.dev", now) if err == nil { t.Fatal("ConfirmChange on an executed change: want error, got nil") } // pc (above) is still genuinely pending at real `now` — only `past` made // it look expired — so it legitimately remains in this account/app's // listing; what must be true is that the newly-executed pc2 does not. list2, err := s.PendingChangesFor(accountID, "kass", now) if err != nil { t.Fatal(err) } for _, c := range list2 { if c.ID == pc2.ID { t.Fatalf("PendingChangesFor after execution still lists executed change %s: %+v", pc2.ID, list2) } } if got, err := s.PendingChange(pc2.ID, now); err != nil || got != nil { t.Fatalf("PendingChange after execution = %v, %v, want nil, nil", got, err) } // --- delete --- pc3, err := s.ProposeChange(accountID, "kass", "promote", []byte(`{"v":5}`), 1, "paul@keymail.dev", true, now) if err != nil { t.Fatal(err) } if err := s.DeleteChange(pc3.ID); err != nil { t.Fatal(err) } if got, err := s.PendingChange(pc3.ID, now); err != nil || got != nil { t.Fatalf("PendingChange after DeleteChange = %v, %v, want nil, nil", got, err) } } // TestConfirmChangeRefusalsAreSentinels: ConfirmChange's three refusals are // values a caller can test for, not prose it has to grep. The HTTP mapping // (refuseConfirm) turns them into 404 / 410 / 409, and a reworded message // must not be able to silently downgrade any of those to a 500 that tells a // member to "try again" about something that can never succeed. func TestConfirmChangeRefusalsAreSentinels(t *testing.T) { s := openTestStore(t) now := time.Now() accountID := testAccountID() if _, _, err := s.ConfirmChange("nosuchid", "paul@keymail.dev", now); !errors.Is(err, ErrNoPendingChange) { t.Errorf("ConfirmChange on an unknown id = %v, want ErrNoPendingChange", err) } expiring, err := s.ProposeChange(accountID, "kass", "promote", []byte(`{}`), 2, "paul@keymail.dev", true, now) if err != nil { t.Fatal(err) } _, _, err = s.ConfirmChange(expiring.ID, "bob@keymail.dev", expiring.ExpiresAt.Add(time.Second)) if !errors.Is(err, ErrPendingChangeExpired) { t.Errorf("ConfirmChange past expiry = %v, want ErrPendingChangeExpired", err) } // The id stays in the message: a sentinel is for the caller, the text is // for the log. if err == nil || !strings.Contains(err.Error(), expiring.ID) { t.Errorf("expiry error %v does not name the change", err) } done, err := s.ProposeChange(accountID, "kass", "promote", []byte(`{}`), 2, "paul@keymail.dev", true, now) if err != nil { t.Fatal(err) } if err := s.MarkExecuted(done.ID, now); err != nil { t.Fatal(err) } if _, _, err := s.ConfirmChange(done.ID, "bob@keymail.dev", now); !errors.Is(err, ErrPendingChangeExecuted) { t.Errorf("ConfirmChange on an executed change = %v, want ErrPendingChangeExecuted", err) } } // TestRefuseConfirmMapsSentinels: the status each sentinel earns, pinned // against errors.Is rather than the wording — the wording here is deliberately // nothing like the store's, which is exactly the case substring matching used // to get wrong. func TestRefuseConfirmMapsSentinels(t *testing.T) { fx := newConsoleFixture(t) for _, tc := range []struct { name string err error want int }{ {"unknown", fmt.Errorf("wrapped in something else entirely: %w", ErrNoPendingChange), http.StatusNotFound}, {"expired", fmt.Errorf("wrapped in something else entirely: %w", ErrPendingChangeExpired), http.StatusGone}, {"executed", fmt.Errorf("wrapped in something else entirely: %w", ErrPendingChangeExecuted), http.StatusConflict}, {"other", errors.New("database is locked"), http.StatusInternalServerError}, } { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/a/bab/apps/widget/pending/x/confirm", nil) fx.srv.refuseConfirm(rec, req, tc.err) if rec.Code != tc.want { t.Errorf("refuseConfirm(%s) = %d, want %d", tc.name, rec.Code, tc.want) } } }