fix(passkey): 删除 passkey 先确认并显示名称与注册时间,邮箱未验证时后端拒删最后一把,错误内联显示
This commit is contained in:
16 files changed
+355
-59
No files matched your search
@@ -46,6 +46,8 @@ func plantPasskeyChallenge(repo *fakeRepo, id string, expiresAt time.Time, sessi
|
||||
func TestPasskeyRegisterVertical(t *testing.T) {
|
||||
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
|
||||
repo := newFakeRepo()
|
||||
// A verified email keeps a door open, so step 5 may remove the only passkey.
|
||||
repo.staff["u1"] = &StaffUser{ID: "u1", Username: "u1", Email: "[email protected]", Role: "user", EmailVerified: true}
|
||||
v := &fakePasskeyVerifier{
|
||||
options: json.RawMessage(`{"publicKey":{"challenge":"Y2hhbGxlbmdl"}}`),
|
||||
credential: VerifiedCredential{
|
||||
@@ -294,6 +296,65 @@ func TestPasskeyDeleteScoping(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPasskeyDeleteLastGuard pins the last-passkey guard: without a verified email
|
||||
// the only passkey is the account's way in, so its delete is a 409 that leaves it
|
||||
// bound; a second passkey or a verified email lets the delete through.
|
||||
func TestPasskeyDeleteLastGuard(t *testing.T) {
|
||||
cred := func(id string) PasskeyCredential {
|
||||
return PasskeyCredential{ID: id, UserID: "u1", CredentialID: "c-" + id, CreatedAt: frozenNow}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
verified bool
|
||||
creds []string
|
||||
want int
|
||||
}{
|
||||
{"only passkey, email unverified", false, []string{"a"}, http.StatusConflict},
|
||||
{"only passkey, email verified", true, []string{"a"}, http.StatusNoContent},
|
||||
{"two passkeys, email unverified", false, []string{"a", "b"}, http.StatusNoContent},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for _, role := range []string{"user", "admin"} {
|
||||
user := &Principal{UserID: "u1", Email: "[email protected]", Role: role}
|
||||
repo := newFakeRepo()
|
||||
repo.staff["u1"] = &StaffUser{ID: "u1", Username: "u1", Email: "[email protected]", Role: role, EmailVerified: tc.verified}
|
||||
for _, id := range tc.creds {
|
||||
repo.passkeyCreds[id] = cred(id)
|
||||
}
|
||||
eh := newPasskeyAPI(repo, &fakePasskeyVerifier{}, user)
|
||||
w := do(eh, "DELETE", "/api/v1/account/passkey/credentials/a", "", nil)
|
||||
if w.Code != tc.want {
|
||||
t.Fatalf("%s: code = %d body %s, want %d", role, w.Code, w.Body.String(), tc.want)
|
||||
}
|
||||
_, kept := repo.passkeyCreds["a"]
|
||||
if tc.want == http.StatusConflict {
|
||||
if got := decodeErr(t, w); got != "last_passkey" {
|
||||
t.Errorf("%s: error code = %q, want last_passkey", role, got)
|
||||
}
|
||||
if !kept {
|
||||
t.Errorf("%s: a refused delete must leave the passkey bound", role)
|
||||
}
|
||||
} else if kept {
|
||||
t.Errorf("%s: an allowed delete must remove the passkey", role)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Removing one of two leaves the other as the last one, which is then guarded.
|
||||
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
|
||||
repo := newFakeRepo()
|
||||
repo.staff["u1"] = &StaffUser{ID: "u1", Username: "u1", Email: "[email protected]", Role: "user"}
|
||||
repo.passkeyCreds["a"], repo.passkeyCreds["b"] = cred("a"), cred("b")
|
||||
eh := newPasskeyAPI(repo, &fakePasskeyVerifier{}, user)
|
||||
if w := do(eh, "DELETE", "/api/v1/account/passkey/credentials/a", "", nil); w.Code != http.StatusNoContent {
|
||||
t.Fatalf("first delete: code = %d, want 204", w.Code)
|
||||
}
|
||||
if w := do(eh, "DELETE", "/api/v1/account/passkey/credentials/b", "", nil); w.Code != http.StatusConflict {
|
||||
t.Fatalf("second delete: code = %d, want 409 (it is now the last one)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPasskeyDeleteUnknown pins the unknown-id path: deleting an id that does not exist
|
||||
// is a 404, never a silent 204.
|
||||
func TestPasskeyDeleteUnknown(t *testing.T) {
|
||||
|
||||
Reference in new issue
Block a user