321 lines
9.9 KiB
JavaScript
321 lines
9.9 KiB
JavaScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
async function loadAuth() {
|
|
vi.resetModules();
|
|
return import("../../../frontend/src/auth.js");
|
|
}
|
|
|
|
describe("auth helpers", () => {
|
|
beforeEach(() => {
|
|
globalThis.__ATLAS_KEYCLOAK_FACTORY__ = null;
|
|
globalThis.__ATLAS_KEYCLOAK_CONSTRUCTOR__ = null;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllGlobals();
|
|
vi.resetModules();
|
|
});
|
|
|
|
it("normalizes token groups", async () => {
|
|
const { normalizeGroups } = await loadAuth();
|
|
|
|
expect(normalizeGroups(["/dev", "admin", 3, null, ""])).toEqual(["dev", "admin"]);
|
|
expect(normalizeGroups("not-an-array")).toEqual([]);
|
|
});
|
|
|
|
it("attaches a bearer token when fetching", async () => {
|
|
const authModule = await loadAuth();
|
|
authModule.auth.token = "bearer-token";
|
|
|
|
const fetchMock = vi.fn(async () => new Response("{}", { status: 200 }));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
await authModule.authFetch("/api/healthz");
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const [, options] = fetchMock.mock.calls[0];
|
|
expect(new Headers(options.headers).get("Authorization")).toBe("Bearer bearer-token");
|
|
});
|
|
|
|
it("initializes auth state from the auth config endpoint", async () => {
|
|
const authModule = await loadAuth();
|
|
vi.spyOn(window, "setInterval").mockReturnValue(1234);
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () =>
|
|
new Response(JSON.stringify({ enabled: false, login_url: "", reset_url: "" }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
await authModule.initAuth();
|
|
|
|
expect(authModule.auth.ready).toBe(true);
|
|
expect(authModule.auth.enabled).toBe(false);
|
|
});
|
|
|
|
it("falls back to the keycloak-js constructor when no injected factory exists", async () => {
|
|
const client = {
|
|
authenticated: true,
|
|
token: "mock-token",
|
|
tokenParsed: {
|
|
preferred_username: "bob",
|
|
email: "bob@example.dev",
|
|
groups: ["/ops"],
|
|
},
|
|
init: vi.fn(async () => true),
|
|
login: vi.fn(async () => {}),
|
|
logout: vi.fn(async () => {}),
|
|
updateToken: vi.fn(async () => true),
|
|
};
|
|
globalThis.__ATLAS_KEYCLOAK_CONSTRUCTOR__ = function MockKeycloak() {
|
|
return client;
|
|
};
|
|
|
|
const authModule = await loadAuth();
|
|
const created = authModule.createKeycloak({ url: "https://sso.example.dev" });
|
|
|
|
expect(created).toBe(client);
|
|
});
|
|
|
|
it("uses the real Keycloak constructor when no test hooks are present", async () => {
|
|
const authModule = await loadAuth();
|
|
const client = authModule.createKeycloak({
|
|
url: "https://sso.example.dev",
|
|
realm: "atlas",
|
|
clientId: "portal-client",
|
|
});
|
|
|
|
expect(client).toHaveProperty("init");
|
|
expect(client).toHaveProperty("login");
|
|
expect(client).toHaveProperty("logout");
|
|
});
|
|
|
|
it("reuses the auth initialization promise and tolerates empty token fields", async () => {
|
|
const client = {
|
|
authenticated: false,
|
|
token: "",
|
|
tokenParsed: undefined,
|
|
init: vi.fn(async () => true),
|
|
login: vi.fn(async () => {}),
|
|
logout: vi.fn(async () => {}),
|
|
updateToken: vi.fn(async () => true),
|
|
};
|
|
globalThis.__ATLAS_KEYCLOAK_FACTORY__ = () => client;
|
|
|
|
const authModule = await loadAuth();
|
|
vi.spyOn(window, "setInterval").mockReturnValue(1234);
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
enabled: true,
|
|
url: "https://sso.example.dev",
|
|
realm: "atlas",
|
|
client_id: "portal-client",
|
|
login_url: "https://sso.example.dev/login",
|
|
reset_url: "https://sso.example.dev/reset",
|
|
account_url: "https://sso.example.dev/account",
|
|
account_password_url: "https://sso.example.dev/account/#/security/signingin",
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
},
|
|
),
|
|
),
|
|
);
|
|
|
|
const first = authModule.initAuth();
|
|
const second = authModule.initAuth();
|
|
|
|
await first;
|
|
await second;
|
|
client.onAuthSuccess();
|
|
|
|
expect(authModule.auth.authenticated).toBe(false);
|
|
expect(authModule.auth.username).toBe("");
|
|
expect(authModule.auth.email).toBe("");
|
|
expect(authModule.auth.groups).toEqual([]);
|
|
});
|
|
|
|
it("hydrates and proxies keycloak actions when enabled", async () => {
|
|
const calls = { init: 0, login: 0, logout: 0, updateToken: 0 };
|
|
const client = {
|
|
authenticated: true,
|
|
token: "mock-token",
|
|
tokenParsed: {
|
|
preferred_username: "alice",
|
|
email: "alice@example.dev",
|
|
groups: ["/dev", "/admin"],
|
|
},
|
|
init: vi.fn(async () => {
|
|
calls.init += 1;
|
|
return true;
|
|
}),
|
|
login: vi.fn(async () => {
|
|
calls.login += 1;
|
|
}),
|
|
logout: vi.fn(async () => {
|
|
calls.logout += 1;
|
|
}),
|
|
updateToken: vi.fn(async () => {
|
|
calls.updateToken += 1;
|
|
return true;
|
|
}),
|
|
};
|
|
globalThis.__ATLAS_KEYCLOAK_FACTORY__ = () => client;
|
|
|
|
const authModule = await loadAuth();
|
|
vi.spyOn(window, "setInterval").mockReturnValue(1234);
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
enabled: true,
|
|
url: "https://sso.example.dev",
|
|
realm: "atlas",
|
|
client_id: "portal-client",
|
|
login_url: "https://sso.example.dev/login",
|
|
reset_url: "https://sso.example.dev/reset",
|
|
account_url: "https://sso.example.dev/account",
|
|
account_password_url: "https://sso.example.dev/account/#/security/signingin",
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
},
|
|
),
|
|
),
|
|
);
|
|
|
|
await authModule.initAuth();
|
|
await authModule.login("/account", " ");
|
|
await authModule.login("/account", "alice");
|
|
await authModule.logout();
|
|
await authModule.authFetch("/api/healthz");
|
|
|
|
expect(calls.init).toBe(1);
|
|
expect(calls.login).toBe(2);
|
|
expect(calls.logout).toBe(1);
|
|
expect(calls.updateToken).toBeGreaterThan(0);
|
|
expect(client.login.mock.calls[0][0]).not.toHaveProperty("loginHint");
|
|
expect(client.login.mock.calls[1][0]).toMatchObject({ loginHint: "alice" });
|
|
expect(authModule.auth.ready).toBe(true);
|
|
expect(authModule.auth.enabled).toBe(true);
|
|
expect(authModule.auth.authenticated).toBe(true);
|
|
expect(authModule.auth.username).toBe("alice");
|
|
expect(authModule.auth.email).toBe("alice@example.dev");
|
|
expect(authModule.auth.groups).toEqual(["dev", "admin"]);
|
|
expect(authModule.auth.token).toBe("mock-token");
|
|
});
|
|
|
|
it("covers the auth refresh handlers and polling branches", async () => {
|
|
const intervalCalls = [];
|
|
const client = {
|
|
authenticated: true,
|
|
token: "refresh-token",
|
|
tokenParsed: {
|
|
preferred_username: "carol",
|
|
email: "carol@example.dev",
|
|
groups: ["/ops"],
|
|
},
|
|
init: vi.fn(async () => true),
|
|
login: vi.fn(async () => {}),
|
|
logout: vi.fn(async () => {}),
|
|
updateToken: vi.fn(async () => true),
|
|
};
|
|
globalThis.__ATLAS_KEYCLOAK_FACTORY__ = () => client;
|
|
|
|
const authModule = await loadAuth();
|
|
vi.spyOn(window, "setInterval").mockImplementation((callback) => {
|
|
intervalCalls.push(callback);
|
|
return 1234;
|
|
});
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
enabled: true,
|
|
url: "https://sso.example.dev",
|
|
realm: "atlas",
|
|
client_id: "portal-client",
|
|
login_url: "https://sso.example.dev/login",
|
|
reset_url: "https://sso.example.dev/reset",
|
|
account_url: "https://sso.example.dev/account",
|
|
account_password_url: "https://sso.example.dev/account/#/security/signingin",
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
},
|
|
),
|
|
),
|
|
);
|
|
|
|
await authModule.initAuth();
|
|
expect(intervalCalls).toHaveLength(1);
|
|
|
|
client.onAuthSuccess();
|
|
client.onAuthLogout();
|
|
client.onAuthRefreshSuccess();
|
|
|
|
client.updateToken.mockResolvedValueOnce(true);
|
|
client.onTokenExpired();
|
|
await Promise.resolve();
|
|
|
|
client.updateToken.mockRejectedValueOnce(new Error("boom"));
|
|
client.onTokenExpired();
|
|
await Promise.resolve();
|
|
|
|
client.updateToken.mockResolvedValueOnce(true);
|
|
intervalCalls[0]();
|
|
await Promise.resolve();
|
|
|
|
client.authenticated = false;
|
|
intervalCalls[0]();
|
|
await Promise.resolve();
|
|
|
|
client.authenticated = true;
|
|
client.updateToken.mockRejectedValueOnce(new Error("refresh failed"));
|
|
await authModule.authFetch("/api/healthz");
|
|
|
|
expect(authModule.auth.username).toBe("carol");
|
|
expect(authModule.auth.groups).toEqual(["ops"]);
|
|
expect(client.updateToken).toHaveBeenCalled();
|
|
});
|
|
|
|
it("leaves auth alone when login/logout are called before initialization", async () => {
|
|
const authModule = await loadAuth();
|
|
const fetchMock = vi.fn(async () => new Response("{}", { status: 200 }));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
await authModule.login("/account", "alice");
|
|
await authModule.logout();
|
|
await authModule.authFetch("/api/healthz", { headers: { "X-Test": "1" } });
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const [, options] = fetchMock.mock.calls[0];
|
|
const headers = new Headers(options.headers);
|
|
expect(headers.get("X-Test")).toBe("1");
|
|
expect(headers.get("Authorization")).toBeNull();
|
|
});
|
|
|
|
it("recovers when the auth config endpoint fails", async () => {
|
|
const authModule = await loadAuth();
|
|
vi.stubGlobal("fetch", vi.fn(async () => new Response("boom", { status: 500 })));
|
|
|
|
await authModule.initAuth();
|
|
|
|
expect(authModule.auth.ready).toBe(true);
|
|
expect(authModule.auth.enabled).toBe(false);
|
|
expect(authModule.auth.authenticated).toBe(false);
|
|
});
|
|
});
|