311 lines
9.5 KiB
TypeScript
311 lines
9.5 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import type { Context } from "@wrnexus/core";
|
|
import { createPluginRunner } from "@wrnexus/plugin";
|
|
import { authPlugin } from "../src/plugin.ts";
|
|
import { AUTH_ROUTE_DEFINITIONS } from "../src/routes/definitions.ts";
|
|
import { readFileSync } from "node:fs";
|
|
import { createAuthEngine } from "../src/engine.ts";
|
|
import { MemoryAuthStore } from "../src/stores/memory.ts";
|
|
import { invokeAuthHandler } from "../src/routes/api.ts";
|
|
|
|
function routeContext(request: Request): Context {
|
|
const values = new Map<string, unknown>();
|
|
return {
|
|
req: request,
|
|
url: new URL(request.url),
|
|
params: {},
|
|
locals: {},
|
|
lang: "en",
|
|
t: (key: string) => key,
|
|
ip: "127.0.0.1",
|
|
user: null,
|
|
cookies: {
|
|
get: (name: string) => (name === "wrn-csrf" ? "plugin-test-csrf-token" : undefined),
|
|
} as Context["cookies"],
|
|
localStorage: {} as Context["localStorage"],
|
|
session: {
|
|
id: () => "plugin-test-session",
|
|
get: <T>(key: string) => values.get(key) as T | undefined,
|
|
getAll: () => Object.fromEntries(values),
|
|
set: (key: string, value: unknown) => {
|
|
values.set(key, value);
|
|
},
|
|
delete: (key: string) => {
|
|
values.delete(key);
|
|
},
|
|
regenerate: () => {},
|
|
clear: () => {
|
|
values.clear();
|
|
},
|
|
},
|
|
} as Context;
|
|
}
|
|
|
|
test("plugin contributes components, runtime, styles, migration, and toolbar", async () => {
|
|
const metadata = new Map<string, unknown>();
|
|
const runner = createPluginRunner(
|
|
authPlugin({ includeRoutes: true, includeMigrations: true, includeMiddleware: true }),
|
|
{
|
|
root: process.cwd(),
|
|
mode: "development",
|
|
command: "dev",
|
|
metadata,
|
|
warn() {},
|
|
},
|
|
);
|
|
const contributions = await runner.contributions();
|
|
expect(contributions.componentDirs).toHaveLength(1);
|
|
expect(contributions.clientRuntimes[0]).toMatchObject({ id: "auth", singleton: true });
|
|
expect(contributions.styles[0]?.id).toBe("auth-components");
|
|
expect(contributions.migrations.map((migration) => migration.id)).toEqual([
|
|
"wrnexus-auth-001",
|
|
"wrnexus-auth-002-otp-purpose",
|
|
]);
|
|
expect(contributions.routes.length).toBeGreaterThanOrEqual(30);
|
|
expect(contributions.middleware).toHaveLength(1);
|
|
});
|
|
|
|
test("unconfigured automatic discovery fails closed for routes, middleware, and migrations", async () => {
|
|
const metadata = new Map<string, unknown>();
|
|
const runner = createPluginRunner(authPlugin(), {
|
|
root: process.cwd(),
|
|
mode: "development",
|
|
command: "dev",
|
|
metadata,
|
|
warn() {},
|
|
});
|
|
|
|
const contributions = await runner.contributions();
|
|
expect(contributions.routes).toHaveLength(0);
|
|
expect(contributions.middleware).toHaveLength(0);
|
|
expect(contributions.migrations).toHaveLength(0);
|
|
expect(contributions.componentDirs).toHaveLength(1);
|
|
expect(contributions.clientRuntimes).toHaveLength(1);
|
|
});
|
|
|
|
test("config.auth controls route groups and migrations without explicit plugin options", async () => {
|
|
const metadata = new Map<string, unknown>();
|
|
const runner = createPluginRunner(authPlugin(), {
|
|
root: process.cwd(),
|
|
mode: "development",
|
|
command: "dev",
|
|
metadata,
|
|
warn() {},
|
|
});
|
|
await runner.configure({
|
|
auth: {
|
|
engine: {} as never,
|
|
migrations: false,
|
|
routes: { enabled: true, registration: false, passkeys: false },
|
|
},
|
|
});
|
|
const contributions = await runner.contributions();
|
|
expect(contributions.migrations).toHaveLength(0);
|
|
expect(contributions.routes.some((route) => route.path === "/api/auth/register")).toBe(false);
|
|
expect(contributions.routes.some((route) => route.path.includes("/passkeys/"))).toBe(false);
|
|
expect(contributions.routes.some((route) => route.path === "/api/auth/login")).toBe(true);
|
|
});
|
|
|
|
test("a hook set on config.auth.engine fires when a request goes through the route layer", async () => {
|
|
const calls: string[] = [];
|
|
const engine = createAuthEngine({
|
|
store: new MemoryAuthStore(),
|
|
secret: "plugin-navigation-hooks-secret-longer-than-thirty-two-characters",
|
|
onSignedIn(ctx, returnTo) {
|
|
calls.push(`in:${returnTo}`);
|
|
return Response.redirect(new URL(returnTo ?? "/account", ctx.url), 303);
|
|
},
|
|
onSignedOut(ctx) {
|
|
calls.push("out");
|
|
return Response.redirect(new URL("/sign-in", ctx.url), 303);
|
|
},
|
|
});
|
|
await engine.register({
|
|
email: "plugin-navigation@example.com",
|
|
password: "StrongPassword123",
|
|
});
|
|
|
|
const runner = createPluginRunner(authPlugin(), {
|
|
root: process.cwd(),
|
|
mode: "development",
|
|
command: "dev",
|
|
metadata: new Map(),
|
|
warn() {},
|
|
});
|
|
|
|
await runner.configure({
|
|
auth: { engine, routes: true },
|
|
});
|
|
|
|
const loginResponse = await invokeAuthHandler(
|
|
"login",
|
|
routeContext(
|
|
new Request("https://example.test/api/auth/login", {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-csrf-token": "plugin-test-csrf-token",
|
|
},
|
|
body: JSON.stringify({
|
|
identifier: "plugin-navigation@example.com",
|
|
password: "StrongPassword123",
|
|
returnTo: "/dashboard",
|
|
}),
|
|
}),
|
|
),
|
|
);
|
|
expect(loginResponse.status).toBe(303);
|
|
expect(loginResponse.headers.get("location")).toBe("https://example.test/dashboard");
|
|
|
|
const logoutResponse = await invokeAuthHandler(
|
|
"logout",
|
|
routeContext(
|
|
new Request("https://example.test/api/auth/logout", {
|
|
method: "POST",
|
|
headers: { "x-csrf-token": "plugin-test-csrf-token" },
|
|
}),
|
|
),
|
|
);
|
|
expect(logoutResponse.status).toBe(303);
|
|
expect(logoutResponse.headers.get("location")).toBe("https://example.test/sign-in");
|
|
expect(calls).toEqual(["in:/dashboard", "out"]);
|
|
});
|
|
|
|
test("auth runtime contains built-in browser schemas", async () => {
|
|
const metadata = new Map<string, unknown>();
|
|
const runner = createPluginRunner(authPlugin({ includeMigrations: false }), {
|
|
root: process.cwd(),
|
|
mode: "development",
|
|
command: "dev",
|
|
metadata,
|
|
warn() {},
|
|
});
|
|
const contributions = await runner.contributions();
|
|
expect(contributions.clientRuntimes[0]?.source).toContain("auth-password-request");
|
|
expect(contributions.clientRuntimes[0]?.source).toContain("auth-register");
|
|
});
|
|
|
|
test("each package auth route uses a route-specific entry module", async () => {
|
|
const metadata = new Map<string, unknown>();
|
|
const runner = createPluginRunner(
|
|
authPlugin({
|
|
includeRoutes: true,
|
|
includeMigrations: false,
|
|
includeMiddleware: false,
|
|
}),
|
|
{
|
|
root: process.cwd(),
|
|
mode: "development",
|
|
command: "dev",
|
|
metadata,
|
|
warn() {},
|
|
},
|
|
);
|
|
|
|
const contributions = await runner.contributions();
|
|
const entries = contributions.routes.map((route) => route.entry);
|
|
const passwordRequest = contributions.routes.find(
|
|
(route) => route.path === "/api/auth/password/request",
|
|
);
|
|
|
|
expect(new Set(entries).size).toBe(entries.length);
|
|
expect(
|
|
passwordRequest?.entry.replace(/\\/g, "/").endsWith("/src/routes/api/password-request.ts"),
|
|
).toBe(true);
|
|
for (const definition of AUTH_ROUTE_DEFINITIONS) {
|
|
const route = contributions.routes.find((item) => item.path === definition.path);
|
|
expect(route).toBeDefined();
|
|
const source = readFileSync(route!.entry, "utf8");
|
|
expect(source).toContain(`invokeAuthHandler("${definition.handler}"`);
|
|
expect(source.includes("dispatchAuthRoute")).toBe(false);
|
|
for (const method of definition.methods) {
|
|
expect(source).toContain(`export function ${method}`);
|
|
}
|
|
}
|
|
});
|
|
test("config.auth registers package routes", async () => {
|
|
const metadata = new Map<string, unknown>();
|
|
|
|
const runner = createPluginRunner(authPlugin(), {
|
|
root: process.cwd(),
|
|
mode: "development",
|
|
command: "dev",
|
|
metadata,
|
|
warn() {},
|
|
});
|
|
|
|
await runner.configure({
|
|
auth: {
|
|
engine: {} as never,
|
|
routes: true,
|
|
middleware: true,
|
|
migrations: false,
|
|
},
|
|
});
|
|
|
|
const contributions = await runner.contributions();
|
|
|
|
expect(contributions.routes.some((route) => route.path === "/api/auth/password/request")).toBe(
|
|
true,
|
|
);
|
|
|
|
expect(contributions.routes.some((route) => route.path === "/api/auth/register")).toBe(true);
|
|
|
|
expect(contributions.routes.some((route) => route.path === "/api/auth/login")).toBe(true);
|
|
|
|
expect(contributions.middleware).toHaveLength(1);
|
|
|
|
expect(contributions.migrations).toHaveLength(0);
|
|
});
|
|
test("config.auth contributes a reusable forward-auth verification route", async () => {
|
|
const runner = createPluginRunner(authPlugin(), {
|
|
root: process.cwd(),
|
|
mode: "development",
|
|
command: "dev",
|
|
metadata: new Map<string, unknown>(),
|
|
warn() {},
|
|
});
|
|
await runner.configure({
|
|
auth: {
|
|
engine: {} as never,
|
|
routes: true,
|
|
},
|
|
});
|
|
const contributions = await runner.contributions();
|
|
expect(contributions.routes.some((route) => route.path === "/api/auth/session/verify")).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("config.auth can disable route groups", async () => {
|
|
const metadata = new Map<string, unknown>();
|
|
|
|
const runner = createPluginRunner(authPlugin(), {
|
|
root: process.cwd(),
|
|
mode: "development",
|
|
command: "dev",
|
|
metadata,
|
|
warn() {},
|
|
});
|
|
|
|
await runner.configure({
|
|
auth: {
|
|
engine: {} as never,
|
|
migrations: false,
|
|
routes: {
|
|
enabled: true,
|
|
password: true,
|
|
passkeys: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
const contributions = await runner.contributions();
|
|
|
|
expect(contributions.routes.some((route) => route.path === "/api/auth/password/request")).toBe(
|
|
true,
|
|
);
|
|
|
|
expect(contributions.routes.some((route) => route.path.includes("/passkeys/"))).toBe(false);
|
|
});
|