release: WRNexusJS 0.5.0
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user