feat: add bootstrap script for API test fixtures and enhance live API tests

This commit is contained in:
2026-06-10 20:57:07 -07:00
parent b692affdc8
commit 11d80a6f32
5 changed files with 437 additions and 5 deletions

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@ node_modules/
dist/ dist/
.wrangler/ .wrangler/
.dev.vars .dev.vars
.api-test.env
coverage/ coverage/
reports/ reports/
*.log *.log

View File

@@ -13,10 +13,16 @@ Run repository tests without network dependencies:
pnpm test pnpm test
``` ```
Create or refresh writable live-test fixtures:
```bash
pnpm api:test:bootstrap
```
Run live smoke scenarios: Run live smoke scenarios:
```bash ```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: 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. - `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. - `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 ## 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. 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. - anonymous `GET /get-session` behavior.
- invalid email sign-in rejection. - invalid email sign-in rejection.
- optional namespaced email sign-up when write tests are explicitly enabled. - 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 ## Safety

View File

@@ -13,6 +13,7 @@
"test": "vitest run", "test": "vitest run",
"api:test:live": "vitest run tests/auth-live-api.test.ts", "api:test:live": "vitest run tests/auth-live-api.test.ts",
"api:test:contract": "bash scripts/api-contract-test.sh", "api:test:contract": "bash scripts/api-contract-test.sh",
"api:test:bootstrap": "node scripts/bootstrap-api-test-fixtures.mjs",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"ready": "pnpm db:check && pnpm typecheck && pnpm test" "ready": "pnpm db:check && pnpm typecheck && pnpm test"
}, },

View File

@@ -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, "''")}'`;
}

View File

@@ -1,12 +1,24 @@
import { existsSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
const API_BASE_URL = process.env.API_BASE_URL?.replace(/\/+$/, ""); const apiTestEnv = readApiTestEnv();
const API_TOKEN = process.env.API_TOKEN; const API_BASE_URL = (process.env.API_BASE_URL ?? apiTestEnv.API_BASE_URL)?.replace(/\/+$/, "");
const ALLOW_AUTH_WRITE_TESTS = process.env.ALLOW_AUTH_WRITE_TESTS === "true"; const API_TOKEN = process.env.API_TOKEN ?? apiTestEnv.API_TOKEN;
const RUN_ID = process.env.RUN_ID ?? `api-test-${Date.now()}`; 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 liveDescribe = API_BASE_URL ? describe : describe.skip;
const writeDescribe = API_BASE_URL && ALLOW_AUTH_WRITE_TESTS ? 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 { interface OpenAPIDocument {
openapi: string; openapi: string;
@@ -46,6 +58,25 @@ async function requestJson<T = unknown>(
return { status: response.status, headers: response.headers, body }; return { status: response.status, headers: response.headers, body };
} }
function readApiTestEnv(): Record<string, string> {
const path = process.env.API_TEST_ENV_FILE ?? ".api-test.env";
if (!existsSync(path)) return {};
const values: Record<string, string> = {};
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 { function expectJsonResponse(headers: Headers): void {
expect(headers.get("content-type") ?? "").toContain("application/json"); 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<JsonObject>(
`/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<JsonObject[]>("/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,
}),
]),
);
});
});