snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
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
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
This commit is contained in:
@@ -0,0 +1,769 @@
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user