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
12 changes: 12 additions & 0 deletions packages/client/src/__tests__/urql-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,18 @@ describe('URQL auth exchange hardening', () => {
expect(authConfig.didAuthError(forbiddenError)).toBe(false);
});

it('does not treat ONBOARDING_REQUIRED as an auth error', async () => {
const { authConfig } = await initializeAuth(async () => null);

// The token is valid — the account just has no workspace. Refreshing it
// cannot help, and doing so drags the user through a pointless re-login.
const onboardingError = {
graphQLErrors: [{ extensions: { code: 'ONBOARDING_REQUIRED' } }],
};

expect(authConfig.didAuthError(onboardingError)).toBe(false);
});

it('does not eagerly trigger auth refresh before a server auth error', async () => {
const { authConfig } = await initializeAuth(async () => null);

Expand Down
37 changes: 37 additions & 0 deletions packages/client/src/__tests__/urql-error-handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { OperationResult } from 'urql';
import { handleUrqlError } from '../providers/urql-error-handler.js';

const { toastErrorMock } = vi.hoisted(() => ({
toastErrorMock: vi.fn(),
}));

vi.mock('sonner', () => ({
toast: { error: toastErrorMock },
}));

function resultWithCode(code: string): OperationResult {
return {
error: { graphQLErrors: [{ message: 'nope', extensions: { code } }] },
} as unknown as OperationResult;
}

describe('handleUrqlError', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('toasts ordinary GraphQL errors', () => {
handleUrqlError(resultWithCode('FORBIDDEN'));

expect(toastErrorMock).toHaveBeenCalledTimes(1);
});

it('stays silent for ONBOARDING_REQUIRED', () => {
// Every guarded operation fails at once for an unprovisioned account; the
// /welcome screen is the message, so toasts would only pile up behind it.
handleUrqlError(resultWithCode('ONBOARDING_REQUIRED'));

expect(toastErrorMock).not.toHaveBeenCalled();
});
});
81 changes: 79 additions & 2 deletions packages/client/src/components/__tests__/protected-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,34 +8,58 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ProtectedRoute, PublicOnlyGuard } from '../../router/guards/auth-guards.js';
import { ROUTES } from '../../router/routes.js';

const { useAuth0Mock } = vi.hoisted(() => ({
const { useAuth0Mock, useViewerMock } = vi.hoisted(() => ({
useAuth0Mock: vi.fn(),
useViewerMock: vi.fn(),
}));

vi.mock('@auth0/auth0-react', () => ({
useAuth0: useAuth0Mock,
}));

// ProtectedRoute composes OnboardingGuard, which queries `viewer` through urql.
// Stubbing the hook keeps these cases about auth state alone.
vi.mock('../../hooks/use-viewer.js', () => ({
useViewer: useViewerMock,
}));

const ACTIVE_VIEWER = {
fetching: false,
error: undefined,
viewer: { email: 'member@example.com', emailVerified: true, status: 'ACTIVE' },
};

type AuthState = {
isAuthenticated: boolean;
isLoading: boolean;
};

type ViewerState = typeof ACTIVE_VIEWER | Record<string, unknown>;

(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;

async function renderProtectedPath(pathname: string, authState: AuthState) {
async function renderProtectedPath(
pathname: string,
authState: AuthState,
viewerState: ViewerState = ACTIVE_VIEWER,
) {
useAuth0Mock.mockReturnValue(authState);
useViewerMock.mockReturnValue(viewerState);

const router = createMemoryRouter(
[
{
path: ROUTES.LOGIN,
element: React.createElement('div', null, 'Login Page'),
},
{
path: ROUTES.WELCOME,
element: React.createElement('div', null, 'Welcome Page'),
},
{
path: ROUTES.CHARGES.ROOT,
element: React.createElement(
Expand Down Expand Up @@ -167,6 +191,59 @@ describe('ProtectedRoute', () => {
});
});

describe('OnboardingGuard', () => {
beforeEach(() => {
vi.clearAllMocks();
});

const authenticated = { isAuthenticated: true, isLoading: false };

it('redirects an authenticated user with no workspace to /welcome', async () => {
const { router, cleanup } = await renderProtectedPath(ROUTES.CHARGES.ROOT, authenticated, {
fetching: false,
error: undefined,
viewer: { email: 'new@example.com', emailVerified: true, status: 'NO_WORKSPACE' },
});

expect(router.state.location.pathname).toBe(ROUTES.WELCOME);
await cleanup();
});

it('redirects an unverified-email user to /welcome', async () => {
const { router, cleanup } = await renderProtectedPath(ROUTES.CHARGES.ROOT, authenticated, {
fetching: false,
error: undefined,
viewer: { email: 'new@example.com', emailVerified: false, status: 'EMAIL_UNVERIFIED' },
});

expect(router.state.location.pathname).toBe(ROUTES.WELCOME);
await cleanup();
});

it('holds the app shell back while the viewer query is in flight', async () => {
const { html, cleanup } = await renderProtectedPath(ROUTES.CHARGES.ROOT, authenticated, {
fetching: true,
error: undefined,
viewer: null,
});

expect(html).not.toContain('Charges Page');
await cleanup();
});

it('renders the app on a viewer query error rather than trapping the user', async () => {
const { html, router, cleanup } = await renderProtectedPath(ROUTES.CHARGES.ROOT, authenticated, {
fetching: false,
error: new Error('network down'),
viewer: null,
});

expect(router.state.location.pathname).toBe(ROUTES.CHARGES.ROOT);
expect(html).toContain('Charges Page');
await cleanup();
});
});

describe('PublicOnlyGuard', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down
121 changes: 121 additions & 0 deletions packages/client/src/components/screens/__tests__/welcome.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// @vitest-environment happy-dom

import React from 'react';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ROUTES } from '../../../router/routes.js';
import { WelcomePage } from '../welcome.js';

const { useAuth0Mock, useViewerMock, useLogoutMock } = vi.hoisted(() => ({
useAuth0Mock: vi.fn(),
useViewerMock: vi.fn(),
useLogoutMock: vi.fn(),
}));

vi.mock('@auth0/auth0-react', () => ({
useAuth0: useAuth0Mock,
}));

vi.mock('../../../hooks/use-viewer.js', () => ({
useViewer: useViewerMock,
}));

vi.mock('../../../hooks/use-logout.js', () => ({
useLogout: useLogoutMock,
}));

(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;

async function renderWelcome(viewerState: Record<string, unknown>) {
useAuth0Mock.mockReturnValue({ isAuthenticated: true, isLoading: false });
useViewerMock.mockReturnValue(viewerState);
useLogoutMock.mockReturnValue(vi.fn());

const router = createMemoryRouter(
[
{ path: ROUTES.WELCOME, element: React.createElement(WelcomePage) },
{ path: ROUTES.HOME, element: React.createElement('div', null, 'Home Page') },
],
{ initialEntries: [ROUTES.WELCOME] },
);

const container = document.createElement('div');
document.body.append(container);

let root: Root | null = null;
await act(async () => {
root = createRoot(container);
root.render(React.createElement(RouterProvider, { router }));
await Promise.resolve();
});

const html = container.innerHTML;

const cleanup = async () => {
await act(async () => {
root?.unmount();
await Promise.resolve();
});
container.remove();
};

return { html, router, cleanup };
}

describe('WelcomePage', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('explains the invitation-only model when the user has no workspace', async () => {
const { html, cleanup } = await renderWelcome({
fetching: false,
error: undefined,
viewer: { email: 'new@example.com', emailVerified: true, status: 'NO_WORKSPACE' },
});

expect(html).toContain('No workspace yet');
expect(html).toContain('invitation-only');
await cleanup();
});

it('asks an unverified user to verify their email', async () => {
const { html, cleanup } = await renderWelcome({
fetching: false,
error: undefined,
viewer: { email: 'new@example.com', emailVerified: false, status: 'EMAIL_UNVERIFIED' },
});

expect(html).toContain('Verify your email');
await cleanup();
});

it('reports an unknown state instead of "no workspace" when the query fails', async () => {
const { html, cleanup } = await renderWelcome({
fetching: false,
error: new Error('network down'),
viewer: null,
});

expect(html).toContain('Could not check your account');
expect(html).not.toContain('No workspace yet');
await cleanup();
});

it('returns an active viewer to the app', async () => {
const { router, cleanup } = await renderWelcome({
fetching: false,
error: undefined,
viewer: { email: 'member@example.com', emailVerified: true, status: 'ACTIVE' },
});

expect(router.state.location.pathname).toBe(ROUTES.HOME);
await cleanup();
});
});
Loading
Loading