Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 171 additions & 18 deletions tests/authProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,43 @@ const buildCredential = (overrides = {}) =>
...overrides,
}) as any;

describe('AuthProvider', () => {
const apiHost = 'https://api.example.com/';
const apiHost = 'https://api.example.com/';

/**
* Renders the provider with an authenticated session already loaded and hands
* back the context, so tests can drive helpers directly rather than through UI.
*/
const renderAndCaptureAuth = async (credentials: unknown[] = []) => {
let auth: ReturnType<typeof useAuth> | null = null;

const Capture = () => {
auth = useAuth();
return null;
};

mockFetchWithAuthImpl.mockResolvedValueOnce({
ok: true,
json: async () => ({
user: { id: '1', email: 'test@example.com', phone: '', roles: [] },
credentials,
}),
} as any);

await act(async () => {
render(
<AuthProvider apiHost={apiHost}>
<Capture />
</AuthProvider>
);
});

return auth as unknown as ReturnType<typeof useAuth>;
};

const failure = (status = 500, body: unknown = { error: 'Nope' }) =>
({ ok: false, status, json: async () => body }) as any;

describe('AuthProvider', () => {
beforeEach(() => {
jest.clearAllMocks();
});
Expand Down Expand Up @@ -413,34 +447,153 @@ describe('AuthProvider', () => {
expect(returned).not.toHaveProperty('message');
});

describe('finishOAuthLogin failures', () => {
const renderAndCaptureAuth = async () => {
let auth: ReturnType<typeof useAuth> | null = null;

const Capture = () => {
auth = useAuth();
describe('failure paths', () => {
it('throws when useAuth is called outside a provider', () => {
const Orphan = () => {
useAuth();
return null;
};

mockFetchWithAuthImpl.mockResolvedValueOnce({
ok: true,
json: async () => ({
user: { id: '1', email: 'test@example.com', phone: '', roles: [] },
credentials: [],
}),
} as any);
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});

expect(() => render(<Orphan />)).toThrow(/must be used within an AuthProvider/i);

consoleSpy.mockRestore();
});

it('reports a failed passkey login without touching the session', async () => {
const auth = await renderAndCaptureAuth();

// The start call fails, so no assertion is ever attempted.
mockFetchWithAuthImpl.mockResolvedValueOnce(failure(401));

await expect(auth.handlePasskeyLogin()).resolves.toBe(false);
});

it('clears auth state even when signing out fails', async () => {
const auth = await renderAndCaptureAuth();

mockFetchWithAuthImpl.mockResolvedValueOnce(failure(500));

await act(async () => {
await auth.logoutAllSessions();
});

// Local state must not survive a failed sign-out, otherwise the UI keeps
// showing an authenticated user who no longer has a usable session.
expect(screen.queryByTestId('user')).toBeNull();
});

it('surfaces a failed account deletion', async () => {
const auth = await renderAndCaptureAuth();

mockFetchWithAuthImpl.mockResolvedValueOnce(
failure(403, { error: 'Deletion is disabled' })
);

await expect(auth.deleteUser()).rejects.toMatchObject({
message: 'Deletion is disabled',
status: 403,
});
});

it('surfaces a failed credential update', async () => {
const auth = await renderAndCaptureAuth([buildCredential()]);

mockFetchWithAuthImpl.mockResolvedValueOnce(
failure(409, { error: 'Name already used' })
);

await expect(
auth.updateCredential({ ...buildCredential(), friendlyName: 'x' })
).rejects.toMatchObject({ message: 'Name already used', status: 409 });
});

it('surfaces a failed credential deletion', async () => {
const auth = await renderAndCaptureAuth([buildCredential()]);

mockFetchWithAuthImpl.mockResolvedValueOnce(failure(404));

await expect(auth.deleteCredential('cred-1')).rejects.toMatchObject({
status: 404,
});
});

it('surfaces a failed organization switch', async () => {
const auth = await renderAndCaptureAuth();

mockFetchWithAuthImpl.mockResolvedValueOnce(
failure(403, { error: 'Not a member' })
);

await expect(auth.switchOrganization('org-1')).rejects.toMatchObject({
message: 'Not a member',
});
});

it('throws from the OAuth helpers rather than returning a result', async () => {
const auth = await renderAndCaptureAuth();

mockFetchWithAuthImpl
.mockResolvedValueOnce(failure(500))
.mockResolvedValueOnce(failure(400, { error: 'Unknown provider' }));

await expect(auth.listOAuthProviders()).rejects.toMatchObject({ status: 500 });
await expect(auth.startOAuthLogin({ providerId: 'nope' })).rejects.toMatchObject({
message: 'Unknown provider',
});
});

it('clears step-up status when it cannot be loaded', async () => {
const auth = await renderAndCaptureAuth();

mockFetchWithAuthImpl.mockResolvedValueOnce(failure(401));

await expect(auth.refreshStepUpStatus()).resolves.toBeNull();
});

it('leaves step-up status untouched when verification fails', async () => {
mockFetchWithAuthImpl
.mockResolvedValueOnce({
ok: true,
json: async () => ({
user: { id: '1', email: 'test@example.com', phone: '', roles: [] },
credentials: [],
}),
} as any)
// A 200 that does not report a fresh verification is still a failure,
// so provider state must not record a step-up that did not happen.
.mockResolvedValueOnce({
ok: true,
json: async () => ({ message: 'Rejected', fresh: false, method: null }),
} as any);

await act(async () => {
render(
<AuthProvider apiHost={apiHost}>
<Capture />
<Consumer />
</AuthProvider>
);
});

return auth as unknown as ReturnType<typeof useAuth>;
};
await waitFor(() => {
expect(screen.getByTestId('user')).toHaveTextContent('test@example.com');
});

fireEvent.click(screen.getByRole('button', { name: /verify totp step-up/i }));

await waitFor(() => {
expect(mockFetchWithAuthImpl).toHaveBeenCalledWith(
'/totp/verify-mfa',
expect.anything()
);
});

expect(screen.getByTestId('stepUpFresh')).toHaveTextContent('false');
});
});

describe('finishOAuthLogin failures', () => {
const input = { providerId: 'github', code: 'code', state: 'state' };

it('surfaces the auth server error detail instead of a generic message', async () => {
Expand Down
59 changes: 59 additions & 0 deletions tests/createSeamlessAuthClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,65 @@ describe('createSeamlessAuthClient', () => {
expect(removed.data?.message).toBe('Success');
});

it('uses the remaining organization read and update endpoints', async () => {
mockFetchWithAuth.mockResolvedValue({ ok: true, json: async () => ({}) });

const client = createSeamlessAuthClient({
apiHost: 'https://api.example.com',
});

await client.getOrganization('org 1');
await client.updateOrganization('org 1', { name: 'Renamed' });
await client.listOrganizationMembers('org 1');

expect(mockFetchWithAuth).toHaveBeenNthCalledWith(1, '/organizations/org%201', {
method: 'GET',
});
expect(mockFetchWithAuth).toHaveBeenNthCalledWith(2, '/organizations/org%201', {
method: 'PATCH',
body: JSON.stringify({ name: 'Renamed' }),
});
expect(mockFetchWithAuth).toHaveBeenNthCalledWith(
3,
'/organizations/org%201/members',
{ method: 'GET' }
);
});

it('uses the account and magic-link request endpoints', async () => {
mockFetchWithAuth.mockResolvedValue({
ok: true,
json: async () => ({ message: 'Success' }),
});

const client = createSeamlessAuthClient({
apiHost: 'https://api.example.com',
});

expect((await client.deleteUser()).error).toBeNull();
expect((await client.requestMagicLink()).data?.message).toBe('Success');

expect(mockFetchWithAuth).toHaveBeenNthCalledWith(1, '/users/delete', {
method: 'DELETE',
});
expect(mockFetchWithAuth).toHaveBeenNthCalledWith(2, '/magic-link', {
method: 'GET',
});
});

it('reports a transport failure as an error result rather than rejecting', async () => {
mockFetchWithAuth.mockRejectedValueOnce(new TypeError('Failed to fetch'));

const client = createSeamlessAuthClient({
apiHost: 'https://api.example.com',
});

const { data, error } = await client.getCurrentUser();

expect(data).toBeNull();
expect(error?.status).toBe(0);
});

it('uses OAuth login endpoints', async () => {
const providersResult = {
providers: [{ id: 'google', name: 'Google', scopes: ['openid', 'email'] }],
Expand Down
Loading