feat: add bootstrap script for API test fixtures and enhance live API tests
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@ node_modules/
|
||||
dist/
|
||||
.wrangler/
|
||||
.dev.vars
|
||||
.api-test.env
|
||||
coverage/
|
||||
reports/
|
||||
*.log
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
315
scripts/bootstrap-api-test-fixtures.mjs
Normal file
315
scripts/bootstrap-api-test-fixtures.mjs
Normal 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, "''")}'`;
|
||||
}
|
||||
@@ -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<T = unknown>(
|
||||
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 {
|
||||
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,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user