This commit is contained in:
45 files changed
+1141
-906
No files matched your search
@@ -0,0 +1,99 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react-native';
|
||||
import React from 'react';
|
||||
|
||||
import { AuthProvider, useAuth } from '@/auth/AuthContext';
|
||||
import { queryKeys } from '@/queries/queryKeys';
|
||||
|
||||
const TOKEN_KEY = 'cnapp.authToken';
|
||||
|
||||
const grisen = { username: 'grisen', email: 'grisen@example.test', is_member: true };
|
||||
|
||||
// gcTime: Infinity so no GC timers keep Jest alive
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { gcTime: Infinity } } });
|
||||
|
||||
const renderAuth = ({
|
||||
initialToken,
|
||||
queryClient = createQueryClient(),
|
||||
}: { initialToken?: string | null; queryClient?: QueryClient } = {}) => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider initialToken={initialToken}>{children}</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
return { queryClient, ...renderHook(() => useAuth(), { wrapper }) };
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
// The AsyncStorage mock's methods are shared jest.fn()s; clear their history
|
||||
jest.clearAllMocks();
|
||||
await AsyncStorage.clear();
|
||||
});
|
||||
|
||||
it('renders nothing until the stored session is restored', async () => {
|
||||
await AsyncStorage.setItem(TOKEN_KEY, 'token-123');
|
||||
|
||||
const { result } = renderAuth();
|
||||
// The gate: children (and thus the hook) have not rendered yet
|
||||
expect(result.current).toBeNull();
|
||||
|
||||
await waitFor(() => expect(result.current).not.toBeNull());
|
||||
expect(result.current.token).toBe('token-123');
|
||||
expect(result.current.isAuthenticated).toBe(true);
|
||||
});
|
||||
|
||||
it('starts logged out when nothing is stored', async () => {
|
||||
const { result } = renderAuth();
|
||||
|
||||
await waitFor(() => expect(result.current).not.toBeNull());
|
||||
expect(result.current.token).toBeNull();
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('runs the redux-persist migration and seeds the cached user', async () => {
|
||||
await AsyncStorage.setItem(
|
||||
'persist:root',
|
||||
JSON.stringify({ userToken: JSON.stringify('9a8b'), user: JSON.stringify(grisen) }),
|
||||
);
|
||||
|
||||
const { result, queryClient } = renderAuth();
|
||||
|
||||
await waitFor(() => expect(result.current?.token).toBe('9a8b'));
|
||||
expect(queryClient.getQueryData(queryKeys.user)).toEqual(grisen);
|
||||
expect(await AsyncStorage.getItem('persist:root')).toBeNull();
|
||||
});
|
||||
|
||||
it('signIn stores the token and flips isAuthenticated', async () => {
|
||||
const { result } = renderAuth({ initialToken: null });
|
||||
|
||||
act(() => result.current.signIn('token-1'));
|
||||
|
||||
expect(result.current.token).toBe('token-1');
|
||||
expect(result.current.isAuthenticated).toBe(true);
|
||||
await waitFor(async () => expect(await AsyncStorage.getItem(TOKEN_KEY)).toBe('token-1'));
|
||||
});
|
||||
|
||||
it('signOut clears the token and drops the cached user', async () => {
|
||||
const queryClient = createQueryClient();
|
||||
queryClient.setQueryData(queryKeys.user, grisen);
|
||||
await AsyncStorage.setItem(TOKEN_KEY, 'token-1');
|
||||
|
||||
const { result } = renderAuth({ initialToken: 'token-1', queryClient });
|
||||
act(() => result.current.signOut());
|
||||
|
||||
expect(result.current.token).toBeNull();
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
expect(queryClient.getQueryData(queryKeys.user)).toBeUndefined();
|
||||
await waitFor(async () => expect(await AsyncStorage.getItem(TOKEN_KEY)).toBeNull());
|
||||
});
|
||||
|
||||
it('skips the storage bootstrap when given an initialToken', () => {
|
||||
const getItemSpy = jest.spyOn(AsyncStorage, 'getItem');
|
||||
|
||||
const { result } = renderAuth({ initialToken: 'token-x' });
|
||||
|
||||
// Synchronously ready: no gate, no storage reads
|
||||
expect(result.current.token).toBe('token-x');
|
||||
expect(getItemSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { migrateReduxPersistAuth } from '@/auth/migrateFromReduxPersist';
|
||||
import { getStoredToken, setStoredToken } from '@/auth/tokenStorage';
|
||||
import { queryKeys } from '@/queries/queryKeys';
|
||||
|
||||
interface AuthContextValue {
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
signIn: (token: string) => void;
|
||||
signOut: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: React.ReactNode;
|
||||
/** Test-only: start with this token and skip the storage bootstrap. */
|
||||
initialToken?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the auth token. Renders nothing until the stored session is known.
|
||||
* Must sit under the query provider.
|
||||
*/
|
||||
export function AuthProvider({ children, initialToken }: AuthProviderProps) {
|
||||
const skipBootstrap = initialToken !== undefined;
|
||||
const [token, setToken] = useState<string | null>(initialToken ?? null);
|
||||
const [ready, setReady] = useState(skipBootstrap);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (skipBootstrap) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const migrated = await migrateReduxPersistAuth();
|
||||
if (migrated.user) {
|
||||
queryClient.setQueryData(queryKeys.user, migrated.user);
|
||||
}
|
||||
let storedToken: string | null = null;
|
||||
try {
|
||||
storedToken = await getStoredToken();
|
||||
} catch {
|
||||
// Unreadable storage means logged out for this launch
|
||||
}
|
||||
if (!cancelled) {
|
||||
setToken(storedToken);
|
||||
setReady(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [skipBootstrap, queryClient]);
|
||||
|
||||
const signIn = useCallback((newToken: string) => {
|
||||
setToken(newToken);
|
||||
setStoredToken(newToken).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(() => {
|
||||
setToken(null);
|
||||
setStoredToken(null).catch(() => {});
|
||||
// So an in-flight /api/me/ can't resurrect the user after logout
|
||||
queryClient.cancelQueries({ queryKey: queryKeys.user }).catch(() => {});
|
||||
queryClient.removeQueries({ queryKey: queryKeys.user });
|
||||
}, [queryClient]);
|
||||
|
||||
const value = useMemo(() => ({ token, isAuthenticated: token !== null, signIn, signOut }), [token, signIn, signOut]);
|
||||
|
||||
if (!ready) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const value = useContext(AuthContext);
|
||||
if (!value) {
|
||||
throw new Error('useAuth must be used inside an AuthProvider');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
import { migrateReduxPersistAuth } from '@/auth/migrateFromReduxPersist';
|
||||
|
||||
const TOKEN_KEY = 'cnapp.authToken';
|
||||
const PERSIST_KEY = 'persist:root';
|
||||
|
||||
const grisen = { username: 'grisen', email: 'grisen@example.test', is_member: true };
|
||||
|
||||
// redux-persist double-encodes: each value in the blob is its own JSON document
|
||||
const persistBlob = (fields: Record<string, unknown>) =>
|
||||
JSON.stringify(Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, JSON.stringify(value)])));
|
||||
|
||||
const seedBlob = (fields: Record<string, unknown>) =>
|
||||
AsyncStorage.setItem(PERSIST_KEY, persistBlob({ _persist: { version: -1, rehydrated: true }, ...fields }));
|
||||
|
||||
beforeEach(async () => {
|
||||
// The AsyncStorage mock's methods are shared jest.fn()s; clear their history
|
||||
jest.clearAllMocks();
|
||||
await AsyncStorage.clear();
|
||||
});
|
||||
|
||||
it('reports logged out when no redux-persist blob exists', async () => {
|
||||
await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: null, user: null });
|
||||
|
||||
expect(await AsyncStorage.getItem(TOKEN_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('moves the token to the new key and returns the cached user', async () => {
|
||||
await seedBlob({ userToken: '9a8b', user: grisen });
|
||||
|
||||
await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: '9a8b', user: grisen });
|
||||
|
||||
expect(await AsyncStorage.getItem(TOKEN_KEY)).toBe('9a8b');
|
||||
expect(await AsyncStorage.getItem(PERSIST_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('treats a malformed outer blob as logged out and removes it', async () => {
|
||||
await AsyncStorage.setItem(PERSIST_KEY, 'not-json{');
|
||||
|
||||
await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: null, user: null });
|
||||
|
||||
expect(await AsyncStorage.getItem(TOKEN_KEY)).toBeNull();
|
||||
expect(await AsyncStorage.getItem(PERSIST_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('treats a blob without userToken as logged out and removes it', async () => {
|
||||
await seedBlob({ user: grisen });
|
||||
|
||||
await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: null, user: null });
|
||||
|
||||
expect(await AsyncStorage.getItem(PERSIST_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('treats a logged out blob (userToken null) as logged out and removes it', async () => {
|
||||
await seedBlob({ userToken: null });
|
||||
|
||||
await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: null, user: null });
|
||||
|
||||
expect(await AsyncStorage.getItem(TOKEN_KEY)).toBeNull();
|
||||
expect(await AsyncStorage.getItem(PERSIST_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('treats a non-string token as logged out and removes the blob', async () => {
|
||||
await seedBlob({ userToken: 42 });
|
||||
|
||||
await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: null, user: null });
|
||||
|
||||
expect(await AsyncStorage.getItem(PERSIST_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('treats an unparseable inner token as logged out and removes the blob', async () => {
|
||||
await AsyncStorage.setItem(PERSIST_KEY, JSON.stringify({ userToken: 'not{json' }));
|
||||
|
||||
await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: null, user: null });
|
||||
|
||||
expect(await AsyncStorage.getItem(PERSIST_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns logged out and touches nothing when storage is unreadable', async () => {
|
||||
await seedBlob({ userToken: '9a8b' });
|
||||
jest.spyOn(AsyncStorage, 'getItem').mockRejectedValueOnce(new Error('storage unreadable'));
|
||||
const removeSpy = jest.spyOn(AsyncStorage, 'removeItem');
|
||||
|
||||
await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: null, user: null });
|
||||
|
||||
expect(removeSpy).not.toHaveBeenCalled();
|
||||
expect(await AsyncStorage.getItem(PERSIST_KEY)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps an already-migrated token, without seeding, and still removes the blob', async () => {
|
||||
await AsyncStorage.setItem(TOKEN_KEY, 'existing-token');
|
||||
await seedBlob({ userToken: 'old-token', user: grisen });
|
||||
|
||||
await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: 'existing-token', user: null });
|
||||
|
||||
expect(await AsyncStorage.getItem(TOKEN_KEY)).toBe('existing-token');
|
||||
expect(await AsyncStorage.getItem(PERSIST_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('migrates dev fake tokens verbatim', async () => {
|
||||
await seedBlob({ userToken: 'dev-fake-token:member' });
|
||||
|
||||
const result = await migrateReduxPersistAuth();
|
||||
|
||||
expect(result.token).toBe('dev-fake-token:member');
|
||||
expect(await AsyncStorage.getItem(TOKEN_KEY)).toBe('dev-fake-token:member');
|
||||
});
|
||||
|
||||
it('keeps the token when the user blob is unparseable', async () => {
|
||||
await AsyncStorage.setItem(PERSIST_KEY, JSON.stringify({ userToken: JSON.stringify('9a8b'), user: 'not{json' }));
|
||||
|
||||
await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: '9a8b', user: null });
|
||||
|
||||
expect(await AsyncStorage.getItem(TOKEN_KEY)).toBe('9a8b');
|
||||
expect(await AsyncStorage.getItem(PERSIST_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the blob for a retry when the token cannot be stored', async () => {
|
||||
await seedBlob({ userToken: '9a8b' });
|
||||
jest.spyOn(AsyncStorage, 'setItem').mockRejectedValueOnce(new Error('disk full'));
|
||||
|
||||
await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: null, user: null });
|
||||
|
||||
expect(await AsyncStorage.getItem(PERSIST_KEY)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('never throws when removing the blob fails', async () => {
|
||||
await seedBlob({ userToken: '9a8b' });
|
||||
jest.spyOn(AsyncStorage, 'removeItem').mockRejectedValueOnce(new Error('storage error'));
|
||||
|
||||
await expect(migrateReduxPersistAuth()).resolves.toMatchObject({ token: '9a8b' });
|
||||
|
||||
expect(await AsyncStorage.getItem(TOKEN_KEY)).toBe('9a8b');
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
import { getStoredToken, setStoredToken } from '@/auth/tokenStorage';
|
||||
import type { UserType } from '@/types/data';
|
||||
|
||||
const REDUX_PERSIST_KEY = 'persist:root';
|
||||
|
||||
interface MigratedAuth {
|
||||
token: string | null;
|
||||
user: UserType | null;
|
||||
}
|
||||
|
||||
const LOGGED_OUT: MigratedAuth = { token: null, user: null };
|
||||
|
||||
/**
|
||||
* One-shot move of the session out of the redux-persist blob into
|
||||
* tokenStorage.
|
||||
* The blob is only removed once the token is safely stored or provably
|
||||
* absent, so failures retry next launch.
|
||||
*/
|
||||
export async function migrateReduxPersistAuth(): Promise<MigratedAuth> {
|
||||
let blob: string | null;
|
||||
try {
|
||||
blob = await AsyncStorage.getItem(REDUX_PERSIST_KEY);
|
||||
} catch {
|
||||
return LOGGED_OUT;
|
||||
}
|
||||
if (blob === null) {
|
||||
return LOGGED_OUT;
|
||||
}
|
||||
|
||||
// The blob is a JSON object whose values are each their own JSON document
|
||||
let token: string | null = null;
|
||||
let user: UserType | null = null;
|
||||
try {
|
||||
const root = JSON.parse(blob);
|
||||
const innerToken = JSON.parse(root.userToken);
|
||||
if (typeof innerToken === 'string') {
|
||||
token = innerToken;
|
||||
try {
|
||||
const innerUser = JSON.parse(root.user);
|
||||
if (innerUser && typeof innerUser === 'object') {
|
||||
user = innerUser;
|
||||
}
|
||||
} catch {
|
||||
// The token still migrates; a refetch covers the user
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Unparseable blob or no token → logged out
|
||||
}
|
||||
|
||||
if (token !== null) {
|
||||
try {
|
||||
const existingToken = await getStoredToken();
|
||||
if (existingToken !== null) {
|
||||
// An earlier run already migrated; don't overwrite, don't seed
|
||||
token = existingToken;
|
||||
user = null;
|
||||
} else {
|
||||
await setStoredToken(token);
|
||||
}
|
||||
} catch {
|
||||
// The token never reached its new home; keep the blob for a retry
|
||||
return LOGGED_OUT;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await AsyncStorage.removeItem(REDUX_PERSIST_KEY);
|
||||
} catch {
|
||||
// Re-runs next launch
|
||||
}
|
||||
|
||||
return { token, user };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
import { getStoredToken, setStoredToken } from '@/auth/tokenStorage';
|
||||
|
||||
beforeEach(async () => {
|
||||
await AsyncStorage.clear();
|
||||
});
|
||||
|
||||
it('returns null when no token is stored', async () => {
|
||||
expect(await getStoredToken()).toBeNull();
|
||||
});
|
||||
|
||||
it('round-trips a token through AsyncStorage', async () => {
|
||||
await setStoredToken('token-123');
|
||||
|
||||
expect(await getStoredToken()).toBe('token-123');
|
||||
expect(await AsyncStorage.getItem('cnapp.authToken')).toBe('token-123');
|
||||
});
|
||||
|
||||
it('removes the token when set to null', async () => {
|
||||
await setStoredToken('token-123');
|
||||
await setStoredToken(null);
|
||||
|
||||
expect(await getStoredToken()).toBeNull();
|
||||
expect(await AsyncStorage.getItem('cnapp.authToken')).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
const TOKEN_KEY = 'cnapp.authToken';
|
||||
|
||||
export async function getStoredToken(): Promise<string | null> {
|
||||
return AsyncStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export async function setStoredToken(token: string | null): Promise<void> {
|
||||
if (token === null) {
|
||||
await AsyncStorage.removeItem(TOKEN_KEY);
|
||||
} else {
|
||||
await AsyncStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user