111 lines
4.8 KiB
TypeScript
111 lines
4.8 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const rows: unknown[][] = [];
|
|
const values = new Map<string, string>();
|
|
const set = vi.fn(async (key: string, value: string) => { values.set(key, value); });
|
|
const removeSession = vi.fn(async () => undefined);
|
|
const updateUser = vi.fn(async () => undefined);
|
|
const redis = { get: vi.fn(async (key: string) => values.get(key) ?? null), set };
|
|
|
|
vi.mock("server-only", () => ({}));
|
|
vi.mock("@/db", () => ({ getDb: () => ({
|
|
select: () => ({ from: () => ({ where: () => ({ limit: async () => rows.shift() ?? [] }) }) }),
|
|
update: () => ({ set: () => ({ where: updateUser }) }),
|
|
delete: () => ({ where: removeSession }),
|
|
}) }));
|
|
vi.mock("@/lib/redis/client", () => ({
|
|
getRedisClient: async () => redis, redisCachePrefix: () => "test",
|
|
}));
|
|
|
|
const { bindSudlohSession, refreshLinkedSudlohProfile, validateSudlohSession } = await import("./sudloh");
|
|
const expiresAt = new Date(Date.now() + 60 * 60_000);
|
|
const account = { id: "account-1", accountId: "sub-1", accessToken: "access-1", accessTokenExpiresAt: expiresAt };
|
|
|
|
beforeEach(() => {
|
|
rows.length = 0;
|
|
values.clear();
|
|
vi.clearAllMocks();
|
|
process.env.SUDLOH_OIDC_ISSUER = "https://account.test/api/auth";
|
|
process.env.SUDLOH_OIDC_CLIENT_ID = "client";
|
|
process.env.SUDLOH_OIDC_CLIENT_SECRET = "secret";
|
|
});
|
|
|
|
function provider(active: boolean, profileSub = "sub-1") {
|
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
|
const url = String(input);
|
|
if (url.endsWith("openid-configuration")) return Response.json({
|
|
issuer: "https://account.test/api/auth",
|
|
introspection_endpoint: "https://account.test/api/auth/oauth2/introspect",
|
|
userinfo_endpoint: "https://account.test/api/auth/oauth2/userinfo",
|
|
});
|
|
if (url.endsWith("introspect")) return Response.json({ active, sub: "sub-1",
|
|
exp: Math.floor(Date.now() / 1000) + 3600 });
|
|
if (url.endsWith("userinfo")) return Response.json({ sub: profileSub, name: "New Name",
|
|
email: "[email protected]", email_verified: true, picture: "https://account.test/avatar.png" });
|
|
throw new Error(`unexpected URL: ${url}`);
|
|
});
|
|
return fetchMock;
|
|
}
|
|
|
|
describe("Sudloh session validation", () => {
|
|
it("rejects a new Guide session when the callback did not bind it", async () => {
|
|
rows.push([account]);
|
|
expect(await validateSudlohSession("user-1", "session-1")).toBe(false);
|
|
expect(removeSession).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("binds the callback token to its Guide session and caches a verified check", async () => {
|
|
rows.push([account]);
|
|
await bindSudlohSession("user-1", "session-1");
|
|
const fetchMock = provider(true);
|
|
rows.push([account], [{ name: "Old Name", email: "[email protected]", emailVerified: true, image: null }]);
|
|
expect(await validateSudlohSession("user-1", "session-1")).toBe(true);
|
|
expect(updateUser).toHaveBeenCalledOnce();
|
|
const fetchCount = fetchMock.mock.calls.length;
|
|
rows.push([account]);
|
|
expect(await validateSudlohSession("user-1", "session-1")).toBe(true);
|
|
expect(fetchMock).toHaveBeenCalledTimes(fetchCount);
|
|
fetchMock.mockRestore();
|
|
});
|
|
|
|
it("ends the Guide session when Sudloh reports the bound token inactive", async () => {
|
|
rows.push([account]);
|
|
await bindSudlohSession("user-1", "session-1");
|
|
const fetchMock = provider(false);
|
|
rows.push([account]);
|
|
expect(await validateSudlohSession("user-1", "session-1")).toBe(false);
|
|
expect(removeSession).toHaveBeenCalledOnce();
|
|
fetchMock.mockRestore();
|
|
});
|
|
|
|
it("fails closed when UserInfo returns another subject", async () => {
|
|
rows.push([account]);
|
|
await bindSudlohSession("user-1", "session-1");
|
|
const fetchMock = provider(true, "someone-else");
|
|
rows.push([account]);
|
|
await expect(validateSudlohSession("user-1", "session-1"))
|
|
.rejects.toMatchObject({ status: 503 });
|
|
expect(updateUser).not.toHaveBeenCalled();
|
|
fetchMock.mockRestore();
|
|
});
|
|
|
|
it("reports a verified Sudloh email that conflicts with another Guide account", async () => {
|
|
rows.push([account]);
|
|
await bindSudlohSession("user-1", "session-1");
|
|
const fetchMock = provider(true);
|
|
rows.push([account], [{ name: "Old Name", email: "[email protected]", emailVerified: true, image: null }]);
|
|
updateUser.mockRejectedValueOnce({ cause: { code: "23505" } });
|
|
await expect(validateSudlohSession("user-1", "session-1"))
|
|
.rejects.toMatchObject({ status: 409, message: "sudloh-email-conflict" });
|
|
fetchMock.mockRestore();
|
|
});
|
|
|
|
it("refreshes a linked profile during the legacy sign-in transition", async () => {
|
|
const fetchMock = provider(true);
|
|
rows.push([account], [{ name: "Old Name", email: "[email protected]", emailVerified: true, image: null }]);
|
|
await refreshLinkedSudlohProfile("user-1");
|
|
expect(updateUser).toHaveBeenCalledOnce();
|
|
fetchMock.mockRestore();
|
|
});
|
|
});
|