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:
@@ -475,3 +475,139 @@ describe('GET /api/me — hasLocalCredential (AUTH-LOCAL-17)', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user