diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index b52907c..69f871f 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -59,7 +59,7 @@ services: PLC_URL: http://plc:2582 RELAY_URL: http://plc:2582 SESSION_SECRET: e2e-test-secret-that-is-at-least-32-bytes - TOKEN_ENCRYPTION_KEY: 0000000000000000000000000000000000000000000000000000000000000042 + TOKEN_ENCRYPTION_KEY: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= JETSTREAM_URL: wss://jetstream1.us-east.bsky.network ports: - "3200:3000" diff --git a/src/setup.rs b/src/setup.rs index 136797d..fb45bce 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -11,12 +11,19 @@ use axum_extra::extract::cookie::{Cookie, Key, SignedCookieJar}; use rand::RngCore; use serde::Deserialize; -use crate::admin::auth::UserAuth; use crate::auth::COOKIE_NAME; use crate::event_log::{EventLog, Severity, log_event}; use crate::service_identity::{self, IdentityMode}; use crate::{AppState, error::AppError}; +async fn require_setup_incomplete(state: &AppState) -> Result<(), AppError> { + let status = service_identity::get_setup_status(&state.db, state.db_backend).await?; + if status.setup_complete { + return Err(AppError::Forbidden("setup is already complete".into())); + } + Ok(()) +} + pub fn routes() -> Router { Router::new() .route("/status", get(status)) @@ -50,9 +57,9 @@ struct PlcSubmitBody { async fn set_identity( State(state): State, - _auth: UserAuth, Json(body): Json, ) -> Result { + require_setup_incomplete(&state).await?; let mode = IdentityMode::parse(&body.mode) .ok_or_else(|| AppError::BadRequest(format!("invalid identity mode: {}", body.mode)))?; @@ -142,8 +149,8 @@ struct PlcRegisterResponse { /// 7. Updates the service_identity row with the new DID async fn plc_register( State(state): State, - _auth: UserAuth, ) -> Result, AppError> { + require_setup_incomplete(&state).await?; let identity = service_identity::get_identity(&state.db, state.db_backend).await?; let identity = identity.ok_or_else(|| AppError::BadRequest("no identity configured".into()))?; @@ -233,10 +240,8 @@ async fn plc_register( Ok(Json(PlcRegisterResponse { did })) } -async fn plc_request( - State(state): State, - _auth: UserAuth, -) -> Result { +async fn plc_request(State(state): State) -> Result { + require_setup_incomplete(&state).await?; let identity = service_identity::get_identity(&state.db, state.db_backend).await?; let identity = identity.ok_or_else(|| AppError::BadRequest("no identity configured".into()))?; @@ -279,9 +284,9 @@ async fn plc_request( async fn plc_submit( State(state): State, - _auth: UserAuth, Json(body): Json, ) -> Result { + require_setup_incomplete(&state).await?; let identity = service_identity::get_identity(&state.db, state.db_backend).await?; let identity = identity.ok_or_else(|| AppError::BadRequest("no identity configured".into()))?; @@ -494,10 +499,8 @@ async fn attach_auth_confirm( Ok((jar, StatusCode::NO_CONTENT)) } -async fn export_rotation_key( - State(state): State, - _auth: UserAuth, -) -> Result { +async fn export_rotation_key(State(state): State) -> Result { + require_setup_incomplete(&state).await?; use base64::Engine; let identity = service_identity::get_identity(&state.db, state.db_backend).await?; @@ -547,7 +550,8 @@ async fn export_rotation_key( )) } -async fn complete(State(state): State, auth: UserAuth) -> Result { +async fn complete(State(state): State) -> Result { + require_setup_incomplete(&state).await?; service_identity::mark_setup_complete(&state.db, state.db_backend).await?; log_event( @@ -555,7 +559,7 @@ async fn complete(State(state): State, auth: UserAuth) -> Result= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -11593,6 +11705,49 @@ "node": ">=4" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/powershell-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", @@ -12888,6 +13043,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -14234,6 +14399,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/web/package.json b/web/package.json index b4f94ff..77b56a7 100644 --- a/web/package.json +++ b/web/package.json @@ -41,12 +41,14 @@ "@playwright/test": "^1.60.0", "@tailwindcss/postcss": "^4.2.0", "@types/node": "^24", + "@types/pg": "^8.20.0", "@types/react": "^19", "@types/react-dom": "^19", "@types/semver": "^7.7.1", "babel-plugin-react-compiler": "1.0.0", "eslint": "^9", "eslint-config-next": "16.1.6", + "pg": "^8.21.0", "shadcn": "^3.8.5", "tailwindcss": "^4.2.0", "tw-animate-css": "^1.4.0", diff --git a/web/src/app/setup/page.tsx b/web/src/app/setup/page.tsx index c7f2da4..0a2d8ee 100644 --- a/web/src/app/setup/page.tsx +++ b/web/src/app/setup/page.tsx @@ -1,21 +1,29 @@ "use client" -import { useAuth } from "@/lib/auth-context" import { useRouter } from "next/navigation" -import { useEffect } from "react" +import { useEffect, useState } from "react" +import { getSetupStatus } from "@/lib/api" import { SetupWizard } from "@/components/setup/setup-wizard" export default function SetupPage() { - const { did } = useAuth() const router = useRouter() + const [ready, setReady] = useState(false) useEffect(() => { - if (!did) { - router.replace("/login") - } - }, [did, router]) + getSetupStatus() + .then((status) => { + if (status.setup_complete) { + router.replace("/dashboard") + } else { + setReady(true) + } + }) + .catch(() => { + setReady(true) + }) + }, [router]) - if (!did) return null + if (!ready) return null return (
diff --git a/web/src/components/setup/setup-wizard.tsx b/web/src/components/setup/setup-wizard.tsx index c06fcc3..8b7de57 100644 --- a/web/src/components/setup/setup-wizard.tsx +++ b/web/src/components/setup/setup-wizard.tsx @@ -1,7 +1,7 @@ "use client" import { useCallback, useEffect, useState } from "react" -import { getSetupStatus } from "@/lib/api" +import { getSetupStatus, setSetupIdentity } from "@/lib/api" import { SetupIdentityMode } from "./setup-identity-mode" import { SetupConfigure } from "./setup-configure" import { SetupAttachAuth } from "./setup-attach-auth" @@ -29,6 +29,7 @@ export function SetupWizard() { } else if (status.plc_verified || (status.identity_mode === "not_exposed")) { setCurrentStep("complete") } else if (status.identity_configured) { + setIdentityMode(status.identity_mode) setCurrentStep("verify") } else if (status.identity_mode) { setIdentityMode(status.identity_mode) @@ -38,9 +39,10 @@ export function SetupWizard() { .finally(() => setLoading(false)) }, []) - const handleModeSelected = useCallback((mode: string) => { + const handleModeSelected = useCallback(async (mode: string) => { setIdentityMode(mode) if (mode === "not_exposed") { + await setSetupIdentity({ mode: "not_exposed" }) setCurrentStep("complete") } else { setCurrentStep("configure") diff --git a/web/tests/e2e/auth-helper.ts b/web/tests/e2e/auth-helper.ts new file mode 100644 index 0000000..451100f --- /dev/null +++ b/web/tests/e2e/auth-helper.ts @@ -0,0 +1,95 @@ +import { createHmac } from "crypto" +import { type Page } from "@playwright/test" +import pg from "pg" + +const SESSION_SECRET = "e2e-test-secret-that-is-at-least-32-bytes" +const COOKIE_NAME = "happyview_session" +const TEST_DID = "did:plc:e2e-test-admin" +const DB_URL = "postgres://happyview:happyview@localhost:5434/happyview_test" + +/** + * HKDF-Expand (RFC 5869 section 2.3) — used by the cookie crate's + * Key::derive_from which treats the master key as PRK directly. + */ +function hkdfExpand(prk: Buffer, info: Buffer, length: number): Buffer { + const hashLen = 32 // SHA-256 + const n = Math.ceil(length / hashLen) + const output = Buffer.alloc(n * hashLen) + let prev = Buffer.alloc(0) + + for (let i = 1; i <= n; i++) { + const hmac = createHmac("sha256", prk) + hmac.update(prev) + hmac.update(info) + hmac.update(Buffer.from([i])) + prev = hmac.digest() + prev.copy(output, (i - 1) * hashLen) + } + + return output.subarray(0, length) +} + +function deriveSigningKey(secret: string): Buffer { + const prk = Buffer.from(secret) + const info = Buffer.from( + "COOKIE;SIGNED:HMAC-SHA256;PRIVATE:AEAD-AES-256-GCM", + ) + const expanded = hkdfExpand(prk, info, 64) + return expanded.subarray(0, 32) +} + +function signCookieValue(signingKey: Buffer, value: string): string { + const mac = createHmac("sha256", signingKey) + mac.update(value) + const digest = mac.digest("base64") + return digest + value +} + +async function ensureTestUser(did: string): Promise { + const client = new pg.Client(DB_URL) + await client.connect() + try { + const id = "e2e-test-user-id" + const now = new Date().toISOString() + await client.query( + `INSERT INTO users (id, did, is_super, created_at) + VALUES ($1, $2, 1, $3) + ON CONFLICT (did) DO NOTHING`, + [id, did, now], + ) + } finally { + await client.end() + } +} + +export async function resetServiceIdentity(): Promise { + const client = new pg.Client(DB_URL) + await client.connect() + try { + await client.query("DELETE FROM service_identity") + } finally { + await client.end() + } +} + +export async function loginAsTestAdmin(page: Page): Promise { + await ensureTestUser(TEST_DID) + + const signingKey = deriveSigningKey(SESSION_SECRET) + const signedValue = signCookieValue(signingKey, TEST_DID) + + const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3200" + const url = new URL(baseURL) + + await page.context().addCookies([ + { + name: COOKIE_NAME, + value: signedValue, + domain: url.hostname, + path: "/", + httpOnly: true, + sameSite: "Lax", + secure: false, + }, + ]) +} diff --git a/web/tests/e2e/lexicon-services.spec.ts b/web/tests/e2e/lexicon-services.spec.ts index 37698e9..eda62a8 100644 --- a/web/tests/e2e/lexicon-services.spec.ts +++ b/web/tests/e2e/lexicon-services.spec.ts @@ -1,19 +1,9 @@ import { test, expect } from "@playwright/test" +import { loginAsTestAdmin } from "./auth-helper" test.describe("Lexicon Services", () => { test.beforeEach(async ({ page }) => { - await page.goto("/setup") - const didWebCard = page.getByText("did:web") - if (await didWebCard.isVisible({ timeout: 3000 }).catch(() => false)) { - await didWebCard.click() - const continueButton = page.getByRole("button", { name: /continue|next|save/i }) - if (await continueButton.isVisible({ timeout: 3000 }).catch(() => false)) { - await continueButton.click() - } - const completeButton = page.getByRole("button", { name: /looks good|complete|continue/i }) - await expect(completeButton).toBeVisible({ timeout: 10000 }) - await completeButton.click() - } + await loginAsTestAdmin(page) }) test("service entry appears in lexicon services sheet", async ({ page }) => { diff --git a/web/tests/e2e/service-identity-settings.spec.ts b/web/tests/e2e/service-identity-settings.spec.ts index 66b2246..94edf70 100644 --- a/web/tests/e2e/service-identity-settings.spec.ts +++ b/web/tests/e2e/service-identity-settings.spec.ts @@ -1,16 +1,31 @@ import { test, expect } from "@playwright/test" +import { loginAsTestAdmin } from "./auth-helper" test.describe("Service Identity Settings", () => { test.beforeEach(async ({ page }) => { - await page.goto("/setup") + await loginAsTestAdmin(page) + await page.goto("/dashboard/settings/service-identity") + }) + + test("add and remove a service entry", async ({ page }) => { + const fragmentInput = page.getByLabel(/fragment/i) + const typeInput = page.getByLabel(/service type/i) + await expect(fragmentInput).toBeVisible({ timeout: 5000 }) - const notExposedCard = page.getByText(/not exposed/i) - if (await notExposedCard.isVisible({ timeout: 3000 }).catch(() => false)) { - await notExposedCard.click() - await expect(page.getByText(/complete|success|done/i)).toBeVisible({ timeout: 5000 }) - } + await fragmentInput.fill("#testentry") + await typeInput.fill("TestAppView") - await page.goto("/dashboard/settings/service-identity") + const addButton = page.getByRole("button", { name: "Add" }) + await expect(addButton).toBeEnabled({ timeout: 3000 }) + await addButton.click() + + await expect(page.getByText("#testentry")).toBeVisible({ timeout: 5000 }) + + const deleteButton = page.getByRole("button", { name: /delete #testentry/i }) + await expect(deleteButton).toBeVisible({ timeout: 3000 }) + await deleteButton.click() + + await expect(page.getByText("#testentry")).not.toBeVisible({ timeout: 5000 }) }) test("change mode redirects to setup", async ({ page }) => { @@ -24,40 +39,4 @@ test.describe("Service Identity Settings", () => { await expect(page).toHaveURL(/\/setup/, { timeout: 10000 }) }) - - test("add and remove a service entry", async ({ page }) => { - const addButton = page.getByRole("button", { name: /add.*entry|new.*entry|add.*service/i }) - if (!(await addButton.isVisible({ timeout: 5000 }).catch(() => false))) { - test.skip(true, "add entry button not visible — UI may differ") - return - } - - await addButton.click() - - const fragmentInput = page.getByLabel(/fragment/i) - const typeInput = page.getByLabel(/type|service type/i) - - if (await fragmentInput.isVisible({ timeout: 3000 }).catch(() => false)) { - await fragmentInput.fill("#testentry") - await typeInput.fill("TestAppView") - - const saveButton = page.getByRole("button", { name: /save|create|add/i }) - await saveButton.click() - - await expect(page.getByText("#testentry")).toBeVisible({ timeout: 5000 }) - - const row = page.getByText("#testentry").locator("..") - const deleteButton = row.getByRole("button", { name: /delete|remove/i }) - if (await deleteButton.isVisible({ timeout: 3000 }).catch(() => false)) { - await deleteButton.click() - - const confirmDelete = page.getByRole("button", { name: /confirm|yes|delete/i }) - if (await confirmDelete.isVisible({ timeout: 3000 }).catch(() => false)) { - await confirmDelete.click() - } - - await expect(page.getByText("#testentry")).not.toBeVisible({ timeout: 5000 }) - } - } - }) }) diff --git a/web/tests/e2e/setup-attach-account.spec.ts b/web/tests/e2e/setup-attach-account.spec.ts index aeafc34..d5f98bd 100644 --- a/web/tests/e2e/setup-attach-account.spec.ts +++ b/web/tests/e2e/setup-attach-account.spec.ts @@ -1,10 +1,11 @@ import { test, expect } from "@playwright/test" +import { loginAsTestAdmin, resetServiceIdentity } from "./auth-helper" const PDS_URL = "http://localhost:3100" async function createPdsAccount(): Promise<{ did: string; handle: string }> { const suffix = Date.now().toString(36) - const handle = `testuser-${suffix}.localhost` + const handle = `testuser-${suffix}.test` const resp = await fetch(`${PDS_URL}/xrpc/com.atproto.server.createAccount`, { method: "POST", @@ -12,7 +13,7 @@ async function createPdsAccount(): Promise<{ did: string; handle: string }> { body: JSON.stringify({ email: `testuser-${suffix}@example.com`, handle, - password: "test-password-e2e-123", + password: "Test-password-e2e-123", }), }) @@ -30,24 +31,10 @@ test.describe("Setup - Attach Account", () => { test.beforeAll(async () => { account = await createPdsAccount() + await resetServiceIdentity() }) test("attach_account flow reaches authenticate step", async ({ page }) => { - // Reset identity by calling the admin API to trigger fresh setup - await page.goto("/dashboard/settings/service-identity") - - const changeModeButton = page.getByRole("button", { - name: /change mode/i, - }) - if (await changeModeButton.isVisible({ timeout: 5000 }).catch(() => false)) { - await changeModeButton.click() - const confirmButton = page.getByRole("button", { - name: /confirm|yes|continue/i, - }) - await expect(confirmButton).toBeVisible() - await confirmButton.click() - } - await page.goto("/setup") await expect( page.getByText(/how should this appview be identified/i), @@ -62,9 +49,10 @@ test.describe("Setup - Attach Account", () => { await expect(identifierInput).toBeVisible({ timeout: 5000 }) await identifierInput.fill(account.did) - // Wait for identity resolution (debounced typeahead) - // The dropdown might or might not appear depending on PLC state - await page.waitForTimeout(500) + // Wait for the typeahead dropdown and select the suggestion + const suggestion = page.locator(".bg-popover button").first() + await expect(suggestion).toBeVisible({ timeout: 5000 }) + await suggestion.click() // Click continue to submit the identity const continueButton = page.getByRole("button", { name: /continue/i }) @@ -76,12 +64,9 @@ test.describe("Setup - Attach Account", () => { page.getByText(/authenticate attached account/i), ).toBeVisible({ timeout: 10000 }) - // Verify the correct account info is displayed - await expect(page.getByText(account.did)).toBeVisible() - - // The "Authenticate as ..." button should be visible + // Verify the authenticate button contains the account handle await expect( - page.getByRole("button", { name: /authenticate as/i }), + page.getByRole("button", { name: new RegExp(`authenticate as.*${account.handle}`, "i") }), ).toBeVisible() }) @@ -95,8 +80,9 @@ test.describe("Setup - Attach Account", () => { await notExposedCard.isVisible({ timeout: 3000 }).catch(() => false) ) { await notExposedCard.click() + await page.getByRole("button", { name: /continue/i }).click() await expect( - page.getByText(/complete|success|done/i), + page.getByText("Setup Complete"), ).toBeVisible({ timeout: 5000 }) } } finally { diff --git a/web/tests/e2e/setup-wizard.spec.ts b/web/tests/e2e/setup-wizard.spec.ts index 8283b3b..2f6550c 100644 --- a/web/tests/e2e/setup-wizard.spec.ts +++ b/web/tests/e2e/setup-wizard.spec.ts @@ -1,51 +1,31 @@ import { test, expect } from "@playwright/test" test.describe("Setup Wizard", () => { - test.beforeEach(async ({ page }) => { + test("did:web flow completes successfully", async ({ page }) => { await page.goto("/setup") - }) - test("did:web flow completes successfully", async ({ page }) => { await page.getByText("did:web").click() + await page.getByRole("button", { name: /continue/i }).click() - await expect(page.getByText("Configure")).toBeVisible() - - const continueButton = page.getByRole("button", { name: /continue|next|save/i }) - if (await continueButton.isVisible()) { - await continueButton.click() - } + await expect(page.getByText("Configure did:web")).toBeVisible({ timeout: 5000 }) + await page.getByRole("button", { name: /continue/i }).click() - await expect(page.getByText(/verify|review/i)).toBeVisible({ timeout: 10000 }) + await expect(page.getByText("Verify DID Document")).toBeVisible({ timeout: 10000 }) - await expect(page.getByText("did:web:")).toBeVisible({ timeout: 5000 }) + // Verify the wizard resumes at the correct step after reload + await page.reload() + await expect(page.getByText("Verify DID Document")).toBeVisible({ timeout: 10000 }) - const completeButton = page.getByRole("button", { name: /looks good|complete|continue/i }) + const completeButton = page.getByRole("button", { name: /looks good/i }) await expect(completeButton).toBeVisible({ timeout: 5000 }) await completeButton.click() - await expect(page.getByText(/complete|success|done/i)).toBeVisible({ timeout: 5000 }) - }) - - test("not_exposed skips to complete", async ({ page }) => { - await page.getByText(/not exposed/i).click() - - await expect(page.getByText(/complete|success|done/i)).toBeVisible({ timeout: 5000 }) + await expect(page.getByText("Setup Complete")).toBeVisible({ timeout: 5000 }) }) - test("setup resumes at correct step after page reload", async ({ page }) => { - await page.getByText("did:web").click() - - await expect(page.getByText("Configure")).toBeVisible() - - const continueButton = page.getByRole("button", { name: /continue|next|save/i }) - if (await continueButton.isVisible({ timeout: 3000 }).catch(() => false)) { - await continueButton.click() - } - - await expect(page.getByText(/verify|review/i)).toBeVisible({ timeout: 10000 }) - - await page.reload() + test("setup page redirects to dashboard after completion", async ({ page }) => { + await page.goto("/setup") - await expect(page.getByText(/verify|review/i)).toBeVisible({ timeout: 10000 }) + await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }) }) })