337 lines
11 KiB
TypeScript
337 lines
11 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import type { Context } from "@wrnexus/core";
|
|
import { createAuthEngine } from "../src/engine.ts";
|
|
import { createAuthHttpHandlers } from "../src/http/index.ts";
|
|
import { MemoryAuthStore } from "../src/stores/memory.ts";
|
|
|
|
function context(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: {} as Context["cookies"],
|
|
localStorage: {} as Context["localStorage"],
|
|
session: {
|
|
id: () => "http-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("login handler never trusts a browser captchaVerified field", async () => {
|
|
const engine = createAuthEngine({
|
|
store: new MemoryAuthStore(),
|
|
secret: "http-handler-secret-that-is-longer-than-thirty-two-characters",
|
|
captchaThreshold: 0,
|
|
});
|
|
await engine.register({ email: "captcha@example.com", password: "StrongPassword123" });
|
|
const handlers = createAuthHttpHandlers({ engine });
|
|
const ctx = context(
|
|
new Request("https://example.test/api/auth/login", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
identifier: "captcha@example.com",
|
|
password: "StrongPassword123",
|
|
captchaVerified: true,
|
|
}),
|
|
}),
|
|
);
|
|
const response = await handlers.login(ctx);
|
|
expect(response.status).toBe(401);
|
|
expect(await response.json()).toMatchObject({ code: "captcha-required" });
|
|
});
|
|
|
|
test("server-populated CAPTCHA verification permits the login attempt", async () => {
|
|
const engine = createAuthEngine({
|
|
store: new MemoryAuthStore(),
|
|
secret: "http-handler-secret-that-is-longer-than-thirty-two-characters",
|
|
captchaThreshold: 0,
|
|
});
|
|
await engine.register({ email: "verified@example.com", password: "StrongPassword123" });
|
|
const handlers = createAuthHttpHandlers({ engine });
|
|
const ctx = context(
|
|
new Request("https://example.test/api/auth/login", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ identifier: "verified@example.com", password: "StrongPassword123" }),
|
|
}),
|
|
);
|
|
ctx.locals.captcha = { success: true };
|
|
const response = await handlers.login(ctx);
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toMatchObject({ ok: true });
|
|
});
|
|
|
|
test("login API returns a safe actionable invalid-credentials message", async () => {
|
|
const engine = createAuthEngine({
|
|
store: new MemoryAuthStore(),
|
|
secret: "http-login-message-secret-longer-than-thirty-two-characters",
|
|
});
|
|
const handlers = createAuthHttpHandlers({ engine });
|
|
const response = await handlers.login(
|
|
context(
|
|
new Request("https://example.test/api/auth/login", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
identifier: "missing@example.com",
|
|
password: "WrongPassword999",
|
|
}),
|
|
}),
|
|
),
|
|
);
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(await response.json()).toMatchObject({
|
|
ok: false,
|
|
code: "invalid-credentials",
|
|
message: "The email, phone, username, or password you entered is incorrect.",
|
|
});
|
|
});
|
|
|
|
test("HTTP handlers use navigation hooks configured on the auth engine", async () => {
|
|
const calls: string[] = [];
|
|
const engine = createAuthEngine({
|
|
store: new MemoryAuthStore(),
|
|
secret: "engine-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: "navigation@example.com",
|
|
password: "StrongPassword123",
|
|
});
|
|
const handlers = createAuthHttpHandlers({ engine });
|
|
const loginResponse = await handlers.login(
|
|
context(
|
|
new Request("https://example.test/api/auth/login", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
identifier: "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 handlers.logout(
|
|
context(new Request("https://example.test/api/auth/logout", { method: "POST" })),
|
|
);
|
|
expect(logoutResponse.status).toBe(303);
|
|
expect(logoutResponse.headers.get("location")).toBe("https://example.test/sign-in");
|
|
expect(calls).toEqual(["in:/dashboard", "out"]);
|
|
});
|
|
|
|
test("register handler returns validation field errors before calling the engine", async () => {
|
|
let registerCalls = 0;
|
|
const engine = {
|
|
register: async () => {
|
|
registerCalls += 1;
|
|
return { ok: true };
|
|
},
|
|
} as unknown as ReturnType<typeof createAuthEngine>;
|
|
const handlers = createAuthHttpHandlers({ engine });
|
|
const ctx = context(
|
|
new Request("https://example.test/api/auth/register", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ displayName: "A", email: "bad", password: "short" }),
|
|
}),
|
|
);
|
|
|
|
const response = await handlers.register(ctx);
|
|
expect(response.status).toBe(400);
|
|
expect(await response.json()).toMatchObject({
|
|
ok: false,
|
|
errors: {
|
|
displayName: expect.any(String),
|
|
email: expect.any(String),
|
|
password: expect.any(String),
|
|
},
|
|
});
|
|
expect(registerCalls).toBe(0);
|
|
});
|
|
|
|
test("successful signup redirects to sign-in by default", async () => {
|
|
const engine = createAuthEngine({
|
|
store: new MemoryAuthStore(),
|
|
secret: "default-signup-redirect-secret-longer-than-thirty-two-characters",
|
|
});
|
|
const handlers = createAuthHttpHandlers({ engine });
|
|
const response = await handlers.register(
|
|
context(
|
|
new Request("https://example.test/api/auth/register", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
displayName: "Default Redirect",
|
|
email: "default-signup@example.com",
|
|
password: "StrongPassword123",
|
|
consent: true,
|
|
}),
|
|
}),
|
|
),
|
|
);
|
|
|
|
expect(response.status).toBe(303);
|
|
expect(response.headers.get("location")).toBe("https://example.test/sign-in");
|
|
});
|
|
|
|
test("onSuccessfulSignUp can safely auto-sign-in and redirect the new user", async () => {
|
|
const engine = createAuthEngine({
|
|
store: new MemoryAuthStore(),
|
|
secret: "automatic-signup-login-secret-longer-than-thirty-two-characters",
|
|
onSuccessfulSignUp() {
|
|
return { autoSignIn: true, redirectTo: "/welcome" };
|
|
},
|
|
});
|
|
const handlers = createAuthHttpHandlers({ engine });
|
|
const ctx = context(
|
|
new Request("https://example.test/api/auth/register", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
displayName: "Automatic Login",
|
|
email: "automatic-signup@example.com",
|
|
password: "StrongPassword123",
|
|
consent: true,
|
|
}),
|
|
}),
|
|
);
|
|
const response = await handlers.register(ctx);
|
|
|
|
expect(response.status).toBe(303);
|
|
expect(response.headers.get("location")).toBe("https://example.test/welcome");
|
|
expect(ctx.locals.authUser).toMatchObject({ displayName: "Automatic Login" });
|
|
expect(ctx.locals.authSession).toBeDefined();
|
|
});
|
|
|
|
test("signup auto-sign-in does not bypass verification policy", async () => {
|
|
const engine = createAuthEngine({
|
|
store: new MemoryAuthStore(),
|
|
secret: "verified-signup-policy-secret-longer-than-thirty-two-characters",
|
|
requireVerifiedEmail: true,
|
|
onSuccessfulSignUp() {
|
|
return { autoSignIn: true, redirectTo: "/account" };
|
|
},
|
|
});
|
|
const handlers = createAuthHttpHandlers({ engine });
|
|
const ctx = context(
|
|
new Request("https://example.test/api/auth/register", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
displayName: "Verification Required",
|
|
email: "verify-signup@example.com",
|
|
password: "StrongPassword123",
|
|
consent: true,
|
|
}),
|
|
}),
|
|
);
|
|
const response = await handlers.register(ctx);
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(await response.json()).toMatchObject({
|
|
ok: false,
|
|
code: "email-unverified",
|
|
message: "Verify your email address before signing in.",
|
|
});
|
|
expect(ctx.locals.authSession).toBeUndefined();
|
|
});
|
|
|
|
test("authenticated OTP issue rejects an unlinked destination without throwing", async () => {
|
|
const engine = createAuthEngine({
|
|
store: new MemoryAuthStore(),
|
|
secret: "http-otp-secret-that-is-longer-than-thirty-two-characters",
|
|
});
|
|
const registered = await engine.register({
|
|
email: "owner@example.com",
|
|
password: "StrongPassword123",
|
|
});
|
|
const handlers = createAuthHttpHandlers({ engine });
|
|
const ctx = context(
|
|
new Request("https://example.test/api/auth/otp", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ method: "email-otp", destination: "other@example.com" }),
|
|
}),
|
|
);
|
|
ctx.user = registered.user!;
|
|
ctx.locals.authUser = registered.user!;
|
|
const response = await handlers.issueOtp(ctx);
|
|
expect(response.status).toBe(400);
|
|
expect(await response.json()).toMatchObject({ ok: false });
|
|
});
|
|
|
|
test("verification resend is generic and can resolve an unauthenticated identifier", async () => {
|
|
const messages: Array<{ template: string }> = [];
|
|
const engine = createAuthEngine({
|
|
store: new MemoryAuthStore(),
|
|
secret: "verification-resend-secret-that-is-longer-than-thirty-two-characters",
|
|
delivery: {
|
|
async send(message) {
|
|
messages.push(message);
|
|
},
|
|
},
|
|
});
|
|
await engine.register({
|
|
email: "resend@example.com",
|
|
password: "StrongPassword123",
|
|
});
|
|
const handlers = createAuthHttpHandlers({ engine });
|
|
const before = messages.length;
|
|
const known = await handlers.requestVerification(
|
|
context(
|
|
new Request("https://example.test/api/auth/verification/request", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ type: "email", identifier: "resend@example.com" }),
|
|
}),
|
|
),
|
|
);
|
|
expect(known.status).toBe(200);
|
|
expect(await known.json()).toEqual({ ok: true });
|
|
expect(messages).toHaveLength(before + 1);
|
|
|
|
const unknown = await handlers.requestVerification(
|
|
context(
|
|
new Request("https://example.test/api/auth/verification/request", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ type: "email", identifier: "missing@example.com" }),
|
|
}),
|
|
),
|
|
);
|
|
expect(unknown.status).toBe(200);
|
|
expect(await unknown.json()).toEqual({ ok: true });
|
|
expect(messages).toHaveLength(before + 1);
|
|
});
|