99 lines
4.4 KiB
TypeScript
99 lines
4.4 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { connectedAccountCandidates, classifySessionMatch } from '../auth-store';
|
|
import type { Identity } from '@/lib/jmap/types';
|
|
|
|
// The account-switch guard force-re-auths only when the connected session
|
|
// matches NONE of its server-confirmed identifiers. accountId is generated
|
|
// from the primary-identity email (OAuth) OR the login username (basic), so
|
|
// the candidate set must cover both: the JMAP Session.username (authenticated
|
|
// login) and the primary sending-identity email.
|
|
|
|
const SERVER = 'https://mail.example.com';
|
|
|
|
const fakeClient = (opts: {
|
|
sessionUsername?: string;
|
|
constructorUsername?: string;
|
|
identities?: Identity[] | Error;
|
|
}) =>
|
|
({
|
|
getSessionUsername: () => opts.sessionUsername,
|
|
getUsername: () => opts.constructorUsername ?? '',
|
|
getIdentities: async () => {
|
|
if (opts.identities instanceof Error) throw opts.identities;
|
|
return opts.identities ?? [];
|
|
},
|
|
}) as never;
|
|
|
|
const id = (over: Partial<Identity> = {}): Identity => ({
|
|
id: 'id-1', name: 'Real User', email: 'real@example.com', mayDelete: true, ...over,
|
|
});
|
|
|
|
describe('connectedAccountCandidates (account-switch guard)', () => {
|
|
it('includes the primary-identity email (OAuth registers by email)', async () => {
|
|
const out = await connectedAccountCandidates(
|
|
fakeClient({ sessionUsername: 'preferred_user', identities: [id()] }), SERVER,
|
|
);
|
|
expect(out).toContain('real@example.com@mail.example.com');
|
|
});
|
|
|
|
it('includes the session login username (basic auth registers by login)', async () => {
|
|
// support@ case: login is the email, but the primary sending identity is a
|
|
// different address. The login must still be accepted.
|
|
const out = await connectedAccountCandidates(
|
|
fakeClient({ sessionUsername: 'support@linux-hosting.co.il', identities: [id({ email: 'alias@elsewhere.com' })] }),
|
|
SERVER,
|
|
);
|
|
expect(out).toContain('support@linux-hosting.co.il@mail.example.com');
|
|
expect(out).toContain('alias@elsewhere.com@mail.example.com');
|
|
});
|
|
|
|
it('excludes the constructor username (cannot mask a desync)', async () => {
|
|
// A desynced slot: client built for support@ but the token resolves to
|
|
// shuki@. Candidates come only from the server (session + identities),
|
|
// never the constructor echo, so support@ is NOT among them.
|
|
const out = await connectedAccountCandidates(
|
|
fakeClient({ sessionUsername: 'shuki@linux-hosting.co.il', constructorUsername: 'support@linux-hosting.co.il', identities: [id({ email: 'shuki@linux-hosting.co.il' })] }),
|
|
SERVER,
|
|
);
|
|
expect(out).not.toContain('support@linux-hosting.co.il@mail.example.com');
|
|
expect(out).toContain('shuki@linux-hosting.co.il@mail.example.com');
|
|
});
|
|
|
|
it('returns empty when nothing can be confirmed (caller must not bounce)', async () => {
|
|
const out = await connectedAccountCandidates(
|
|
fakeClient({ sessionUsername: undefined, identities: new Error('no idents') }), SERVER,
|
|
);
|
|
expect(out).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('classifySessionMatch (account-switch verdict)', () => {
|
|
const CANDIDATES = ['linus@rathblume.de@mail.example.com', 'admin@rbm.systems@mail.example.com'];
|
|
|
|
it('accepts when a candidate equals the stored accountId (full-email login)', () => {
|
|
expect(classifySessionMatch(CANDIDATES, 'linus@rathblume.de@mail.example.com', [])).toBe('accept');
|
|
});
|
|
|
|
it('accepts a short-username accountId via captured server identifiers', () => {
|
|
// accountId built from the short login `linus`, which the server canonicalizes
|
|
// to linus@rathblume.de — the id itself never appears among the candidates.
|
|
expect(classifySessionMatch(CANDIDATES, 'linus@mail.example.com', CANDIDATES)).toBe('accept');
|
|
});
|
|
|
|
it('trusts a legacy account with no captured baseline (TOFU self-heal)', () => {
|
|
expect(classifySessionMatch(CANDIDATES, 'linus@mail.example.com', undefined)).toBe('trust');
|
|
});
|
|
|
|
it('rejects a real desync once a baseline exists', () => {
|
|
// slot resolves to a genuinely different account; baseline was captured before.
|
|
expect(
|
|
classifySessionMatch(['stranger@mail.example.com'], 'linus@mail.example.com', CANDIDATES),
|
|
).toBe('reject');
|
|
});
|
|
|
|
it('accepts when nothing could be confirmed (empty candidates never bounce)', () => {
|
|
expect(classifySessionMatch([], 'linus@mail.example.com', undefined)).toBe('accept');
|
|
expect(classifySessionMatch([], 'linus@mail.example.com', CANDIDATES)).toBe('accept');
|
|
});
|
|
});
|