Files
upstream-ory-kratos/test/e2e/playwright/tests/desktop/regression/phone-normalization.spec.ts
upstream-mirror c60cc7ca75
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
2026-09-15 21:19:12 +05:00

770 lines
27 KiB
TypeScript

// Copyright © 2024 Ory Corp
// SPDX-License-Identifier: Apache-2.0
import { APIRequestContext } from "@playwright/test"
import type { OryKratosConfiguration } from "../../../../shared/config"
import { downgradeLegacyIdentity } from "../../../actions/db"
import {
createIdentity,
createIdentityWithPhoneAndPassword,
} from "../../../actions/identity"
import { hasNoSession, hasSession } from "../../../actions/session"
import {
deleteDocument,
documentUrl,
fetchDocument,
} from "../../../actions/webhook"
import { expect, test } from "../../../fixtures"
import smsSchema from "../../../fixtures/schemas/sms"
import smsOidcSchema from "../../../fixtures/schemas/sms-oidc"
import smsPasswordSchema from "../../../fixtures/schemas/sms-password"
import { LoginPage } from "../../../models/elements/login"
import { RegistrationPage } from "../../../models/elements/registration"
test.skip(
process.env.DB !== "sqlite",
"These tests rely on a SQL downgrade that only works with SQLite.",
)
type DeepPartial<T> = T extends object
? { [P in keyof T]?: DeepPartial<T[P]> }
: T
// Test phone numbers — deterministic so the SQL downgrade can target the known E.164 form
const DE_LEGACY = "+49 176 671 11 638"
const DE_E164 = "+4917667111638"
const DE_DASHED = "+49-176-671-11-638"
const US_LEGACY = "+1 (415) 555-2671"
const US_E164 = "+14155552671"
const smsDocumentId = "doc-sms-" + Math.random().toString(36).substring(7)
function smsSchemaBase64(schema: object) {
return (
"base64://" +
Buffer.from(JSON.stringify(schema), "ascii").toString("base64")
)
}
// Always register all three SMS schemas so identities from any test group
// can be listed and deleted via the admin API regardless of which schema
// is "active". Without this, cleanIdentities() silently fails when the
// current config doesn't know the schema of an existing identity.
function allSmsSchemas() {
return [
{ id: "sms" as const, url: smsSchemaBase64(smsSchema) },
{ id: "sms-password" as const, url: smsSchemaBase64(smsPasswordSchema) },
{ id: "sms-oidc" as const, url: smsSchemaBase64(smsOidcSchema) },
// Include default and email schemas so cleanIdentities can list identities
// left over from other test specs that use these schemas.
{
id: "default" as const,
url: "file://test/e2e/profiles/oidc/identity.traits.schema.json",
},
{
id: "email" as const,
url: "file://test/e2e/profiles/email/identity.traits.schema.json",
},
]
}
function smsCourierChannels() {
return [
{
id: "sms" as const,
type: "http" as const,
request_config: {
body: "base64://ZnVuY3Rpb24oY3R4KSB7DQpjdHg6IGN0eCwNCn0=",
method: "PUT",
url: documentUrl(smsDocumentId),
},
},
]
}
/**
* Config for code-only login flows (A, B, F tests).
* Password is disabled so the unified login page renders a single identifier
* input, avoiding the strict-mode violation caused by two `input[name=identifier]`
* elements when both code and password methods are active.
*/
function smsCodeConfig(schemaId: string): DeepPartial<OryKratosConfiguration> {
return {
security: { account_enumeration: { enabled: false } },
selfservice: {
flows: {
login: { style: "unified" },
registration: {
after: { code: { hooks: [{ hook: "session" }] } },
},
recovery: { enabled: true, use: "code" },
},
methods: {
code: { passwordless_enabled: true },
password: { enabled: false },
},
},
courier: { channels: smsCourierChannels() },
identity: { default_schema_id: schemaId, schemas: allSmsSchemas() },
}
}
/**
* Config for password+recovery flows (C, D, E tests).
* Code is enabled (for recovery) but passwordless_enabled is false so the
* login page shows only the password form (single identifier input).
* Recovery V2 (choose_recovery_address) is enabled so that SMS phone addresses
* are supported — Recovery V1 hardcodes AddressTypeEmail and never sends SMS.
*/
function smsPasswordConfig(
schemaId: string,
): DeepPartial<OryKratosConfiguration> {
return {
security: { account_enumeration: { enabled: false } },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
feature_flags: {
// choose_recovery_address enables Recovery V2 which supports SMS phone
// addresses — Recovery V1 hardcodes AddressTypeEmail and never sends SMS.
choose_recovery_address: true,
use_continue_with_transitions: true,
} as any,
selfservice: {
flows: {
login: { style: "unified" },
registration: {
after: { password: { hooks: [{ hook: "session" }] } },
},
recovery: { enabled: true, use: "code" },
},
methods: {
code: { enabled: true, passwordless_enabled: false },
password: { enabled: true },
},
},
courier: { channels: smsCourierChannels() },
identity: { default_schema_id: schemaId, schemas: allSmsSchemas() },
}
}
/**
* Deletes all identities via the admin API.
* Called in beforeEach to ensure a clean DB state for every test,
* preventing UNIQUE constraint violations from previous test runs or retries.
*/
async function cleanIdentities(request: APIRequestContext) {
const resp = await request.get(
"http://localhost:4434/admin/identities?per_page=250",
)
if (!resp.ok()) {
console.error(
`cleanIdentities: list failed with status ${resp.status()}: ${await resp.text()}`,
)
return
}
const identities: Array<{ id: string }> = await resp.json()
for (const identity of identities) {
const del = await request.delete(
`http://localhost:4434/admin/identities/${identity.id}`,
)
if (!del.ok()) {
console.error(
`cleanIdentities: delete ${identity.id} failed with status ${del.status()}`,
)
}
}
}
// ────────────────────────────────────────────────────────────────────
// A. Code login (SMS) — legacy data
// ────────────────────────────────────────────────────────────────────
test.describe("A: code login with legacy phone data", () => {
test.use({
configOverride: smsCodeConfig("sms"),
})
test.beforeEach(async ({ page }) => {
await cleanIdentities(page.request)
})
test.afterEach(async () => {
await deleteDocument(smsDocumentId)
})
test("A1: login succeeds with exact legacy format", async ({
page,
config,
kratosPublicURL,
}) => {
// Create identity with DE_LEGACY via admin API — Kratos normalizes and stores DE_E164
const identity = await createIdentity(page.request, {
schema_id: "sms",
traits: { phone: DE_LEGACY },
})
// Revert DE_E164 → DE_LEGACY to simulate a pre-normalization DB record
downgradeLegacyIdentity(identity.id, DE_LEGACY, DE_E164)
const login = new LoginPage(page, config)
await login.open()
await login.triggerLoginWithCode(DE_LEGACY)
const result = await fetchDocument(smsDocumentId)
await login.codeInput.input.fill(result.ctx.template_data.login_code)
await Promise.all([
page.waitForURL(
new RegExp(config.selfservice.default_browser_return_url),
),
login.codeSubmit.getByText("Continue").click(),
])
await hasSession(page.request, kratosPublicURL)
})
test("A2: login fails with E.164 when DB has legacy format (known limitation)", async ({
page,
config,
kratosPublicURL,
}) => {
// Create with legacy, store as DE_E164, then downgrade to legacy
const identity = await createIdentity(page.request, {
schema_id: "sms",
traits: { phone: DE_LEGACY },
})
downgradeLegacyIdentity(identity.id, DE_LEGACY, DE_E164)
const login = new LoginPage(page, config)
await login.open()
// Enter E.164 format — IN query becomes IN(DE_E164, DE_E164), no match on legacy DB row
await login.inputField("identifier").fill(DE_E164)
await Promise.all([
page.waitForLoadState("networkidle"),
login.codeSubmit.click(),
])
// No SMS code is sent because no identity was found
await hasNoSession(page.request, kratosPublicURL)
})
test("A3: login fails with dashed format when DB has legacy format", async ({
page,
config,
kratosPublicURL,
}) => {
const identity = await createIdentity(page.request, {
schema_id: "sms",
traits: { phone: DE_LEGACY },
})
downgradeLegacyIdentity(identity.id, DE_LEGACY, DE_E164)
const login = new LoginPage(page, config)
await login.open()
await login.inputField("identifier").fill(DE_DASHED)
await Promise.all([
page.waitForLoadState("networkidle"),
login.codeSubmit.click(),
])
await hasNoSession(page.request, kratosPublicURL)
})
})
// ────────────────────────────────────────────────────────────────────
// B. Code login (SMS) — new data (control group)
// ────────────────────────────────────────────────────────────────────
test.describe("B: code login with newly registered phone data", () => {
test.use({
configOverride: smsCodeConfig("sms"),
})
test.beforeEach(async ({ page }) => {
await cleanIdentities(page.request)
})
test.afterEach(async () => {
await deleteDocument(smsDocumentId)
})
test("B1: register with spaces, login with E.164", async ({
page,
config,
kratosPublicURL,
}) => {
// Register with non-normalized phone — new Kratos normalizes and stores DE_E164
const registration = new RegistrationPage(page, config)
await registration.open()
await registration.triggerRegistrationWithCode(DE_LEGACY)
let result = await fetchDocument(smsDocumentId)
const regCode = result.ctx.template_data.registration_code
await registration.inputField("code").fill(regCode)
await Promise.all([
page.waitForURL(
new RegExp(config.selfservice.default_browser_return_url),
),
registration.submitField("code").getByText("Continue").click(),
])
await hasSession(page.request, kratosPublicURL)
// Clear session for login test
await page.context().clearCookies()
await deleteDocument(smsDocumentId)
// Login with E.164 — should succeed because DB now stores E.164 (new normalization)
const login = new LoginPage(page, config)
await login.open()
await login.triggerLoginWithCode(DE_E164)
result = await fetchDocument(smsDocumentId)
await login.codeInput.input.fill(result.ctx.template_data.login_code)
await login.codeSubmit.getByText("Continue").click()
await hasSession(page.request, kratosPublicURL)
})
test("B2: register with spaces, login with same spaces", async ({
page,
config,
kratosPublicURL,
}) => {
const registration = new RegistrationPage(page, config)
await registration.open()
await registration.triggerRegistrationWithCode(DE_LEGACY)
let result = await fetchDocument(smsDocumentId)
const regCode = result.ctx.template_data.registration_code
await registration.inputField("code").fill(regCode)
await Promise.all([
page.waitForURL(
new RegExp(config.selfservice.default_browser_return_url),
),
registration.submitField("code").getByText("Continue").click(),
])
await hasSession(page.request, kratosPublicURL)
await page.context().clearCookies()
await deleteDocument(smsDocumentId)
// Login with same spaced format — IN query's 2nd value (raw input) matches
const login = new LoginPage(page, config)
await login.open()
await login.triggerLoginWithCode(DE_LEGACY)
result = await fetchDocument(smsDocumentId)
await login.codeInput.input.fill(result.ctx.template_data.login_code)
await login.codeSubmit.getByText("Continue").click()
await hasSession(page.request, kratosPublicURL)
})
})
// ────────────────────────────────────────────────────────────────────
// C. Password login with phone — legacy data
// ────────────────────────────────────────────────────────────────────
test.describe("C: password login with legacy phone data", () => {
const password = "vErY-s3cur3-p@ss!"
test.use({
configOverride: smsPasswordConfig("sms-password"),
})
test.beforeEach(async ({ page }) => {
await cleanIdentities(page.request)
})
test.afterEach(async () => {
await deleteDocument(smsDocumentId)
})
test("C1: password login succeeds with exact legacy phone format", async ({
page,
config,
kratosPublicURL,
}) => {
// Create via admin API — Kratos stores DE_E164, then downgrade to legacy
const { identity } = await createIdentityWithPhoneAndPassword(
page.request,
DE_LEGACY,
password,
"sms-password",
)
downgradeLegacyIdentity(identity.id, DE_LEGACY, DE_E164)
const login = new LoginPage(page, config)
await login.open()
await login.loginWithPassword(DE_LEGACY, password)
await hasSession(page.request, kratosPublicURL)
})
test("C2: password login fails with E.164 when DB has legacy format (known limitation)", async ({
page,
config,
kratosPublicURL,
}) => {
const { identity } = await createIdentityWithPhoneAndPassword(
page.request,
DE_LEGACY,
password,
"sms-password",
)
downgradeLegacyIdentity(identity.id, DE_LEGACY, DE_E164)
const login = new LoginPage(page, config)
await login.open()
// E.164 input — IN query produces IN(DE_E164, DE_E164) — no match on legacy value
await login.loginWithPassword(DE_E164, password)
await page.waitForLoadState("networkidle")
await hasNoSession(page.request, kratosPublicURL)
})
})
// ────────────────────────────────────────────────────────────────────
// D. Recovery via SMS — legacy data
// ────────────────────────────────────────────────────────────────────
test.describe("D: SMS recovery with legacy phone data", () => {
const password = "vErY-s3cur3-p@ss!"
test.use({
configOverride: smsPasswordConfig("sms-password"),
})
test.beforeEach(async ({ page }) => {
await cleanIdentities(page.request)
})
test.afterEach(async () => {
await deleteDocument(smsDocumentId)
})
test("D1: recovery succeeds with exact legacy phone format", async ({
page,
config,
}) => {
const { identity } = await createIdentityWithPhoneAndPassword(
page.request,
US_LEGACY,
password,
"sms-password",
)
downgradeLegacyIdentity(identity.id, US_LEGACY, US_E164)
await page.goto(config.selfservice.flows!.recovery!.ui_url!)
await page.locator('input[name="recovery_address"]').fill(US_LEGACY)
await page.locator('button[name="method"][value="code"]').click()
// Recovery code should be sent via SMS webhook
// RecoveryCodeValidModel.RecoveryCode is marshalled as "verification_code"
const result = await fetchDocument(smsDocumentId)
expect(result.ctx.template_data).toBeDefined()
expect(result.ctx.template_data.verification_code).toBeDefined()
})
test("D2: recovery fails with E.164 when DB has legacy format (known limitation)", async ({
page,
config,
}) => {
const { identity } = await createIdentityWithPhoneAndPassword(
page.request,
US_LEGACY,
password,
"sms-password",
)
downgradeLegacyIdentity(identity.id, US_LEGACY, US_E164)
await page.goto(config.selfservice.flows!.recovery!.ui_url!)
await page.locator('input[name="recovery_address"]').fill(US_E164)
await page.locator('button[name="method"][value="code"]').click()
// Wait for Kratos to process, then verify no SMS was sent
await page.waitForTimeout(2000)
try {
const result = await fetchDocument(smsDocumentId)
// If a document exists, recovery was triggered — unexpected for this known-limitation case
expect(result.ctx?.template_data?.verification_code).toBeUndefined()
} catch {
// fetchDocument retries and throws on 404 — no document means no SMS, which is expected
}
})
})
// ────────────────────────────────────────────────────────────────────
// E. Verification — legacy data
// ────────────────────────────────────────────────────────────────────
test.describe("E: verification address integrity with legacy data", () => {
test.use({
configOverride: smsPasswordConfig("sms-password"),
})
test.beforeEach(async ({ page }) => {
await cleanIdentities(page.request)
})
test("E1: legacy identity retains verification address", async ({ page }) => {
const { identity } = await createIdentityWithPhoneAndPassword(
page.request,
DE_LEGACY,
"some-password-123!",
"sms-password",
)
downgradeLegacyIdentity(identity.id, DE_LEGACY, DE_E164)
// Fetch the identity via admin API and verify the address is still present
const resp = await page.request.get(
`http://localhost:4434/admin/identities/${identity.id}?include_credential=password`,
)
expect(resp.status()).toBe(200)
const fetched = await resp.json()
// The verifiable address should still exist (even if value was reverted to legacy)
expect(fetched.verifiable_addresses).toBeDefined()
expect(fetched.verifiable_addresses.length).toBeGreaterThan(0)
})
})
// ────────────────────────────────────────────────────────────────────
// F. Duplicate identity detection (ConflictingIdentity)
// ────────────────────────────────────────────────────────────────────
test.describe("F: duplicate identity detection with phone normalization", () => {
test.use({
configOverride: smsCodeConfig("sms"),
})
test.beforeEach(async ({ page }) => {
await cleanIdentities(page.request)
})
test.afterEach(async () => {
await deleteDocument(smsDocumentId)
})
test("F1: legacy phone in DB, register with E.164 creates duplicate (known gap)", async ({
page,
config,
kratosPublicURL,
}) => {
// Create identity with DE_LEGACY → stored as DE_E164 → then downgrade to DE_LEGACY
const legacyIdentity = await createIdentity(page.request, {
schema_id: "sms",
traits: { phone: DE_LEGACY },
})
downgradeLegacyIdentity(legacyIdentity.id, DE_LEGACY, DE_E164)
// Register a new identity with DE_E164 (the E.164 form of the same number)
const registration = new RegistrationPage(page, config)
await registration.open()
await registration.triggerRegistrationWithCode(DE_E164)
// The IN query becomes IN(DE_E164, DE_E164) which does NOT match legacy "+49 176 671 11 638"
// So no conflict is detected and a registration code is sent
const result = await fetchDocument(smsDocumentId)
expect(result.ctx.template_data.registration_code).toBeDefined()
// Complete registration — this proves the system allowed a duplicate
await registration
.inputField("code")
.fill(result.ctx.template_data.registration_code)
await Promise.all([
page.waitForURL(
new RegExp(config.selfservice.default_browser_return_url),
),
registration.submitField("code").getByText("Continue").click(),
])
await hasSession(page.request, kratosPublicURL)
// Verify two distinct identities exist for the same logical phone number — BUG
const listResp = await page.request.get(
"http://localhost:4434/admin/identities?per_page=250",
)
const identities = await listResp.json()
const matching = identities.filter(
(i: { traits: { phone: string } }) =>
i.traits.phone === DE_E164 || i.traits.phone === DE_LEGACY,
)
// BUG: Two identities exist for the same phone number
expect(matching.length).toBe(2)
})
test("F2: new E.164 in DB, register with spaced format detects conflict", async ({
page,
config,
}) => {
// Create identity with E.164 directly (modern Kratos stores this as-is)
await createIdentity(page.request, {
schema_id: "sms",
traits: { phone: DE_E164 },
})
// Attempt to register with the spaced variant of the same number.
// The IN query becomes IN(DE_E164, DE_LEGACY) — DE_E164 matches the DB record.
const registration = new RegistrationPage(page, config)
await registration.open()
await registration.triggerRegistrationWithCode(DE_LEGACY)
const result = await fetchDocument(smsDocumentId)
expect(result.ctx.template_data.registration_code).toBeDefined()
await registration
.inputField("code")
.fill(result.ctx.template_data.registration_code)
await registration.submitField("code").getByText("Continue").click()
// Conflict is detected: only one identity should exist
const listResp = await page.request.get(
"http://localhost:4434/admin/identities?per_page=250",
)
const identities = await listResp.json()
const matching = identities.filter(
(i: { traits: { phone: string } }) =>
i.traits.phone === DE_E164 || i.traits.phone === DE_LEGACY,
)
expect(matching.length).toBe(1)
})
})
// ────────────────────────────────────────────────────────────────────
// G. OIDC account linking — legacy data
// ────────────────────────────────────────────────────────────────────
const oidcConfig: DeepPartial<OryKratosConfiguration> = {
security: {
account_enumeration: {
enabled: false,
},
},
selfservice: {
flows: {
login: {
style: "unified",
},
registration: {
after: {
oidc: {
hooks: [{ hook: "session" }],
},
code: {
hooks: [{ hook: "session" }],
},
},
},
},
methods: {
code: {
passwordless_enabled: true,
},
password: {
enabled: false,
},
oidc: {
enabled: true,
config: {
providers: [
{
id: "hydra",
label: "Ory",
provider: "generic",
client_id: process.env["OIDC_HYDRA_CLIENT_ID"],
client_secret: process.env["OIDC_HYDRA_CLIENT_SECRET"],
issuer_url: "http://localhost:4444/",
scope: ["offline"],
mapper_url:
"file://test/e2e/playwright/fixtures/mappers/hydra-phone.jsonnet",
},
],
},
},
},
},
courier: { channels: smsCourierChannels() },
identity: { default_schema_id: "sms-oidc", schemas: allSmsSchemas() },
}
test.describe("G: OIDC account linking with legacy phone data", () => {
test.use({
configOverride: oidcConfig,
})
test.beforeEach(async ({ page }) => {
await cleanIdentities(page.request)
})
test.afterEach(async () => {
await deleteDocument(smsDocumentId)
})
test("G1: legacy phone in DB, OIDC returns E.164 creates duplicate (known gap)", async ({
page,
config,
kratosPublicURL,
}) => {
// Create identity with legacy phone via admin API, then downgrade to legacy format
const legacyIdentity = await createIdentity(page.request, {
schema_id: "sms-oidc",
traits: { phone: DE_LEGACY },
})
downgradeLegacyIdentity(legacyIdentity.id, DE_LEGACY, DE_E164)
// Initiate OIDC registration — Hydra returns DE_E164 as sub claim
await page.goto(config.selfservice.flows!.registration!.ui_url!)
await page.locator('button[name="provider"][value="hydra"]').click()
// Hydra login: use E.164 phone as the username (sub claim)
await page.locator("input[name=username]").fill(DE_E164)
await page.locator("button[name=action][value=accept]").click()
// Consent screen
await page.locator("#offline").check()
await page.locator("#openid").check()
await page.locator("button[name=action][value=accept]").click()
// ConflictingIdentity uses IN(DE_E164, DE_E164) which does NOT match legacy record
// BUG: A second identity is created instead of linking to the existing one
await page.waitForURL(
new RegExp(config.selfservice.default_browser_return_url),
)
await hasSession(page.request, kratosPublicURL)
const listResp = await page.request.get(
"http://localhost:4434/admin/identities?per_page=250",
)
const identities = await listResp.json()
const matching = identities.filter(
(i: { traits: { phone: string } }) =>
i.traits.phone === DE_E164 || i.traits.phone === DE_LEGACY,
)
// BUG: Two identities exist for the same phone number
expect(matching.length).toBe(2)
})
test("G2: new E.164 in DB, OIDC registration detects conflict (no duplicate)", async ({
page,
config,
}) => {
// Create identity with E.164 phone (modern format — no downgrade needed)
await createIdentity(page.request, {
schema_id: "sms-oidc",
traits: { phone: DE_E164 },
})
// Initiate OIDC registration — Hydra returns DE_E164 as sub claim
await page.goto(config.selfservice.flows!.registration!.ui_url!)
await page.locator('button[name="provider"][value="hydra"]').click()
await page.locator("input[name=username]").fill(DE_E164)
await page.locator("button[name=action][value=accept]").click()
await page.locator("#offline").check()
await page.locator("#openid").check()
await page.locator("button[name=action][value=accept]").click()
// ConflictingIdentity uses IN(DE_E164, DE_E164) which DOES match DE_E164 in DB.
// Conflict is detected → Kratos shows duplicate-credentials / account-linking
// page instead of completing registration. No session is created.
await page.waitForLoadState("networkidle")
// Key assertion: conflict was detected, so NO duplicate identity was created.
const listResp = await page.request.get(
"http://localhost:4434/admin/identities?per_page=250",
)
const identities = await listResp.json()
const matching = identities.filter(
(i: { traits: { phone: string } }) => i.traits.phone === DE_E164,
)
expect(matching.length).toBe(1)
})
})