517 lines
15 KiB
TypeScript
517 lines
15 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
|
|
const apiTestEnv = readApiTestEnv();
|
|
const API_BASE_URL = (process.env.API_BASE_URL ?? apiTestEnv.API_BASE_URL)?.replace(/\/+$/, "");
|
|
const API_TEST_ORIGIN = process.env.API_TEST_ORIGIN ?? apiTestEnv.API_TEST_ORIGIN ?? "http://localhost:8787";
|
|
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 RUN_ID = process.env.RUN_ID ?? apiTestEnv.RUN_ID ?? `api-test-${Date.now()}`;
|
|
const D1_DATABASE = process.env.API_TEST_D1_DATABASE ?? "cfw-auth";
|
|
const USE_REMOTE_D1 = process.env.API_TEST_USE_REMOTE_D1 !== "false";
|
|
|
|
const scenarioDescribe =
|
|
API_BASE_URL && API_TEST_SESSION_COOKIE && API_TEST_ADMIN_SESSION_COOKIE ? describe : describe.skip;
|
|
|
|
type JsonObject = Record<string, unknown>;
|
|
|
|
interface ApiResponse<T> {
|
|
status: number;
|
|
headers: Headers;
|
|
body: T;
|
|
setCookie: string[];
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
if (!API_BASE_URL || !API_TEST_SESSION_COOKIE || !API_TEST_ADMIN_SESSION_COOKIE) {
|
|
return;
|
|
}
|
|
|
|
const userSession = await requestJson<{ user?: JsonObject }>("/get-session", {
|
|
cookie: API_TEST_SESSION_COOKIE,
|
|
});
|
|
const adminSession = await requestJson<{ user?: JsonObject }>("/get-session", {
|
|
cookie: API_TEST_ADMIN_SESSION_COOKIE,
|
|
});
|
|
|
|
expect(userSession.status).toBe(200);
|
|
expect(adminSession.status).toBe(200);
|
|
});
|
|
|
|
scenarioDescribe("cfw-auth account and session business scenarios", () => {
|
|
it("enforces email verification before allowing password sign-in", async () => {
|
|
const email = uniqueEmail("unverified");
|
|
const password = "ScenarioPassword123!";
|
|
|
|
const signUp = await requestJson<{ user: JsonObject }>("/sign-up/email", {
|
|
method: "POST",
|
|
body: {
|
|
name: "Unverified Scenario User",
|
|
email,
|
|
password,
|
|
rememberMe: true,
|
|
},
|
|
});
|
|
expect(signUp.status).toBe(200);
|
|
expect(signUp.body.user).toEqual(
|
|
expect.objectContaining({
|
|
email,
|
|
emailVerified: false,
|
|
}),
|
|
);
|
|
|
|
const signIn = await requestJson<JsonObject>("/sign-in/email", {
|
|
method: "POST",
|
|
body: {
|
|
email,
|
|
password,
|
|
rememberMe: true,
|
|
},
|
|
});
|
|
|
|
expect(signIn.status).toBe(403);
|
|
expect(signIn.body).toEqual(
|
|
expect.objectContaining({
|
|
code: "EMAIL_NOT_VERIFIED",
|
|
}),
|
|
);
|
|
}, 30_000);
|
|
|
|
it("signs in a verified user, reads the session, and signs out", async () => {
|
|
const email = uniqueEmail("verified");
|
|
const password = "ScenarioPassword123!";
|
|
const created = await createVerifiedUser({ email, password, name: "Verified Scenario User" });
|
|
|
|
const signIn = await requestJson<{ token?: string; user: JsonObject }>("/sign-in/email", {
|
|
method: "POST",
|
|
body: {
|
|
email,
|
|
password,
|
|
rememberMe: true,
|
|
},
|
|
});
|
|
expect(signIn.status).toBe(200);
|
|
expect(signIn.body.user).toEqual(
|
|
expect.objectContaining({
|
|
id: created.id,
|
|
email,
|
|
}),
|
|
);
|
|
|
|
const cookie = sessionCookieFromSetCookie(signIn.setCookie);
|
|
expect(cookie).toContain("better-auth.session");
|
|
|
|
const session = await requestJson<{ session: JsonObject; user: JsonObject }>("/get-session", {
|
|
cookie,
|
|
});
|
|
expect(session.status).toBe(200);
|
|
expect(session.body.user).toEqual(
|
|
expect.objectContaining({
|
|
id: created.id,
|
|
}),
|
|
);
|
|
|
|
const signOut = await requestJson<{ success: boolean }>("/sign-out", {
|
|
method: "POST",
|
|
cookie,
|
|
body: {},
|
|
});
|
|
expect(signOut.status).toBe(200);
|
|
expect(signOut.body).toEqual({ success: true });
|
|
}, 30_000);
|
|
});
|
|
|
|
scenarioDescribe("cfw-auth admin user management business scenarios", () => {
|
|
it("creates, reads, updates, bans, unbans, and removes a user", async () => {
|
|
const email = uniqueEmail("admin-managed");
|
|
|
|
const create = await requestJson<{ user: JsonObject }>("/admin/create-user", {
|
|
method: "POST",
|
|
cookie: API_TEST_ADMIN_SESSION_COOKIE,
|
|
body: {
|
|
email,
|
|
password: "ManagedPassword123!",
|
|
name: "Managed Scenario User",
|
|
role: "user",
|
|
},
|
|
});
|
|
expect(create.status).toBe(200);
|
|
const userId = String(create.body.user.id);
|
|
expect(create.body.user).toEqual(expect.objectContaining({ email, role: "user" }));
|
|
|
|
const get = await requestJson<JsonObject>(`/admin/get-user?id=${encodeURIComponent(userId)}`, {
|
|
cookie: API_TEST_ADMIN_SESSION_COOKIE,
|
|
});
|
|
expect(get.status).toBe(200);
|
|
expect(get.body).toEqual(expect.objectContaining({ id: userId, email }));
|
|
|
|
const update = await requestJson<{ user: JsonObject }>("/admin/update-user", {
|
|
method: "POST",
|
|
cookie: API_TEST_ADMIN_SESSION_COOKIE,
|
|
body: {
|
|
userId,
|
|
data: {
|
|
name: "Managed Scenario User Updated",
|
|
},
|
|
},
|
|
});
|
|
expect(update.status).toBe(200);
|
|
expect(update.body).toEqual(
|
|
expect.objectContaining({
|
|
id: userId,
|
|
name: "Managed Scenario User Updated",
|
|
}),
|
|
);
|
|
|
|
const ban = await requestJson<{ user: JsonObject }>("/admin/ban-user", {
|
|
method: "POST",
|
|
cookie: API_TEST_ADMIN_SESSION_COOKIE,
|
|
body: {
|
|
userId,
|
|
banReason: "business scenario",
|
|
banExpiresIn: 60,
|
|
},
|
|
});
|
|
expect(ban.status).toBe(200);
|
|
expect(ban.body.user).toEqual(
|
|
expect.objectContaining({
|
|
id: userId,
|
|
banned: true,
|
|
banReason: "business scenario",
|
|
}),
|
|
);
|
|
|
|
const unban = await requestJson<{ user: JsonObject }>("/admin/unban-user", {
|
|
method: "POST",
|
|
cookie: API_TEST_ADMIN_SESSION_COOKIE,
|
|
body: { userId },
|
|
});
|
|
expect(unban.status).toBe(200);
|
|
expect(unban.body.user).toEqual(
|
|
expect.objectContaining({
|
|
id: userId,
|
|
banned: false,
|
|
banReason: null,
|
|
}),
|
|
);
|
|
|
|
const remove = await requestJson<{ success: boolean }>("/admin/remove-user", {
|
|
method: "POST",
|
|
cookie: API_TEST_ADMIN_SESSION_COOKIE,
|
|
body: { userId },
|
|
});
|
|
expect(remove.status).toBe(200);
|
|
expect(remove.body).toEqual({ success: true });
|
|
|
|
const getRemoved = await requestJson<JsonObject>(
|
|
`/admin/get-user?id=${encodeURIComponent(userId)}`,
|
|
{
|
|
cookie: API_TEST_ADMIN_SESSION_COOKIE,
|
|
},
|
|
);
|
|
expect(getRemoved.status).toBe(404);
|
|
}, 30_000);
|
|
|
|
it("rejects admin operations from a regular user session", async () => {
|
|
const response = await requestJson<JsonObject>("/admin/list-users?limit=1", {
|
|
cookie: API_TEST_SESSION_COOKIE,
|
|
});
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(response.body).toEqual(
|
|
expect.objectContaining({
|
|
message: expect.any(String),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
scenarioDescribe("cfw-auth organization and team business scenarios", () => {
|
|
it("creates, updates, and removes a team inside the bootstrapped organization", async () => {
|
|
const teamName = `Scenario Team ${Date.now()}`;
|
|
|
|
const setActive = await requestJson<JsonObject>("/organization/set-active", {
|
|
method: "POST",
|
|
cookie: API_TEST_SESSION_COOKIE,
|
|
body: {
|
|
organizationId: API_TEST_ORGANIZATION_ID,
|
|
},
|
|
});
|
|
expect(setActive.status).toBe(200);
|
|
expect(setActive.body).toEqual(expect.objectContaining({ id: API_TEST_ORGANIZATION_ID }));
|
|
|
|
const create = await requestJson<JsonObject>("/organization/create-team", {
|
|
method: "POST",
|
|
cookie: API_TEST_SESSION_COOKIE,
|
|
body: {
|
|
name: teamName,
|
|
organizationId: API_TEST_ORGANIZATION_ID,
|
|
},
|
|
});
|
|
expect(create.status).toBe(200);
|
|
const teamId = String(create.body.id);
|
|
expect(create.body).toEqual(
|
|
expect.objectContaining({
|
|
name: teamName,
|
|
organizationId: API_TEST_ORGANIZATION_ID,
|
|
}),
|
|
);
|
|
|
|
const addMember = await requestJson<JsonObject>("/organization/add-team-member", {
|
|
method: "POST",
|
|
cookie: API_TEST_SESSION_COOKIE,
|
|
body: {
|
|
teamId,
|
|
userId: API_TEST_USER_ID,
|
|
organizationId: API_TEST_ORGANIZATION_ID,
|
|
},
|
|
});
|
|
expect(addMember.status).toBe(200);
|
|
expect(addMember.body).toEqual(expect.objectContaining({ teamId, userId: API_TEST_USER_ID }));
|
|
|
|
const listMembers = await requestJson<JsonObject[]>(
|
|
`/organization/list-team-members?teamId=${encodeURIComponent(
|
|
teamId,
|
|
)}&organizationId=${encodeURIComponent(API_TEST_ORGANIZATION_ID ?? "")}`,
|
|
{
|
|
cookie: API_TEST_SESSION_COOKIE,
|
|
},
|
|
);
|
|
expect(listMembers.status).toBe(200);
|
|
expect(listMembers.body).toEqual(
|
|
expect.arrayContaining([expect.objectContaining({ teamId, userId: API_TEST_USER_ID })]),
|
|
);
|
|
|
|
const update = await requestJson<JsonObject>("/organization/update-team", {
|
|
method: "POST",
|
|
cookie: API_TEST_SESSION_COOKIE,
|
|
body: {
|
|
teamId,
|
|
organizationId: API_TEST_ORGANIZATION_ID,
|
|
data: {
|
|
name: `${teamName} Updated`,
|
|
},
|
|
},
|
|
});
|
|
expect(update.status).toBe(200);
|
|
expect(update.body).toEqual(expect.objectContaining({ id: teamId, name: `${teamName} Updated` }));
|
|
|
|
const removeMember = await requestJson<JsonObject>("/organization/remove-team-member", {
|
|
method: "POST",
|
|
cookie: API_TEST_SESSION_COOKIE,
|
|
body: {
|
|
teamId,
|
|
userId: API_TEST_USER_ID,
|
|
organizationId: API_TEST_ORGANIZATION_ID,
|
|
},
|
|
});
|
|
expect(removeMember.status).toBe(200);
|
|
|
|
const removeTeam = await requestJson<JsonObject>("/organization/remove-team", {
|
|
method: "POST",
|
|
cookie: API_TEST_SESSION_COOKIE,
|
|
body: {
|
|
teamId,
|
|
organizationId: API_TEST_ORGANIZATION_ID,
|
|
},
|
|
});
|
|
expect(removeTeam.status).toBe(200);
|
|
}, 30_000);
|
|
});
|
|
|
|
scenarioDescribe("cfw-auth API key business scenarios", () => {
|
|
it("creates, reads, rejects server-only client updates, and deletes an API key", async () => {
|
|
const name = `scenario-key-${Date.now()}`;
|
|
|
|
const create = await requestJson<JsonObject>("/api-key/create", {
|
|
method: "POST",
|
|
cookie: API_TEST_SESSION_COOKIE,
|
|
body: {
|
|
name,
|
|
expiresIn: null,
|
|
remaining: null,
|
|
metadata: {
|
|
runId: RUN_ID,
|
|
},
|
|
},
|
|
});
|
|
expect(create.status).toBe(200);
|
|
const keyId = String(create.body.id);
|
|
expect(create.body).toEqual(
|
|
expect.objectContaining({
|
|
id: expect.any(String),
|
|
key: expect.stringMatching(/^cfw_/),
|
|
name,
|
|
enabled: true,
|
|
}),
|
|
);
|
|
|
|
const read = await requestJson<JsonObject>(`/api-key/get?id=${encodeURIComponent(keyId)}`, {
|
|
cookie: API_TEST_SESSION_COOKIE,
|
|
});
|
|
expect(read.status).toBe(200);
|
|
expect(read.body).toEqual(expect.objectContaining({ id: keyId, name }));
|
|
expect(read.body).not.toHaveProperty("key");
|
|
|
|
const serverOnlyUpdate = await requestJson<JsonObject>("/api-key/update", {
|
|
method: "POST",
|
|
cookie: API_TEST_SESSION_COOKIE,
|
|
body: {
|
|
keyId,
|
|
expiresIn: null,
|
|
permissions: null,
|
|
enabled: true,
|
|
metadata: {
|
|
updated: true,
|
|
},
|
|
},
|
|
});
|
|
expect(serverOnlyUpdate.status).toBe(400);
|
|
expect(serverOnlyUpdate.body).toEqual(
|
|
expect.objectContaining({
|
|
code: "SERVER_ONLY_PROPERTY",
|
|
}),
|
|
);
|
|
|
|
const remove = await requestJson<{ success: boolean }>("/api-key/delete", {
|
|
method: "POST",
|
|
cookie: API_TEST_SESSION_COOKIE,
|
|
body: {
|
|
keyId,
|
|
},
|
|
});
|
|
expect(remove.status).toBe(200);
|
|
expect(remove.body).toEqual({ success: true });
|
|
|
|
const readDeleted = await requestJson<JsonObject>(`/api-key/get?id=${encodeURIComponent(keyId)}`, {
|
|
cookie: API_TEST_SESSION_COOKIE,
|
|
});
|
|
expect(readDeleted.status).toBe(404);
|
|
}, 30_000);
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
async function requestJson<T = unknown>(
|
|
path: string,
|
|
{
|
|
method = "GET",
|
|
body,
|
|
cookie,
|
|
headers,
|
|
}: {
|
|
method?: string;
|
|
body?: unknown;
|
|
cookie?: string;
|
|
headers?: Record<string, string>;
|
|
} = {},
|
|
): Promise<ApiResponse<T>> {
|
|
if (!API_BASE_URL) {
|
|
throw new Error("Set API_BASE_URL before running business scenario tests.");
|
|
}
|
|
|
|
const requestHeaders = new Headers(headers);
|
|
requestHeaders.set("accept", "application/json");
|
|
requestHeaders.set("origin", API_TEST_ORIGIN);
|
|
if (body !== undefined) requestHeaders.set("content-type", "application/json");
|
|
if (cookie) requestHeaders.set("cookie", cookie);
|
|
|
|
const response = await fetch(new URL(`${API_BASE_URL}${path}`), {
|
|
method,
|
|
headers: requestHeaders,
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
const text = await response.text();
|
|
|
|
return {
|
|
status: response.status,
|
|
headers: response.headers,
|
|
body: text ? (JSON.parse(text) as T) : (undefined as T),
|
|
setCookie: response.headers.getSetCookie?.() ?? splitSetCookie(response.headers.get("set-cookie")),
|
|
};
|
|
}
|
|
|
|
async function createVerifiedUser({
|
|
email,
|
|
password,
|
|
name,
|
|
}: {
|
|
email: string;
|
|
password: string;
|
|
name: string;
|
|
}): Promise<JsonObject> {
|
|
const create = await requestJson<{ user: JsonObject }>("/admin/create-user", {
|
|
method: "POST",
|
|
cookie: API_TEST_ADMIN_SESSION_COOKIE,
|
|
body: {
|
|
email,
|
|
password,
|
|
name,
|
|
role: "user",
|
|
},
|
|
});
|
|
expect(create.status).toBe(200);
|
|
|
|
markEmailVerified(String(create.body.user.id));
|
|
return create.body.user;
|
|
}
|
|
|
|
function markEmailVerified(userId: string): void {
|
|
const sql = `update "user" set "emailVerified" = 1 where "id" = ${sqlString(userId)};`;
|
|
runD1(sql);
|
|
}
|
|
|
|
function runD1(command: string): void {
|
|
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) as Array<{ success?: boolean }>;
|
|
const first = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
if (!first?.success) throw new Error(`D1 command failed: ${raw}`);
|
|
}
|
|
|
|
function sqlString(value: string): string {
|
|
return `'${value.replace(/'/g, "''")}'`;
|
|
}
|
|
|
|
function sessionCookieFromSetCookie(setCookieHeaders: string[]): string {
|
|
return setCookieHeaders
|
|
.map((entry) => entry.split(";")[0])
|
|
.filter((entry) => /better-auth\.session|session_token|session/.test(entry))
|
|
.join("; ");
|
|
}
|
|
|
|
function splitSetCookie(value: string | null): string[] {
|
|
if (!value) return [];
|
|
return value.split(/,(?=\s*[^;,]+=)/g);
|
|
}
|
|
|
|
function uniqueEmail(label: string): string {
|
|
return `${RUN_ID}-${label}-${Date.now()}-${Math.random().toString(16).slice(2)}@example.invalid`;
|
|
}
|