AI Implementation feature(881): Authenticated Shell, Quick Tabs and Change Password 1.05 (#20)
This commit was merged in pull request #20.
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
CROSS_TAB_CHANNEL_NAME,
|
||||
CROSS_TAB_STORAGE_KEY,
|
||||
CrossTabInvalidationMessage,
|
||||
encodeInvalidationMessage,
|
||||
isCrossTabInvalidationMessage,
|
||||
isStorageInvalidationEvent,
|
||||
} from '../../frontend/src/app/core/services/auth-session-events.pure';
|
||||
|
||||
describe('auth-session-events.pure', () => {
|
||||
describe('encodeInvalidationMessage', () => {
|
||||
it('produces a session-invalidated message with origin and timestamp', () => {
|
||||
const before = Date.now();
|
||||
const msg = encodeInvalidationMessage('tab-A');
|
||||
const after = Date.now();
|
||||
expect(msg.type).toBe('session-invalidated');
|
||||
expect(msg.origin).toBe('tab-A');
|
||||
expect(msg.ts).toBeGreaterThanOrEqual(before);
|
||||
expect(msg.ts).toBeLessThanOrEqual(after);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCrossTabInvalidationMessage', () => {
|
||||
it('accepts a properly shaped message', () => {
|
||||
const msg: CrossTabInvalidationMessage = {
|
||||
type: 'session-invalidated',
|
||||
origin: 'x',
|
||||
ts: 1,
|
||||
};
|
||||
expect(isCrossTabInvalidationMessage(msg)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-objects', () => {
|
||||
expect(isCrossTabInvalidationMessage(null)).toBe(false);
|
||||
expect(isCrossTabInvalidationMessage(undefined)).toBe(false);
|
||||
expect(isCrossTabInvalidationMessage('string')).toBe(false);
|
||||
expect(isCrossTabInvalidationMessage(42)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects objects with wrong type or missing fields', () => {
|
||||
expect(isCrossTabInvalidationMessage({ type: 'other', origin: 'a', ts: 1 })).toBe(false);
|
||||
expect(isCrossTabInvalidationMessage({ type: 'session-invalidated', origin: 1, ts: 1 })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isCrossTabInvalidationMessage({ type: 'session-invalidated', origin: 'a' })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
isCrossTabInvalidationMessage({ type: 'session-invalidated', origin: 'a', ts: 'now' }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isStorageInvalidationEvent', () => {
|
||||
it('accepts a well-formed storage event for the namespaced key', () => {
|
||||
const ev = {
|
||||
key: CROSS_TAB_STORAGE_KEY,
|
||||
newValue: JSON.stringify(encodeInvalidationMessage('tab-X')),
|
||||
};
|
||||
expect(isStorageInvalidationEvent(ev)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects events with a different key', () => {
|
||||
const ev = {
|
||||
key: 'other.key',
|
||||
newValue: JSON.stringify(encodeInvalidationMessage('tab-X')),
|
||||
};
|
||||
expect(isStorageInvalidationEvent(ev)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects events with no newValue (cleared key)', () => {
|
||||
const ev = { key: CROSS_TAB_STORAGE_KEY, newValue: null };
|
||||
expect(isStorageInvalidationEvent(ev)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects malformed JSON payloads', () => {
|
||||
const ev = { key: CROSS_TAB_STORAGE_KEY, newValue: 'not-json' };
|
||||
expect(isStorageInvalidationEvent(ev)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects nullish events', () => {
|
||||
expect(isStorageInvalidationEvent(null)).toBe(false);
|
||||
expect(isStorageInvalidationEvent(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('constant exports', () => {
|
||||
it('exposes a stable channel name and storage key', () => {
|
||||
expect(CROSS_TAB_CHANNEL_NAME).toBe('hipctf.auth.v1');
|
||||
expect(CROSS_TAB_STORAGE_KEY).toBe('hipctf.auth.invalidate.v1');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,10 @@ if (typeof (globalThis as any).TextDecoder === 'undefined') {
|
||||
}
|
||||
|
||||
interface EventSourceLike {
|
||||
addEventListener(type: 'open' | 'message' | 'error', listener: (ev: MessageEvent | Event) => void): void;
|
||||
addEventListener(
|
||||
type: 'open' | 'message' | 'error' | 'unauthorized',
|
||||
listener: (ev: MessageEvent | Event) => void,
|
||||
): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
@@ -33,6 +36,13 @@ function makeCaptureableFetch(frames: string[]) {
|
||||
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.
|
||||
@@ -42,12 +52,20 @@ function openAuthenticatedLike(
|
||||
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' });
|
||||
if (!res || !(res as any).body) return;
|
||||
const reader = ((res as any).body as ReadableStream<Uint8Array>).getReader();
|
||||
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
|
||||
@@ -70,11 +88,13 @@ function openAuthenticatedLike(
|
||||
}
|
||||
})();
|
||||
return {
|
||||
addEventListener(_type, cb) {
|
||||
handler = cb as (ev: MessageEvent) => void;
|
||||
addEventListener(type, cb) {
|
||||
if (type === 'unauthorized') unauthorizedHandler = cb as (ev: Event) => void;
|
||||
else handler = cb as (ev: MessageEvent) => void;
|
||||
},
|
||||
close() {
|
||||
handler = null;
|
||||
unauthorizedHandler = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -150,4 +170,38 @@ describe('authenticated event source transport contract', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user