425 lines
14 KiB
TypeScript
425 lines
14 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const { mockApi, mockOptima, mockIsInvalidSignatureError, mockRedirect } =
|
|
vi.hoisted(() => ({
|
|
mockApi: {
|
|
get: vi.fn(),
|
|
},
|
|
mockOptima: {
|
|
auth: {},
|
|
user: {
|
|
isLoggedIn: vi.fn(),
|
|
isLoggedInServer: vi.fn(),
|
|
logout: vi.fn(),
|
|
refreshSession: vi.fn(),
|
|
fetchInfo: vi.fn(),
|
|
checkPermissions: vi.fn(),
|
|
},
|
|
},
|
|
mockIsInvalidSignatureError: vi.fn(),
|
|
mockRedirect: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("$lib", () => ({ optima: mockOptima }));
|
|
vi.mock("$lib/optima-api/axios", () => ({ default: mockApi, api: mockApi }));
|
|
vi.mock("$lib/optima-api/errorHandler", () => ({
|
|
isInvalidSignatureError: mockIsInvalidSignatureError,
|
|
}));
|
|
vi.mock("@sveltejs/kit", () => ({
|
|
redirect: mockRedirect,
|
|
}));
|
|
|
|
import { handle } from "./hooks.server";
|
|
|
|
function createMockEvent(overrides: Record<string, unknown> = {}) {
|
|
const cookies: Record<string, string> = {};
|
|
return {
|
|
url: new URL("http://localhost/"),
|
|
cookies: {
|
|
get: vi.fn((name: string) => cookies[name] ?? null),
|
|
set: vi.fn((name: string, value: string) => {
|
|
cookies[name] = value;
|
|
}),
|
|
delete: vi.fn((name: string) => {
|
|
delete cookies[name];
|
|
}),
|
|
},
|
|
locals: {} as Record<string, unknown>,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createResolve(response = new Response("OK")) {
|
|
return vi.fn().mockResolvedValue(response);
|
|
}
|
|
|
|
describe("hooks.server handle", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockIsInvalidSignatureError.mockReturnValue(false);
|
|
});
|
|
|
|
// ── Health check bypass ───────────────────────────────────────────
|
|
|
|
it("passes /healthz through without API check", async () => {
|
|
const event = createMockEvent({
|
|
url: new URL("http://localhost/healthz"),
|
|
});
|
|
const resolve = createResolve();
|
|
|
|
await handle({ event, resolve } as any);
|
|
|
|
expect(mockApi.get).not.toHaveBeenCalled();
|
|
expect(resolve).toHaveBeenCalledWith(event);
|
|
});
|
|
|
|
// ── API health check ─────────────────────────────────────────────
|
|
|
|
it("returns 503 page when API teapot check fails", async () => {
|
|
mockApi.get.mockRejectedValueOnce(new Error("Network error"));
|
|
const event = createMockEvent();
|
|
const resolve = createResolve();
|
|
|
|
const response = await handle({ event, resolve } as any);
|
|
|
|
expect(response.status).toBe(503);
|
|
const html = await response.text();
|
|
expect(html).toContain("Unable to Reach API");
|
|
});
|
|
|
|
it("returns 503 when API teapot returns non-418 status", async () => {
|
|
mockApi.get.mockResolvedValueOnce({ status: 200 });
|
|
const event = createMockEvent();
|
|
const resolve = createResolve();
|
|
|
|
const response = await handle({ event, resolve } as any);
|
|
|
|
expect(response.status).toBe(503);
|
|
});
|
|
|
|
// ── Logout path ──────────────────────────────────────────────────
|
|
|
|
it("clears cookies and redirects on /logout", async () => {
|
|
mockApi.get.mockResolvedValueOnce({ status: 418 });
|
|
const event = createMockEvent({
|
|
url: new URL("http://localhost/logout"),
|
|
});
|
|
event.cookies.get = vi
|
|
.fn()
|
|
.mockReturnValueOnce("access-tok")
|
|
.mockReturnValueOnce("refresh-tok");
|
|
const resolve = createResolve();
|
|
|
|
try {
|
|
await handle({ event, resolve } as any);
|
|
} catch {
|
|
// redirect throws
|
|
}
|
|
|
|
expect(event.cookies.delete).toHaveBeenCalledWith("accessToken", {
|
|
path: "/",
|
|
});
|
|
expect(event.cookies.delete).toHaveBeenCalledWith("refreshToken", {
|
|
path: "/",
|
|
});
|
|
expect(mockRedirect).toHaveBeenCalledWith(303, "/login");
|
|
});
|
|
|
|
// ── Login path when already logged in ─────────────────────────────
|
|
|
|
it("redirects to / when visiting /login while already logged in", async () => {
|
|
mockApi.get.mockResolvedValueOnce({ status: 418 });
|
|
mockOptima.user.isLoggedInServer.mockReturnValue(true);
|
|
const event = createMockEvent({
|
|
url: new URL("http://localhost/login"),
|
|
});
|
|
const resolve = createResolve();
|
|
|
|
try {
|
|
await handle({ event, resolve } as any);
|
|
} catch {
|
|
// redirect throws
|
|
}
|
|
|
|
expect(mockRedirect).toHaveBeenCalledWith(303, "/");
|
|
});
|
|
|
|
// ── Login path when not logged in ─────────────────────────────────
|
|
|
|
it("resolves /login normally when user is not logged in", async () => {
|
|
mockApi.get.mockResolvedValueOnce({ status: 418 });
|
|
mockOptima.user.isLoggedInServer.mockReturnValue(false);
|
|
const event = createMockEvent({
|
|
url: new URL("http://localhost/login"),
|
|
});
|
|
const resolve = createResolve();
|
|
|
|
await handle({ event, resolve } as any);
|
|
|
|
expect(resolve).toHaveBeenCalledWith(event);
|
|
});
|
|
|
|
// ── No tokens — force logout ──────────────────────────────────────
|
|
|
|
it("forces logout when no access or refresh tokens exist", async () => {
|
|
mockApi.get.mockResolvedValueOnce({ status: 418 });
|
|
const event = createMockEvent({
|
|
url: new URL("http://localhost/dashboard"),
|
|
});
|
|
event.cookies.get = vi.fn().mockReturnValue(null);
|
|
const resolve = createResolve();
|
|
|
|
try {
|
|
await handle({ event, resolve } as any);
|
|
} catch {
|
|
// redirect throws
|
|
}
|
|
|
|
expect(mockOptima.user.logout).toHaveBeenCalled();
|
|
expect(mockRedirect).toHaveBeenCalledWith(303, "/login");
|
|
});
|
|
|
|
// ── Valid access token — normal flow ──────────────────────────────
|
|
|
|
it("resolves normally with valid non-expired access token", async () => {
|
|
mockApi.get.mockResolvedValueOnce({ status: 418 });
|
|
// Create a JWT payload with exp far in the future
|
|
const payload = {
|
|
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
sub: "user-1",
|
|
};
|
|
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString(
|
|
"base64url"
|
|
);
|
|
const fakeJwt = `header.${encodedPayload}.signature`;
|
|
|
|
const event = createMockEvent({
|
|
url: new URL("http://localhost/dashboard"),
|
|
});
|
|
event.cookies.get = vi.fn((name: string) => {
|
|
if (name === "accessToken") return fakeJwt;
|
|
if (name === "refreshToken") return "refresh-tok";
|
|
return null;
|
|
});
|
|
const resolve = createResolve();
|
|
|
|
await handle({ event, resolve } as any);
|
|
|
|
expect(resolve).toHaveBeenCalledWith(event);
|
|
// setTokens should have set session on locals
|
|
expect(event.locals.session).toBeDefined();
|
|
});
|
|
|
|
// ── Expired access token — refresh ────────────────────────────────
|
|
|
|
it("refreshes expired access token using refresh token", async () => {
|
|
mockApi.get.mockResolvedValueOnce({ status: 418 });
|
|
// Create an expired JWT
|
|
const payload = { exp: Math.floor(Date.now() / 1000) - 100, sub: "u1" };
|
|
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString(
|
|
"base64url"
|
|
);
|
|
const fakeJwt = `h.${encodedPayload}.s`;
|
|
|
|
mockOptima.user.refreshSession.mockResolvedValueOnce({
|
|
accessToken: "new-access",
|
|
refreshToken: "new-refresh",
|
|
});
|
|
|
|
const event = createMockEvent({
|
|
url: new URL("http://localhost/dashboard"),
|
|
});
|
|
event.cookies.get = vi.fn((name: string) => {
|
|
if (name === "accessToken") return fakeJwt;
|
|
if (name === "refreshToken") return "refresh-tok";
|
|
return null;
|
|
});
|
|
const resolve = createResolve();
|
|
|
|
await handle({ event, resolve } as any);
|
|
|
|
expect(mockOptima.user.refreshSession).toHaveBeenCalledWith("refresh-tok");
|
|
expect(event.cookies.set).toHaveBeenCalledWith(
|
|
"accessToken",
|
|
"new-access",
|
|
{ path: "/" }
|
|
);
|
|
expect(resolve).toHaveBeenCalledWith(event);
|
|
});
|
|
|
|
// ── Expired token, no refresh token — force logout ────────────────
|
|
|
|
it("forces logout when token expired and no refresh token", async () => {
|
|
mockApi.get.mockResolvedValueOnce({ status: 418 });
|
|
const payload = { exp: Math.floor(Date.now() / 1000) - 100, sub: "u1" };
|
|
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString(
|
|
"base64url"
|
|
);
|
|
const fakeJwt = `h.${encodedPayload}.s`;
|
|
|
|
const event = createMockEvent({
|
|
url: new URL("http://localhost/dashboard"),
|
|
});
|
|
event.cookies.get = vi.fn((name: string) => {
|
|
if (name === "accessToken") return fakeJwt;
|
|
if (name === "refreshToken") return null;
|
|
return null;
|
|
});
|
|
const resolve = createResolve();
|
|
|
|
try {
|
|
await handle({ event, resolve } as any);
|
|
} catch {
|
|
// redirect throws
|
|
}
|
|
|
|
expect(mockOptima.user.logout).toHaveBeenCalled();
|
|
expect(mockRedirect).toHaveBeenCalledWith(303, "/login");
|
|
});
|
|
|
|
// ── Invalid signature error — force logout ────────────────────────
|
|
|
|
it("forces logout on invalid signature error during token decode", async () => {
|
|
mockApi.get.mockResolvedValueOnce({ status: 418 });
|
|
mockIsInvalidSignatureError.mockReturnValue(true);
|
|
|
|
const event = createMockEvent({
|
|
url: new URL("http://localhost/dashboard"),
|
|
});
|
|
// Return a malformed JWT that will throw when parsed
|
|
event.cookies.get = vi.fn((name: string) => {
|
|
if (name === "accessToken") return "bad.!!!.token";
|
|
if (name === "refreshToken") return "refresh-tok";
|
|
return null;
|
|
});
|
|
const resolve = createResolve();
|
|
|
|
try {
|
|
await handle({ event, resolve } as any);
|
|
} catch {
|
|
// redirect throws
|
|
}
|
|
|
|
expect(mockOptima.user.logout).toHaveBeenCalled();
|
|
expect(mockRedirect).toHaveBeenCalledWith(303, "/login");
|
|
});
|
|
|
|
// ── Malformed token, refresh succeeds ─────────────────────────────
|
|
|
|
it("refreshes when access token is malformed and refresh token exists", async () => {
|
|
mockApi.get.mockResolvedValueOnce({ status: 418 });
|
|
mockIsInvalidSignatureError.mockReturnValue(false);
|
|
mockOptima.user.refreshSession.mockResolvedValueOnce({
|
|
accessToken: "recovered-access",
|
|
refreshToken: "recovered-refresh",
|
|
});
|
|
|
|
const event = createMockEvent({
|
|
url: new URL("http://localhost/dashboard"),
|
|
});
|
|
event.cookies.get = vi.fn((name: string) => {
|
|
if (name === "accessToken") return "totally.broken";
|
|
if (name === "refreshToken") return "refresh-tok";
|
|
return null;
|
|
});
|
|
const resolve = createResolve();
|
|
|
|
await handle({ event, resolve } as any);
|
|
|
|
expect(mockOptima.user.refreshSession).toHaveBeenCalledWith("refresh-tok");
|
|
expect(resolve).toHaveBeenCalled();
|
|
});
|
|
|
|
// ── No access token, has refresh — try refresh ────────────────────
|
|
|
|
it("attempts refresh when only refresh token exists", async () => {
|
|
mockApi.get.mockResolvedValueOnce({ status: 418 });
|
|
mockOptima.user.refreshSession.mockResolvedValueOnce({
|
|
accessToken: "new-access",
|
|
refreshToken: "new-refresh",
|
|
});
|
|
|
|
const event = createMockEvent({
|
|
url: new URL("http://localhost/dashboard"),
|
|
});
|
|
event.cookies.get = vi.fn((name: string) => {
|
|
if (name === "accessToken") return null;
|
|
if (name === "refreshToken") return "refresh-tok";
|
|
return null;
|
|
});
|
|
const resolve = createResolve();
|
|
|
|
await handle({ event, resolve } as any);
|
|
|
|
expect(mockOptima.user.refreshSession).toHaveBeenCalledWith("refresh-tok");
|
|
expect(resolve).toHaveBeenCalled();
|
|
});
|
|
|
|
// ── No access token, refresh fails — force logout ─────────────────
|
|
|
|
it("forces logout when only refresh token exists but refresh fails", async () => {
|
|
mockApi.get.mockResolvedValueOnce({ status: 418 });
|
|
mockIsInvalidSignatureError.mockReturnValue(false);
|
|
mockOptima.user.refreshSession.mockRejectedValueOnce(
|
|
new Error("Refresh failed")
|
|
);
|
|
|
|
const event = createMockEvent({
|
|
url: new URL("http://localhost/dashboard"),
|
|
});
|
|
event.cookies.get = vi.fn((name: string) => {
|
|
if (name === "accessToken") return null;
|
|
if (name === "refreshToken") return "refresh-tok";
|
|
return null;
|
|
});
|
|
const resolve = createResolve();
|
|
|
|
try {
|
|
await handle({ event, resolve } as any);
|
|
} catch {
|
|
// redirect throws
|
|
}
|
|
|
|
expect(mockOptima.user.logout).toHaveBeenCalled();
|
|
expect(mockRedirect).toHaveBeenCalledWith(303, "/login");
|
|
});
|
|
|
|
// ── Refresh fails with invalid signature ──────────────────────────
|
|
|
|
it("logs warning on invalid signature during refresh fallback", async () => {
|
|
mockApi.get.mockResolvedValueOnce({ status: 418 });
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
|
|
// First call: malformed token parse error (not invalid signature)
|
|
// Second call: refresh also fails with invalid signature
|
|
mockIsInvalidSignatureError
|
|
.mockReturnValueOnce(false)
|
|
.mockReturnValueOnce(true);
|
|
mockOptima.user.refreshSession.mockRejectedValueOnce(
|
|
new Error("invalid signature")
|
|
);
|
|
|
|
const event = createMockEvent({
|
|
url: new URL("http://localhost/dashboard"),
|
|
});
|
|
event.cookies.get = vi.fn((name: string) => {
|
|
if (name === "accessToken") return "bad.!!!.token";
|
|
if (name === "refreshToken") return "refresh-tok";
|
|
return null;
|
|
});
|
|
const resolve = createResolve();
|
|
|
|
try {
|
|
await handle({ event, resolve } as any);
|
|
} catch {
|
|
// redirect throws
|
|
}
|
|
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
"Invalid refresh token signature — forcing logout."
|
|
);
|
|
expect(mockOptima.user.logout).toHaveBeenCalled();
|
|
warnSpy.mockRestore();
|
|
});
|
|
});
|