diff --git a/.gitignore b/.gitignore index 7e64fa0..9dda8ab 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ dist/ .wrangler/ .dev.vars +.api-test.env coverage/ reports/ *.log diff --git a/docs/api-automation-tests.md b/docs/api-automation-tests.md index 1d66028..99563c9 100644 --- a/docs/api-automation-tests.md +++ b/docs/api-automation-tests.md @@ -13,10 +13,16 @@ Run repository tests without network dependencies: pnpm test ``` +Create or refresh writable live-test fixtures: + +```bash +pnpm api:test:bootstrap +``` + Run live smoke scenarios: ```bash -API_BASE_URL=https://cfw-auth.bowong.cc/api/auth pnpm test tests/auth-live-api.test.ts +pnpm api:test:live ``` Run OpenAPI contract checks: @@ -41,6 +47,15 @@ API_BASE_URL=https://cfw-auth.bowong.cc/api/auth MAX_EXAMPLES=100 pnpm api:test: - `ALLOW_AUTH_WRITE_TESTS=true`: enables Vitest scenarios that create authentication data. - `MAX_EXAMPLES`, `TEST_SEED`, `REQUEST_TIMEOUT`, `SCHEMATHESIS_PHASES`, `SCHEMATHESIS_CHECKS`, `REPORT_DIR`: tune Schemathesis execution. +`pnpm api:test:bootstrap` writes `.api-test.env`, which is ignored by git. It contains generated passwords, session cookies, API keys, and fixture IDs for later live tests: + +- `API_TEST_USER_ID`, `API_TEST_USER_EMAIL`, `API_TEST_SESSION_COOKIE` +- `API_TEST_ADMIN_ID`, `API_TEST_ADMIN_EMAIL`, `API_TEST_ADMIN_SESSION_COOKIE` +- `API_TEST_ORGANIZATION_ID`, `API_TEST_ORGANIZATION_SLUG` +- `API_TEST_API_KEY_ID`, `API_TEST_API_KEY` + +The bootstrap script creates users through the public auth API, then uses Wrangler D1 access to mark only those namespaced test users as email-verified and to assign the test admin role. This keeps administrator bootstrap outside the public auth API. + ## Coverage OpenAPI automation covers static schema linting and schema-driven request/response checks. The default Schemathesis phases are `examples,coverage` to avoid broad fuzzing against production by accident. @@ -52,6 +67,7 @@ Vitest live tests currently cover: - anonymous `GET /get-session` behavior. - invalid email sign-in rejection. - optional namespaced email sign-up when write tests are explicitly enabled. +- bootstrapped user session, admin user lookup, organization listing, and API key listing when `.api-test.env` exists. ## Safety diff --git a/package.json b/package.json index 668431a..e92a1ac 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test": "vitest run", "api:test:live": "vitest run tests/auth-live-api.test.ts", "api:test:contract": "bash scripts/api-contract-test.sh", + "api:test:bootstrap": "node scripts/bootstrap-api-test-fixtures.mjs", "typecheck": "tsc --noEmit", "ready": "pnpm db:check && pnpm typecheck && pnpm test" }, diff --git a/scripts/bootstrap-api-test-fixtures.mjs b/scripts/bootstrap-api-test-fixtures.mjs new file mode 100644 index 0000000..f0151a1 --- /dev/null +++ b/scripts/bootstrap-api-test-fixtures.mjs @@ -0,0 +1,315 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; + +const API_BASE_URL = trimTrailingSlash( + process.env.API_BASE_URL ?? "https://cfw-auth.bowong.cc/api/auth", +); +const TRUSTED_ORIGIN = process.env.API_TEST_ORIGIN ?? "http://localhost:8787"; +const RUN_ID = process.env.RUN_ID ?? `api-test-${new Date().toISOString().slice(0, 10)}`; +const ENV_FILE = process.env.API_TEST_ENV_FILE ?? ".api-test.env"; +const D1_DATABASE = process.env.API_TEST_D1_DATABASE ?? "cfw-auth"; +const USE_REMOTE_D1 = process.env.API_TEST_USE_REMOTE_D1 !== "false"; + +const existingEnv = readEnvFile(ENV_FILE); +const password = existingEnv.API_TEST_PASSWORD ?? makePassword(); +const adminPassword = existingEnv.API_TEST_ADMIN_PASSWORD ?? makePassword(); +const userEmail = existingEnv.API_TEST_USER_EMAIL ?? `${slug(RUN_ID)}-user@example.invalid`; +const adminEmail = existingEnv.API_TEST_ADMIN_EMAIL ?? `${slug(RUN_ID)}-admin@example.invalid`; +const organizationSlug = + existingEnv.API_TEST_ORGANIZATION_SLUG ?? `${slug(RUN_ID)}-org`.slice(0, 48); + +const state = { + API_BASE_URL, + API_TEST_ORIGIN: TRUSTED_ORIGIN, + RUN_ID, + API_TEST_USER_EMAIL: userEmail, + API_TEST_PASSWORD: password, + API_TEST_ADMIN_EMAIL: adminEmail, + API_TEST_ADMIN_PASSWORD: adminPassword, + API_TEST_ORGANIZATION_SLUG: organizationSlug, +}; + +await ensureSignedUp({ + email: userEmail, + password, + name: `API Test User ${RUN_ID}`, +}); + +await ensureSignedUp({ + email: adminEmail, + password: adminPassword, + name: `API Test Admin ${RUN_ID}`, +}); +const databaseUser = getUserByEmail(userEmail); +const databaseAdmin = getUserByEmail(adminEmail); +if (!databaseUser || !databaseAdmin) { + throw new Error("Created test users were not found in D1."); +} + +markVerifiedAndAdmin({ + userId: databaseUser.id, + adminId: databaseAdmin.id, +}); + +const userSession = await signIn({ email: userEmail, password }); +state.API_TEST_SESSION_COOKIE = userSession.cookie; +state.API_TOKEN = userSession.token ?? ""; +state.API_TEST_USER_ID = userSession.user.id; + +const adminSession = await signIn({ email: adminEmail, password: adminPassword }); +state.API_TEST_ADMIN_SESSION_COOKIE = adminSession.cookie; +state.API_TEST_ADMIN_TOKEN = adminSession.token ?? ""; +state.API_TEST_ADMIN_ID = adminSession.user.id; + +const organization = await ensureOrganization({ + cookie: userSession.cookie, + name: `API Test Org ${RUN_ID}`, + slug: organizationSlug, +}); +if (organization?.id) { + state.API_TEST_ORGANIZATION_ID = organization.id; +} + +const apiKey = await createApiKey({ cookie: userSession.cookie, runId: RUN_ID }); +if (apiKey?.id) { + state.API_TEST_API_KEY_ID = apiKey.id; +} +if (apiKey?.key) { + state.API_TEST_API_KEY = apiKey.key; +} + +writeEnvFile(ENV_FILE, state); + +console.log( + JSON.stringify( + { + envFile: ENV_FILE, + apiBaseUrl: API_BASE_URL, + runId: RUN_ID, + user: { id: state.API_TEST_USER_ID, email: userEmail }, + admin: { id: state.API_TEST_ADMIN_ID, email: adminEmail }, + organization: state.API_TEST_ORGANIZATION_ID + ? { id: state.API_TEST_ORGANIZATION_ID, slug: organizationSlug } + : null, + apiKey: state.API_TEST_API_KEY_ID ? { id: state.API_TEST_API_KEY_ID } : null, + }, + null, + 2, + ), +); + +function trimTrailingSlash(value) { + return value.replace(/\/+$/, ""); +} + +function slug(value) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40); +} + +function makePassword() { + return `ApiTest-${randomBytes(12).toString("hex")}-Aa1!`; +} + +function readEnvFile(path) { + if (!existsSync(path)) return {}; + const out = {}; + for (const line of readFileSync(path, "utf8").split(/\r?\n/)) { + if (!line || line.trimStart().startsWith("#")) continue; + const index = line.indexOf("="); + if (index === -1) continue; + const key = line.slice(0, index).trim(); + const raw = line.slice(index + 1).trim(); + out[key] = raw.replace(/^"|"$/g, "").replace(/\\"/g, '"'); + } + return out; +} + +function writeEnvFile(path, values) { + const lines = [ + "# Generated by scripts/bootstrap-api-test-fixtures.mjs", + "# Contains test credentials. Do not commit.", + ]; + for (const [key, value] of Object.entries(values)) { + lines.push(`${key}=${quoteEnv(String(value ?? ""))}`); + } + writeFileSync(path, `${lines.join("\n")}\n`, "utf8"); +} + +function quoteEnv(value) { + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +async function ensureSignedUp({ email, password, name }) { + const response = await request("/sign-up/email", { + method: "POST", + body: { + email, + password, + name, + rememberMe: true, + }, + }); + + if (response.status === 200) return response.body; + if (response.status === 422 || response.status === 400) { + const user = getUserByEmail(email); + if (user) return { user }; + } + + throw new Error( + `Failed to create or load ${email}: ${response.status} ${JSON.stringify(response.body)}`, + ); +} + +async function signIn({ email, password }) { + const response = await request("/sign-in/email", { + method: "POST", + body: { + email, + password, + rememberMe: true, + }, + }); + + if (response.status !== 200) { + throw new Error(`Failed to sign in ${email}: ${response.status} ${JSON.stringify(response.body)}`); + } + + const cookie = sessionCookieFromSetCookie(response.setCookie); + if (!cookie) throw new Error(`Sign-in for ${email} did not return a session cookie.`); + const session = await request("/get-session", { cookie }); + if (session.status !== 200 || !session.body?.user?.id) { + throw new Error(`Failed to read session for ${email}: ${session.status}`); + } + return { cookie, token: response.body?.token, user: session.body.user }; +} + +async function ensureOrganization({ cookie, name, slug }) { + const response = await request("/organization/create", { + method: "POST", + cookie, + body: { + name, + slug, + keepCurrentActiveOrganization: true, + metadata: { runId: RUN_ID }, + }, + }); + + if (response.status === 200) return response.body; + if (response.status === 400 || response.status === 422) { + const existing = getOrganizationBySlug(slug); + if (existing) return existing; + } + + console.warn(`Organization fixture not created: ${response.status} ${JSON.stringify(response.body)}`); + return null; +} + +async function createApiKey({ cookie, runId }) { + if (existingEnv.API_TEST_API_KEY && existingEnv.API_TEST_API_KEY_ID) { + return { id: existingEnv.API_TEST_API_KEY_ID, key: existingEnv.API_TEST_API_KEY }; + } + + const response = await request("/api-key/create", { + method: "POST", + cookie, + body: { + name: `api-test-${runId}`, + expiresIn: null, + remaining: null, + metadata: { runId }, + }, + }); + + if (response.status === 200) return response.body; + console.warn(`API key fixture not created: ${response.status} ${JSON.stringify(response.body)}`); + return null; +} + +async function request(path, { method = "GET", body, cookie } = {}) { + const headers = { + accept: "application/json", + origin: TRUSTED_ORIGIN, + }; + if (body !== undefined) headers["content-type"] = "application/json"; + if (cookie) headers.cookie = cookie; + + const response = await fetch(`${API_BASE_URL}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + redirect: "manual", + }); + const text = await response.text(); + return { + status: response.status, + body: text ? JSON.parse(text) : null, + setCookie: response.headers.getSetCookie?.() ?? splitSetCookie(response.headers.get("set-cookie")), + }; +} + +function splitSetCookie(value) { + if (!value) return []; + return value.split(/,(?=\s*[^;,]+=)/g); +} + +function sessionCookieFromSetCookie(setCookieHeaders) { + return setCookieHeaders + .map((entry) => entry.split(";")[0]) + .filter((entry) => /better-auth\.session|session_token|session/.test(entry)) + .join("; "); +} + +function markVerifiedAndAdmin({ userId, adminId }) { + const userIdSql = sqlString(userId); + const adminIdSql = sqlString(adminId); + const sql = [ + `update "user" set "emailVerified" = 1 where "id" in (${userIdSql}, ${adminIdSql});`, + `update "user" set "role" = 'admin' where "id" = ${adminIdSql};`, + ].join(" "); + runD1(sql); +} + +function getUserByEmail(email) { + const rows = runD1( + `select "id", "name", "email", "emailVerified", "role" from "user" where "email" = ${sqlString( + email.toLowerCase(), + )} limit 1;`, + ); + return rows[0] ?? null; +} + +function getOrganizationBySlug(organizationSlugValue) { + const rows = runD1( + `select "id", "name", "slug", "createdAt" from "organization" where "slug" = ${sqlString( + organizationSlugValue, + )} limit 1;`, + ); + return rows[0] ?? null; +} + +function runD1(command) { + const args = ["wrangler", "d1", "execute", D1_DATABASE, "--command", command, "--json"]; + if (USE_REMOTE_D1) args.push("--remote"); + const raw = execFileSync("pnpm", ["exec", ...args], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + const parsed = JSON.parse(raw); + const first = Array.isArray(parsed) ? parsed[0] : parsed; + if (!first?.success) { + throw new Error(`D1 command failed: ${JSON.stringify(parsed)}`); + } + return first.results ?? []; +} + +function sqlString(value) { + return `'${String(value).replace(/'/g, "''")}'`; +} diff --git a/tests/auth-live-api.test.ts b/tests/auth-live-api.test.ts index ec1cc82..46e3cef 100644 --- a/tests/auth-live-api.test.ts +++ b/tests/auth-live-api.test.ts @@ -1,12 +1,24 @@ +import { existsSync, readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -const API_BASE_URL = process.env.API_BASE_URL?.replace(/\/+$/, ""); -const API_TOKEN = process.env.API_TOKEN; -const ALLOW_AUTH_WRITE_TESTS = process.env.ALLOW_AUTH_WRITE_TESTS === "true"; -const RUN_ID = process.env.RUN_ID ?? `api-test-${Date.now()}`; +const apiTestEnv = readApiTestEnv(); +const API_BASE_URL = (process.env.API_BASE_URL ?? apiTestEnv.API_BASE_URL)?.replace(/\/+$/, ""); +const API_TOKEN = process.env.API_TOKEN ?? apiTestEnv.API_TOKEN; +const API_TEST_SESSION_COOKIE = process.env.API_TEST_SESSION_COOKIE ?? apiTestEnv.API_TEST_SESSION_COOKIE; +const API_TEST_ADMIN_SESSION_COOKIE = + process.env.API_TEST_ADMIN_SESSION_COOKIE ?? apiTestEnv.API_TEST_ADMIN_SESSION_COOKIE; +const API_TEST_USER_ID = process.env.API_TEST_USER_ID ?? apiTestEnv.API_TEST_USER_ID; +const API_TEST_ORGANIZATION_ID = + process.env.API_TEST_ORGANIZATION_ID ?? apiTestEnv.API_TEST_ORGANIZATION_ID; +const API_TEST_API_KEY_ID = process.env.API_TEST_API_KEY_ID ?? apiTestEnv.API_TEST_API_KEY_ID; +const ALLOW_AUTH_WRITE_TESTS = + process.env.ALLOW_AUTH_WRITE_TESTS === "true" || apiTestEnv.ALLOW_AUTH_WRITE_TESTS === "true"; +const RUN_ID = process.env.RUN_ID ?? apiTestEnv.RUN_ID ?? `api-test-${Date.now()}`; const liveDescribe = API_BASE_URL ? describe : describe.skip; const writeDescribe = API_BASE_URL && ALLOW_AUTH_WRITE_TESTS ? describe : describe.skip; +const fixtureDescribe = + API_BASE_URL && API_TEST_SESSION_COOKIE && API_TEST_ADMIN_SESSION_COOKIE ? describe : describe.skip; interface OpenAPIDocument { openapi: string; @@ -46,6 +58,25 @@ async function requestJson( return { status: response.status, headers: response.headers, body }; } +function readApiTestEnv(): Record { + const path = process.env.API_TEST_ENV_FILE ?? ".api-test.env"; + if (!existsSync(path)) return {}; + + const values: Record = {}; + for (const line of readFileSync(path, "utf8").split(/\r?\n/)) { + if (!line || line.trimStart().startsWith("#")) continue; + const index = line.indexOf("="); + if (index === -1) continue; + values[line.slice(0, index).trim()] = line + .slice(index + 1) + .trim() + .replace(/^"|"$/g, "") + .replace(/\\"/g, '"') + .replace(/\\\\/g, "\\"); + } + return values; +} + function expectJsonResponse(headers: Headers): void { expect(headers.get("content-type") ?? "").toContain("application/json"); } @@ -136,3 +167,71 @@ writeDescribe("cfw-auth live API write scenarios", () => { ); }); }); + +fixtureDescribe("cfw-auth live API fixture-backed scenarios", () => { + it("reads the bootstrapped user session", async () => { + const response = await requestJson<{ session: JsonObject; user: JsonObject }>("/get-session", { + headers: { + cookie: API_TEST_SESSION_COOKIE, + }, + }); + + expect(response.status).toBe(200); + expect(response.body.user).toEqual( + expect.objectContaining({ + id: API_TEST_USER_ID, + emailVerified: true, + }), + ); + }); + + it("lets the bootstrapped admin read the test user", async () => { + const response = await requestJson( + `/admin/get-user?id=${encodeURIComponent(API_TEST_USER_ID ?? "")}`, + { + headers: { + cookie: API_TEST_ADMIN_SESSION_COOKIE, + }, + }, + ); + + expect(response.status).toBe(200); + expect(response.body).toEqual( + expect.objectContaining({ + id: API_TEST_USER_ID, + }), + ); + }); + + it("lists the bootstrapped organization and API key for the test user", async () => { + const organizations = await requestJson("/organization/list", { + headers: { + cookie: API_TEST_SESSION_COOKIE, + }, + }); + + expect(organizations.status).toBe(200); + expect(organizations.body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: API_TEST_ORGANIZATION_ID, + }), + ]), + ); + + const apiKeys = await requestJson<{ apiKeys: JsonObject[] }>("/api-key/list", { + headers: { + cookie: API_TEST_SESSION_COOKIE, + }, + }); + + expect(apiKeys.status).toBe(200); + expect(apiKeys.body.apiKeys).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: API_TEST_API_KEY_ID, + }), + ]), + ); + }); +});