diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ee6be4177..f385926aa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -178,6 +178,94 @@ jobs: retention-days: 14 if-no-files-found: ignore + noter-e2e: + name: Noter / Playwright E2E + runs-on: ubuntu-latest + timeout-minutes: 25 + + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: noter + POSTGRES_PASSWORD: noter_secret + POSTGRES_DB: noter + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U noter" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + DATABASE_URL: postgresql://noter:noter_secret@localhost:5432/noter + NEXT_PUBLIC_APP_URL: http://localhost:3002 + PORT: "3002" + NODE_ENV: test + PLAYWRIGHT: "True" + # NEXT_PUBLIC_* Enoki/Sui vars are inlined at build time — placeholders + # are fine here since the e2e suite authenticates via delegate key, not + # the Google/Enoki popup flow (that needs a real OAuth session and stays + # a manual check, same as researcher's live-Walrus canary in #680). + NEXT_PUBLIC_ENOKI_API_KEY: ci-placeholder-not-used-tests-use-delegate-key + NEXT_PUBLIC_GOOGLE_CLIENT_ID: ci-placeholder-not-used-tests-use-delegate-key + NEXT_PUBLIC_SUI_NETWORK: testnet + NEXT_PUBLIC_MEMWAL_PACKAGE_ID: "0xcf6ad755a1cdff7217865c796778fabe5aa399cb0cf2eba986f4b582047229c6" + NEXT_PUBLIC_MEMWAL_REGISTRY_ID: "0xe80f2feec1c139616a86c9f71210152e2a7ca552b20841f2e192f99f75864437" + NEXT_PUBLIC_MEMWAL_SERVER_URL: https://relayer.dev.memwal.ai + # No live-Walrus canary in this suite: isTestEnvironment flips the + # binding check onto the fixture pool, so even a real on-chain key + # would fail at login. CI covers auth + note CRUD + the memory API + # contract; remember → recall against production stays a manual check. + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + + - name: Install deps + run: pnpm install --frozen-lockfile + + - name: Build SDK (workspace dep of noter) + run: pnpm build:sdk + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + # Separate from chatbot's `pw-` key so the two jobs don't race the + # same cache entry. Fall back to the shared prefix on a cold start. + key: pw-noter-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + pw-noter-${{ runner.os }}- + pw-${{ runner.os }}- + + - name: Install Playwright (Chromium + OS deps) + timeout-minutes: 8 + run: pnpm --filter @memwal/noter playwright:install + + - name: Run Playwright E2E + run: pnpm --filter @memwal/noter test:e2e + + - name: Upload Playwright report + traces + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report-noter + path: | + apps/noter/playwright-report + apps/noter/test-results + retention-days: 14 + if-no-files-found: ignore + server-e2e: name: Server / E2E runs-on: ubuntu-latest @@ -364,10 +452,20 @@ jobs: run: pnpm exec next build noter-checks: - name: Noter / Unit tests + name: Noter / Unit tests + Build runs-on: ubuntu-latest timeout-minutes: 20 + env: + # Dummy DB — next build type-checks route handlers but doesn't connect. + DATABASE_URL: postgresql://dummy:dummy@localhost:5432/dummy + NEXT_PUBLIC_ENOKI_API_KEY: ci-placeholder-build-only + NEXT_PUBLIC_GOOGLE_CLIENT_ID: ci-placeholder-build-only + NEXT_PUBLIC_SUI_NETWORK: testnet + NEXT_PUBLIC_MEMWAL_PACKAGE_ID: "0xcf6ad755a1cdff7217865c796778fabe5aa399cb0cf2eba986f4b582047229c6" + NEXT_PUBLIC_MEMWAL_REGISTRY_ID: "0xe80f2feec1c139616a86c9f71210152e2a7ca552b20841f2e192f99f75864437" + NEXT_PUBLIC_MEMWAL_SERVER_URL: https://relayer.dev.memwal.ai + steps: - name: Checkout uses: actions/checkout@v4 @@ -393,6 +491,14 @@ jobs: - name: Unit tests (vitest) run: pnpm --filter @memwal/noter test:unit + - name: Type-check (tsc --noEmit) + working-directory: apps/noter + run: pnpm exec tsc --noEmit + + - name: Build (Next.js, type-check inclusive) + working-directory: apps/noter + run: pnpm exec next build + server-checks: name: Server / Clippy + Unit tests runs-on: ubuntu-latest diff --git a/apps/noter/.gitignore b/apps/noter/.gitignore index 5ef6a5207..15781331b 100644 --- a/apps/noter/.gitignore +++ b/apps/noter/.gitignore @@ -39,3 +39,7 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# playwright +/test-results/ +/playwright-report/ diff --git a/apps/noter/app/components/enoki-login-card.tsx b/apps/noter/app/components/enoki-login-card.tsx index 2659bdcb6..89d0c58b1 100644 --- a/apps/noter/app/components/enoki-login-card.tsx +++ b/apps/noter/app/components/enoki-login-card.tsx @@ -16,9 +16,10 @@ import { useCurrentAccount, useSignPersonalMessage, useSignTransaction, - useSuiClient, } from "@mysten/dapp-kit"; import { isEnokiWallet } from "@mysten/enoki"; +import { bcs } from "@mysten/sui/bcs"; +import type { SuiGrpcClient } from "@mysten/sui/grpc"; import { Transaction } from "@mysten/sui/transactions"; import { createSponsorAuthorization } from "@mysten-incubation/memwal"; import { Loader2 } from "lucide-react"; @@ -26,6 +27,8 @@ import { Button } from "@/shared/components/ui/button"; import { enokiConfig } from "@/lib/enoki/config"; import { useAuth } from "@/feature/auth"; import { trpc } from "@/shared/lib/trpc/client"; +import { getSuiGrpcClient } from "@/lib/sui/grpc-client"; +import { AccountCreatedBcs, AccountRegistryBcs } from "@/lib/sui/account-bcs"; type Step = | "idle" @@ -62,14 +65,14 @@ function uint8ArrayToBase64(bytes: Uint8Array): string { async function sponsoredSignAndExecute( transaction: Transaction, sender: string, - suiClient: ReturnType, + suiClient: SuiGrpcClient, signTransaction: (args: { - transaction: Transaction; + transaction: Transaction | string; }) => Promise<{ signature: string }>, signPersonalMessage: (message: Uint8Array) => Promise<{ signature: string }>, ): Promise<{ digest: string }> { const kindBytes = await transaction.build({ - client: suiClient as any, + client: suiClient, onlyTransactionKind: true, }); const authorization = await createSponsorAuthorization( @@ -95,7 +98,13 @@ async function sponsoredSignAndExecute( const sponsored = await sponsorRes.json(); const sponsoredTx = Transaction.from(sponsored.bytes); - const { signature } = await signTransaction({ transaction: sponsoredTx }); + // dapp-kit's useSignTransaction resolves move-call ABIs via the ambient + // client from SuiClientProvider, which is JSON-RPC (deprecated, no longer + // CORS-enabled for browser origins). Pre-serializing with our gRPC client + // and handing off the resulting string short-circuits that internal + // resolution — dapp-kit passes a string through as-is. + const sponsoredTxJson = await sponsoredTx.toJSON({ client: suiClient }); + const { signature } = await signTransaction({ transaction: sponsoredTxJson }); const execRes = await fetch( `${enokiConfig.memwalServerUrl}/sponsor/execute`, @@ -118,7 +127,7 @@ export function EnokiLoginCard() { const wallets = useWallets(); const { mutateAsync: connect } = useConnectWallet(); const currentAccount = useCurrentAccount(); - const suiClient = useSuiClient(); + const suiClient = getSuiGrpcClient(); const { mutateAsync: signTransaction } = useSignTransaction(); const { mutateAsync: signPersonalMessage } = useSignPersonalMessage(); const { connectEnoki } = useAuth(); @@ -197,36 +206,24 @@ export function EnokiLoginCard() { let knownAccountId: string | null = null; try { - const registryObj = await suiClient.getObject({ - id: enokiConfig.memwalRegistryId, - options: { showContent: true }, + const registryRes = await suiClient.getObject({ + objectId: enokiConfig.memwalRegistryId, + include: { content: true }, }); - if ( - registryObj?.data?.content && - "fields" in registryObj.data.content - ) { - const fields = registryObj.data.content.fields as any; - const tableId = fields?.accounts?.fields?.id?.id; - if (tableId) { - const dynField = await suiClient.getDynamicFieldObject({ - parentId: tableId, - name: { type: "address", value: address }, - }); - if ( - dynField?.data?.content && - "fields" in dynField.data.content - ) { - knownAccountId = (dynField.data.content.fields as any) - .value as string; - } - } + if (registryRes.object.content) { + const registry = AccountRegistryBcs.parse(registryRes.object.content); + const dynField = await suiClient.getDynamicField({ + parentId: registry.accounts.id, + name: { type: "address", bcs: bcs.Address.serialize(address).toBytes() }, + }); + knownAccountId = bcs.Address.parse(dynField.dynamicField.value.bcs); } } catch { // Dynamic field not found → no account yet } const pubKeyBytes = Array.from(publicKeyRaw); - const sign = (args: { transaction: Transaction }) => + const sign = (args: { transaction: Transaction | string }) => signTransaction(args); if (knownAccountId) { @@ -267,18 +264,17 @@ export function EnokiLoginCard() { ); await suiClient.waitForTransaction({ digest: createResult.digest }); - const txDetails = await suiClient.getTransactionBlock({ + const txResult = await suiClient.getTransaction({ digest: createResult.digest, - options: { showObjectChanges: true }, + include: { events: true }, }); - const createdObj = txDetails.objectChanges?.find( - (c) => - c.type === "created" && - "objectType" in c && - c.objectType.includes("MemWalAccount"), + const txDetails = + txResult.$kind === "Transaction" ? txResult.Transaction : txResult.FailedTransaction; + const createdEvent = txDetails.events?.find((e) => + e.eventType.endsWith("::account::AccountCreated"), ); - if (createdObj && "objectId" in createdObj) { - knownAccountId = createdObj.objectId; + if (createdEvent) { + knownAccountId = AccountCreatedBcs.parse(createdEvent.bcs).account_id; } if (!knownAccountId) { diff --git a/apps/noter/app/components/sui-providers.tsx b/apps/noter/app/components/sui-providers.tsx index 6b2137919..3ec6c0097 100644 --- a/apps/noter/app/components/sui-providers.tsx +++ b/apps/noter/app/components/sui-providers.tsx @@ -5,22 +5,31 @@ import { createNetworkConfig, SuiClientProvider, WalletProvider, - useSuiClientContext, } from "@mysten/dapp-kit"; import { isEnokiNetwork, registerEnokiWallets } from "@mysten/enoki"; import { getJsonRpcFullnodeUrl } from "@mysten/sui/jsonRpc"; import { enokiConfig } from "@/lib/enoki/config"; +import { getSuiGrpcClient } from "@/lib/sui/grpc-client"; const { networkConfig } = createNetworkConfig({ testnet: { url: getJsonRpcFullnodeUrl("testnet"), network: "testnet" }, mainnet: { url: getJsonRpcFullnodeUrl("mainnet"), network: "mainnet" }, }); -/** Registers Enoki wallets (Google OAuth) with dapp-kit on mount. No-op if env vars are missing. */ +/** + * Registers Enoki wallets (Google OAuth) with dapp-kit on mount. No-op if env + * vars are missing. + * + * Uses a standalone SuiGrpcClient rather than SuiClientProvider's client: + * dapp-kit's SuiClientProvider is hard-typed to SuiJsonRpcClient (even in the + * latest published version), and Sui's public JSON-RPC fullnodes no longer + * serve JSON-RPC — so useSuiClientContext()'s client can't be used here. + * Enoki's `client` option accepts the same ClientWithCoreApi interface a + * gRPC client satisfies, so this is otherwise a drop-in swap. + */ function RegisterEnokiWallets() { - const { client, network } = useSuiClientContext(); - useEffect(() => { + const network = enokiConfig.suiNetwork; if (!isEnokiNetwork(network)) return; if (!enokiConfig.enokiApiKey || !enokiConfig.googleClientId) return; @@ -29,12 +38,12 @@ function RegisterEnokiWallets() { providers: { google: { clientId: enokiConfig.googleClientId }, }, - client, + client: getSuiGrpcClient(), network, }); return unregister; - }, [client, network]); + }, []); return null; } diff --git a/apps/noter/lib/constants.ts b/apps/noter/lib/constants.ts new file mode 100644 index 000000000..d56523879 --- /dev/null +++ b/apps/noter/lib/constants.ts @@ -0,0 +1,15 @@ +export const isProductionEnvironment = process.env.NODE_ENV === "production"; +export const isDevelopmentEnvironment = process.env.NODE_ENV === "development"; + +/** + * Playwright / CI test runner only. Fail-closed in production so a leaked + * PLAYWRIGHT=True on Railway cannot flip assertDelegateAccountBinding onto + * the fixture-key mock (which would let anyone log in as 0x00… / 0x40…). + */ +export const isTestEnvironment = + process.env.NODE_ENV !== "production" && + Boolean( + process.env.PLAYWRIGHT_TEST_BASE_URL || + process.env.PLAYWRIGHT || + process.env.CI_PLAYWRIGHT + ); diff --git a/apps/noter/lib/sui/account-bcs.ts b/apps/noter/lib/sui/account-bcs.ts new file mode 100644 index 000000000..8d8309032 --- /dev/null +++ b/apps/noter/lib/sui/account-bcs.ts @@ -0,0 +1,63 @@ +/** + * BCS schemas for reading `memwal::account`'s on-chain structs. gRPC + * object/dynamic-field/event reads return raw BCS bytes (unlike JSON-RPC's + * parsed `.fields`), so these are needed to decode on-chain state. + * + * These schemas intentionally decode only the leading fields this app reads + * (id, accounts / id, owner, delegate_keys, active) and rely on the BCS + * parser stopping there rather than erroring on trailing bytes. That's safe + * against the package this app is currently configured against, but NOT + * against services/contract/sources/account.move as it reads today — + * that source has already grown migration/import fields on AccountRegistry + * (migration_finalized, pinned_allowlist_root, expected/imported counters, + * version) and MemWalAccount (admin_quarantined, legacy_account_id, and + * more) that aren't modeled here at all. Verified correct against the + * live, currently-deployed bytecode as of this change (decoded delegate key + * bytes matched a known-good derived public key; a registry dynamic-field + * lookup round-tripped to the expected account id) — but that means the + * source has moved ahead of what's published, not that these schemas are + * future-proof. If/when that contract version is published to the package + * this app points at, these schemas need the new fields added (in order) + * or reads will silently decode wrong instead of erroring. + */ +import { bcs } from "@mysten/sui/bcs"; + +/** memwal::account::DelegateKey */ +export const DelegateKeyBcs = bcs.struct("DelegateKey", { + public_key: bcs.vector(bcs.U8), + sui_address: bcs.Address, + label: bcs.String, + created_at: bcs.U64, +}); + +/** memwal::account::MemWalAccount */ +export const MemWalAccountBcs = bcs.struct("MemWalAccount", { + id: bcs.Address, + owner: bcs.Address, + delegate_keys: bcs.vector(DelegateKeyBcs), + created_at: bcs.U64, + active: bcs.Bool, +}); + +/** + * sui::table::Table — framework struct, not defined in account.move, + * but referenced by AccountRegistry.accounts: Table. Table's + * own layout is `{ id: UID, size: u64 }`; entries live as dynamic fields on + * `id`, not inlined in this struct. + */ +export const TableBcs = bcs.struct("Table", { + id: bcs.Address, + size: bcs.U64, +}); + +/** memwal::account::AccountRegistry */ +export const AccountRegistryBcs = bcs.struct("AccountRegistry", { + id: bcs.Address, + accounts: TableBcs, +}); + +/** memwal::account::AccountCreated (event) */ +export const AccountCreatedBcs = bcs.struct("AccountCreated", { + account_id: bcs.Address, + owner: bcs.Address, +}); diff --git a/apps/noter/lib/sui/account-bcs.unit.test.ts b/apps/noter/lib/sui/account-bcs.unit.test.ts new file mode 100644 index 000000000..5bd3dc309 --- /dev/null +++ b/apps/noter/lib/sui/account-bcs.unit.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { + AccountCreatedBcs, + AccountRegistryBcs, + MemWalAccountBcs, +} from "./account-bcs"; + +const ID = `0x${"11".repeat(32)}`; +const OWNER = `0x${"22".repeat(32)}`; +const TABLE = `0x${"33".repeat(32)}`; + +describe("account BCS schemas", () => { + it("round-trips a MemWalAccount", () => { + const value = { + id: ID, + owner: OWNER, + delegate_keys: [], + created_at: BigInt(1), + active: true, + }; + const parsed = MemWalAccountBcs.parse(MemWalAccountBcs.serialize(value).toBytes()); + expect(parsed.id).toBe(ID); + expect(parsed.owner).toBe(OWNER); + expect(parsed.active).toBe(true); + expect(parsed.delegate_keys).toEqual([]); + }); + + it("round-trips an AccountCreated event", () => { + const parsed = AccountCreatedBcs.parse( + AccountCreatedBcs.serialize({ account_id: ID, owner: OWNER }).toBytes() + ); + expect(parsed.account_id).toBe(ID); + expect(parsed.owner).toBe(OWNER); + }); + + it("round-trips an AccountRegistry", () => { + const parsed = AccountRegistryBcs.parse( + AccountRegistryBcs.serialize({ + id: ID, + accounts: { id: TABLE, size: BigInt(1) }, + }).toBytes() + ); + expect(parsed.id).toBe(ID); + expect(parsed.accounts.id).toBe(TABLE); + }); + + it("documents leftover-byte behavior for appended contract fields", () => { + const encoded = MemWalAccountBcs.serialize({ + id: ID, + owner: OWNER, + delegate_keys: [], + created_at: BigInt(0), + active: true, + }).toBytes(); + const withTrailing = new Uint8Array(encoded.length + 2); + withTrailing.set(encoded); + withTrailing[encoded.length] = 0; + withTrailing[encoded.length + 1] = 1; + + // If this throws, the live decoder will fail closed when the published + // package grows trailing fields. If it parses, appended fields are + // ignored and only *inserted* fields would silently decode wrong. + const parseWithTrailing = () => MemWalAccountBcs.parse(withTrailing); + try { + const parsed = parseWithTrailing(); + expect(parsed.id).toBe(ID); + expect(parsed.active).toBe(true); + } catch (error) { + expect(error).toBeInstanceOf(Error); + } + }); +}); diff --git a/apps/noter/lib/sui/grpc-client.ts b/apps/noter/lib/sui/grpc-client.ts new file mode 100644 index 000000000..8424e3d53 --- /dev/null +++ b/apps/noter/lib/sui/grpc-client.ts @@ -0,0 +1,34 @@ +/** + * Sui gRPC client — used for Enoki's on-chain registration flow. + * + * Sui's public JSON-RPC fullnodes were deprecated in 2026 in favor of gRPC. + * @mysten/dapp-kit's SuiClientProvider/useSuiClient are still hard-typed to + * SuiJsonRpcClient (confirmed against the latest published dapp-kit, 1.1.17) + * and can't be swapped for a gRPC client, so this bypasses that provider + * entirely for the one place noter needs live chain reads: registering an + * Enoki wallet with @mysten/enoki (whose `client` option accepts the + * ClientWithCoreApi interface both SuiJsonRpcClient and SuiGrpcClient + * satisfy) and the on-chain account lookup/creation in enoki-login-card.tsx. + */ +import { SuiGrpcClient } from "@mysten/sui/grpc"; +import { enokiConfig } from "@/lib/enoki/config"; + +// Same hostnames Sui's own JSON-RPC used — gRPC-web is served from the same +// fullnode, dispatched by content-type/path rather than a separate host. +const GRPC_BASE_URLS = { + testnet: "https://fullnode.testnet.sui.io:443", + mainnet: "https://fullnode.mainnet.sui.io:443", +} as const; + +let cached: SuiGrpcClient | null = null; +let cachedNetwork: keyof typeof GRPC_BASE_URLS | null = null; + +/** Memoized SuiGrpcClient for the app's configured network. */ +export function getSuiGrpcClient(): SuiGrpcClient { + const network = enokiConfig.suiNetwork; + if (cached && cachedNetwork === network) return cached; + + cached = new SuiGrpcClient({ network, baseUrl: GRPC_BASE_URLS[network] }); + cachedNetwork = network; + return cached; +} diff --git a/apps/noter/package.json b/apps/noter/package.json index a29dd7754..f97dbd5c7 100644 --- a/apps/noter/package.json +++ b/apps/noter/package.json @@ -15,6 +15,8 @@ "db:purge-legacy-sessions": "tsx scripts/purge-legacy-zklogin-sessions.ts", "verify:memwal": "tsx ../../scripts/verify-memwal-credentials.ts", "test:unit": "vitest run", + "playwright:install": "playwright install --with-deps chromium", + "test:e2e": "PLAYWRIGHT=True playwright test", "clean": "rm -rf .next out build" }, "dependencies": { @@ -81,6 +83,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@playwright/test": "^1.50.1", "@tailwindcss/postcss": "^4", "@types/jwt-decode": "^3.1.0", "@types/node": "^20", diff --git a/apps/noter/package/feature/auth/hook/use-auth.ts b/apps/noter/package/feature/auth/hook/use-auth.ts index d16e6ea2c..511faf16c 100644 --- a/apps/noter/package/feature/auth/hook/use-auth.ts +++ b/apps/noter/package/feature/auth/hook/use-auth.ts @@ -71,11 +71,9 @@ export function useAuth() { accountId?: string; }) => { try { - setLoading(true); const result = await connectEnokiMutation.mutateAsync(params); if ("needsSetup" in result && result.needsSetup) { - setLoading(false); return result; } @@ -94,19 +92,17 @@ export function useAuth() { return result; } catch (error) { - setLoading(false); console.error("Enoki connection failed:", error); throw error; } }, - [connectEnokiMutation, setSession, setAuthenticated, setLoading] + [connectEnokiMutation, setSession, setAuthenticated] ); /** Connect with delegate key (manual key + account ID). */ const connectDelegateKey = useCallback( async (params: { privateKey: string; accountId: string }) => { try { - setLoading(true); const result = await connectDelegateKeyMutation.mutateAsync(params); setSession(result.sessionData); @@ -120,12 +116,11 @@ export function useAuth() { return result; } catch (error) { - setLoading(false); console.error("Delegate key connection failed:", error); throw error; } }, - [connectDelegateKeyMutation, setSession, setAuthenticated, setLoading] + [connectDelegateKeyMutation, setSession, setAuthenticated] ); /** Logout — clear session, auth state, and disconnect wallet (prevents autoConnect). */ diff --git a/apps/noter/package/feature/auth/lib/delegate-account.mock.ts b/apps/noter/package/feature/auth/lib/delegate-account.mock.ts new file mode 100644 index 000000000..243e32b63 --- /dev/null +++ b/apps/noter/package/feature/auth/lib/delegate-account.mock.ts @@ -0,0 +1,50 @@ +import * as ed from "@noble/ed25519"; +import { sha512 } from "@noble/hashes/sha2.js"; +import { toBase64 } from "@mysten/sui/utils"; +import { enokiConfig } from "@/lib/enoki/config"; +import { findDelegateFixture } from "./delegate-fixtures"; + +if (!ed.etc.sha512Sync) { + ed.etc.sha512Sync = (...m: Uint8Array[]) => { + const h = sha512.create(); + for (const msg of m) h.update(msg); + return h.digest(); + }; +} + +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +/** + * Fabricated stand-in for the Sui gRPC getObject response, shaped exactly + * like the fields delegate-account.ts validates. Returns null for account + * ids outside the shared fixture pool (the "object not found" case). + * + * public_key is base64 to match real gRPC `include: { json: true }` + * responses (standard protobuf JSON mapping for bytes). The parser now + * accepts hex as well; base64 here is about matching production shape, + * not working around the decoder. + */ +export function mockDelegateAccountObject( + accountId: string, +): { type: string; json: unknown } | null { + const fixture = findDelegateFixture(accountId); + if (!fixture) { + return null; + } + + const publicKey = toBase64(ed.getPublicKey(hexToBytes(fixture.privateKey))); + return { + type: `${enokiConfig.memwalPackageId}::account::MemWalAccount`, + json: { + owner: fixture.owner, + active: true, + delegate_keys: [{ public_key: publicKey }], + }, + }; +} diff --git a/apps/noter/package/feature/auth/lib/delegate-account.ts b/apps/noter/package/feature/auth/lib/delegate-account.ts index 26772f12b..be6d66467 100644 --- a/apps/noter/package/feature/auth/lib/delegate-account.ts +++ b/apps/noter/package/feature/auth/lib/delegate-account.ts @@ -2,6 +2,7 @@ import "server-only"; import { SuiGrpcClient } from "@mysten/sui/grpc"; import { fromBase64, normalizeSuiAddress, toHex } from "@mysten/sui/utils"; +import { isTestEnvironment } from "@/lib/constants"; import { enokiConfig } from "@/lib/enoki/config"; export class DelegateAccountBindingError extends Error { @@ -32,11 +33,19 @@ export async function deriveDelegatePublicKeyHex( } function publicKeyHex(value: unknown): string | null { + // Hex first: every 64-char hex string (alphabet 0-9a-f, length divisible + // by 4) is also valid base64 of the *wrong* bytes. fromBase64() will not + // throw — it just decodes garbage — so a hex-first check is required. + // Matches researcher/lib/auth/delegate-account.ts. + if (typeof value === "string" && /^[0-9a-f]{64}$/i.test(value)) { + return value.toLowerCase(); + } if (typeof value === "string") { try { - return toHex(fromBase64(value)).toLowerCase(); + const decoded = fromBase64(value); + return decoded.length === 32 ? toHex(decoded).toLowerCase() : null; } catch { - return /^[0-9a-f]{64}$/i.test(value) ? value.toLowerCase() : null; + return null; } } if ( @@ -111,6 +120,28 @@ export async function assertDelegateAccountBinding(input: { ); } + if (isTestEnvironment) { + // Playwright runs have no chain to read. Serve a fixture object instead + // of the gRPC fetch so the real validation below still executes — an + // unknown account or unregistered key fails the same way it would live. + const { mockDelegateAccountObject } = await import( + "./delegate-account.mock" + ); + const mocked = mockDelegateAccountObject(input.accountId); + if (!mocked) { + throw new DelegateAccountBindingError( + "Unable to verify Walrus Memory account" + ); + } + const mockError = delegateAccountBindingError(mocked.type, mocked.json, { + owner: input.owner, + publicKeyHex: input.publicKeyHex, + packageId: enokiConfig.memwalPackageId, + }); + if (mockError) throw new DelegateAccountBindingError(mockError); + return; + } + const network = enokiConfig.suiNetwork; const defaultUrl = `https://fullnode.${network}.sui.io:443`; const client = new SuiGrpcClient({ diff --git a/apps/noter/package/feature/auth/lib/delegate-account.unit.test.ts b/apps/noter/package/feature/auth/lib/delegate-account.unit.test.ts index 06bb271e4..bc51b5944 100644 --- a/apps/noter/package/feature/auth/lib/delegate-account.unit.test.ts +++ b/apps/noter/package/feature/auth/lib/delegate-account.unit.test.ts @@ -63,4 +63,26 @@ describe("delegate account binding", () => { }) ).resolves.toMatch(/inactive/); }); + + it("accepts a hex-encoded public_key without treating it as base64", async () => { + // 64-char hex is also valid-but-wrong base64. A base64-first parser + // silently decodes the wrong bytes and rejects a registered key. + await expect( + validate({ + active: true, + owner: OWNER, + delegate_keys: [{ public_key: KEY }], + }) + ).resolves.toBeNull(); + }); + + it("rejects a different hex-encoded public_key", async () => { + await expect( + validate({ + active: true, + owner: OWNER, + delegate_keys: [{ public_key: "cd".repeat(32) }], + }) + ).resolves.toMatch(/not registered/); + }); }); diff --git a/apps/noter/package/feature/auth/lib/delegate-fixtures.ts b/apps/noter/package/feature/auth/lib/delegate-fixtures.ts new file mode 100644 index 000000000..cbd474f8b --- /dev/null +++ b/apps/noter/package/feature/auth/lib/delegate-fixtures.ts @@ -0,0 +1,51 @@ +/** + * Deterministic delegate-key identities for Playwright. + * + * Shared by the server-side gRPC mock (`delegate-account.mock.ts`) and the + * Playwright fixture (`tests/playwright/fixtures/delegate-key.ts`) so the + * two cannot drift. Index N → accountId byte `N` repeated, owner byte + * `N + 0x80` repeated, privateKey byte `N + 0x40` repeated. + * + * Sized as a pool (not a pair) because noter authenticates a fresh identity + * per test: with 2 workers and ~15 login call sites, two shared identities + * would collide on each other's notes. + */ +export const DELEGATE_FIXTURE_COUNT = 24; + +export type DelegateFixture = { + accountId: string; + owner: string; + privateKey: string; +}; + +function hex2(n: number): string { + return n.toString(16).padStart(2, "0"); +} + +export function delegateFixtureAt(index: number): DelegateFixture { + if (!Number.isInteger(index) || index < 0 || index >= DELEGATE_FIXTURE_COUNT) { + throw new Error( + `Delegate fixture index ${index} is out of range (0..${DELEGATE_FIXTURE_COUNT - 1}). ` + + `Bump DELEGATE_FIXTURE_COUNT if the suite needs more identities.` + ); + } + return { + accountId: `0x${hex2(index).repeat(32)}`, + owner: `0x${hex2(index + 0x80).repeat(32)}`, + privateKey: hex2(index + 0x40).repeat(32), + }; +} + +export const TEST_DELEGATE_ACCOUNTS: readonly DelegateFixture[] = Array.from( + { length: DELEGATE_FIXTURE_COUNT }, + (_, i) => delegateFixtureAt(i) +); + +export function findDelegateFixture( + accountId: string +): DelegateFixture | undefined { + const needle = accountId.toLowerCase(); + return TEST_DELEGATE_ACCOUNTS.find( + (account) => account.accountId.toLowerCase() === needle + ); +} diff --git a/apps/noter/package/feature/auth/lib/delegate-fixtures.unit.test.ts b/apps/noter/package/feature/auth/lib/delegate-fixtures.unit.test.ts new file mode 100644 index 000000000..7fe672cd2 --- /dev/null +++ b/apps/noter/package/feature/auth/lib/delegate-fixtures.unit.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { + DELEGATE_FIXTURE_COUNT, + TEST_DELEGATE_ACCOUNTS, + delegateFixtureAt, + findDelegateFixture, +} from "./delegate-fixtures"; + +describe("delegate fixtures", () => { + it("builds a unique deterministic pool", () => { + expect(TEST_DELEGATE_ACCOUNTS).toHaveLength(DELEGATE_FIXTURE_COUNT); + + const accountIds = new Set(TEST_DELEGATE_ACCOUNTS.map((a) => a.accountId)); + const privateKeys = new Set(TEST_DELEGATE_ACCOUNTS.map((a) => a.privateKey)); + expect(accountIds.size).toBe(DELEGATE_FIXTURE_COUNT); + expect(privateKeys.size).toBe(DELEGATE_FIXTURE_COUNT); + + expect(delegateFixtureAt(0)).toEqual({ + accountId: `0x${"00".repeat(32)}`, + owner: `0x${"80".repeat(32)}`, + privateKey: "40".repeat(32), + }); + }); + + it("looks up by account id case-insensitively", () => { + const fixture = delegateFixtureAt(1); + expect(findDelegateFixture(fixture.accountId.toUpperCase())).toEqual(fixture); + expect(findDelegateFixture(`0x${"ff".repeat(32)}`)).toBeUndefined(); + }); + + it("throws instead of wrapping past the pool", () => { + expect(() => delegateFixtureAt(DELEGATE_FIXTURE_COUNT)).toThrow(/out of range/); + expect(() => delegateFixtureAt(-1)).toThrow(/out of range/); + }); +}); diff --git a/apps/noter/package/feature/auth/state/atom.ts b/apps/noter/package/feature/auth/state/atom.ts index 8fb7b933a..76242275e 100644 --- a/apps/noter/package/feature/auth/state/atom.ts +++ b/apps/noter/package/feature/auth/state/atom.ts @@ -27,6 +27,17 @@ export const authAtom = atom({ /** * Current session data * Persisted in sessionStorage (browser only) + * + * getOnInit is true so the session is read synchronously on first render + * instead of via atomWithStorage's post-mount onMount effect. With + * getOnInit:false, every fresh page load (e.g. the window.location.href + * redirect after login, or a plain refresh of /note) renders once with + * session=null before the async hydration catches up — and useAuth's own + * effect can read that stale null first and conclude "logged out", + * bouncing an already-authenticated user back to "/". Safe to read eagerly + * here because nothing renders `session` directly: authAtom (a separate, + * plain atom that always starts isLoading:true) is what every page branches + * on, so there's no server/client markup to mismatch on. */ export const sessionAtom = atomWithStorage( STORAGE_KEYS.sessionId, @@ -41,7 +52,7 @@ export const sessionAtom = atomWithStorage( key: () => null, } as Storage) ), - { getOnInit: false } + { getOnInit: true } ); // ═══════════════════════════════════════════════════════════════ diff --git a/apps/noter/playwright.config.ts b/apps/noter/playwright.config.ts new file mode 100644 index 000000000..58d66f7bc --- /dev/null +++ b/apps/noter/playwright.config.ts @@ -0,0 +1,69 @@ +import { defineConfig, devices } from "@playwright/test"; +import { config } from "dotenv"; + +config({ path: ".env.local" }); + +// package.json's dev script hard-codes `next dev --port 3002` — the `--port` +// CLI flag wins over any PORT env var, so this can't be env-derived without +// going stale the moment .env.local's PORT is repurposed for something else +// (e.g. manually running noter on 5173 to match Enoki's registered OAuth +// origin, which is what broke this once already). +const PORT = "3002"; +const baseURL = `http://localhost:${PORT}`; + +const isCI = !!process.env.CI; + +export default defineConfig({ + testDir: "./tests/playwright", + outputDir: "./test-results", + fullyParallel: true, + forbidOnly: isCI, + retries: isCI ? 2 : 0, + workers: isCI ? 2 : undefined, + reporter: isCI + ? [ + ["html", { open: "never", outputFolder: "playwright-report" }], + ["github"], + ["list"], + ["junit", { outputFile: "playwright-report/junit.xml" }], + ] + : [["html", { open: "never", outputFolder: "playwright-report" }], ["list"]], + + globalSetup: require.resolve("./tests/playwright/global-setup"), + + use: { + baseURL, + trace: "retain-on-failure", + video: isCI ? "retain-on-failure" : "off", + screenshot: "only-on-failure", + actionTimeout: 10_000, + // 30s to tolerate cold Next.js/Turbopack compile on 2-vCPU CI runners; + // globalSetup also warms `/` to make first-nav fast on the happy path. + navigationTimeout: 30_000, + }, + + timeout: 60_000, + expect: { timeout: 10_000 }, + + projects: [ + { + name: "e2e", + testMatch: /e2e\/.*\.test\.ts$/, + use: { ...devices["Desktop Chrome"] }, + }, + ], + + webServer: { + command: "pnpm dev", + // `/api/memory/health` answers 503 when Walrus Memory is unconfigured, which + // Playwright would read as "server not up" — gate on the landing page instead. + url: baseURL, + timeout: 120_000, + reuseExistingServer: !isCI, + stdout: "pipe", + stderr: "pipe", + // Flips lib/constants.ts's isTestEnvironment, which gates + // delegate-account.ts onto the fixture mock instead of a live gRPC read. + env: { PORT, PLAYWRIGHT: "True" }, + }, +}); diff --git a/apps/noter/tests/playwright/e2e/app.test.ts b/apps/noter/tests/playwright/e2e/app.test.ts new file mode 100644 index 000000000..55d359e52 --- /dev/null +++ b/apps/noter/tests/playwright/e2e/app.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "@playwright/test"; + +test.describe("App shell", () => { + test("landing page renders the delegate-key sign-in path", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByRole("heading", { name: "Welcome to Noter" })).toBeVisible(); + await expect(page.getByText("AI-powered note-taking on Sui blockchain")).toBeVisible(); + await expect(page.getByRole("button", { name: /sign in with delegate key/i })).toBeVisible(); + // Google/Enoki registers in a client effect and needs a real Enoki wallet + // adapter. Placeholder CI keys may never produce one — don't fail the + // shell test on that path. The Google flow stays a manual check. + }); + + test("delegate key form stays collapsed until requested", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByPlaceholder(/private key/i)).toBeHidden(); + + await page.getByRole("button", { name: /sign in with delegate key/i }).click(); + + await expect(page.getByPlaceholder(/account id/i)).toBeVisible(); + await expect(page.getByPlaceholder(/private key/i)).toBeVisible(); + }); + + test("private key input is masked by default", async ({ page }) => { + await page.goto("/"); + await page.getByRole("button", { name: /sign in with delegate key/i }).click(); + + await expect(page.getByPlaceholder(/private key/i)).toHaveAttribute("type", "password"); + }); + + test("/note bounces an unauthenticated visitor back to the landing page", async ({ page }) => { + await page.goto("/note"); + + await expect(page).toHaveURL("/"); + await expect(page.getByRole("heading", { name: "Welcome to Noter" })).toBeVisible(); + }); + + test("memory health endpoint answers with a structured status", async ({ request }) => { + const response = await request.get("/api/memory/health"); + + // 200 when a server-side MEMWAL key is configured, 503 when it isn't. + expect([200, 503]).toContain(response.status()); + + const body = await response.json(); + expect(body).toHaveProperty("status"); + expect(["ok", "not_configured"]).toContain(body.status); + }); +}); diff --git a/apps/noter/tests/playwright/e2e/auth.test.ts b/apps/noter/tests/playwright/e2e/auth.test.ts new file mode 100644 index 000000000..f1b131dc4 --- /dev/null +++ b/apps/noter/tests/playwright/e2e/auth.test.ts @@ -0,0 +1,114 @@ +import { expect, test } from "@playwright/test"; +import { + openDelegateKeyForm, + nextDelegateCredentials, + readSessionId, + signInWithDelegateKey, +} from "../fixtures/delegate-key"; + +test.describe("Delegate key authentication", () => { + test("submit stays disabled until both fields are filled", async ({ page }) => { + const { privateKey, accountId } = nextDelegateCredentials(); + await page.goto("/"); + await openDelegateKeyForm(page); + + const submit = page.getByRole("button", { name: "Sign In", exact: true }); + await expect(submit).toBeDisabled(); + + await page.getByPlaceholder(/account id/i).fill(accountId); + await expect(submit).toBeDisabled(); + + await page.getByPlaceholder(/private key/i).fill(privateKey); + await expect(submit).toBeEnabled(); + }); + + test("signs in and lands on the notes route", async ({ page }) => { + await signInWithDelegateKey(page); + + await expect(page).toHaveURL(/\/note(\/|$)/); + }); + + test("persists a session id for tRPC to authenticate with", async ({ page }) => { + await signInWithDelegateKey(page); + + const sessionId = await readSessionId(page); + expect(sessionId).toBeTruthy(); + }); + + test("session survives a reload", async ({ page }) => { + await signInWithDelegateKey(page); + const before = await readSessionId(page); + + await page.reload(); + + expect(await readSessionId(page)).toBe(before); + await expect(page).toHaveURL(/\/note(\/|$)/); + }); + + test("does not sign in with a key that is not 64 hex characters", async ({ page }) => { + const { accountId } = nextDelegateCredentials(); + await page.goto("/"); + await openDelegateKeyForm(page); + + await page.getByPlaceholder(/account id/i).fill(accountId); + await page.getByPlaceholder(/private key/i).fill("deadbeef"); + await page.getByRole("button", { name: "Sign In", exact: true }).click(); + + // Server-side zod guard: /^[0-9a-f]{64}$/i — no session is issued. + await expect(page.locator("p.text-destructive")).toContainText(/64 hex/i); + await expect(page).toHaveURL("/"); + expect(await readSessionId(page)).toBeNull(); + }); + + test("surfaces the delegate key validation error to the user", async ({ page }) => { + const { accountId } = nextDelegateCredentials(); + await page.goto("/"); + await openDelegateKeyForm(page); + + await page.getByPlaceholder(/account id/i).fill(accountId); + await page.getByPlaceholder(/private key/i).fill("deadbeef"); + await page.getByRole("button", { name: "Sign In", exact: true }).click(); + + // Regression: useAuth.connectDelegateKey used to flip authAtom.isLoading + // for the duration of the mutation, and app/page.tsx renders + // only while `!isAuthenticated && !isLoading` — so the + // form unmounted mid-submit, taking its `error` state with it before the + // catch block could set it. connectDelegateKey/connectEnoki no longer + // touch the global loading flag; isLoginPending (from the mutation hooks) + // is what the submit button reads instead. + await expect(page.locator("p.text-destructive")).toContainText(/64 hex/i); + }); + + test("stays on /note after sign-in instead of bouncing to the landing page", async ({ page }) => { + const credentials = nextDelegateCredentials(); + + await page.goto("/"); + await openDelegateKeyForm(page); + await page.getByPlaceholder(/account id/i).fill(credentials.accountId); + await page.getByPlaceholder(/private key/i).fill(credentials.privateKey); + await page.getByRole("button", { name: "Sign In", exact: true }).click(); + + // Regression: sessionAtom used to be atomWithStorage(..., { getOnInit: + // false }), so on the hard navigation triggered by + // window.location.href = "/note" the session was still null on first + // render. useAuth's effect took the `!session && !auth.isAuthenticated` + // branch and cleared isLoading before sessionStorage had hydrated, and + // /note's guard fired router.replace("/") — bouncing an authenticated + // user back to the landing page. sessionAtom now reads sessionStorage + // synchronously on first render (getOnInit: true), so no such gap exists. + await page.waitForURL(/\/note(\/|$)/); + await expect(page).toHaveURL(/\/note(\/|$)/); + }); + + test("two sign-ins with different keys produce different sessions", async ({ page }) => { + await signInWithDelegateKey(page); + const first = await readSessionId(page); + + await page.evaluate(() => sessionStorage.clear()); + await signInWithDelegateKey(page); + const second = await readSessionId(page); + + expect(second).toBeTruthy(); + expect(second).not.toBe(first); + }); +}); diff --git a/apps/noter/tests/playwright/e2e/memory.test.ts b/apps/noter/tests/playwright/e2e/memory.test.ts new file mode 100644 index 000000000..3643e0112 --- /dev/null +++ b/apps/noter/tests/playwright/e2e/memory.test.ts @@ -0,0 +1,83 @@ +import { expect, test } from "@playwright/test"; +import { readSessionId, signInWithDelegateKey } from "../fixtures/delegate-key"; + +const SAMPLE_TEXT = + "Harry prefers dark roast coffee and always reviews pull requests on Friday afternoons."; + +test.describe("Memory API contract", () => { + test("rejects an unauthenticated request", async ({ request }) => { + // memory-request.ts's authorizeMemoryRequest runs before any body + // parsing — no session header fails closed with 401, not the 400 the + // route used to return for a missing `text` field. + const response = await request.post("/api/memory/remember", { data: {} }); + + expect(response.status()).toBe(401); + expect((await response.json()).error).toMatch(/authentication required/i); + }); + + test("rejects a request with no text", async ({ page, request }) => { + const sessionId = await signInAndGetSessionId(page); + + const response = await request.post("/api/memory/remember", { + headers: { "x-session-id": sessionId }, + data: {}, + }); + + expect(response.status()).toBe(400); + expect((await response.json()).error).toMatch(/text is required/i); + }); + + test("rejects text shorter than the analysis threshold", async ({ page, request }) => { + const sessionId = await signInAndGetSessionId(page); + + const response = await request.post("/api/memory/remember", { + headers: { "x-session-id": sessionId }, + data: { text: "short" }, + }); + + expect(response.status()).toBe(400); + expect((await response.json()).error).toMatch(/too short/i); + }); +}); + +test.describe("Memory write", () => { + test("a fixture delegate key does not reach Walrus", async ({ page, request }) => { + const sessionId = await signInAndGetSessionId(page); + + const response = await request.post("/api/memory/remember", { + headers: { "x-session-id": sessionId }, + data: { text: SAMPLE_TEXT }, + timeout: 30_000, + }); + + // assertDelegateAccountBinding only checks the fixture pool + // (delegate-account.mock.ts, gated by isTestEnvironment) — it never + // touches the real chain, so the session resolves a key that isn't + // registered with production Walrus Memory. The route attempts a real + // write and the relayer rejects it. Either a 500 with an error or a 200 + // with no facts is a legitimate outcome — what must not happen is a + // silent claim that facts were persisted. + if (response.status() === 200) { + expect((await response.json()).count).toBe(0); + } else { + expect(response.status()).toBe(500); + expect((await response.json()).error).toBeTruthy(); + } + }); +}); + +// No "real credentials" write path here by design: isTestEnvironment is +// unconditionally true for every Playwright run (playwright.config.ts sets +// PLAYWRIGHT=True on the webServer), so connectDelegateKey's binding check +// only ever consults the fixture pool — a real, on-chain-registered key +// would fail at login before it ever reached this route. The live +// remember -> recall round trip against the production relayer stays a +// manual check, same as researcher's PR #680 documents for its live-Walrus +// canary. + +async function signInAndGetSessionId(page: Parameters[0]): Promise { + await signInWithDelegateKey(page); + const sessionId = await readSessionId(page); + if (!sessionId) throw new Error("Expected a session id after delegate-key sign-in"); + return sessionId; +} diff --git a/apps/noter/tests/playwright/e2e/note.test.ts b/apps/noter/tests/playwright/e2e/note.test.ts new file mode 100644 index 000000000..5b3655202 --- /dev/null +++ b/apps/noter/tests/playwright/e2e/note.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from "@playwright/test"; +import { gotoNotes, signInWithDelegateKey } from "../fixtures/delegate-key"; + +const NOTE_URL = /\/note\/[0-9a-f-]{36}$/i; + +test.describe("Note lifecycle", () => { + test("a fresh user sees the empty state", async ({ page }) => { + await signInWithDelegateKey(page); + + await expect(page).toHaveURL(/\/note$/); + await expect(page.getByRole("heading", { name: "No notes yet" })).toBeVisible(); + await expect(page.getByRole("button", { name: /create your first note/i })).toBeVisible(); + }); + + test("creating the first note opens its editor", async ({ page }) => { + await signInWithDelegateKey(page); + + await page.getByRole("button", { name: /create your first note/i }).click(); + + await expect(page).toHaveURL(NOTE_URL); + await expect(page.locator(".note-editor-content")).toBeVisible(); + }); + + test("a created note outlives the page that created it", async ({ page }) => { + await signInWithDelegateKey(page); + await page.getByRole("button", { name: /create your first note/i }).click(); + await expect(page).toHaveURL(NOTE_URL); + + // Cold-load the notes route: the empty state must be gone and /note must + // forward to the persisted note. + await gotoNotes(page); + + await expect(page).toHaveURL(NOTE_URL); + await expect(page.getByRole("heading", { name: /no notes yet/i })).toBeHidden(); + await expect(page.locator(".note-editor-content")).toBeVisible(); + }); + + test("editor content is autosaved and survives a cold load", async ({ page }) => { + const body = `Playwright autosave check ${Date.now()}`; + + await signInWithDelegateKey(page); + await page.getByRole("button", { name: /create your first note/i }).click(); + await expect(page).toHaveURL(NOTE_URL); + + const editor = page.locator(".note-editor-content"); + await editor.click(); + + // Saves are debounced 3s (use-note.ts) from the last keystroke. Wait for + // the actual note.update response instead of a fixed timeout — a bare + // sleep race against the debounce window is exactly what flaked here. + const saved = page.waitForResponse( + (res) => res.url().includes("/api/trpc/note.update") && res.ok(), + { timeout: 10_000 }, + ); + await editor.pressSequentially(body, { delay: 10 }); + await expect(editor).toContainText(body); + await saved; + + await gotoNotes(page); + + await expect(page.locator(".note-editor-content")).toContainText(body); + }); + + test("notes belong to their own user", async ({ page }) => { + await signInWithDelegateKey(page); + await page.getByRole("button", { name: /create your first note/i }).click(); + await expect(page).toHaveURL(NOTE_URL); + + // Re-authenticate as a different delegate key — the previous note must not leak. + await page.evaluate(() => sessionStorage.clear()); + await signInWithDelegateKey(page); + + await expect(page).toHaveURL(/\/note$/); + await expect(page.getByRole("heading", { name: "No notes yet" })).toBeVisible(); + }); +}); diff --git a/apps/noter/tests/playwright/fixtures/delegate-key.ts b/apps/noter/tests/playwright/fixtures/delegate-key.ts new file mode 100644 index 000000000..d9f547009 --- /dev/null +++ b/apps/noter/tests/playwright/fixtures/delegate-key.ts @@ -0,0 +1,102 @@ +/** + * Delegate-key auth helpers. + * + * `connectDelegateKey` calls `assertDelegateAccountBinding`, which verifies + * the derived public key is registered on the claimed account — on real + * chain data outside tests, and against the shared fixture pool + * (`package/feature/auth/lib/delegate-fixtures.ts`) when isTestEnvironment + * is set (playwright.config.ts / test:e2e pass PLAYWRIGHT=True). + * + * Identities come from that single pool so the mock and this fixture cannot + * drift. The allocator throws when the pool is exhausted rather than wrapping + * back to fixture 0 (which would collide two tests on the same notes). + * + * A fixture key is NOT enough to write to Walrus Memory: isTestEnvironment + * is true for every Playwright-started server, so the binding check only + * ever consults the fixture pool. There's no live-write path in this suite + * by design; see the note in e2e/memory.test.ts. + */ +import { test, type Page } from "@playwright/test"; +import { + DELEGATE_FIXTURE_COUNT, + delegateFixtureAt, +} from "../../../package/feature/auth/lib/delegate-fixtures"; + +export type DelegateCredentials = { + privateKey: string; + accountId: string; +}; + +// Module-scoped counter, one instance per Playwright worker PROCESS (workers: +// 2 spawns separate processes, not just separate async contexts — a plain +// counter alone would restart at 0 in each, so two workers' first calls would +// both claim fixture 0). Interleave by test.info().parallelIndex so worker 0 +// claims 0, 2, 4, ... and worker 1 claims 1, 3, 5, ... +let localCallCount = 0; + +/** A never-yet-used fixture identity from the pool this worker owns exclusively. */ +export function nextDelegateCredentials(): DelegateCredentials { + const parallelIndex = test.info().parallelIndex; + const workerCount = Number(test.info().config.workers); + const i = localCallCount * workerCount + parallelIndex; + if (i >= DELEGATE_FIXTURE_COUNT) { + throw new Error( + `Delegate fixture pool exhausted (need index ${i}, have ${DELEGATE_FIXTURE_COUNT}). ` + + `Bump DELEGATE_FIXTURE_COUNT in package/feature/auth/lib/delegate-fixtures.ts.` + ); + } + localCallCount += 1; + const fixture = delegateFixtureAt(i); + return { accountId: fixture.accountId, privateKey: fixture.privateKey }; +} + +/** Open the collapsed "Sign in with delegate key" form on the landing page. */ +export async function openDelegateKeyForm(page: Page): Promise { + await page.getByRole("button", { name: /sign in with delegate key/i }).click(); + await page.getByPlaceholder(/private key/i).waitFor({ state: "visible" }); +} + +/** + * Drive the real login UI end to end and land on /note. + * Returns the credentials used so the caller can reuse them. + */ +export async function signInWithDelegateKey( + page: Page, + credentials: DelegateCredentials = nextDelegateCredentials(), +): Promise { + await page.goto("/"); + await openDelegateKeyForm(page); + + await page.getByPlaceholder(/account id/i).fill(credentials.accountId); + await page.getByPlaceholder(/private key/i).fill(credentials.privateKey); + await page.getByRole("button", { name: "Sign In", exact: true }).click(); + + // The form hard-navigates with window.location.href = "/note"; wait for that + // full page load to land. If the sessionAtom-hydration regression (guarded + // by auth.test.ts) ever comes back, this — and every test that depends on + // it — fails loudly instead of silently working around the bounce. + await page.waitForURL(/\/note(\/|$)/); + return credentials; +} + +/** Cold-load the notes route. Use instead of page.reload() when a test needs to be on /note afterwards. */ +export async function gotoNotes(page: Page): Promise { + await page.goto("/note"); +} + +/** + * Read the session id the app persists for tRPC's `x-session-id` header. + * Jotai's atomWithStorage may wrap the payload under `value`. + */ +export async function readSessionId(page: Page): Promise { + return page.evaluate(() => { + const raw = sessionStorage.getItem("zklogin:session:id"); + if (!raw) return null; + try { + const outer = JSON.parse(raw); + return (outer?.value ?? outer)?.sessionId ?? null; + } catch { + return null; + } + }); +} diff --git a/apps/noter/tests/playwright/global-setup.ts b/apps/noter/tests/playwright/global-setup.ts new file mode 100644 index 000000000..a7472d405 --- /dev/null +++ b/apps/noter/tests/playwright/global-setup.ts @@ -0,0 +1,55 @@ +/** + * Playwright global setup. + * + * Runs once before any test (after the webServer is spawned). Responsible for: + * 1. Applying Drizzle migrations so note/user/session tables exist. + * 2. Failing fast with a clear error if DATABASE_URL is missing in CI. + * 3. Warming the `/` route so the first `page.goto("/")` in the suite + * doesn't race Turbopack's lazy cold compile against navigationTimeout. + * + * Note: migrations run via `tsx package/shared/lib/db/migrate.ts` — the same + * entrypoint the Docker image uses — rather than `pnpm db:push`. drizzle-kit + * 0.31.x rejects the pinned drizzle-orm 0.45.2 ("requires newer version of + * drizzle-orm"), so every `db:*` script currently fails. + */ +import { spawnSync } from "node:child_process"; + +const MIGRATE_ENTRYPOINT = "package/shared/lib/db/migrate.ts"; + +export default async function globalSetup(): Promise { + const url = process.env.DATABASE_URL; + + if (!url) { + if (process.env.CI) { + throw new Error( + "DATABASE_URL is required in CI. Start a Postgres service container and export the URL.", + ); + } + console.warn( + "[playwright] DATABASE_URL not set — skipping migrations (local dev only). " + + "Start the local database with: cd apps/noter && docker compose up -d", + ); + } else { + console.log("[playwright] Applying Drizzle migrations..."); + const result = spawnSync("pnpm", ["exec", "tsx", MIGRATE_ENTRYPOINT], { + stdio: "inherit", + env: process.env, + }); + + if (result.status !== 0) { + throw new Error(`[playwright] Migration failed with exit code ${result.status}`); + } + } + + // Prime Next.js/Turbopack's per-route compile cache for `/`. On a cold runner + // the first `page.goto("/")` can take 15-30s (routes compile lazily on first + // hit), which exceeds navigationTimeout and flakes tests until retries kick + // in. Fetching here moves that cost into setup. + const port = process.env.PORT || "3002"; + try { + await fetch(`http://localhost:${port}/`); + console.log("[playwright] Warmed / route"); + } catch { + console.warn("[playwright] Could not warm / route — continuing anyway"); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e38c79f6..71e65283e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -680,6 +680,9 @@ importers: specifier: ^4.3.6 version: 4.3.6 devDependencies: + '@playwright/test': + specifier: ^1.50.1 + version: 1.58.2 '@tailwindcss/postcss': specifier: ^4 version: 4.2.1