Files
suaveplan/packages/foundation/data/clipboard/src/security/security.test.ts
2026-05-27 23:47:53 +01:00

159 lines
5.0 KiB
TypeScript

import { describe, expect, it } from "@suaveplan/testing/runner";
import { CLIPBOARD_CODES, ClipboardError } from "../errors/errors.js";
import {
isSecure,
requireSecureContext,
waitForNextFocus,
} from "./security.js";
describe("isSecure", () => {
it("treats undefined window as secure (SSR-safe)", {
timeout: 5000,
}, () => {
expect(isSecure(undefined)).toBe(true);
});
it("returns true when window.isSecureContext is true", {
timeout: 5000,
}, () => {
expect(isSecure({ isSecureContext: true })).toBe(true);
});
it("returns false when window.isSecureContext is false", {
timeout: 5000,
}, () => {
expect(isSecure({ isSecureContext: false })).toBe(false);
});
it("falls back to global window when none supplied", {
timeout: 5000,
}, () => {
// happy-dom global window is a secure context by default
expect(isSecure()).toBe(true);
});
});
describe("requireSecureContext", () => {
it("requireSecureContext throws INSECURE_CONTEXT when window flags insecure", {
timeout: 5000,
}, () => {
let thrown: unknown;
try {
requireSecureContext({ isSecureContext: false });
} catch (err) {
thrown = err;
}
expect(thrown).toBeInstanceOf(ClipboardError);
expect((thrown as ClipboardError).code).toBe(
CLIPBOARD_CODES.INSECURE_CONTEXT
);
});
it("requireSecureContext returns silently for secure context", {
timeout: 5000,
}, () => {
expect(() =>
requireSecureContext({ isSecureContext: true })
).not.toThrow();
});
});
describe("waitForNextFocus", () => {
it("resolves false when window has no addEventListener", {
timeout: 5000,
}, async () => {
const resolved = await waitForNextFocus({}, 50);
expect(resolved).toBe(false);
});
it("resolves true when focus event fires before timeout", {
timeout: 5000,
}, async () => {
const listeners = new Map<string, () => void>();
const win = {
addEventListener: (type: string, cb: () => void) => {
listeners.set(type, cb);
},
removeEventListener: (type: string) => {
listeners.delete(type);
},
};
const promise = waitForNextFocus(win, 200);
setTimeout(() => {
listeners.get("focus")?.();
}, 10);
const resolved = await promise;
expect(resolved).toBe(true);
});
it("resolves false on timeout", { timeout: 5000 }, async () => {
const listeners = new Map<string, () => void>();
const win = {
addEventListener: (type: string, cb: () => void) => {
listeners.set(type, cb);
},
removeEventListener: (type: string) => {
listeners.delete(type);
},
};
const resolved = await waitForNextFocus(win, 30);
expect(resolved).toBe(false);
});
it("ignores late focus after timeout fired", {
timeout: 5000,
}, async () => {
const focusListeners: Array<() => void> = [];
const win = {
addEventListener: (_type: string, cb: () => void) => {
focusListeners.push(cb);
},
removeEventListener: () => {
// Keep listener registered so we can fire it post-timeout to
// exercise the settled-guard branch in waitForNextFocus.
},
};
const result = await waitForNextFocus(win, 20);
expect(result).toBe(false);
// Now fire focus AFTER timeout — settled flag must suppress resolve.
for (const cb of focusListeners) {
cb();
}
});
it("timeout firing after focus is a no-op", {
timeout: 5000,
}, async () => {
const focusListeners: Array<() => void> = [];
const win = {
addEventListener: (_type: string, cb: () => void) => {
focusListeners.push(cb);
},
removeEventListener: () => {},
};
const promise = waitForNextFocus(win, 30);
setTimeout(() => focusListeners[0]?.(), 5);
const resolved = await promise;
expect(resolved).toBe(true);
// Allow the 30ms timeout to fire — it must noop because settled.
await new Promise<void>((r) => setTimeout(r, 50));
});
it("ignores duplicate focus events", {
timeout: 5000,
}, async () => {
const focusListeners: Array<() => void> = [];
const win = {
addEventListener: (_type: string, cb: () => void) => {
focusListeners.push(cb);
},
removeEventListener: () => {},
};
const promise = waitForNextFocus(win, 200);
focusListeners[0]?.();
focusListeners[0]?.();
const resolved = await promise;
expect(resolved).toBe(true);
});
});