From e06b5fec3cd1a867fbde1984b58eaa38f3dca81c Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 3 May 2026 23:26:47 +0200 Subject: [PATCH] =?UTF-8?q?test(harness):=20testUsers=20helpers=20?= =?UTF-8?q?=E2=80=94=20registerLocal=20+=20createFederatedUser=20via=20pro?= =?UTF-8?q?duction=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/server/test/helpers/testUsers.ts | 133 ++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 packages/server/test/helpers/testUsers.ts diff --git a/packages/server/test/helpers/testUsers.ts b/packages/server/test/helpers/testUsers.ts new file mode 100644 index 00000000..60acec38 --- /dev/null +++ b/packages/server/test/helpers/testUsers.ts @@ -0,0 +1,133 @@ +import crypto from 'node:crypto'; +import type { SpawnedInstance } from './twoInstanceHarness.js'; + +export interface TestUser { + id: string; + username: string; + password: string; + token: string; + origin: string; // origin where this token authenticates + homeUserId?: string; // present for federated users โ€” the id on the user's home instance + homeInstance?: string; // present for federated users +} + +const nano = () => crypto.randomBytes(4).toString('hex'); + +/** + * Register a native local user on `instance` via POST /api/auth/register. + * Returns the user record + JWT. + * + * Endpoint contract (verified against packages/server/src/routes/auth.ts): + * - Request body: { username, password, displayName?, avatarColor?, homeInstance?, homeUserId? } + * - Response 201: { token, user: { id, ... } } + */ +export async function registerLocal(instance: SpawnedInstance, baseName = 't'): Promise { + // Username validator (auth.ts): /^[a-z0-9_]+$/, length 3-32. Lowercase the + // baseName and use underscore separator so callers can pass readable names + // like "probe-local" without tripping the validator. + const safeBase = baseName.toLowerCase().replace(/[^a-z0-9_]/g, '_'); + const username = `${safeBase}_${nano()}`; + const password = `pw_${crypto.randomBytes(8).toString('hex')}`; + const res = await fetch(`${instance.origin}/api/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password, displayName: username }), + }); + if (!res.ok) { + const txt = await res.text(); + throw new Error(`registerLocal failed (${instance.origin}): ${res.status} ${txt}`); + } + const data = await res.json() as { user: { id: string }; token: string }; + return { + id: data.user.id, + username, + password, + token: data.token, + origin: instance.origin, + }; +} + +/** + * Create a federated account: register `home` first (native), then create the + * `username@homeDomain` mirror on `remote` via POST /api/auth/register with + * homeInstance + homeUserId โ€” the production federated-account-creation path + * documented in client-federation.md ยง1. Returns BOTH user records. + * + * Also seeds the home's user_federation_registry via PUT + * /api/users/@me/federation-registry so the home's federation-identity + * delete endpoint sees this remote in the user's registry. + * + * Registry PUT contract (verified against packages/server/src/routes/users.ts): + * - Body: { registry: Array<{...}>, updatedAt: number } + * NOTE: the route's array key is `registry`, NOT `entries` as the original + * plan template suggested. The deviation has been applied here. + * - Per-entry required: { origin: string, status: 'connected'|'disconnected'|'unreachable'|'auth_expired', addedAt: number } + * - Per-entry optional: { label, username, remoteUserId, lastConnectedAt, disconnectedAt, errorMessage } + * - LWW guard: updatedAt must be > stored federationRegistryUpdatedAt (else 409). + */ +export async function createFederatedUser( + home: SpawnedInstance, + remote: SpawnedInstance, + baseName = 't', +): Promise<{ homeUser: TestUser; remoteUser: TestUser }> { + const homeUser = await registerLocal(home, baseName); + const federatedUsername = `${homeUser.username}@${home.domain}`; + + const res = await fetch(`${remote.origin}/api/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: federatedUsername, + password: homeUser.password, + displayName: homeUser.username, + homeInstance: home.domain, + homeUserId: homeUser.id, + }), + }); + if (!res.ok) { + const txt = await res.text(); + throw new Error(`createFederatedUser remote register failed: ${res.status} ${txt}`); + } + const data = await res.json() as { user: { id: string }; token: string }; + + const remoteUser: TestUser = { + id: data.user.id, + username: federatedUsername, + password: homeUser.password, + token: data.token, + origin: remote.origin, + homeUserId: homeUser.id, + homeInstance: home.domain, + }; + + // Seed the home user's federation registry. Body shape matches the route's + // declared Body<{ registry, updatedAt }> โ€” see users.ts L591-L594. + const registryUpdatedAt = Date.now(); + const registryRes = await fetch(`${home.origin}/api/users/@me/federation-registry`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${homeUser.token}`, + }, + body: JSON.stringify({ + updatedAt: registryUpdatedAt, + registry: [ + { + origin: remote.origin, + label: remote.domain, + username: federatedUsername, + remoteUserId: remoteUser.id, + status: 'connected', + addedAt: registryUpdatedAt, + lastConnectedAt: registryUpdatedAt, + }, + ], + }), + }); + if (!registryRes.ok) { + const txt = await registryRes.text(); + throw new Error(`registry sync failed: ${registryRes.status} ${txt}`); + } + + return { homeUser, remoteUser }; +}