mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-04 18:53:52 +10:00
Fix authentication recovery, account imports, application tracking, resume editing and exports, sharing, API contracts, provider selection, and private local attachments. Preserve authored content during PDF pagination. Update guides, generated OpenAPI output, and translation catalogs to match verified behavior and documented constraints. Validation: 1,019 tests passed; 12 database/OAuth integration tests skipped. Ten affected package typechecks, production build, Biome, and package boundaries passed.
227 lines
8.8 KiB
TypeScript
227 lines
8.8 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
getSession: vi.fn(),
|
|
consent: vi.fn(),
|
|
continueOAuth: vi.fn(),
|
|
handler: vi.fn(),
|
|
env: {
|
|
SERVER_PORT: 3001,
|
|
APP_URL: "http://localhost:3000",
|
|
FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI: false,
|
|
},
|
|
}));
|
|
|
|
vi.mock("@reactive-resume/auth/config", () => ({
|
|
auth: {
|
|
api: {
|
|
getSession: mocks.getSession,
|
|
oauth2Consent: mocks.consent,
|
|
oauth2Continue: mocks.continueOAuth,
|
|
},
|
|
handler: mocks.handler,
|
|
},
|
|
}));
|
|
|
|
vi.mock("@reactive-resume/db/client", () => ({ db: {} }));
|
|
vi.mock("@reactive-resume/db/schema", () => ({ oauthClient: {}, verification: {} }));
|
|
vi.mock("@reactive-resume/env/server", () => ({
|
|
env: mocks.env,
|
|
}));
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mocks.env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI = false;
|
|
mocks.handler.mockResolvedValue(new Response("ok"));
|
|
});
|
|
|
|
describe("handleAuth", () => {
|
|
it.each(["203.0.113.9", "unknown"])("uses only the adapter's client address (%s)", async (trustedClient) => {
|
|
const { handleAuth } = await import("./auth");
|
|
await handleAuth(
|
|
new Request("http://localhost:3000/api/auth/get-session", {
|
|
headers: {
|
|
"cf-connecting-ip": "198.51.100.1",
|
|
"true-client-ip": "198.51.100.2",
|
|
"x-forwarded-for": "198.51.100.3, 192.0.2.1",
|
|
"x-real-ip": "198.51.100.4",
|
|
},
|
|
}),
|
|
trustedClient,
|
|
);
|
|
const request = mocks.handler.mock.calls[0]?.[0] as Request;
|
|
expect(request.headers.get("cf-connecting-ip")).toBeNull();
|
|
expect(request.headers.get("true-client-ip")).toBeNull();
|
|
expect(request.headers.get("x-forwarded-for")).toBeNull();
|
|
expect(request.headers.get("x-real-ip")).toBe(trustedClient === "unknown" ? null : trustedClient);
|
|
});
|
|
it.for([null, false, 42, "client", [], [{ redirect_uris: [] }]])(
|
|
"rejects non-object registration payload %j",
|
|
async (body) => {
|
|
const { handleAuth } = await import("./auth");
|
|
const response = await handleAuth(
|
|
new Request("http://localhost:3000/api/auth/oauth2/register", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
}),
|
|
);
|
|
expect(response.status).toBe(400);
|
|
await expect(response.json()).resolves.toEqual({ message: "Invalid registration payload" });
|
|
expect(mocks.handler).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("rejects unsafe dynamic OAuth redirect URIs in safe mode", async () => {
|
|
const { handleAuth } = await import("./auth");
|
|
|
|
const response = await handleAuth(
|
|
new Request("http://localhost:3001/api/auth/oauth2/register", {
|
|
method: "POST",
|
|
body: JSON.stringify({ redirect_uris: ["https://192.168.1.10/callback"] }),
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
);
|
|
|
|
expect(response.status).toBe(400);
|
|
await expect(response.json()).resolves.toEqual({
|
|
error: "invalid_redirect_uri",
|
|
error_description: "redirect_uri is not allowed",
|
|
});
|
|
expect(mocks.handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["localhost", "127.0.0.1", "[::1]"])(
|
|
"infers native application type for exact %s loopback callbacks",
|
|
async (host) => {
|
|
const { handleAuth } = await import("./auth");
|
|
await handleAuth(
|
|
new Request("http://localhost:3000/api/auth/oauth2/register", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ redirect_uris: [`http://${host}:3210/callback`] }),
|
|
}),
|
|
);
|
|
const forwarded = mocks.handler.mock.calls[0]?.[0] as Request;
|
|
await expect(forwarded.json()).resolves.toMatchObject({
|
|
application_type: "native",
|
|
token_endpoint_auth_method: "none",
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each(["client_secret_basic", "client_secret_post"])(
|
|
"keeps an explicitly registered %s so the client receives a client secret",
|
|
async (method) => {
|
|
const { handleAuth } = await import("./auth");
|
|
await handleAuth(
|
|
new Request("http://localhost:3000/api/auth/oauth2/register", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
redirect_uris: ["https://example.com/callback"],
|
|
token_endpoint_auth_method: method,
|
|
}),
|
|
}),
|
|
);
|
|
const forwarded = mocks.handler.mock.calls[0]?.[0] as Request;
|
|
await expect(forwarded.json()).resolves.toMatchObject({ token_endpoint_auth_method: method });
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ redirect_uris: ["https://example.com/callback"] },
|
|
{ redirect_uris: ["http://localhost.evil.example/callback"] },
|
|
{ redirect_uris: ["http://localhost:3210/callback"], application_type: "web" },
|
|
{ redirect_uris: ["http://localhost:3210/callback", "https://example.com/callback"] },
|
|
])("does not infer native for explicit web or non-loopback clients: %j", async (body) => {
|
|
const { handleAuth } = await import("./auth");
|
|
mocks.env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI = true;
|
|
await handleAuth(
|
|
new Request("http://localhost:3000/api/auth/oauth2/register", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
}),
|
|
);
|
|
const forwarded = mocks.handler.mock.calls[0]?.[0] as Request;
|
|
expect((await forwarded.json()).application_type).not.toBe("native");
|
|
});
|
|
});
|
|
|
|
describe("handleOAuth", () => {
|
|
it("redirects unauthenticated users to the same-origin login route", async () => {
|
|
const { handleOAuth } = await import("./auth");
|
|
mocks.getSession.mockResolvedValueOnce(null);
|
|
|
|
const response = await handleOAuth(
|
|
new Request(
|
|
"http://localhost:3001/api/auth/oauth?client_id=test-client&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&state=abc&exp=123&sig=456",
|
|
),
|
|
);
|
|
|
|
expect(response.status).toBe(302);
|
|
const location = response.headers.get("Location");
|
|
expect(location).toMatch(/^\/auth\/login\?/);
|
|
|
|
const loginUrl = new URL(location ?? "", "http://localhost:3000");
|
|
const callbackUrl = new URL(loginUrl.searchParams.get("callbackURL") ?? "", "http://localhost:3000");
|
|
|
|
expect(loginUrl.origin).toBe("http://localhost:3000");
|
|
expect(callbackUrl.pathname).toBe("/api/auth/oauth");
|
|
expect(callbackUrl.searchParams.get("client_id")).toBe("test-client");
|
|
expect(callbackUrl.searchParams.get("redirect_uri")).toBe("https://example.com/callback");
|
|
expect(callbackUrl.searchParams.get("state")).toBe("abc");
|
|
expect(callbackUrl.searchParams.get("exp")).toBe("123");
|
|
expect(callbackUrl.searchParams.get("sig")).toBe("456");
|
|
});
|
|
it("continues signed authorization without approving consent on GET", async () => {
|
|
const { handleOAuth } = await import("./auth");
|
|
mocks.getSession.mockResolvedValueOnce({ user: { id: "owner" } });
|
|
mocks.continueOAuth.mockResolvedValueOnce(
|
|
Response.json({ redirect: true, url: "/auth/consent?client_id=client&sig=signed" }),
|
|
);
|
|
const query = "client_id=client&resource=one&resource=two&exp=123&sig=456";
|
|
const response = await handleOAuth(new Request(`http://localhost:3000/api/auth/oauth?${query}`));
|
|
expect(mocks.continueOAuth).toHaveBeenCalledWith(
|
|
expect.objectContaining({ body: { postLogin: true, oauth_query: query } }),
|
|
);
|
|
expect(mocks.consent).not.toHaveBeenCalled();
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.get("location")).toBe("/auth/consent?client_id=client&sig=signed");
|
|
});
|
|
|
|
it("preserves provider cookies and cache headers on forced reauthentication", async () => {
|
|
const { handleOAuth } = await import("./auth");
|
|
mocks.getSession.mockResolvedValueOnce({ user: { id: "owner" } });
|
|
const headers = new Headers({ "cache-control": "no-store", "content-length": "123" });
|
|
headers.append("set-cookie", "oauth_state=state; Path=/; HttpOnly");
|
|
headers.append("set-cookie", "session=refreshed; Path=/; HttpOnly");
|
|
mocks.continueOAuth.mockResolvedValueOnce(
|
|
Response.json({ redirect: true, url: "/api/auth/oauth?prompt=login&sig=signed" }, { headers }),
|
|
);
|
|
const response = await handleOAuth(new Request("http://localhost:3000/api/auth/oauth?sig=original"));
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.get("location")).toMatch(/^\/auth\/login\?reauthenticate=true&/);
|
|
expect(response.headers.getSetCookie()).toEqual(headers.getSetCookie());
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
expect(response.headers.get("content-type")).toBeNull();
|
|
expect(response.headers.get("content-length")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("OAuth provider response validation", () => {
|
|
it.for([{}, { url: null }, { url: 7 }, { url: "" }, { url: "undefined" }, { url: "javascript:alert(1)" }])(
|
|
"fails closed for malformed provider response %j",
|
|
async (body) => {
|
|
const { handleOAuth } = await import("./auth");
|
|
mocks.getSession.mockResolvedValueOnce({ user: { id: "owner" } });
|
|
mocks.continueOAuth.mockResolvedValueOnce(Response.json(body));
|
|
const response = await handleOAuth(new Request("http://localhost:3000/api/auth/oauth?sig=signed"));
|
|
expect(response.status).toBe(502);
|
|
expect(response.headers.get("location")).toBeNull();
|
|
expect(mocks.consent).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
});
|