283 lines
10 KiB
JavaScript
283 lines
10 KiB
JavaScript
import { renderHook, act, waitFor } from '@testing-library/react';
|
|
|
|
jest.mock('@/api/doormile', () => ({
|
|
__esModule: true,
|
|
DOORMILE_TOKEN_KEY: 'doormileToken',
|
|
DOORMILE_USER_KEY: 'doormileUser',
|
|
loginAdmin: jest.fn(),
|
|
logoutAdmin: jest.fn(),
|
|
readStoredToken: jest.fn(),
|
|
readStoredUser: jest.fn()
|
|
}));
|
|
|
|
import { AuthProvider, useAuth } from '@/lib/AuthContext';
|
|
import { loginAdmin, logoutAdmin, readStoredToken, readStoredUser } from '@/api/doormile';
|
|
|
|
const wrapper = ({ children }) => <AuthProvider>{children}</AuthProvider>;
|
|
const renderAuth = () => renderHook(() => useAuth(), { wrapper });
|
|
|
|
/**
|
|
* Session state for the console shell.
|
|
*
|
|
* What these tests protect is the authorization gate itself: who is considered
|
|
* signed in, what a refused credential does (show the server's wording, do NOT
|
|
* throw), and that a sign-out in one tab ends the session in every other tab
|
|
* rather than leaving a rendered console around a token that is gone.
|
|
*/
|
|
describe('AuthContext', () => {
|
|
let replace;
|
|
|
|
beforeEach(() => {
|
|
replace = jest.fn();
|
|
Object.defineProperty(window, 'location', {
|
|
writable: true,
|
|
configurable: true,
|
|
value: { href: 'http://console.local/deliveries', replace }
|
|
});
|
|
readStoredToken.mockReturnValue(null);
|
|
readStoredUser.mockReturnValue(null);
|
|
});
|
|
|
|
describe('boot', () => {
|
|
it('should report a signed-out shell when no token is stored', async () => {
|
|
const { result } = renderAuth();
|
|
await waitFor(() => expect(result.current.authChecked).toBe(true));
|
|
|
|
expect(result.current.isAuthenticated).toBe(false);
|
|
expect(result.current.user).toBeNull();
|
|
expect(result.current.isLoadingAuth).toBe(false);
|
|
});
|
|
|
|
it('should restore the signed-in admin from storage', async () => {
|
|
readStoredToken.mockReturnValue('jwt-abc');
|
|
readStoredUser.mockReturnValue({ userid: 7, tenantid: '0' });
|
|
|
|
const { result } = renderAuth();
|
|
await waitFor(() => expect(result.current.authChecked).toBe(true));
|
|
|
|
expect(result.current.isAuthenticated).toBe(true);
|
|
expect(result.current.user).toEqual({ userid: 7, tenantid: '0' });
|
|
});
|
|
|
|
it('should treat the token as the credential — no token means no user', async () => {
|
|
// A stale user record without a token must not produce a rendered console
|
|
// that 401s on every fetch.
|
|
readStoredToken.mockReturnValue(null);
|
|
readStoredUser.mockReturnValue({ userid: 7 });
|
|
|
|
const { result } = renderAuth();
|
|
await waitFor(() => expect(result.current.authChecked).toBe(true));
|
|
|
|
expect(result.current.isAuthenticated).toBe(false);
|
|
expect(result.current.user).toBeNull();
|
|
});
|
|
|
|
it('should mark the auth check complete exactly once on mount', async () => {
|
|
const { result } = renderAuth();
|
|
await waitFor(() => expect(result.current.authChecked).toBe(true));
|
|
expect(readStoredToken).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('login', () => {
|
|
it('should sign the operator in on a successful credential', async () => {
|
|
loginAdmin.mockResolvedValue({ success: true, user: { userid: 7 } });
|
|
const { result } = renderAuth();
|
|
await waitFor(() => expect(result.current.authChecked).toBe(true));
|
|
|
|
let outcome;
|
|
await act(async () => {
|
|
outcome = await result.current.login('ops@doormile.com', 'hunter2');
|
|
});
|
|
|
|
expect(loginAdmin).toHaveBeenCalledWith('ops@doormile.com', 'hunter2');
|
|
expect(outcome).toEqual({ success: true });
|
|
expect(result.current.isAuthenticated).toBe(true);
|
|
expect(result.current.user).toEqual({ userid: 7 });
|
|
});
|
|
|
|
it('should fall back to the stored record when the response omits the user', async () => {
|
|
loginAdmin.mockResolvedValue({ success: true });
|
|
readStoredUser.mockReturnValue({ userid: 9 });
|
|
const { result } = renderAuth();
|
|
await waitFor(() => expect(result.current.authChecked).toBe(true));
|
|
|
|
await act(async () => {
|
|
await result.current.login('ops@doormile.com', 'hunter2');
|
|
});
|
|
|
|
expect(result.current.user).toEqual({ userid: 9 });
|
|
});
|
|
|
|
it("should surface the server's own wording for a refused credential", async () => {
|
|
// Resolving rather than throwing is deliberate: the form shows the
|
|
// server's message instead of a generic failure.
|
|
loginAdmin.mockResolvedValue({ success: false, message: 'Account is locked' });
|
|
const { result } = renderAuth();
|
|
await waitFor(() => expect(result.current.authChecked).toBe(true));
|
|
|
|
let outcome;
|
|
await act(async () => {
|
|
outcome = await result.current.login('ops@doormile.com', 'wrong');
|
|
});
|
|
|
|
expect(outcome).toEqual({ success: false, message: 'Account is locked' });
|
|
expect(result.current.authError).toBe('Account is locked');
|
|
expect(result.current.isAuthenticated).toBe(false);
|
|
expect(result.current.user).toBeNull();
|
|
});
|
|
|
|
it('should use a default message when the server refuses without one', async () => {
|
|
loginAdmin.mockResolvedValue({ success: false });
|
|
const { result } = renderAuth();
|
|
await waitFor(() => expect(result.current.authChecked).toBe(true));
|
|
|
|
let outcome;
|
|
await act(async () => {
|
|
outcome = await result.current.login('a@b.c', 'x');
|
|
});
|
|
|
|
expect(outcome.success).toBe(false);
|
|
expect(outcome.message).toBe('Those credentials were not accepted');
|
|
});
|
|
|
|
it('should report a transport failure without throwing at the form', async () => {
|
|
loginAdmin.mockRejectedValue({ message: 'Network Error' });
|
|
const { result } = renderAuth();
|
|
await waitFor(() => expect(result.current.authChecked).toBe(true));
|
|
|
|
let outcome;
|
|
await act(async () => {
|
|
outcome = await result.current.login('a@b.c', 'x');
|
|
});
|
|
|
|
expect(outcome).toEqual({ success: false, message: 'Network Error' });
|
|
expect(result.current.isAuthenticated).toBe(false);
|
|
});
|
|
|
|
it('should report a reachability message when the rejection carries none', async () => {
|
|
loginAdmin.mockRejectedValue({});
|
|
const { result } = renderAuth();
|
|
await waitFor(() => expect(result.current.authChecked).toBe(true));
|
|
|
|
let outcome;
|
|
await act(async () => {
|
|
outcome = await result.current.login('a@b.c', 'x');
|
|
});
|
|
|
|
expect(outcome.message).toBe('Could not reach the Doormile API');
|
|
});
|
|
|
|
it('should clear a previous error when a retry succeeds', async () => {
|
|
loginAdmin.mockResolvedValueOnce({ success: false, message: 'Invalid credentials' });
|
|
loginAdmin.mockResolvedValueOnce({ success: true, user: { userid: 7 } });
|
|
const { result } = renderAuth();
|
|
await waitFor(() => expect(result.current.authChecked).toBe(true));
|
|
|
|
await act(async () => {
|
|
await result.current.login('a@b.c', 'wrong');
|
|
});
|
|
expect(result.current.authError).toBe('Invalid credentials');
|
|
|
|
await act(async () => {
|
|
await result.current.login('a@b.c', 'right');
|
|
});
|
|
expect(result.current.authError).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('logout', () => {
|
|
it('should clear the stored session and send the operator to login', async () => {
|
|
readStoredToken.mockReturnValue('jwt-abc');
|
|
readStoredUser.mockReturnValue({ userid: 7 });
|
|
const { result } = renderAuth();
|
|
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
|
|
|
|
act(() => {
|
|
result.current.logout();
|
|
});
|
|
|
|
expect(logoutAdmin).toHaveBeenCalledTimes(1);
|
|
expect(result.current.isAuthenticated).toBe(false);
|
|
expect(result.current.user).toBeNull();
|
|
expect(replace).toHaveBeenCalledWith('/login');
|
|
});
|
|
|
|
it('should still clear state when the caller suppresses the redirect', async () => {
|
|
readStoredToken.mockReturnValue('jwt-abc');
|
|
const { result } = renderAuth();
|
|
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
|
|
|
|
act(() => {
|
|
result.current.logout(false);
|
|
});
|
|
|
|
expect(logoutAdmin).toHaveBeenCalledTimes(1);
|
|
expect(result.current.isAuthenticated).toBe(false);
|
|
expect(replace).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('cross-tab session sync', () => {
|
|
it('should end this tab session when the token disappears in another tab', async () => {
|
|
readStoredToken.mockReturnValue('jwt-abc');
|
|
readStoredUser.mockReturnValue({ userid: 7 });
|
|
const { result } = renderAuth();
|
|
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
|
|
|
|
readStoredToken.mockReturnValue(null);
|
|
readStoredUser.mockReturnValue(null);
|
|
act(() => {
|
|
window.dispatchEvent(new StorageEvent('storage', { key: 'doormileToken' }));
|
|
});
|
|
|
|
await waitFor(() => expect(result.current.isAuthenticated).toBe(false));
|
|
});
|
|
|
|
it('should pick up a sign-IN that happened in another tab', async () => {
|
|
const { result } = renderAuth();
|
|
await waitFor(() => expect(result.current.authChecked).toBe(true));
|
|
|
|
readStoredToken.mockReturnValue('jwt-abc');
|
|
readStoredUser.mockReturnValue({ userid: 7 });
|
|
act(() => {
|
|
window.dispatchEvent(new StorageEvent('storage', { key: 'doormileUser' }));
|
|
});
|
|
|
|
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
|
|
});
|
|
|
|
it('should ignore an unrelated storage key', async () => {
|
|
readStoredToken.mockReturnValue('jwt-abc');
|
|
const { result } = renderAuth();
|
|
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
|
|
readStoredToken.mockClear();
|
|
|
|
act(() => {
|
|
window.dispatchEvent(new StorageEvent('storage', { key: 'doormilePreferences' }));
|
|
});
|
|
|
|
expect(readStoredToken).not.toHaveBeenCalled();
|
|
expect(result.current.isAuthenticated).toBe(true);
|
|
});
|
|
|
|
it('should stop listening once unmounted', async () => {
|
|
const removeSpy = jest.spyOn(window, 'removeEventListener');
|
|
const { unmount, result } = renderAuth();
|
|
await waitFor(() => expect(result.current.authChecked).toBe(true));
|
|
unmount();
|
|
expect(removeSpy).toHaveBeenCalledWith('storage', expect.any(Function));
|
|
});
|
|
});
|
|
|
|
describe('useAuth outside a provider', () => {
|
|
it('should throw a named error rather than returning undefined', () => {
|
|
// Silent undefined here would render an unauthenticated shell as if it
|
|
// were authorized.
|
|
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
expect(() => renderHook(() => useAuth())).toThrow('useAuth must be used within an AuthProvider');
|
|
spy.mockRestore();
|
|
});
|
|
});
|
|
});
|