feat: add google oauth
This commit is contained in:
207
src/contexts/__tests__/AuthContext.oauth.test.tsx
Normal file
207
src/contexts/__tests__/AuthContext.oauth.test.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useEffect } from 'react';
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
import { AuthProvider, useAuth } from '../AuthContext';
|
||||
|
||||
const {
|
||||
loginMock,
|
||||
registerMock,
|
||||
logoutMock,
|
||||
restoreSessionMock,
|
||||
getGoogleOAuthUrlMock,
|
||||
exchangeGoogleCodeMock,
|
||||
} = vi.hoisted(() => ({
|
||||
loginMock: vi.fn(),
|
||||
registerMock: vi.fn(),
|
||||
logoutMock: vi.fn(),
|
||||
restoreSessionMock: vi.fn(() => null),
|
||||
getGoogleOAuthUrlMock: vi.fn(),
|
||||
exchangeGoogleCodeMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/authService', () => ({
|
||||
authService: {
|
||||
login: loginMock,
|
||||
register: registerMock,
|
||||
logout: logoutMock,
|
||||
restoreSession: restoreSessionMock,
|
||||
getGoogleOAuthUrl: getGoogleOAuthUrlMock,
|
||||
exchangeGoogleCode: exchangeGoogleCodeMock,
|
||||
},
|
||||
}));
|
||||
|
||||
function Harness({ onReady }: { onReady: (ctx: ReturnType<typeof useAuth>) => void }) {
|
||||
const auth = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
onReady(auth);
|
||||
}, [auth, onReady]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderWithProvider(onReady: (ctx: ReturnType<typeof useAuth>) => void) {
|
||||
return render(
|
||||
<AuthProvider>
|
||||
<Harness onReady={onReady} />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('AuthContext OAuth flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
restoreSessionMock.mockReturnValue(null);
|
||||
});
|
||||
|
||||
it('beginGoogleOAuth writes state and redirect then redirects browser', async () => {
|
||||
getGoogleOAuthUrlMock.mockResolvedValue({ authUrl: 'https://accounts.google.com/o/oauth2/v2/auth' });
|
||||
let ctxRef: ReturnType<typeof useAuth> | null = null;
|
||||
|
||||
renderWithProvider((ctx) => {
|
||||
ctxRef = ctx;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ctxRef).toBeTruthy();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await (ctxRef as ReturnType<typeof useAuth>).beginGoogleOAuth();
|
||||
});
|
||||
|
||||
expect(sessionStorage.getItem('texpixel_oauth_state')).toBeTruthy();
|
||||
expect(sessionStorage.getItem('texpixel_post_login_redirect')).toBe(window.location.href);
|
||||
expect(getGoogleOAuthUrlMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('completeGoogleOAuth rejects when state mismatches', async () => {
|
||||
sessionStorage.setItem('texpixel_oauth_state', 'expected_state');
|
||||
let ctxRef: ReturnType<typeof useAuth> | null = null;
|
||||
|
||||
renderWithProvider((ctx) => {
|
||||
ctxRef = ctx;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ctxRef).toBeTruthy();
|
||||
});
|
||||
|
||||
let result: { error: Error | null } = { error: null };
|
||||
await act(async () => {
|
||||
result = await (ctxRef as ReturnType<typeof useAuth>).completeGoogleOAuth({
|
||||
code: 'abc',
|
||||
state: 'wrong_state',
|
||||
redirect_uri: 'http://localhost:5173/auth/google/callback',
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.error).toBeTruthy();
|
||||
expect(exchangeGoogleCodeMock).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem('texpixel_token')).toBeNull();
|
||||
});
|
||||
|
||||
it('completeGoogleOAuth stores session on success', async () => {
|
||||
sessionStorage.setItem('texpixel_oauth_state', 'state_ok');
|
||||
exchangeGoogleCodeMock.mockImplementation(async () => {
|
||||
localStorage.setItem('texpixel_token', 'Bearer header.payload.sig');
|
||||
return {
|
||||
token: 'Bearer header.payload.sig',
|
||||
expiresAt: 1999999999,
|
||||
user: {
|
||||
user_id: 7,
|
||||
id: '7',
|
||||
email: 'oauth@example.com',
|
||||
exp: 1999999999,
|
||||
iat: 1111111,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
let ctxRef: ReturnType<typeof useAuth> | null = null;
|
||||
|
||||
renderWithProvider((ctx) => {
|
||||
ctxRef = ctx;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ctxRef).toBeTruthy();
|
||||
});
|
||||
|
||||
let result: { error: Error | null } = { error: null };
|
||||
await act(async () => {
|
||||
result = await (ctxRef as ReturnType<typeof useAuth>).completeGoogleOAuth({
|
||||
code: 'code_ok',
|
||||
state: 'state_ok',
|
||||
redirect_uri: 'http://localhost:5173/auth/google/callback',
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
expect((ctxRef as ReturnType<typeof useAuth>).isAuthenticated).toBe(true);
|
||||
});
|
||||
expect(localStorage.getItem('texpixel_token')).toBe('Bearer header.payload.sig');
|
||||
});
|
||||
|
||||
it('completeGoogleOAuth deduplicates same code requests', async () => {
|
||||
sessionStorage.setItem('texpixel_oauth_state', 'state_ok');
|
||||
|
||||
exchangeGoogleCodeMock.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
localStorage.setItem('texpixel_token', 'Bearer header.payload.sig');
|
||||
resolve({
|
||||
token: 'Bearer header.payload.sig',
|
||||
expiresAt: 1999999999,
|
||||
user: {
|
||||
user_id: 7,
|
||||
id: '7',
|
||||
email: 'oauth@example.com',
|
||||
exp: 1999999999,
|
||||
iat: 1111111,
|
||||
},
|
||||
});
|
||||
}, 20);
|
||||
})
|
||||
);
|
||||
|
||||
let ctxRef: ReturnType<typeof useAuth> | null = null;
|
||||
|
||||
renderWithProvider((ctx) => {
|
||||
ctxRef = ctx;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ctxRef).toBeTruthy();
|
||||
});
|
||||
|
||||
let result1: { error: Error | null } = { error: null };
|
||||
let result2: { error: Error | null } = { error: null };
|
||||
|
||||
await act(async () => {
|
||||
const p1 = (ctxRef as ReturnType<typeof useAuth>).completeGoogleOAuth({
|
||||
code: 'same_code',
|
||||
state: 'state_ok',
|
||||
redirect_uri: 'http://localhost:5173/auth/google/callback',
|
||||
});
|
||||
|
||||
const p2 = (ctxRef as ReturnType<typeof useAuth>).completeGoogleOAuth({
|
||||
code: 'same_code',
|
||||
state: 'state_ok',
|
||||
redirect_uri: 'http://localhost:5173/auth/google/callback',
|
||||
});
|
||||
|
||||
[result1, result2] = await Promise.all([p1, p2]);
|
||||
});
|
||||
|
||||
expect(result1.error).toBeNull();
|
||||
expect(result2.error).toBeNull();
|
||||
expect(exchangeGoogleCodeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user