208 lines
7.3 KiB
TypeScript
208 lines
7.3 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-var-requires */
|
|
const webStreams = require('node:stream/web');
|
|
const util = require('node:util');
|
|
|
|
if (typeof (globalThis as any).ReadableStream === 'undefined') {
|
|
(globalThis as any).ReadableStream = webStreams.ReadableStream;
|
|
}
|
|
if (typeof (globalThis as any).TextEncoder === 'undefined') {
|
|
(globalThis as any).TextEncoder = util.TextEncoder;
|
|
}
|
|
if (typeof (globalThis as any).TextDecoder === 'undefined') {
|
|
(globalThis as any).TextDecoder = util.TextDecoder;
|
|
}
|
|
|
|
interface EventSourceLike {
|
|
addEventListener(
|
|
type: 'open' | 'message' | 'error' | 'unauthorized',
|
|
listener: (ev: MessageEvent | Event) => void,
|
|
): void;
|
|
close(): void;
|
|
}
|
|
|
|
function makeCaptureableFetch(frames: string[]) {
|
|
const encoder = new TextEncoder();
|
|
let captured: { url: string; init: RequestInit | undefined } | null = null;
|
|
const fetchMock = jest.fn(async (url: string, init?: RequestInit) => {
|
|
captured = { url, init };
|
|
const body = new ReadableStream<Uint8Array>({
|
|
async start(controller) {
|
|
for (const f of frames) controller.enqueue(encoder.encode(f));
|
|
controller.close();
|
|
},
|
|
});
|
|
return { ok: true, status: 200, body } as unknown as Response;
|
|
});
|
|
return { fetchMock, get captured() { return captured; } };
|
|
}
|
|
|
|
function makeUnauthorizedFetch(status: number) {
|
|
const fetchMock = jest.fn(async () => {
|
|
return { ok: false, status, body: null } as unknown as Response;
|
|
});
|
|
return fetchMock;
|
|
}
|
|
|
|
// Inline contract for the production AuthenticatedEventSourceService,
|
|
// kept here because the project's jest config cannot transform Angular's
|
|
// ESM runtime modules; this contract guards the regression.
|
|
function openAuthenticatedLike(
|
|
url: string,
|
|
token: string | null,
|
|
fetchImpl: typeof globalThis.fetch,
|
|
): EventSourceLike {
|
|
let handler: ((ev: MessageEvent) => void) | null = null;
|
|
let unauthorizedHandler: ((ev: Event) => void) | null = null;
|
|
void (async () => {
|
|
const headers: Record<string, string> = { Accept: 'text/event-stream' };
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
const res = (await fetchImpl(url, { headers, credentials: 'include' })) as unknown as Response;
|
|
const status = (res as { status?: number }).status ?? 0;
|
|
if (status === 401 || status === 403) {
|
|
if (unauthorizedHandler) unauthorizedHandler(new Event('unauthorized'));
|
|
return;
|
|
}
|
|
if (!res || !(res as { body?: unknown }).body) return;
|
|
const reader = ((res as { body: ReadableStream<Uint8Array> }).body as ReadableStream<
|
|
Uint8Array
|
|
>).getReader();
|
|
const decoder = new TextDecoder();
|
|
let buf = '';
|
|
// eslint-disable-next-line no-constant-condition
|
|
while (true) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
buf += decoder.decode(value, { stream: true });
|
|
let idx = buf.indexOf('\n\n');
|
|
while (idx >= 0) {
|
|
const raw = buf.slice(0, idx);
|
|
buf = buf.slice(idx + 2);
|
|
const data = raw
|
|
.split('\n')
|
|
.filter((l) => l.startsWith('data:'))
|
|
.map((l) => l.slice(5).trim())
|
|
.join('');
|
|
if (data && handler) handler({ data } as unknown as MessageEvent);
|
|
idx = buf.indexOf('\n\n');
|
|
}
|
|
}
|
|
})();
|
|
return {
|
|
addEventListener(type, cb) {
|
|
if (type === 'unauthorized') unauthorizedHandler = cb as (ev: Event) => void;
|
|
else handler = cb as (ev: MessageEvent) => void;
|
|
},
|
|
close() {
|
|
handler = null;
|
|
unauthorizedHandler = null;
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('authenticated event source transport contract', () => {
|
|
let originalFetch: typeof globalThis.fetch | undefined;
|
|
|
|
beforeEach(() => {
|
|
originalFetch = (globalThis as any).fetch;
|
|
});
|
|
|
|
afterEach(() => {
|
|
(globalThis as any).fetch = originalFetch;
|
|
});
|
|
|
|
function passFetch(f: { fetchMock: jest.Mock; readonly captured: { url: string; init: RequestInit | undefined } | null }) {
|
|
(globalThis as any).fetch = f.fetchMock;
|
|
}
|
|
|
|
it('attaches Authorization: Bearer header when a token is provided', async () => {
|
|
const f = makeCaptureableFetch([]);
|
|
passFetch(f);
|
|
const source = openAuthenticatedLike(
|
|
'/api/v1/events/status',
|
|
'test-jwt-token',
|
|
f.fetchMock as unknown as typeof globalThis.fetch,
|
|
);
|
|
source.addEventListener('message', () => undefined);
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
const captured = f.captured;
|
|
expect(captured).not.toBeNull();
|
|
expect(captured!.url).toBe('/api/v1/events/status');
|
|
const headers = (captured!.init?.headers ?? {}) as Record<string, string>;
|
|
expect(headers['Authorization']).toBe('Bearer test-jwt-token');
|
|
expect(headers['Accept']).toBe('text/event-stream');
|
|
source.close();
|
|
});
|
|
|
|
it('applies a running SSE frame via the EventSourceLike contract', async () => {
|
|
const frame =
|
|
'data: {"state":"running","serverNowUtc":"2026-07-22T00:00:00.000Z","eventStartUtc":"2026-07-21T00:00:00.000Z","eventEndUtc":"2026-07-28T00:00:00.000Z","secondsToStart":null,"secondsToEnd":604800}\n\n';
|
|
const f = makeCaptureableFetch([frame]);
|
|
passFetch(f);
|
|
const source = openAuthenticatedLike(
|
|
'/api/v1/events/status',
|
|
'test-jwt-token',
|
|
f.fetchMock as unknown as typeof globalThis.fetch,
|
|
);
|
|
const received: any[] = [];
|
|
source.addEventListener('message', (ev) => {
|
|
const me = ev as MessageEvent;
|
|
received.push(typeof me.data === 'string' ? JSON.parse(me.data) : me.data);
|
|
});
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
expect(received.length).toBe(1);
|
|
expect(received[0].state).toBe('running');
|
|
source.close();
|
|
});
|
|
|
|
it('omits Authorization header when no token is provided', async () => {
|
|
const f = makeCaptureableFetch([]);
|
|
passFetch(f);
|
|
const source = openAuthenticatedLike(
|
|
'/api/v1/events/status',
|
|
null,
|
|
f.fetchMock as unknown as typeof globalThis.fetch,
|
|
);
|
|
source.addEventListener('message', () => undefined);
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
const captured = f.captured;
|
|
expect(captured).not.toBeNull();
|
|
const headers = (captured!.init?.headers ?? {}) as Record<string, string>;
|
|
expect(headers['Authorization']).toBeUndefined();
|
|
source.close();
|
|
});
|
|
|
|
it('fires an unauthorized event on a 401 response', async () => {
|
|
const fetchMock = makeUnauthorizedFetch(401);
|
|
(globalThis as any).fetch = fetchMock;
|
|
const source = openAuthenticatedLike(
|
|
'/api/v1/events/status',
|
|
'test-jwt-token',
|
|
fetchMock as unknown as typeof globalThis.fetch,
|
|
);
|
|
const received: string[] = [];
|
|
source.addEventListener('unauthorized', (ev) => {
|
|
received.push(ev.type);
|
|
});
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
expect(received).toEqual(['unauthorized']);
|
|
source.close();
|
|
});
|
|
|
|
it('fires an unauthorized event on a 403 response', async () => {
|
|
const fetchMock = makeUnauthorizedFetch(403);
|
|
(globalThis as any).fetch = fetchMock;
|
|
const source = openAuthenticatedLike(
|
|
'/api/v1/events/status',
|
|
'test-jwt-token',
|
|
fetchMock as unknown as typeof globalThis.fetch,
|
|
);
|
|
const received: string[] = [];
|
|
source.addEventListener('unauthorized', (ev) => {
|
|
received.push(ev.type);
|
|
});
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
expect(received).toEqual(['unauthorized']);
|
|
source.close();
|
|
});
|
|
});
|