@accounter/client
Version:
Accounter client application
305 lines (238 loc) • 10.6 kB
text/typescript
import { ROUTES } from '../router/routes.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { createClientMock, mapExchangeMock, authExchangeMock, appendHeadersMock } = vi.hoisted(() => {
const appendHeaders = vi.fn((operation: any, headers: Record<string, string>) => ({
...operation,
context: {
...(operation.context ?? {}),
fetchOptions: {
...((operation.context?.fetchOptions as Record<string, unknown>) ?? {}),
headers: {
...(((operation.context?.fetchOptions as { headers?: Record<string, string> })?.headers ??
{}) as Record<string, string>),
...headers,
},
},
},
}));
return {
createClientMock: vi.fn(() => ({ mockClient: true })),
mapExchangeMock: vi.fn(() => ({ mockMapExchange: true })),
authExchangeMock: vi.fn(),
appendHeadersMock: appendHeaders,
};
});
type TestAccessTokenResult =
| string
| null
| { status: 'token'; token: string }
| { status: 'unauthenticated' }
| { status: 'error'; error: unknown };
type TestAccessTokenProvider = (options?: { cacheMode?: 'on' | 'off' }) => Promise<TestAccessTokenResult>;
let authFactory:
| ((utils: { appendHeaders: typeof appendHeadersMock }) => Promise<{
addAuthToOperation: (operation: any) => any;
didAuthError: (error: { graphQLErrors: Array<{ extensions?: { code?: string } | null }> }) => boolean;
refreshAuth: () => Promise<void>;
willAuthError: () => boolean;
}>)
| null = null;
vi.mock('urql', () => ({
createClient: createClientMock,
fetchExchange: { mockFetchExchange: true },
mapExchange: mapExchangeMock,
Provider: ({ children }: { children?: unknown }) => children,
}));
vi.mock('@urql/exchange-auth', () => ({
authExchange: authExchangeMock.mockImplementation(
(
factory: (utils: { appendHeaders: typeof appendHeadersMock }) => Promise<{
addAuthToOperation: (operation: any) => any;
didAuthError: (error: { graphQLErrors: Array<{ extensions?: { code?: string } | null }> }) => boolean;
refreshAuth: () => Promise<void>;
willAuthError: () => boolean;
}>,
) => {
authFactory = factory;
return { mockAuthExchange: true };
},
),
}));
describe('URQL auth exchange hardening', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
authFactory = null;
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
async function initializeAuth(
provider?: TestAccessTokenProvider,
) {
const urql = await import('../providers/urql.js');
urql.resetUrqlClient();
urql.setUrqlAccessTokenProvider(provider ?? null);
urql.getUrqlClient();
if (!authFactory) {
throw new Error('authExchange factory was not captured in test setup');
}
return {
urql,
authConfig: await authFactory({ appendHeaders: appendHeadersMock }),
};
}
it('adds Authorization header when token is available', async () => {
const provider = vi.fn(async () => 'token-123');
const { authConfig } = await initializeAuth(provider);
const operation = { context: {} };
const enrichedOperation = authConfig.addAuthToOperation(operation);
expect(appendHeadersMock).toHaveBeenCalledWith(operation, {
Authorization: 'Bearer token-123',
});
expect(enrichedOperation.context.fetchOptions.headers.Authorization).toBe('Bearer token-123');
});
it('adds X-Dev-Auth header instead of Authorization when VITE_DEV_AUTH=1', async () => {
vi.stubEnv('VITE_DEV_AUTH', '1');
vi.stubEnv('VITE_DEV_AUTH_USER_ID', 'dev-user-123');
const provider = vi.fn(async () => 'token-123');
const { authConfig } = await initializeAuth(provider);
const operation = { context: {} };
const enrichedOperation = authConfig.addAuthToOperation(operation);
expect(appendHeadersMock).toHaveBeenCalledWith(operation, {
'X-Dev-Auth': 'dev-user-123',
});
expect(enrichedOperation.context.fetchOptions.headers['X-Dev-Auth']).toBe('dev-user-123');
expect(enrichedOperation.context.fetchOptions.headers.Authorization).toBeUndefined();
expect(provider).not.toHaveBeenCalled();
});
it('detects only UNAUTHENTICATED GraphQL auth errors', async () => {
const { authConfig } = await initializeAuth(async () => null);
const unauthenticatedError = {
graphQLErrors: [{ extensions: { code: 'UNAUTHENTICATED' } }],
};
const forbiddenError = {
graphQLErrors: [{ extensions: { code: 'FORBIDDEN' } }],
};
expect(authConfig.didAuthError(unauthenticatedError)).toBe(true);
expect(authConfig.didAuthError(forbiddenError)).toBe(false);
});
it('does not eagerly trigger auth refresh before a server auth error', async () => {
const { authConfig } = await initializeAuth(async () => null);
expect(authConfig.willAuthError()).toBe(false);
});
it("refreshAuth forces a fresh token with cacheMode 'off'", async () => {
const provider = vi
.fn<TestAccessTokenProvider>()
.mockResolvedValueOnce('cached-token')
.mockResolvedValueOnce('fresh-token');
const { authConfig } = await initializeAuth(provider);
await authConfig.refreshAuth();
expect(provider).toHaveBeenNthCalledWith(2, { cacheMode: 'off' });
const operation = { context: {} };
const refreshedOperation = authConfig.addAuthToOperation(operation);
expect(refreshedOperation.context.fetchOptions.headers.Authorization).toBe('Bearer fresh-token');
});
it('redirects to /login when forced refresh reports unauthenticated', async () => {
vi.stubGlobal('location', { href: 'http://localhost/' });
const provider = vi
.fn<TestAccessTokenProvider>()
.mockResolvedValueOnce('initial-token')
.mockResolvedValueOnce({ status: 'unauthenticated' });
const { authConfig } = await initializeAuth(provider);
await authConfig.refreshAuth();
expect(globalThis.location.href).toBe(`${ROUTES.LOGIN}?reauth=1`);
});
it('resumes with a fresh token after interactive re-auth instead of redirecting', async () => {
vi.stubGlobal('location', { href: 'http://localhost/' });
const provider = vi
.fn<TestAccessTokenProvider>()
.mockResolvedValueOnce('initial-token')
.mockResolvedValueOnce({ status: 'unauthenticated' })
.mockResolvedValueOnce('fresh-token');
const { setReauthHandler } = await import('../lib/reauth-coordinator.js');
setReauthHandler(async () => 'authenticated');
try {
const { authConfig } = await initializeAuth(provider);
await authConfig.refreshAuth();
expect(globalThis.location.href).toBe('http://localhost/');
const operation = authConfig.addAuthToOperation({ context: {} });
expect(operation.context.fetchOptions.headers.Authorization).toBe('Bearer fresh-token');
} finally {
setReauthHandler(null);
}
});
it('redirects to login when interactive re-auth is declined', async () => {
vi.stubGlobal('location', { href: 'http://localhost/' });
const provider = vi
.fn<TestAccessTokenProvider>()
.mockResolvedValueOnce('initial-token')
.mockResolvedValueOnce({ status: 'unauthenticated' });
const { setReauthHandler } = await import('../lib/reauth-coordinator.js');
setReauthHandler(async () => 'redirect');
try {
const { authConfig } = await initializeAuth(provider);
await authConfig.refreshAuth();
expect(globalThis.location.href).toBe(`${ROUTES.LOGIN}?reauth=1`);
} finally {
setReauthHandler(null);
}
});
it('redirects to /login?reauth=1 only once when repeated unauthenticated refresh attempts fail', async () => {
vi.stubGlobal('location', { href: 'http://localhost/' });
const provider = vi
.fn<TestAccessTokenProvider>()
.mockResolvedValueOnce('initial-token')
.mockResolvedValueOnce({ status: 'unauthenticated' })
.mockResolvedValueOnce({ status: 'unauthenticated' });
const { authConfig } = await initializeAuth(provider);
await authConfig.refreshAuth();
await authConfig.refreshAuth();
expect(globalThis.location.href).toBe(`${ROUTES.LOGIN}?reauth=1`);
});
it('does not redirect to login when forced refresh fails with a transient error', async () => {
vi.stubGlobal('location', { href: 'http://localhost/' });
const provider = vi
.fn<TestAccessTokenProvider>()
.mockResolvedValueOnce('initial-token')
.mockRejectedValueOnce(new Error('network timeout'));
const { authConfig } = await initializeAuth(provider);
await expect(authConfig.refreshAuth()).rejects.toThrow('network timeout');
expect(globalThis.location.href).toBe('http://localhost/');
const operation = authConfig.addAuthToOperation({ context: {} });
expect(operation.context.fetchOptions.headers.Authorization).toBe('Bearer initial-token');
});
it('silent refresh keeps in-flight requests using current token until refresh completes', async () => {
let resolveRefresh: ((value: string | null) => void) | undefined;
const refreshPromise = new Promise<string | null>(resolve => {
resolveRefresh = resolve;
});
const provider = vi
.fn<TestAccessTokenProvider>()
.mockResolvedValueOnce('token-before-refresh')
.mockImplementationOnce(() => refreshPromise);
const { authConfig } = await initializeAuth(provider);
const pendingRefresh = authConfig.refreshAuth();
const inFlightOperation = authConfig.addAuthToOperation({ context: {} });
expect(inFlightOperation.context.fetchOptions.headers.Authorization).toBe(
'Bearer token-before-refresh',
);
resolveRefresh?.('token-after-refresh');
await pendingRefresh;
const postRefreshOperation = authConfig.addAuthToOperation({ context: {} });
expect(postRefreshOperation.context.fetchOptions.headers.Authorization).toBe(
'Bearer token-after-refresh',
);
});
it('keeps existing Auth0 token flow untouched when VITE_DEV_AUTH is disabled', async () => {
vi.stubEnv('VITE_DEV_AUTH', '0');
vi.stubEnv('VITE_DEV_AUTH_USER_ID', 'dev-user-123');
const provider = vi.fn(async () => 'token-abc');
const { authConfig } = await initializeAuth(provider);
const operation = { context: {} };
const enrichedOperation = authConfig.addAuthToOperation(operation);
expect(enrichedOperation.context.fetchOptions.headers.Authorization).toBe('Bearer token-abc');
expect(enrichedOperation.context.fetchOptions.headers['X-Dev-Auth']).toBeUndefined();
});
});