Files
apf_portal/apps/portal-bff/src/session/ioredis-connect-redis-adapter.spec.ts
T
Julien Gautier ed6cca499d
CI / commits (pull_request) Successful in 2m46s
CI / scan (pull_request) Failing after 2m47s
CI / check (pull_request) Successful in 4m24s
CI / a11y (pull_request) Successful in 1m51s
CI / perf (pull_request) Successful in 3m24s
feat(portal-bff): session middleware with AES-256-GCM at rest per ADR-0010
mount express-session + connect-redis at bootstrap on top of the
shared ioredis client. the full json payload is encrypted with
aes-256-gcm before it reaches redis; envelope is versioned
(v1.<iv>.<tag>.<ciphertext>, base64url) so the algorithm or key
derivation can rotate without a flag-day re-encryption.

scope is intentionally infrastructure-only — middleware mounted,
req.session available downstream, cookie set on first write,
encryption-at-rest active. populating req.session.user from
/auth/callback, /me, /auth/logout, and the absolute-timeout
interceptor land in follow-ups.

notable shape choices captured in ADR-0010 (amended here):
- encryption at rest applies to the whole payload, not just a tokens
  sub-field. the session also carries pii claims (oid, tid,
  preferred_username) — encrypting the envelope removes the need to
  classify fields one by one and costs essentially the same.
- connect-redis v9 was rewritten for node-redis v4 and dropped
  ioredis. rather than swap the whole bff to node-redis, a small
  adapter shapes the six commands connect-redis actually calls
  (get / set with {expiration:{type:'EX',value}} / expire / del /
  mGet / scanIterator) to look like node-redis. the rest of the
  bff stays on a single shared ioredis client.

session id: crypto.randomBytes(32).toString('base64url')
cookie name: __Host-portal_session in production, portal_session in
dev (the __Host- prefix mandates Secure which dev http can't
provide). httpOnly + sameSite=lax + path=/. resave:false,
saveUninitialized:false, rolling:true; cookie maxAge follows
SESSION_IDLE_TIMEOUT_SECONDS (default 1800).

env: SESSION_ENCRYPTION_KEY is mandatory (32 bytes after base64url
decode); rejected at boot via a new assertSessionEncryptionKey()
mirroring the other pre-flight validators. SESSION_IDLE_TIMEOUT_SECONDS
and SESSION_ABSOLUTE_TIMEOUT_SECONDS are optional with adr-aligned
defaults (1800 / 43200).
2026-05-12 17:56:52 +02:00

117 lines
4.4 KiB
TypeScript

import IORedis from 'ioredis';
import { adaptIoredisForConnectRedis } from './ioredis-connect-redis-adapter';
/**
* Unit-level tests that pin the **shape** of each adapted call —
* `connect-redis` v9 invokes them in a specific way and any drift
* breaks the session store silently. The behavioural round-trip is
* exercised by `connect-redis`' own integration tests once its
* `RedisStore` is wired in `session.module.spec.ts`.
*/
describe('adaptIoredisForConnectRedis', () => {
function fakeClient(): {
spy: Record<string, jest.Mock>;
client: IORedis;
} {
const spy = {
get: jest.fn().mockResolvedValue('v'),
set: jest.fn().mockResolvedValue('OK'),
expire: jest.fn().mockResolvedValue(1),
del: jest.fn().mockResolvedValue(2),
mget: jest.fn().mockResolvedValue(['v1', null, 'v3']),
scanStream: jest.fn(),
};
// The adapter only touches the 6 methods above — we deliberately
// don't construct a real `ioredis` instance to keep the unit
// pure and avoid hitting the network.
return { spy, client: spy as unknown as IORedis };
}
it('forwards get(key) unchanged', async () => {
const { spy, client } = fakeClient();
const adapter = adaptIoredisForConnectRedis(client);
await adapter.get('sid');
expect(spy['get']).toHaveBeenCalledWith('sid');
});
it('translates set with EX expiration to ioredis varargs', async () => {
const { spy, client } = fakeClient();
const adapter = adaptIoredisForConnectRedis(client);
await adapter.set('sid', 'value', { expiration: { type: 'EX', value: 1800 } });
expect(spy['set']).toHaveBeenCalledWith('sid', 'value', 'EX', 1800);
});
it('translates set without options to a plain ioredis set', async () => {
const { spy, client } = fakeClient();
const adapter = adaptIoredisForConnectRedis(client);
await adapter.set('sid', 'value');
expect(spy['set']).toHaveBeenCalledWith('sid', 'value');
});
it('forwards expire(key, seconds) unchanged', async () => {
const { spy, client } = fakeClient();
const adapter = adaptIoredisForConnectRedis(client);
await adapter.expire('sid', 1800);
expect(spy['expire']).toHaveBeenCalledWith('sid', 1800);
});
it('spreads del([keys]) into ioredis varargs', async () => {
const { spy, client } = fakeClient();
const adapter = adaptIoredisForConnectRedis(client);
await adapter.del(['a', 'b', 'c']);
expect(spy['del']).toHaveBeenCalledWith('a', 'b', 'c');
});
it('short-circuits del([]) without touching the client', async () => {
const { spy, client } = fakeClient();
const adapter = adaptIoredisForConnectRedis(client);
const result = await adapter.del([]);
expect(result).toBe(0);
expect(spy['del']).not.toHaveBeenCalled();
});
it('spreads mGet([keys]) into ioredis varargs and returns the array', async () => {
const { spy, client } = fakeClient();
const adapter = adaptIoredisForConnectRedis(client);
const result = await adapter.mGet(['a', 'b', 'c']);
expect(spy['mget']).toHaveBeenCalledWith('a', 'b', 'c');
expect(result).toEqual(['v1', null, 'v3']);
});
it('short-circuits mGet([]) without touching the client', async () => {
const { spy, client } = fakeClient();
const adapter = adaptIoredisForConnectRedis(client);
expect(await adapter.mGet([])).toEqual([]);
expect(spy['mget']).not.toHaveBeenCalled();
});
it('wraps scanStream into an async iterable of key arrays', async () => {
const { spy, client } = fakeClient();
const fakeStream = makeReadable([['session:a', 'session:b'], ['session:c']]);
(spy['scanStream'] as jest.Mock).mockReturnValueOnce(fakeStream);
const adapter = adaptIoredisForConnectRedis(client);
const iterator = adapter.scanIterator({ MATCH: 'session:*', COUNT: 100 });
const collected: string[][] = [];
for await (const batch of iterator) {
collected.push(batch);
}
expect(spy['scanStream']).toHaveBeenCalledWith({ match: 'session:*', count: 100 });
expect(collected).toEqual([['session:a', 'session:b'], ['session:c']]);
});
});
function makeReadable<T>(chunks: T[]): NodeJS.ReadableStream {
// Minimal async iterable that mimics `ioredis.scanStream`'s
// for-await contract — the adapter only needs `Symbol.asyncIterator`.
return {
async *[Symbol.asyncIterator]() {
for (const chunk of chunks) {
yield chunk;
}
},
} as unknown as NodeJS.ReadableStream;
}