2026-06-30 14:29:36 -04:00
|
|
|
import { test } from 'node:test';
|
|
|
|
|
import assert from 'node:assert/strict';
|
2026-07-01 21:19:02 -04:00
|
|
|
import {
|
|
|
|
|
setFallbackSession,
|
|
|
|
|
removeFallbackSession,
|
|
|
|
|
getFallbackSession,
|
|
|
|
|
subscribeSessionChanges,
|
|
|
|
|
} from './sessions';
|
|
|
|
|
|
|
|
|
|
// The single-key atomic blob (kept in sync with SESSION_BLOB_KEY in sessions.ts).
|
|
|
|
|
const SESSION_BLOB_KEY = 'cinny_session_v1';
|
2026-06-30 14:29:36 -04:00
|
|
|
|
|
|
|
|
// The fallback-session helpers read/write specific `cinny_*` keys directly on
|
|
|
|
|
// `localStorage`. node has none, so install a controllable in-memory mock per
|
|
|
|
|
// case backed by a Map.
|
|
|
|
|
const installStorage = (): Map<string, string> => {
|
|
|
|
|
const store = new Map<string, string>();
|
|
|
|
|
(globalThis as { localStorage?: unknown }).localStorage = {
|
|
|
|
|
getItem: (key: string) => (store.has(key) ? store.get(key) : null),
|
|
|
|
|
setItem: (key: string, value: string) => {
|
|
|
|
|
store.set(key, String(value));
|
|
|
|
|
},
|
|
|
|
|
removeItem: (key: string) => {
|
|
|
|
|
store.delete(key);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
return store;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
test('setFallbackSession writes the cinny_* keys', () => {
|
|
|
|
|
const store = installStorage();
|
|
|
|
|
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
|
|
|
|
|
|
|
|
|
|
assert.equal(store.get('cinny_access_token'), 'token-1');
|
|
|
|
|
assert.equal(store.get('cinny_device_id'), 'DEVICE1');
|
|
|
|
|
assert.equal(store.get('cinny_user_id'), '@alice:example.org');
|
|
|
|
|
assert.equal(store.get('cinny_hs_base_url'), 'https://hs.example.org');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('getFallbackSession round-trips a full session and flags fallback stores', () => {
|
|
|
|
|
installStorage();
|
|
|
|
|
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
|
|
|
|
|
|
|
|
|
|
assert.deepEqual(getFallbackSession(), {
|
|
|
|
|
baseUrl: 'https://hs.example.org',
|
|
|
|
|
userId: '@alice:example.org',
|
|
|
|
|
deviceId: 'DEVICE1',
|
|
|
|
|
accessToken: 'token-1',
|
|
|
|
|
fallbackSdkStores: true,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('getFallbackSession returns undefined when nothing is stored', () => {
|
|
|
|
|
installStorage();
|
|
|
|
|
assert.equal(getFallbackSession(), undefined);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-01 21:19:02 -04:00
|
|
|
test('legacy path: undefined when a single legacy key is missing (no blob)', () => {
|
|
|
|
|
// With no atomic blob, every one of the four legacy keys is required; missing
|
|
|
|
|
// any one yields undefined (the pre-blob behaviour).
|
2026-06-30 14:29:36 -04:00
|
|
|
const keys = [
|
|
|
|
|
'cinny_access_token',
|
|
|
|
|
'cinny_device_id',
|
|
|
|
|
'cinny_user_id',
|
|
|
|
|
'cinny_hs_base_url',
|
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
keys.forEach((missing) => {
|
|
|
|
|
installStorage();
|
|
|
|
|
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
|
2026-07-01 21:19:02 -04:00
|
|
|
localStorage.removeItem(SESSION_BLOB_KEY);
|
2026-06-30 14:29:36 -04:00
|
|
|
localStorage.removeItem(missing);
|
|
|
|
|
assert.equal(getFallbackSession(), undefined, `missing ${missing} should yield undefined`);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-01 21:19:02 -04:00
|
|
|
test('blob wins: a torn legacy key does NOT tear the session while the blob exists', () => {
|
|
|
|
|
installStorage();
|
|
|
|
|
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
|
|
|
|
|
// Simulate a torn legacy write — the authoritative blob must still resolve.
|
|
|
|
|
localStorage.removeItem('cinny_access_token');
|
|
|
|
|
assert.deepEqual(getFallbackSession(), {
|
|
|
|
|
baseUrl: 'https://hs.example.org',
|
|
|
|
|
userId: '@alice:example.org',
|
|
|
|
|
deviceId: 'DEVICE1',
|
|
|
|
|
accessToken: 'token-1',
|
|
|
|
|
fallbackSdkStores: true,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-30 14:29:36 -04:00
|
|
|
test('removeFallbackSession clears all keys', () => {
|
|
|
|
|
const store = installStorage();
|
|
|
|
|
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
|
|
|
|
|
removeFallbackSession();
|
|
|
|
|
|
|
|
|
|
assert.equal(store.size, 0);
|
|
|
|
|
assert.equal(getFallbackSession(), undefined);
|
|
|
|
|
});
|
2026-06-30 15:55:30 -04:00
|
|
|
|
|
|
|
|
test('round-trips an OIDC session (refresh token, expiry, oidc metadata)', () => {
|
|
|
|
|
installStorage();
|
|
|
|
|
setFallbackSession('tok', 'DEV', '@bob:mozilla.org', 'https://matrix-client.mozilla.org', {
|
|
|
|
|
refreshToken: 'refresh-xyz',
|
|
|
|
|
expiresInMs: 3_600_000,
|
|
|
|
|
oidc: {
|
|
|
|
|
issuer: 'https://chat.mozilla.org/',
|
|
|
|
|
clientId: 'client-123',
|
|
|
|
|
redirectUri: 'https://chat.lotusguild.org/auth/oidc/callback',
|
|
|
|
|
idTokenClaims: { sub: '@bob:mozilla.org', aud: 'client-123' },
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const s = getFallbackSession();
|
|
|
|
|
assert.ok(s);
|
|
|
|
|
assert.equal(s.refreshToken, 'refresh-xyz');
|
|
|
|
|
// stored as absolute expiry, read back as remaining lifetime
|
|
|
|
|
assert.ok(s.expiresInMs! > 0 && s.expiresInMs! <= 3_600_000);
|
|
|
|
|
assert.deepEqual(s.oidc, {
|
|
|
|
|
issuer: 'https://chat.mozilla.org/',
|
|
|
|
|
clientId: 'client-123',
|
|
|
|
|
redirectUri: 'https://chat.lotusguild.org/auth/oidc/callback',
|
|
|
|
|
idTokenClaims: { sub: '@bob:mozilla.org', aud: 'client-123' },
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a password session carries no OIDC fields, and re-saving clears stale OIDC keys', () => {
|
|
|
|
|
installStorage();
|
|
|
|
|
// first an OIDC session...
|
|
|
|
|
setFallbackSession('tok', 'DEV', '@bob:mozilla.org', 'https://hs', {
|
|
|
|
|
refreshToken: 'r',
|
|
|
|
|
oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' },
|
|
|
|
|
});
|
|
|
|
|
assert.ok(getFallbackSession()?.oidc);
|
|
|
|
|
// ...overwritten by a plain password session must drop the OIDC state
|
|
|
|
|
setFallbackSession('tok2', 'DEV', '@alice:example.org', 'https://hs');
|
|
|
|
|
const s = getFallbackSession();
|
|
|
|
|
assert.ok(s);
|
|
|
|
|
assert.equal(s.oidc, undefined);
|
|
|
|
|
assert.equal(s.refreshToken, undefined);
|
2026-07-01 21:19:02 -04:00
|
|
|
// The overwritten blob must not retain the stale OIDC state either.
|
|
|
|
|
const blob = JSON.parse(localStorage.getItem(SESSION_BLOB_KEY)!);
|
|
|
|
|
assert.equal(blob.oidc, undefined);
|
|
|
|
|
assert.equal(blob.refreshToken, undefined);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Atomic blob: write/read round-trip
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
test('setFallbackSession writes a single atomic blob under cinny_session_v1', () => {
|
|
|
|
|
const store = installStorage();
|
|
|
|
|
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
|
|
|
|
|
|
|
|
|
|
const raw = store.get(SESSION_BLOB_KEY);
|
|
|
|
|
assert.ok(raw, 'blob key must be written');
|
|
|
|
|
assert.deepEqual(JSON.parse(raw!), {
|
|
|
|
|
accessToken: 'token-1',
|
|
|
|
|
deviceId: 'DEVICE1',
|
|
|
|
|
userId: '@alice:example.org',
|
|
|
|
|
baseUrl: 'https://hs.example.org',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('blob round-trips a full OIDC session (absolute expiry stored, remaining read back)', () => {
|
|
|
|
|
const store = installStorage();
|
|
|
|
|
setFallbackSession('tok', 'DEV', '@bob:mozilla.org', 'https://hs', {
|
|
|
|
|
refreshToken: 'refresh-xyz',
|
|
|
|
|
expiresInMs: 3_600_000,
|
|
|
|
|
oidc: {
|
|
|
|
|
issuer: 'https://i',
|
|
|
|
|
clientId: 'c',
|
|
|
|
|
redirectUri: 'https://cb',
|
|
|
|
|
idTokenClaims: { sub: '@bob:mozilla.org' },
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const blob = JSON.parse(store.get(SESSION_BLOB_KEY)!);
|
|
|
|
|
assert.equal(blob.refreshToken, 'refresh-xyz');
|
|
|
|
|
assert.ok(typeof blob.expiresAt === 'number' && blob.expiresAt > Date.now());
|
|
|
|
|
assert.deepEqual(blob.oidc, {
|
|
|
|
|
issuer: 'https://i',
|
|
|
|
|
clientId: 'c',
|
|
|
|
|
redirectUri: 'https://cb',
|
|
|
|
|
idTokenClaims: { sub: '@bob:mozilla.org' },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const s = getFallbackSession();
|
|
|
|
|
assert.ok(s);
|
|
|
|
|
assert.equal(s.refreshToken, 'refresh-xyz');
|
|
|
|
|
assert.ok(s.expiresInMs! > 0 && s.expiresInMs! <= 3_600_000);
|
|
|
|
|
assert.deepEqual(s.oidc, {
|
|
|
|
|
issuer: 'https://i',
|
|
|
|
|
clientId: 'c',
|
|
|
|
|
redirectUri: 'https://cb',
|
|
|
|
|
idTokenClaims: { sub: '@bob:mozilla.org' },
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Migration: legacy-only storage → transparent read → blob persisted on write
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
test('legacy-only storage (no blob) is read transparently', () => {
|
|
|
|
|
const store = installStorage();
|
|
|
|
|
// Simulate an older build: legacy keys present, no blob.
|
|
|
|
|
store.set('cinny_access_token', 'tok');
|
|
|
|
|
store.set('cinny_device_id', 'DEV');
|
|
|
|
|
store.set('cinny_user_id', '@carol:example.org');
|
|
|
|
|
store.set('cinny_hs_base_url', 'https://hs');
|
|
|
|
|
assert.equal(store.has(SESSION_BLOB_KEY), false);
|
|
|
|
|
|
|
|
|
|
assert.deepEqual(getFallbackSession(), {
|
|
|
|
|
baseUrl: 'https://hs',
|
|
|
|
|
userId: '@carol:example.org',
|
|
|
|
|
deviceId: 'DEV',
|
|
|
|
|
accessToken: 'tok',
|
|
|
|
|
fallbackSdkStores: true,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('first write after a legacy-only read persists the blob (migration)', () => {
|
|
|
|
|
const store = installStorage();
|
|
|
|
|
store.set('cinny_access_token', 'old');
|
|
|
|
|
store.set('cinny_device_id', 'DEV');
|
|
|
|
|
store.set('cinny_user_id', '@carol:example.org');
|
|
|
|
|
store.set('cinny_hs_base_url', 'https://hs');
|
|
|
|
|
|
|
|
|
|
// Reads are side-effect free — no blob yet.
|
|
|
|
|
getFallbackSession();
|
|
|
|
|
assert.equal(store.has(SESSION_BLOB_KEY), false);
|
|
|
|
|
|
|
|
|
|
// The next write (e.g. a token refresh) persists the atomic blob.
|
|
|
|
|
setFallbackSession('new', 'DEV', '@carol:example.org', 'https://hs');
|
|
|
|
|
assert.ok(store.has(SESSION_BLOB_KEY));
|
|
|
|
|
assert.equal(getFallbackSession()?.accessToken, 'new');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Corruption / partial blob → legacy fallback; blob wins on disagreement
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
test('corrupt blob (bad JSON) falls back to the legacy keys', () => {
|
|
|
|
|
const store = installStorage();
|
|
|
|
|
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
|
|
|
|
|
// Corrupt the blob but keep the legacy keys intact.
|
|
|
|
|
store.set(SESSION_BLOB_KEY, '{not valid json');
|
|
|
|
|
|
|
|
|
|
assert.deepEqual(getFallbackSession(), {
|
|
|
|
|
baseUrl: 'https://hs.example.org',
|
|
|
|
|
userId: '@alice:example.org',
|
|
|
|
|
deviceId: 'DEVICE1',
|
|
|
|
|
accessToken: 'token-1',
|
|
|
|
|
fallbackSdkStores: true,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('partial blob (missing a required field) falls back to the legacy keys', () => {
|
|
|
|
|
const store = installStorage();
|
|
|
|
|
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
|
|
|
|
|
// A blob missing accessToken is treated as absent.
|
|
|
|
|
store.set(
|
|
|
|
|
SESSION_BLOB_KEY,
|
|
|
|
|
JSON.stringify({ deviceId: 'DEVICE1', userId: '@alice:example.org', baseUrl: 'https://hs' }),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert.equal(getFallbackSession()?.accessToken, 'token-1');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('blob wins when blob and legacy keys disagree', () => {
|
|
|
|
|
const store = installStorage();
|
|
|
|
|
setFallbackSession('blob-token', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
|
|
|
|
|
// Legacy keys drift to a stale token; the blob is authoritative.
|
|
|
|
|
store.set('cinny_access_token', 'stale-legacy-token');
|
|
|
|
|
|
|
|
|
|
assert.equal(getFallbackSession()?.accessToken, 'blob-token');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Dual-write keeps blob + legacy in sync; removal clears both
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
test('dual-write keeps the legacy keys in sync with the blob', () => {
|
|
|
|
|
const store = installStorage();
|
|
|
|
|
setFallbackSession('tok', 'DEV', '@bob:mozilla.org', 'https://hs', {
|
|
|
|
|
refreshToken: 'r',
|
|
|
|
|
expiresInMs: 1000,
|
|
|
|
|
oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Legacy credential keys
|
|
|
|
|
assert.equal(store.get('cinny_access_token'), 'tok');
|
|
|
|
|
assert.equal(store.get('cinny_device_id'), 'DEV');
|
|
|
|
|
assert.equal(store.get('cinny_user_id'), '@bob:mozilla.org');
|
|
|
|
|
assert.equal(store.get('cinny_hs_base_url'), 'https://hs');
|
|
|
|
|
// Legacy OIDC keys
|
|
|
|
|
assert.equal(store.get('cinny_refresh_token'), 'r');
|
|
|
|
|
assert.ok(store.has('cinny_expires_at'));
|
|
|
|
|
assert.equal(store.get('cinny_oidc_issuer'), 'https://i');
|
|
|
|
|
assert.equal(store.get('cinny_oidc_client_id'), 'c');
|
|
|
|
|
assert.equal(store.get('cinny_oidc_redirect_uri'), 'https://cb');
|
|
|
|
|
// Blob agrees
|
|
|
|
|
const blob = JSON.parse(store.get(SESSION_BLOB_KEY)!);
|
|
|
|
|
assert.equal(blob.accessToken, 'tok');
|
|
|
|
|
assert.equal(blob.refreshToken, 'r');
|
|
|
|
|
assert.equal(store.get('cinny_expires_at'), String(blob.expiresAt));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('removeFallbackSession clears BOTH the blob and every legacy key', () => {
|
|
|
|
|
const store = installStorage();
|
|
|
|
|
setFallbackSession('tok', 'DEV', '@bob:mozilla.org', 'https://hs', {
|
|
|
|
|
refreshToken: 'r',
|
|
|
|
|
expiresInMs: 1000,
|
|
|
|
|
oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' },
|
|
|
|
|
});
|
|
|
|
|
assert.ok(store.size > 0);
|
|
|
|
|
|
|
|
|
|
removeFallbackSession();
|
|
|
|
|
assert.equal(store.size, 0, 'no session key may survive removal');
|
|
|
|
|
assert.equal(getFallbackSession(), undefined);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Token-refresh update path (the path LotusOidcTokenRefresher uses)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
test('token refresh via setFallbackSession updates blob + legacy atomically', () => {
|
|
|
|
|
const store = installStorage();
|
|
|
|
|
// Initial OIDC session.
|
|
|
|
|
setFallbackSession('access-1', 'DEV', '@bob:mozilla.org', 'https://hs', {
|
|
|
|
|
refreshToken: 'refresh-1',
|
|
|
|
|
oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// LotusOidcTokenRefresher.persistTokens() calls setFallbackSession with the
|
|
|
|
|
// rotated tokens and the same identity/oidc refs.
|
|
|
|
|
setFallbackSession('access-2', 'DEV', '@bob:mozilla.org', 'https://hs', {
|
|
|
|
|
refreshToken: 'refresh-2',
|
|
|
|
|
oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Blob updated
|
|
|
|
|
const blob = JSON.parse(store.get(SESSION_BLOB_KEY)!);
|
|
|
|
|
assert.equal(blob.accessToken, 'access-2');
|
|
|
|
|
assert.equal(blob.refreshToken, 'refresh-2');
|
|
|
|
|
// Legacy keys updated in lockstep
|
|
|
|
|
assert.equal(store.get('cinny_access_token'), 'access-2');
|
|
|
|
|
assert.equal(store.get('cinny_refresh_token'), 'refresh-2');
|
|
|
|
|
// Reader sees the fresh token
|
|
|
|
|
const s = getFallbackSession();
|
|
|
|
|
assert.equal(s?.accessToken, 'access-2');
|
|
|
|
|
assert.equal(s?.refreshToken, 'refresh-2');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Cross-tab sync: subscribeSessionChanges
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
// Minimal window/storage-event harness: node has neither.
|
|
|
|
|
const installWindow = (): ((evt: { key: string | null }) => void)[] => {
|
|
|
|
|
const listeners: ((evt: { key: string | null }) => void)[] = [];
|
|
|
|
|
(globalThis as { window?: unknown }).window = {
|
|
|
|
|
addEventListener: (type: string, cb: (evt: { key: string | null }) => void) => {
|
|
|
|
|
if (type === 'storage') listeners.push(cb);
|
|
|
|
|
},
|
|
|
|
|
removeEventListener: (type: string, cb: (evt: { key: string | null }) => void) => {
|
|
|
|
|
if (type !== 'storage') return;
|
|
|
|
|
const i = listeners.indexOf(cb);
|
|
|
|
|
if (i !== -1) listeners.splice(i, 1);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
return listeners;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
test('subscribeSessionChanges fires with the session when a session key changes', () => {
|
|
|
|
|
installStorage();
|
|
|
|
|
const listeners = installWindow();
|
|
|
|
|
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs');
|
|
|
|
|
|
|
|
|
|
let received: unknown = 'unset';
|
|
|
|
|
const unsub = subscribeSessionChanges((s) => {
|
|
|
|
|
received = s;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Simulate another tab writing a new token, then dispatch the storage event.
|
|
|
|
|
setFallbackSession('token-2', 'DEVICE1', '@alice:example.org', 'https://hs');
|
|
|
|
|
listeners.forEach((cb) => cb({ key: SESSION_BLOB_KEY }));
|
|
|
|
|
|
|
|
|
|
assert.notEqual(received, 'unset');
|
|
|
|
|
assert.equal((received as { accessToken?: string })?.accessToken, 'token-2');
|
|
|
|
|
|
|
|
|
|
unsub();
|
|
|
|
|
assert.equal(listeners.length, 0, 'unsubscribe removes the listener');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('subscribeSessionChanges fires with null when the session is removed', () => {
|
|
|
|
|
installStorage();
|
|
|
|
|
const listeners = installWindow();
|
|
|
|
|
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs');
|
|
|
|
|
subscribeSessionChanges((s) => {
|
|
|
|
|
assert.equal(s, null);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
removeFallbackSession();
|
|
|
|
|
listeners.forEach((cb) => cb({ key: SESSION_BLOB_KEY }));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('subscribeSessionChanges treats a null key (localStorage.clear) as a change', () => {
|
|
|
|
|
const store = installStorage();
|
|
|
|
|
const listeners = installWindow();
|
|
|
|
|
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs');
|
|
|
|
|
|
|
|
|
|
let fired = false;
|
|
|
|
|
subscribeSessionChanges(() => {
|
|
|
|
|
fired = true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
store.clear();
|
|
|
|
|
listeners.forEach((cb) => cb({ key: null }));
|
|
|
|
|
assert.equal(fired, true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('subscribeSessionChanges ignores unrelated storage keys', () => {
|
|
|
|
|
installStorage();
|
|
|
|
|
const listeners = installWindow();
|
|
|
|
|
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs');
|
|
|
|
|
|
|
|
|
|
let fired = false;
|
|
|
|
|
subscribeSessionChanges(() => {
|
|
|
|
|
fired = true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
listeners.forEach((cb) => cb({ key: 'some_unrelated_preference' }));
|
|
|
|
|
assert.equal(fired, false);
|
2026-06-30 15:55:30 -04:00
|
|
|
});
|