feat(server): confirm-link endpoint for the one-tap deeplink
Add POST /internal/sessions/email/confirm-link: it verifies a deeplink token via ConfirmByToken and, for a login, mints a session (the deeplink page signs in with it); for a link, attaches the confirmed email and reports "confirmed" or "merge_required" (the app drives the interactive merge). The token, not a request session, is the authorization. Add LinkConfirmation.IsLogin() and integration tests for the login, link and merge branches (the token is read from the mailed link). The gateway RPC, live event and SPA route follow.
This commit is contained in:
@@ -278,3 +278,98 @@ func TestEmailLoginProvisionsGuestUntilConfirmed(t *testing.T) {
|
||||
t.Error("confirming the login must clear the guest flag (promote to durable)")
|
||||
}
|
||||
}
|
||||
|
||||
// confirmToken extracts the one-tap deeplink token from the /app/#/confirm/<token> link
|
||||
// the branded email carries.
|
||||
var confirmToken = regexp.MustCompile(`/confirm/([A-Za-z0-9_-]+)`)
|
||||
|
||||
func tokenFromMail(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
m := confirmToken.FindStringSubmatch(body)
|
||||
if m == nil {
|
||||
t.Fatalf("no confirm token in mail body %q", body)
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
// TestConfirmByTokenLogin: the one-tap deeplink token completes an email login,
|
||||
// clearing the guest flag.
|
||||
func TestConfirmByTokenLogin(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
mailer := &capturingMailer{}
|
||||
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||
email := "tok-login-" + uuid.NewString() + "@example.com"
|
||||
|
||||
id, err := svc.RequestLoginCode(ctx, email, "", "en")
|
||||
if err != nil {
|
||||
t.Fatalf("request login: %v", err)
|
||||
}
|
||||
res, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody))
|
||||
if err != nil {
|
||||
t.Fatalf("confirm by token: %v", err)
|
||||
}
|
||||
if !res.IsLogin() || res.Account != id {
|
||||
t.Fatalf("login result = %+v, want login for %s", res, id)
|
||||
}
|
||||
if !identityConfirmed(t, account.KindEmail, email) {
|
||||
t.Error("email identity must be confirmed after the token login")
|
||||
}
|
||||
if acc, _ := store.GetByID(ctx, id); acc.IsGuest {
|
||||
t.Error("the token login must clear the guest flag")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfirmByTokenLink: the token attaches a free email to the requesting account.
|
||||
func TestConfirmByTokenLink(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
mailer := &capturingMailer{}
|
||||
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||
acc := provisionAccount(t)
|
||||
email := "tok-link-" + uuid.NewString() + "@example.com"
|
||||
|
||||
if err := svc.RequestLinkCode(ctx, acc, email); err != nil {
|
||||
t.Fatalf("request link: %v", err)
|
||||
}
|
||||
res, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody))
|
||||
if err != nil {
|
||||
t.Fatalf("confirm by token: %v", err)
|
||||
}
|
||||
if res.IsLogin() || res.NeedsMerge || res.Account != acc {
|
||||
t.Fatalf("link result = %+v, want a plain link for %s", res, acc)
|
||||
}
|
||||
if !identityConfirmed(t, account.KindEmail, email) {
|
||||
t.Error("email identity must be confirmed after the token link")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfirmByTokenLinkMerge: a token for an address owned by another account signals
|
||||
// a merge (leaving the token unconsumed for the interactive step).
|
||||
func TestConfirmByTokenLinkMerge(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
mailer := &capturingMailer{}
|
||||
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||
email := "tok-merge-" + uuid.NewString() + "@example.com"
|
||||
|
||||
owner := provisionAccount(t)
|
||||
if err := svc.RequestCode(ctx, owner, email); err != nil {
|
||||
t.Fatalf("owner request: %v", err)
|
||||
}
|
||||
if _, err := svc.ConfirmCode(ctx, owner, email, sixDigit.FindString(mailer.lastBody)); err != nil {
|
||||
t.Fatalf("owner confirm: %v", err)
|
||||
}
|
||||
|
||||
other := provisionAccount(t)
|
||||
if err := svc.RequestLinkCode(ctx, other, email); err != nil {
|
||||
t.Fatalf("link request: %v", err)
|
||||
}
|
||||
res, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody))
|
||||
if err != nil {
|
||||
t.Fatalf("confirm by token: %v", err)
|
||||
}
|
||||
if !res.NeedsMerge || res.MergeOwner != owner {
|
||||
t.Fatalf("merge result = %+v, want NeedsMerge with owner=%s", res, owner)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user