test(19-02): add failing tests for linkOidcToUser and POST /api/me/link-oidc

RED phase for Task 3:
- Test 1: linkOidcToUser updates users.oidc_iss/sub and deletes local_credentials
- Test 2: linkOidcToUser throws OidcLinkConflictError on conflict, no local_cred deletion
- Test 3: POST /api/me/link-oidc returns initiation payload (state / authorizationUrl)
This commit is contained in:
Lucas Berger
2026-06-17 16:35:19 -04:00
parent c88f7d41e5
commit 8ced2d0a20
+136
View File
@@ -475,3 +475,139 @@ describe('GET /api/me — hasLocalCredential (AUTH-LOCAL-17)', () => {
expect(body.user.hasLocalCredential).toBe(false); expect(body.user.hasLocalCredential).toBe(false);
}); });
}); });
// ---------------------------------------------------------------------------
// Plan 19-02: linkOidcToUser helper + POST /api/me/link-oidc (AUTH-LOCAL-10)
// ---------------------------------------------------------------------------
describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
beforeEach(() => {
process.env.NODE_ENV = 'test';
process.env.DEV_AUTH_BYPASS = 'true';
process.env.LOCAL_SESSION_SECRET = 'test-secret-that-is-long-enough-32chars!!';
});
it('Test 1: linkOidcToUser updates users.oidc_iss/sub and deletes local_credentials for userId', async () => {
const { db } = await import('../../src/db/client.js');
const { linkOidcToUser } = await import('../../src/auth/linkOidc.js');
const iss = 'https://auth.example.com';
const sub = 'user-sub-abc-123';
let updatedUsers = false;
let deletedLocalCreds = false;
// Mock: SELECT users WHERE oidc_iss = iss AND oidc_sub = sub → no conflict (empty)
let txSelectCount = 0;
const mockTx = {
select: vi.fn().mockImplementation(() => {
txSelectCount++;
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), // no conflict
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
}),
};
}),
update: vi.fn().mockImplementation(() => ({
set: vi.fn().mockImplementation(() => {
updatedUsers = true;
return { where: vi.fn().mockResolvedValue({ rowsAffected: 1 }) };
}),
})),
delete: vi.fn().mockImplementation(() => ({
where: vi.fn().mockImplementation(() => {
deletedLocalCreds = true;
return Promise.resolve({ rowsAffected: 1 });
}),
})),
};
vi.mocked(db.select).mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), // preflight: no conflict
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(db).transaction = vi.fn().mockImplementation(async (fn: (tx: any) => Promise<void>) => {
await fn(mockTx);
});
await linkOidcToUser(42, iss, sub);
expect(updatedUsers).toBe(true);
expect(deletedLocalCreds).toBe(true);
});
it('Test 2: linkOidcToUser throws OidcLinkConflictError and does NOT delete local_credentials when iss+sub belongs to different user', async () => {
const { db } = await import('../../src/db/client.js');
const { linkOidcToUser, OidcLinkConflictError } = await import('../../src/auth/linkOidc.js');
const iss = 'https://auth.example.com';
const sub = 'already-taken-sub';
const conflictingUserId = 99; // different from target userId 42
let deletedLocalCreds = false;
// Preflight SELECT finds a conflicting user (id=99, different from target=42)
vi.mocked(db.select).mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ id: conflictingUserId }]), // conflict!
}),
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any));
// Transaction should NEVER be called on conflict
const mockTx = {
delete: vi.fn().mockImplementation(() => ({
where: vi.fn().mockImplementation(() => {
deletedLocalCreds = true;
return Promise.resolve({ rowsAffected: 1 });
}),
})),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(db).transaction = vi.fn().mockImplementation(async (fn: (tx: any) => Promise<void>) => {
await fn(mockTx);
});
// Should throw OidcLinkConflictError, not proceed to transaction
await expect(linkOidcToUser(42, iss, sub)).rejects.toThrow(OidcLinkConflictError);
// local_credentials row must NOT have been deleted (binding aborted before any write)
expect(deletedLocalCreds).toBe(false);
// Transaction must not have been called
expect(vi.mocked(db).transaction).not.toHaveBeenCalled();
});
it('Test 3: POST /api/me/link-oidc returns response shape with authorization URL / initiation payload', async () => {
const { db } = await import('../../src/db/client.js');
// Mock db — not needed for route shape test but avoids errors
vi.mocked(db.select).mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ isAdmin: false }]) }),
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any));
const { app } = await import('../../src/index.js');
const res = await app.request('/api/me/link-oidc', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
// Must be 200 with an initiation payload (not the actual OIDC redirect — that's 19-03)
expect(res.status).toBe(200);
const body = (await res.json()) as { authorizationUrl?: string; state?: string };
// The response must have at minimum a signedState field (or authorizationUrl)
// — the exact shape depends on implementation; assert it's an object with a useful field
const hasInitiationPayload = 'authorizationUrl' in body || 'state' in body || 'signedState' in body;
expect(hasInitiationPayload).toBe(true);
});
});