218 lines
8.1 KiB
TypeScript
218 lines
8.1 KiB
TypeScript
/**
|
|
* Unit tests for clientAuth.js - token retention, migration, and invalidation.
|
|
*
|
|
* These tests focus on the token lifecycle: reading, writing, loading from
|
|
* legacy localStorage, and clearing memory tokens. Write-auth bootstrap and
|
|
* toast notifications are covered in api_client_dedupe.vitest.ts.
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
// Must match SETTINGS_KEY from ui/app/settingsStore.js
|
|
const SETTINGS_KEY = "mjrSettings";
|
|
|
|
function makeStorage() {
|
|
const store = new Map();
|
|
return {
|
|
getItem(key) {
|
|
return store.has(key) ? store.get(key) : null;
|
|
},
|
|
setItem(key, value) {
|
|
store.set(String(key), String(value));
|
|
},
|
|
removeItem(key) {
|
|
store.delete(String(key));
|
|
},
|
|
_store: store,
|
|
};
|
|
}
|
|
|
|
async function loadAuth() {
|
|
return import("../api/clientAuth.js");
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
globalThis.localStorage = makeStorage();
|
|
globalThis.sessionStorage = makeStorage();
|
|
globalThis.window = {
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
location: { origin: "http://localhost" },
|
|
};
|
|
globalThis.CustomEvent = class {
|
|
constructor(type, init) {
|
|
this.type = type;
|
|
this.detail = init?.detail;
|
|
}
|
|
};
|
|
globalThis.fetch = vi.fn(async () => ({
|
|
status: 200,
|
|
headers: { get: (name) => (name === "content-type" ? "application/json" : null) },
|
|
json: async () => ({ ok: true, data: {} }),
|
|
}));
|
|
globalThis.app = { extensionManager: { toast: { add: vi.fn() } } };
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("setRuntimeSecurityToken", () => {
|
|
it("retains the token in module memory", async () => {
|
|
const { setRuntimeSecurityToken, readAuthToken } = await loadAuth();
|
|
setRuntimeSecurityToken("mjr_abc123");
|
|
expect(readAuthToken()).toBe("mjr_abc123");
|
|
});
|
|
|
|
it("returns true on success", async () => {
|
|
const { setRuntimeSecurityToken } = await loadAuth();
|
|
expect(setRuntimeSecurityToken("mjr_abc123")).toBe(true);
|
|
});
|
|
|
|
it("returns false for empty / whitespace-only input", async () => {
|
|
const { setRuntimeSecurityToken } = await loadAuth();
|
|
expect(setRuntimeSecurityToken("")).toBe(false);
|
|
expect(setRuntimeSecurityToken(" ")).toBe(false);
|
|
expect(setRuntimeSecurityToken(null)).toBe(false);
|
|
});
|
|
|
|
it("trims surrounding whitespace before storing", async () => {
|
|
const { setRuntimeSecurityToken, readAuthToken } = await loadAuth();
|
|
setRuntimeSecurityToken(" mjr_trimmed ");
|
|
expect(readAuthToken()).toBe("mjr_trimmed");
|
|
});
|
|
});
|
|
|
|
describe("readAuthToken", () => {
|
|
it("reads a token previously stored with setRuntimeSecurityToken", async () => {
|
|
const { setRuntimeSecurityToken, readAuthToken } = await loadAuth();
|
|
setRuntimeSecurityToken("mjr_session_token");
|
|
expect(readAuthToken()).toBe("mjr_session_token");
|
|
});
|
|
|
|
it("returns empty string when no token is stored", async () => {
|
|
const { readAuthToken } = await loadAuth();
|
|
expect(readAuthToken()).toBe("");
|
|
});
|
|
|
|
it("prefers runtime memory over localStorage settings", async () => {
|
|
const settings = { security: { apiToken: "local_token" } };
|
|
globalThis.localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
|
|
|
const { setRuntimeSecurityToken, readAuthToken } = await loadAuth();
|
|
setRuntimeSecurityToken("runtime_token");
|
|
expect(readAuthToken()).toBe("runtime_token");
|
|
});
|
|
});
|
|
|
|
describe("readAuthToken - localStorage migration", () => {
|
|
it("loads a legacy apiToken from localStorage into memory", async () => {
|
|
const settings = { security: { apiToken: "mjr_legacy_token" } };
|
|
globalThis.localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
|
|
|
const { readAuthToken } = await loadAuth();
|
|
expect(readAuthToken()).toBe("mjr_legacy_token");
|
|
});
|
|
|
|
it("wipes the apiToken from localStorage after migration", async () => {
|
|
const settings = { security: { apiToken: "mjr_legacy_wipe" } };
|
|
globalThis.localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
|
|
|
const { readAuthToken } = await loadAuth();
|
|
readAuthToken();
|
|
|
|
const stored = JSON.parse(globalThis.localStorage.getItem(SETTINGS_KEY) || "{}");
|
|
expect(String(stored?.security?.apiToken || "")).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("ensureWriteAuthToken with force=true clears stale runtime token", () => {
|
|
it("removes the memory token when bootstrap refreshes only the cookie", async () => {
|
|
globalThis.fetch = vi.fn(async (url) => {
|
|
if (String(url).includes("bootstrap-token")) {
|
|
return {
|
|
status: 200,
|
|
headers: {
|
|
get: (name) => (name === "content-type" ? "application/json" : null),
|
|
},
|
|
json: async () => ({ ok: true, data: {} }),
|
|
};
|
|
}
|
|
return {
|
|
status: 200,
|
|
headers: { get: (name) => (name === "content-type" ? "application/json" : null) },
|
|
json: async () => ({ ok: true, data: null }),
|
|
};
|
|
});
|
|
|
|
const { ensureWriteAuthToken, setRuntimeSecurityToken, readAuthToken } = await loadAuth();
|
|
setRuntimeSecurityToken("stale_token");
|
|
await ensureWriteAuthToken({ force: true });
|
|
|
|
expect(readAuthToken()).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("invalidateAuthTokenCache", () => {
|
|
it("keeps the memory token readable after cache invalidation", async () => {
|
|
const { setRuntimeSecurityToken, readAuthToken, invalidateAuthTokenCache } =
|
|
await loadAuth();
|
|
|
|
setRuntimeSecurityToken("initial_token");
|
|
expect(readAuthToken()).toBe("initial_token");
|
|
|
|
invalidateAuthTokenCache();
|
|
expect(readAuthToken()).toBe("initial_token");
|
|
});
|
|
});
|
|
|
|
describe("setRuntimeSecurityToken token format validation", () => {
|
|
it("accepts a standard mjr_ prefixed hex token", async () => {
|
|
const { setRuntimeSecurityToken, readAuthToken } = await loadAuth();
|
|
const token = "mjr_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6";
|
|
expect(setRuntimeSecurityToken(token)).toBe(true);
|
|
expect(readAuthToken()).toBe(token);
|
|
});
|
|
|
|
it("accepts alphanumeric tokens with dots, hyphens, underscores", async () => {
|
|
const { setRuntimeSecurityToken } = await loadAuth();
|
|
expect(setRuntimeSecurityToken("token.with-dots_and.dashes")).toBe(true);
|
|
expect(setRuntimeSecurityToken("ABC123")).toBe(true);
|
|
expect(setRuntimeSecurityToken("a")).toBe(true);
|
|
});
|
|
|
|
it("accepts RFC 6750 base64url-like tokens", async () => {
|
|
const { setRuntimeSecurityToken } = await loadAuth();
|
|
expect(setRuntimeSecurityToken("abc~def+ghi/jkl=")).toBe(true);
|
|
});
|
|
|
|
it("rejects tokens containing internal spaces", async () => {
|
|
const { setRuntimeSecurityToken } = await loadAuth();
|
|
expect(setRuntimeSecurityToken("token with space")).toBe(false);
|
|
expect(setRuntimeSecurityToken("two spaces")).toBe(false);
|
|
});
|
|
|
|
it("rejects tokens containing control characters", async () => {
|
|
const { setRuntimeSecurityToken } = await loadAuth();
|
|
expect(setRuntimeSecurityToken("tok\nen")).toBe(false);
|
|
expect(setRuntimeSecurityToken("tok\x00en")).toBe(false);
|
|
expect(setRuntimeSecurityToken("tok\ten")).toBe(false);
|
|
});
|
|
|
|
it("rejects tokens containing injection-relevant characters", async () => {
|
|
const { setRuntimeSecurityToken } = await loadAuth();
|
|
expect(setRuntimeSecurityToken('tok"en')).toBe(false);
|
|
expect(setRuntimeSecurityToken("tok,en")).toBe(false);
|
|
expect(setRuntimeSecurityToken("tok;en")).toBe(false);
|
|
expect(setRuntimeSecurityToken("tok@en")).toBe(false);
|
|
});
|
|
|
|
it("does not retain a rejected token", async () => {
|
|
const { setRuntimeSecurityToken, readAuthToken } = await loadAuth();
|
|
setRuntimeSecurityToken("bad token!");
|
|
expect(readAuthToken()).toBe("");
|
|
});
|
|
});
|