Files
Felis/internal/api/reauth_test.go

583 lines
24 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"time"
)
// Reauth: once an account has a passkey or a verified email, adding or removing a
// passkey and changing the email need a factor proven within reauthWindow. These
// tests drive the real SessionAuth, so the proof is read from the session row the
// cookie names, exactly as in production.
const opTok = "tok-op"
// reauthFixture is the sessions fixture (steve: a player with a verified email,
// signed in on the laptop and the phone; alex: a player with no factor) plus a
// passkey verifier and an operator, pam, with a verified email. Every session
// starts with no proof on it.
func reauthFixture(t *testing.T) *sessionsFixture {
t.Helper()
f := newSessionsFixture(t)
f.api.Passkey = &fakePasskeyVerifier{}
f.repo.staff["pam"] = &StaffUser{ID: "u3", Username: "pam", Email: "[email protected]", Role: "admin", EmailVerified: true}
now := f.api.now()
f.repo.sessions[hashCookie(opTok)] = &fakeSession{userID: "u3", lastSeen: now, expiresAt: now.Add(time.Hour)}
for _, s := range f.repo.sessions {
s.reauthAt = time.Time{}
}
f.eh = f.api.ExternalHandler()
return f
}
func (f *sessionsFixture) reauthAt(tok string) time.Time {
return f.repo.sessions[hashCookie(tok)].reauthAt
}
func (f *sessionsFixture) setReauth(tok string, at time.Time) {
f.repo.sessions[hashCookie(tok)].reauthAt = at
}
func jsonCookie(tok string) map[string]string {
h := asCookie(tok)
h["Content-Type"] = "application/json"
return h
}
func TestSigningInByEmailCodeCountsAsReauth(t *testing.T) {
api, repo, mailer := seedLoginEmailAPI(t)
eh := api.ExternalHandler()
if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusAccepted {
t.Fatalf("start = %d (%s)", w.Code, w.Body.String())
}
w := do(eh, "POST", "/api/v1/auth/email/verify", `{"email":"[email protected]","code":"`+mailer.code+`"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("verify = %d (%s)", w.Code, w.Body.String())
}
s := repo.sessions[hashCookie(sessionCookieValue(t, w))]
if !s.reauthAt.Equal(api.now()) {
t.Fatalf("reauth_at = %v, want the sign-in time %v", s.reauthAt, api.now())
}
}
// A bind code proves the Minecraft account, and nothing about the account's own
// passkey or mailbox.
func TestSigningInByBindCodeIsNoReauth(t *testing.T) {
api, repo := seedBindAPI(t)
mintBindCode(t, api, repo, "ABCD2345", bindTestUUID, authSourceMojang)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"ABCD2345"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("bind = %d (%s)", w.Code, w.Body.String())
}
if s := repo.sessions[hashCookie(sessionCookieValue(t, w))]; !s.reauthAt.IsZero() {
t.Fatalf("reauth_at = %v, want none after a bind-code sign-in", s.reauthAt)
}
}
// guardedChange is a request the gate covers, the status it gets once the gate
// lets it through, and a check that it changed nothing when refused.
type guardedChange struct {
name, method, path, body string
ok int
untouched func(f *sessionsFixture) bool
}
var guardedChanges = []guardedChange{
{"add a passkey", "POST", "/api/v1/account/passkey/register/begin", "", http.StatusOK,
func(f *sessionsFixture) bool { return len(f.repo.passkeyChallenges) == 0 }},
{"remove a passkey", "DELETE", "/api/v1/account/passkey/credentials/a", "", http.StatusNoContent,
func(f *sessionsFixture) bool { _, ok := f.repo.passkeyCreds["a"]; return ok }},
{"change the email", "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, http.StatusAccepted,
func(f *sessionsFixture) bool { return len(f.repo.otps) == 0 }},
{"record an unverified email", "POST", "/api/v1/account/email", `{"email":"[email protected]"}`, http.StatusOK,
func(f *sessionsFixture) bool { return f.repo.staff["steve"].EmailVerified }},
}
func TestGuardedChangesNeedARecentReauth(t *testing.T) {
for _, tc := range []struct {
name string
proof time.Duration // how long ago the session proved a factor; -1 = never
wantOK bool
}{
{"never proved", -1, false},
{"proved 6 minutes ago", 6 * time.Minute, false},
{"proved exactly 5 minutes ago", 5 * time.Minute, false},
{"proved 4m59s ago", 5*time.Minute - time.Second, true},
{"proved just now", 0, true},
} {
for _, g := range guardedChanges {
t.Run(tc.name+"/"+g.name, func(t *testing.T) {
f := reauthFixture(t)
f.api.Mailer = &captureMailer{}
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", UserVerified: true, CreatedAt: frozenNow}
if tc.proof >= 0 {
f.setReauth(laptopTok, f.api.now().Add(-tc.proof))
}
w := do(f.eh, g.method, g.path, g.body, jsonCookie(laptopTok))
if tc.wantOK {
if w.Code != g.ok {
t.Fatalf("%s = %d (%s), want %d", g.name, w.Code, w.Body.String(), g.ok)
}
return
}
if w.Code != http.StatusForbidden || decodeErr(t, w) != "reauth_required" {
t.Fatalf("%s = %d (%s), want 403 reauth_required", g.name, w.Code, w.Body.String())
}
if !g.untouched(f) {
t.Fatalf("%s went through despite the refusal", g.name)
}
})
}
}
}
// With no passkey and no verified email the session is the account's only way
// in, so there is nothing a reauth could protect and nothing to give one with.
func TestAccountWithoutAFactorNeedsNoReauth(t *testing.T) {
f := reauthFixture(t)
f.api.Mailer = &captureMailer{}
if w := do(f.eh, "POST", "/api/v1/account/passkey/register/begin", "", asCookie(alexTok)); w.Code != http.StatusOK {
t.Fatalf("register begin = %d (%s)", w.Code, w.Body.String())
}
if w := do(f.eh, "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, jsonCookie(alexTok)); w.Code != http.StatusAccepted {
t.Fatalf("email start = %d (%s)", w.Code, w.Body.String())
}
}
// An unverified address is no factor: it never received a code.
func TestUnverifiedEmailIsNoFactor(t *testing.T) {
f := reauthFixture(t)
f.repo.staff["alex"].Email = "[email protected]"
if w := do(f.eh, "POST", "/api/v1/account/passkey/register/begin", "", asCookie(alexTok)); w.Code != http.StatusOK {
t.Fatalf("register begin = %d (%s)", w.Code, w.Body.String())
}
}
// A passkey alone is a factor worth guarding.
func TestPasskeyAloneNeedsReauth(t *testing.T) {
f := reauthFixture(t)
f.repo.passkeyCreds["x"] = PasskeyCredential{ID: "x", UserID: "u2", CredentialID: "c-x", CreatedAt: frozenNow}
w := do(f.eh, "POST", "/api/v1/account/passkey/register/begin", "", asCookie(alexTok))
if w.Code != http.StatusForbidden || decodeErr(t, w) != "reauth_required" {
t.Fatalf("register begin = %d (%s), want 403 reauth_required", w.Code, w.Body.String())
}
}
// A Cloudflare Access caller is authenticated by the proxy on every request and
// has no session to mark.
func TestAccessCallerNeedsNoReauth(t *testing.T) {
repo := newFakeRepo()
repo.staff["op"] = &StaffUser{ID: "u1", Username: "op", Role: "admin", Email: "[email protected]", EmailVerified: true}
repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", UserVerified: true, CreatedAt: frozenNow}
api := newTestAPI(repo, newFakeCluster())
api.Passkey = &fakePasskeyVerifier{}
api.External = staticExternal{p: &Principal{UserID: "u1", Email: "[email protected]", Role: "admin", EmailVerified: true}}
if w := do(api.ExternalHandler(), "POST", "/api/v1/account/passkey/register/begin", "", nil); w.Code != http.StatusOK {
t.Fatalf("register begin = %d (%s)", w.Code, w.Body.String())
}
}
type reauthStatusBody struct {
Needed bool `json:"needed"`
Until *time.Time `json:"until"`
Factors []string `json:"factors"`
}
func getReauthStatus(t *testing.T, f *sessionsFixture, tok string) reauthStatusBody {
t.Helper()
w := do(f.eh, "GET", "/api/v1/account/reauth", "", asCookie(tok))
if w.Code != http.StatusOK {
t.Fatalf("status = %d (%s)", w.Code, w.Body.String())
}
var b reauthStatusBody
if err := json.Unmarshal(w.Body.Bytes(), &b); err != nil {
t.Fatal(err)
}
return b
}
func TestReauthStatusNamesTheFactors(t *testing.T) {
f := reauthFixture(t)
f.api.Mailer = &captureMailer{}
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", UserVerified: true, CreatedAt: frozenNow}
f.repo.passkeyCreds["p"] = PasskeyCredential{ID: "p", UserID: "u3", CredentialID: "c-p", CreatedAt: frozenNow}
steve := getReauthStatus(t, f, laptopTok)
if !steve.Needed || steve.Until != nil || strings.Join(steve.Factors, ",") != "passkey,email" {
t.Fatalf("player status = %+v, want needed with passkey,email", steve)
}
// An operator's verified email is no factor: signing in as staff by email
// also takes in-game approval.
pam := getReauthStatus(t, f, opTok)
if !pam.Needed || strings.Join(pam.Factors, ",") != "passkey,sign_in" {
t.Fatalf("operator status = %+v, want needed with passkey,sign_in", pam)
}
alex := getReauthStatus(t, f, alexTok)
if alex.Needed || len(alex.Factors) != 0 {
t.Fatalf("no-factor status = %+v, want not needed and no factors", alex)
}
proved := f.api.now().Add(-time.Minute)
f.setReauth(laptopTok, proved)
steve = getReauthStatus(t, f, laptopTok)
if steve.Needed || steve.Until == nil || !steve.Until.Equal(proved.Add(reauthWindow)) {
t.Fatalf("after a proof status = %+v, want not needed until %v", steve, proved.Add(reauthWindow))
}
}
// TestReauthWithoutMailRelay: with no [smtp] relay a verified email is no way in
// (the email and op-login doors answer 503), so it is neither offered as a factor
// nor guarded; a passkey still is, and the email start door says why it cannot help.
func TestReauthWithoutMailRelay(t *testing.T) {
f := reauthFixture(t)
f.repo.passkeyCreds["p"] = PasskeyCredential{ID: "p", UserID: "u3", CredentialID: "c-p", CreatedAt: frozenNow}
steve := getReauthStatus(t, f, laptopTok)
if steve.Needed || len(steve.Factors) != 0 {
t.Fatalf("email-only player status = %+v, want not needed and no factors", steve)
}
pam := getReauthStatus(t, f, opTok)
if !pam.Needed || strings.Join(pam.Factors, ",") != "passkey" {
t.Fatalf("operator with a passkey status = %+v, want needed with passkey only", pam)
}
w := do(f.eh, "POST", "/api/v1/account/reauth/email/start", "", asCookie(laptopTok))
if code, _ := errEnvelope(t, w); w.Code != http.StatusServiceUnavailable || code != "mail_unavailable" {
t.Fatalf("email start = %d %s, want 503 mail_unavailable", w.Code, w.Body.String())
}
if n := len(f.repo.otps); n != 0 {
t.Errorf("a refused start minted %d codes", n)
}
}
func TestReauthByEmailCode(t *testing.T) {
f := reauthFixture(t)
mailer := &captureMailer{}
f.api.Mailer = mailer
if w := do(f.eh, "POST", "/api/v1/account/reauth/email/start", "", asCookie(laptopTok)); w.Code != http.StatusAccepted {
t.Fatalf("start = %d (%s)", w.Code, w.Body.String())
}
if mailer.email != "[email protected]" || mailer.code == "" {
t.Fatalf("code went to %q (%q), want [email protected]", mailer.email, mailer.code)
}
wrong := "000000"
if mailer.code == wrong {
wrong = "111111"
}
w := do(f.eh, "POST", "/api/v1/account/reauth/email/verify", `{"code":"`+wrong+`"}`, jsonCookie(laptopTok))
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("wrong code = %d (%s), want 400 invalid_code", w.Code, w.Body.String())
}
if !f.reauthAt(laptopTok).IsZero() {
t.Fatal("a wrong code marked the session")
}
w = do(f.eh, "POST", "/api/v1/account/reauth/email/verify", `{"code":"`+mailer.code+`"}`, jsonCookie(laptopTok))
if w.Code != http.StatusOK {
t.Fatalf("verify = %d (%s)", w.Code, w.Body.String())
}
var body struct {
OK bool `json:"ok"`
Until time.Time `json:"until"`
}
_ = json.Unmarshal(w.Body.Bytes(), &body)
if !body.OK || !body.Until.Equal(f.api.now().Add(reauthWindow)) {
t.Fatalf("verify body = %s, want ok until now+5m", w.Body.String())
}
if !f.reauthAt(laptopTok).Equal(f.api.now()) {
t.Fatalf("laptop reauth_at = %v, want now", f.reauthAt(laptopTok))
}
// The proof belongs to the session that gave it.
if !f.reauthAt(phoneTok).IsZero() {
t.Fatal("the phone was marked by the laptop's code")
}
if w := do(f.eh, "POST", "/api/v1/account/passkey/register/begin", "", asCookie(laptopTok)); w.Code != http.StatusOK {
t.Fatalf("register begin after reauth = %d (%s)", w.Code, w.Body.String())
}
var audited bool
for _, e := range f.repo.audits {
audited = audited || (e.Action == "account.reauth" && e.ServerName == "email")
}
if !audited {
t.Fatalf("no account.reauth audit naming the email factor: %+v", f.repo.audits)
}
}
// A code from another step-up (the email change, a migration) does not reauth.
func TestReauthCodeIsItsOwnPurpose(t *testing.T) {
f := reauthFixture(t)
mailer := &captureMailer{}
f.api.Mailer = mailer
f.setReauth(laptopTok, f.api.now())
if w := do(f.eh, "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, jsonCookie(laptopTok)); w.Code != http.StatusAccepted {
t.Fatalf("email start = %d (%s)", w.Code, w.Body.String())
}
w := do(f.eh, "POST", "/api/v1/account/reauth/email/verify", `{"code":"`+mailer.code+`"}`, jsonCookie(phoneTok))
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("onboarding code on the reauth door = %d (%s), want 400 invalid_code", w.Code, w.Body.String())
}
}
func TestOperatorsCannotReauthByEmail(t *testing.T) {
f := reauthFixture(t)
mailer := &captureMailer{}
f.api.Mailer = mailer
for _, path := range []string{"/api/v1/account/reauth/email/start", "/api/v1/account/reauth/email/verify"} {
w := do(f.eh, "POST", path, `{"code":"123456"}`, jsonCookie(opTok))
if w.Code != http.StatusForbidden || decodeErr(t, w) != "staff_reauth" {
t.Fatalf("%s = %d (%s), want 403 staff_reauth", path, w.Code, w.Body.String())
}
}
if mailer.calls != 0 {
t.Fatal("a code was mailed to an operator")
}
}
func TestReauthByEmailNeedsAVerifiedAddress(t *testing.T) {
f := reauthFixture(t)
f.api.Mailer = &captureMailer{}
f.repo.staff["alex"].Email = "[email protected]"
w := do(f.eh, "POST", "/api/v1/account/reauth/email/start", "", asCookie(alexTok))
if w.Code != http.StatusConflict || decodeErr(t, w) != "no_step_up_factor" {
t.Fatalf("start = %d (%s), want 409 no_step_up_factor", w.Code, w.Body.String())
}
}
func TestReauthByPasskey(t *testing.T) {
f := reauthFixture(t)
pv := f.api.Passkey.(*fakePasskeyVerifier)
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", SignCount: 4, UserVerified: true, CreatedAt: frozenNow}
finish := func() int {
t.Helper()
if w := do(f.eh, "POST", "/api/v1/account/reauth/passkey/begin", "", asCookie(laptopTok)); w.Code != http.StatusOK {
t.Fatalf("begin = %d (%s)", w.Code, w.Body.String())
}
if len(pv.lastUser.Credentials) != 1 || pv.lastUser.Credentials[0].CredentialID != "c-a" {
t.Fatalf("assertion offered %+v, want the caller's own passkey", pv.lastUser.Credentials)
}
return do(f.eh, "POST", "/api/v1/account/reauth/passkey/finish", `{"assertion":{"id":"c-a"}}`, jsonCookie(laptopTok)).Code
}
pv.failErr = errors.New("assertion rejected")
if code := finish(); code != http.StatusBadRequest {
t.Fatalf("bad assertion = %d, want 400", code)
}
pv.failErr = nil
pv.assertion = VerifiedAssertion{CredentialID: "c-a", SignCount: 2, CloneWarning: true, UserVerified: true}
if code := finish(); code != http.StatusBadRequest {
t.Fatalf("cloned authenticator = %d, want 400", code)
}
if !f.reauthAt(laptopTok).IsZero() {
t.Fatal("a failed assertion marked the session")
}
pv.assertion = VerifiedAssertion{CredentialID: "c-a", SignCount: 5, UserVerified: true}
if code := finish(); code != http.StatusOK {
t.Fatalf("finish = %d, want 200", code)
}
if !f.reauthAt(laptopTok).Equal(f.api.now()) {
t.Fatalf("reauth_at = %v, want now", f.reauthAt(laptopTok))
}
if got := f.repo.passkeyCreds["a"].SignCount; got != 5 {
t.Fatalf("sign count = %d, want it advanced to 5", got)
}
// The challenge was spent: a replayed finish has nothing to consume.
w := do(f.eh, "POST", "/api/v1/account/reauth/passkey/finish", `{"assertion":{"id":"c-a"}}`, jsonCookie(laptopTok))
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" {
t.Fatalf("replayed finish = %d (%s), want 400 passkey_login_invalid", w.Code, w.Body.String())
}
}
// A migration's passkey challenge cannot be spent on a reauth, or the reverse.
func TestReauthPasskeyChallengeIsItsOwnPurpose(t *testing.T) {
f := reauthFixture(t)
pv := f.api.Passkey.(*fakePasskeyVerifier)
pv.assertion = VerifiedAssertion{CredentialID: "c-a", SignCount: 5, UserVerified: true}
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", UserVerified: true, CreatedAt: frozenNow}
if err := f.repo.CreatePasskeyChallenge(t.Context(), "m1", "u1", passkeyPurposeMigrate, []byte("s"), f.api.now().Add(time.Minute)); err != nil {
t.Fatal(err)
}
w := do(f.eh, "POST", "/api/v1/account/reauth/passkey/finish", `{"assertion":{"id":"c-a"}}`, jsonCookie(laptopTok))
if w.Code != http.StatusBadRequest {
t.Fatalf("finish on a migration challenge = %d (%s), want 400", w.Code, w.Body.String())
}
}
// Proving an address by code is an email reauth, so a first-time setup can go on
// to its next guarded step.
func TestVerifyingAnEmailCountsAsReauth(t *testing.T) {
f := reauthFixture(t)
mailer := &captureMailer{}
f.api.Mailer = mailer
if w := do(f.eh, "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, jsonCookie(alexTok)); w.Code != http.StatusAccepted {
t.Fatalf("start = %d (%s)", w.Code, w.Body.String())
}
if w := do(f.eh, "POST", "/api/v1/account/email/verify", `{"code":"`+mailer.code+`"}`, jsonCookie(alexTok)); w.Code != http.StatusOK {
t.Fatalf("verify = %d (%s)", w.Code, w.Body.String())
}
if !f.reauthAt(alexTok).Equal(f.api.now()) {
t.Fatalf("reauth_at = %v, want now", f.reauthAt(alexTok))
}
}
func TestRegisteringAPasskeyCountsAsReauth(t *testing.T) {
f := reauthFixture(t)
f.api.Passkey.(*fakePasskeyVerifier).credential = VerifiedCredential{CredentialID: "c-new", PublicKey: "pk"}
if w := do(f.eh, "POST", "/api/v1/account/passkey/register/begin", "", asCookie(alexTok)); w.Code != http.StatusOK {
t.Fatalf("begin = %d (%s)", w.Code, w.Body.String())
}
if w := do(f.eh, "POST", "/api/v1/account/passkey/register/finish", `{"attestation":{"id":"x"}}`, jsonCookie(alexTok)); w.Code != http.StatusCreated {
t.Fatalf("finish = %d (%s)", w.Code, w.Body.String())
}
if !f.reauthAt(alexTok).Equal(f.api.now()) {
t.Fatalf("reauth_at = %v, want now", f.reauthAt(alexTok))
}
}
// ---- change notices ----
func noticeFixture(t *testing.T) (*sessionsFixture, *noticeMailer) {
t.Helper()
f := reauthFixture(t)
mailer := &noticeMailer{}
f.api.Mailer = mailer
f.api.ClientIPHeader = "CF-Connecting-IP"
f.setReauth(laptopTok, f.api.now())
return f, mailer
}
func fromIP(h map[string]string) map[string]string {
h["CF-Connecting-IP"] = "203.0.113.9"
return h
}
func onlyNotice(t *testing.T, m *noticeMailer) (to, subject, body string) {
t.Helper()
if len(m.notices) != 1 {
t.Fatalf("notices = %q, want exactly one", m.notices)
}
parts := strings.SplitN(m.notices[0], "|", 3)
return parts[0], parts[1], parts[2]
}
func TestRemovingAPasskeyMailsTheAccount(t *testing.T) {
f, mailer := noticeFixture(t)
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", UserVerified: true, CreatedAt: frozenNow}
if w := do(f.eh, "DELETE", "/api/v1/account/passkey/credentials/a", "", fromIP(asCookie(laptopTok))); w.Code != http.StatusNoContent {
t.Fatalf("delete = %d (%s)", w.Code, w.Body.String())
}
to, subject, body := onlyNotice(t, mailer)
if to != "[email protected]" || subject != "Felis 已删除 Passkey · passkey removed" {
t.Fatalf("notice to %q subject %q", to, subject)
}
for _, want := range []string{
"A passkey was just removed from your Felis account, and every other device was signed out.",
"Time: 2023-11-14 22:13 UTC",
"From IP: 203.0.113.9",
"check the passkeys that remain",
"来源 IP:203.0.113.9",
} {
if !strings.Contains(body, want) {
t.Errorf("notice body lacks %q:\n%s", want, body)
}
}
}
func TestAddingAPasskeyMailsTheAccount(t *testing.T) {
f, mailer := noticeFixture(t)
f.api.Passkey.(*fakePasskeyVerifier).credential = VerifiedCredential{CredentialID: "c-new", PublicKey: "pk"}
if w := do(f.eh, "POST", "/api/v1/account/passkey/register/begin", "", asCookie(laptopTok)); w.Code != http.StatusOK {
t.Fatalf("begin = %d (%s)", w.Code, w.Body.String())
}
if w := do(f.eh, "POST", "/api/v1/account/passkey/register/finish", `{"attestation":{"id":"x"}}`, fromIP(jsonCookie(laptopTok))); w.Code != http.StatusCreated {
t.Fatalf("finish = %d (%s)", w.Code, w.Body.String())
}
to, subject, body := onlyNotice(t, mailer)
if to != "[email protected]" || subject != "Felis 已添加 Passkey · passkey added" ||
!strings.Contains(body, "A passkey was just added to your Felis account.") ||
!strings.Contains(body, "remove that passkey") {
t.Fatalf("notice to %q subject %q body:\n%s", to, subject, body)
}
}
// Replacing the address mails the OLD one, which is the mailbox the owner still
// reads if someone else made the change. The new address is masked.
func TestChangingTheEmailMailsTheOldAddress(t *testing.T) {
f, mailer := noticeFixture(t)
if w := do(f.eh, "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, jsonCookie(laptopTok)); w.Code != http.StatusAccepted {
t.Fatalf("start = %d (%s)", w.Code, w.Body.String())
}
if w := do(f.eh, "POST", "/api/v1/account/email/verify", `{"code":"`+mailer.code+`"}`, fromIP(jsonCookie(laptopTok))); w.Code != http.StatusOK {
t.Fatalf("verify = %d (%s)", w.Code, w.Body.String())
}
to, subject, body := onlyNotice(t, mailer)
if to != "[email protected]" || subject != "Felis 邮箱已更换 · email changed" {
t.Fatalf("notice to %q subject %q", to, subject)
}
if !strings.Contains(body, "The email on your Felis account was just changed to s***@new.example.") ||
!strings.Contains(body, "From IP: 203.0.113.9") {
t.Fatalf("notice body:\n%s", body)
}
if strings.Contains(body, "[email protected]") {
t.Fatalf("notice hands the new address to the old mailbox:\n%s", body)
}
}
// A first verification and a re-verification of the same address move nothing.
func TestVerifyingAFirstOrSameEmailMailsNoNotice(t *testing.T) {
for _, tc := range []struct {
name, tok, address string
}{
{"first address", alexTok, "[email protected]"},
{"same address", laptopTok, "[email protected]"},
} {
t.Run(tc.name, func(t *testing.T) {
f, mailer := noticeFixture(t)
if w := do(f.eh, "POST", "/api/v1/account/email/start", `{"email":"`+tc.address+`"}`, jsonCookie(tc.tok)); w.Code != http.StatusAccepted {
t.Fatalf("start = %d (%s)", w.Code, w.Body.String())
}
if w := do(f.eh, "POST", "/api/v1/account/email/verify", `{"code":"`+mailer.code+`"}`, jsonCookie(tc.tok)); w.Code != http.StatusOK {
t.Fatalf("verify = %d (%s)", w.Code, w.Body.String())
}
if len(mailer.notices) != 0 {
t.Fatalf("notices = %q, want none", mailer.notices)
}
})
}
}
// Nothing proves an unverified address belongs to the owner, so nothing is sent
// there.
func TestPasskeyNoticeSkipsAnUnverifiedAddress(t *testing.T) {
f, mailer := noticeFixture(t)
f.repo.staff["alex"].Email = "[email protected]"
// Two passkeys, so removing one is allowed without a verified email.
f.repo.passkeyCreds["x"] = PasskeyCredential{ID: "x", UserID: "u2", CredentialID: "c-x", CreatedAt: frozenNow}
f.repo.passkeyCreds["y"] = PasskeyCredential{ID: "y", UserID: "u2", CredentialID: "c-y", CreatedAt: frozenNow}
f.setReauth(alexTok, f.api.now())
if w := do(f.eh, "DELETE", "/api/v1/account/passkey/credentials/x", "", asCookie(alexTok)); w.Code != http.StatusNoContent {
t.Fatalf("delete = %d (%s)", w.Code, w.Body.String())
}
if len(mailer.notices) != 0 {
t.Fatalf("notices = %q, want none", mailer.notices)
}
}
func TestMaskEmail(t *testing.T) {
for in, want := range map[string]string{
"[email protected]": "a***@example.com",
"李雷@example.cn": "李***@example.cn",
"[email protected]": "a***@b.c",
"broken": "***",
"@nolocal.example": "***",
} {
if got := maskEmail(in); got != want {
t.Errorf("maskEmail(%q) = %q, want %q", in, got, want)
}
}
}