Files
apf_portal/apps/portal-bff/src/session/ioredis-connect-redis-adapter.spec.ts
T
julien 758d723744
CI / commits (push) Has been skipped
CI / scan (push) Failing after 2m25s
CI / check (push) Successful in 3m48s
CI / a11y (push) Successful in 1m27s
CI / perf (push) Successful in 4m28s
feat(portal-bff): session middleware with AES-256-GCM at rest per ADR-0010 (#110)
## Summary

Mounts `express-session` + `connect-redis` at bootstrap on top of the shared `ioredis` client, with **AES-256-GCM applied to the full JSON payload before it lands in Redis** (per ADR-0010). The configured middleware is exposed as a NestJS provider (`SESSION_MIDDLEWARE`) and `main.ts` mounts it through `app.get(...)` so it sits on the same Redis connection the rest of the BFF uses — no second client at the bootstrap layer.

Envelope is versioned (`v1.<iv>.<tag>.<ciphertext>`, all base64url) so the algorithm / key derivation can rotate without a flag-day re-encryption. Tamper / wrong-key / unknown-version all raise `SessionDecryptError`; for now the failure is logged via Pino with `event: session.decrypt_failed` — the first-class audit event lands with ADR-0013.

Scope is intentionally **infrastructure only**:
- middleware mounted on every request, `req.session` available downstream
- session id = `crypto.randomBytes(32).toString('base64url')` (256 bits per ADR-0010)
- cookie name: `__Host-portal_session` in production, `portal_session` in dev (the `__Host-` prefix mandates `Secure`, which dev HTTP can't satisfy)
- `httpOnly + sameSite=lax + path=/`; `resave:false`, `saveUninitialized:false`, `rolling:true`
- cookie `maxAge` follows `SESSION_IDLE_TIMEOUT_SECONDS` (default 1800)
- encryption-at-rest active end-to-end

Out of scope, landing in follow-ups: `/auth/callback` populating `req.session.user`, `/me`, `/auth/logout`, the absolute-timeout interceptor, and the `user_sessions:{userId}` secondary index.

## Notable shape choices (ADR-0010 amended in the same commit)

**Full-payload encryption vs. just the `tokens` field.** The first draft of ADR-0010 scoped at-rest encryption to a `tokens` sub-field. The session also carries claims (`oid`, `tid`, `preferred_username`, …) that qualify as PII under GDPR — for an APF-Handicap portal handling health-adjacent data this matters. Encrypting the envelope is strictly stronger and removes the need to classify fields one by one. The ADR text is updated to match.

**`ioredis` + adapter vs. switching the BFF to `node-redis`.** `connect-redis` v9 was rewritten for `node-redis` v4 and no longer accepts `ioredis` directly. Two reasonable paths:
1. **Adapter (chosen)** — keep the shared `ioredis` client; shim the six commands `connect-redis` actually calls (`get`, `set` with `{expiration:{type:'EX',value}}`, `expire`, `del`, `mGet`, `scanIterator`) to the node-redis shape. Smallest blast radius — RedisModule, OBO cache (ADR-0014), future pub/sub all stay on a single Redis library.
2. **Switch RedisModule to `node-redis`** — clean alignment with `connect-redis`'s expectations, but touches every Redis consumer and would itself require an ADR amendment.

The adapter is reversible: if we ever decide to standardise on `node-redis`, deleting one file removes it. Happy to switch if you'd rather take that path.

## Env vars

- `SESSION_ENCRYPTION_KEY` — **mandatory**, AES-256-GCM key (32 bytes after base64url decode). New `assertSessionEncryptionKey()` validator wired in `main.ts` alongside the other pre-flight checks.
- `SESSION_IDLE_TIMEOUT_SECONDS` — optional, default `1800`.
- `SESSION_ABSOLUTE_TIMEOUT_SECONDS` — optional, default `43200` (consumed by the absolute-timeout interceptor in a follow-up).

`.env.example` updated; the three variables are promoted from the "future vars" block to the active section.

## Test plan

- [x] `pnpm nx test portal-bff` — **99/99 pass** (was 62 before this PR; +37 new specs across the 5 new files).
- [x] `pnpm nx build portal-bff` — clean webpack build.
- [x] `pnpm nx lint portal-bff` — clean.
- [x] Prettier-clean for all PR source files.
- [ ] Local smoke test once the next PR wires `/auth/callback` → `req.session.user`; this PR has no user-visible behaviour to exercise on its own.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #110
2026-05-12 18:06:13 +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;
}