139 lines
5.3 KiB
TypeScript
139 lines
5.3 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const storeMock = vi.hoisted(() => ({
|
|
createUserSession: vi.fn(),
|
|
revokeSessionByToken: vi.fn(),
|
|
}));
|
|
const adminStoreMock = vi.hoisted(() => ({
|
|
createAdminSession: vi.fn(),
|
|
revokeAdminSessionByToken: vi.fn(),
|
|
}));
|
|
vi.mock("@hivegrid/config", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@hivegrid/config")>();
|
|
return {
|
|
...actual,
|
|
loadRequiredWebConfig: () => ({
|
|
routing: {
|
|
appBaseDomain: "example.test",
|
|
protocol: "https",
|
|
webAppHost: "app.example.test",
|
|
adminAppHost: "admin.example.test",
|
|
},
|
|
}),
|
|
};
|
|
});
|
|
vi.mock("@hivegrid/db/admin-sessions.server", () => ({
|
|
createAdminSession: (...args: unknown[]) => adminStoreMock.createAdminSession(...args),
|
|
revokeAdminSessionByToken: (...args: unknown[]) => adminStoreMock.revokeAdminSessionByToken(...args),
|
|
}));
|
|
vi.mock("./sessionStore.server", () => ({
|
|
createUserSession: (...args: unknown[]) => storeMock.createUserSession(...args),
|
|
revokeSessionByToken: (...args: unknown[]) => storeMock.revokeSessionByToken(...args),
|
|
}));
|
|
|
|
const geoMock = vi.hoisted(() => ({ lookupCountry: vi.fn() }));
|
|
vi.mock("~/modules/ipAddress/services/geoip.server", () => ({
|
|
// Realistischer Mock: der Hash enthaelt die Roh-IP NICHT (wie der echte hashIp).
|
|
hashIp: () => "HASHED",
|
|
lookupCountry: (...args: unknown[]) => geoMock.lookupCountry(...args),
|
|
}));
|
|
|
|
import { buildSessionDeviceContext, endServerSession, startServerSession } from "./sessionLifecycle.server";
|
|
|
|
function requestWith(headers: Record<string, string>) {
|
|
return new Request("https://example.test/login", { headers });
|
|
}
|
|
|
|
function adminRequestWith(headers: Record<string, string>) {
|
|
return new Request("https://admin.example.test/login", { headers });
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
geoMock.lookupCountry.mockResolvedValue({ countryCode: "DE", countryName: "Germany" });
|
|
});
|
|
|
|
describe("buildSessionDeviceContext (AD-22)", () => {
|
|
it("hashes the IP and resolves the country, never exposing the raw IP", async () => {
|
|
const ctx = await buildSessionDeviceContext(
|
|
requestWith({
|
|
"user-agent": "Safari",
|
|
"cf-connecting-ip": "9.9.9.9",
|
|
"x-forwarded-for": "198.51.100.9, 173.245.48.10",
|
|
}),
|
|
);
|
|
expect(ctx.ipHash).toBe("HASHED");
|
|
expect(ctx.userAgent).toBe("Safari");
|
|
expect(ctx.approxLocation).toBe("Germany");
|
|
expect(JSON.stringify(ctx)).not.toContain("9.9.9.9");
|
|
});
|
|
|
|
it("yields nulls and skips the geo lookup when no IP is present", async () => {
|
|
const ctx = await buildSessionDeviceContext(requestWith({}));
|
|
expect(ctx.ipHash).toBeNull();
|
|
expect(ctx.userAgent).toBeNull();
|
|
expect(ctx.approxLocation).toBeNull();
|
|
expect(geoMock.lookupCountry).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("startServerSession", () => {
|
|
it("creates a session row with the pseudonymized device context and returns the raw token", async () => {
|
|
storeMock.createUserSession.mockResolvedValue({ session: { id: "s1" }, rawToken: "tok-123" });
|
|
const token = await startServerSession({
|
|
request: requestWith({
|
|
"user-agent": "Safari",
|
|
"cf-connecting-ip": "1.2.3.4",
|
|
"x-forwarded-for": "198.51.100.4, 173.245.48.10",
|
|
}),
|
|
userId: "u1",
|
|
});
|
|
expect(token).toBe("tok-123");
|
|
expect(storeMock.createUserSession).toHaveBeenCalledWith({ userId: "u1", ipHash: "HASHED", userAgent: "Safari", approxLocation: "Germany" });
|
|
});
|
|
|
|
it("creates a dedicated eight-hour AdminSession only with Authentik MFA assurance", async () => {
|
|
const mfaAssuredAt = new Date("2026-07-24T18:00:00.000Z");
|
|
adminStoreMock.createAdminSession.mockResolvedValue({ session: { id: "as1" }, rawToken: "admin-token" });
|
|
const token = await startServerSession({
|
|
request: adminRequestWith({ "user-agent": "Safari" }),
|
|
userId: "admin-1",
|
|
adminAuthenticationMethods: ["pwd", "mfa"],
|
|
adminMfaAssuredAt: mfaAssuredAt,
|
|
});
|
|
expect(token).toBe("admin-token");
|
|
expect(adminStoreMock.createAdminSession).toHaveBeenCalledWith({
|
|
adminUserId: "admin-1",
|
|
authenticationMethods: ["pwd", "mfa"],
|
|
mfaAssuredAt,
|
|
ipHash: null,
|
|
userAgent: "Safari",
|
|
approxLocation: null,
|
|
});
|
|
expect(storeMock.createUserSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails closed on the admin host without MFA assurance", async () => {
|
|
await expect(startServerSession({ request: adminRequestWith({}), userId: "admin-1" })).rejects.toThrow(
|
|
"Admin server session requires Authentik MFA assurance"
|
|
);
|
|
expect(adminStoreMock.createAdminSession).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("endServerSession", () => {
|
|
it("delegates revocation to the store", async () => {
|
|
storeMock.revokeSessionByToken.mockResolvedValue(true);
|
|
await endServerSession("tok-123");
|
|
expect(storeMock.revokeSessionByToken).toHaveBeenCalledWith("tok-123");
|
|
});
|
|
|
|
it("revokes only the dedicated AdminSession on the admin host", async () => {
|
|
adminStoreMock.revokeAdminSessionByToken.mockResolvedValue(true);
|
|
const request = adminRequestWith({});
|
|
await endServerSession("admin-token", request);
|
|
expect(adminStoreMock.revokeAdminSessionByToken).toHaveBeenCalledWith("admin-token");
|
|
expect(storeMock.revokeSessionByToken).not.toHaveBeenCalled();
|
|
});
|
|
});
|