diff --git a/app/(app)/(tabs)/profile.tsx b/app/(app)/(tabs)/profile.tsx
index f620b6a..c008fc7 100644
--- a/app/(app)/(tabs)/profile.tsx
+++ b/app/(app)/(tabs)/profile.tsx
@@ -15,6 +15,12 @@ import {
spacing,
} from '@/design/tokens';
import { useSession } from '@/providers/SessionProvider';
+import { useState } from 'react';
+import { InlineError } from '@/components/feedback/InlineError';
+import { ConnectionBanner } from '@/components/feedback/ConnectionBanner';
+import { Spinner } from '@/components/ui/Spinner';
+// Temporary AppError import until API is merged
+import { AppError } from '@/services/storage/TokenStore';
type Badge = {
id: string;
@@ -139,6 +145,36 @@ export default function ProfileRoute() {
session.status === 'authenticated' ? session.user.role : 'student';
const initial = displayName.charAt(0).toUpperCase();
const earnedCount = BADGES.filter((b) => b.earned).length;
+ const [signingOut, setSigningOut] = useState(false);
+ const [error, setError] = useState('');
+ const [connectionError, setConnectionError] = useState('');
+
+ const signOutPressed = async () => {
+ setSigningOut(true);
+ setError('');
+ setConnectionError('');
+
+ try {
+ await signOut();
+ } catch (error) {
+ setSigningOut(false);
+ if (error instanceof AppError) {
+ switch (error.code) {
+ case 'NETWORK_ERROR':
+ setConnectionError(error.message);
+ break;
+ case 'REQUEST_TIMEOUT':
+ setConnectionError(error.message);
+ break;
+ default:
+ setError(error.message);
+ break;
+ }
+ } else {
+ setError('Sign out failed. Please try again.');
+ }
+ }
+ };
return (
@@ -258,15 +294,26 @@ export default function ProfileRoute() {
[
s.signOutBtn,
pressed && s.signOutBtnPressed,
]}
>
- Sign out
+ {signingOut ? (
+
+ ) : (
+ Sign out
+ )}
+ {error && }
+
+ {connectionError && (
+
+ )}
+
diff --git a/app/(public)/sign-in.tsx b/app/(public)/sign-in.tsx
index f3c3dfe..a5584aa 100644
--- a/app/(public)/sign-in.tsx
+++ b/app/(public)/sign-in.tsx
@@ -26,17 +26,29 @@ import {
radii,
spacing,
} from '@/design/tokens';
+
import { useSession } from '@/providers/SessionProvider';
+import { Spinner } from '@/components/ui/Spinner';
+import { InlineError } from '@/components/feedback/InlineError';
+import { ConnectionBanner } from '@/components/feedback/ConnectionBanner';
+import { isNonEmptyString } from '@/utils/isNonEmptyString';
+// Temporary AppError import until API is merged
+import { AppError } from '@/services/storage/TokenStore';
const chessPiecePattern = require('@/assets/images/chess-piece-pattern.png');
export default function SignInRoute() {
- const { mockSignInAs } = useSession();
+ const { mockSignInAs, signIn, session } = useSession();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [usernameFocused, setUsernameFocused] = useState(false);
const [passwordFocused, setPasswordFocused] = useState(false);
const [showPassword, setShowPassword] = useState(false);
+ const [signingIn, setSigningIn] = useState(false);
+ const [error, setError] = useState(
+ session.status === 'expired' ? session.expiredMessage : '',
+ );
+ const [connectionError, setConnectionError] = useState('');
const [btnScale] = useState(() => new Animated.Value(1));
const pressIn = () =>
@@ -54,6 +66,41 @@ export default function SignInRoute() {
bounciness: 4,
}).start();
+ const signInPressed = async () => {
+ setError('');
+ setConnectionError('');
+ setSigningIn(true);
+
+ if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
+ setSigningIn(false);
+
+ setError('Please enter your username and password');
+
+ return;
+ }
+
+ try {
+ await signIn(username, password);
+ } catch (error) {
+ setSigningIn(false);
+ if (error instanceof AppError) {
+ switch (error.code) {
+ case 'NETWORK_ERROR':
+ setConnectionError(error.message);
+ break;
+ case 'REQUEST_TIMEOUT':
+ setConnectionError(error.message);
+ break;
+ default:
+ setError(error.message);
+ break;
+ }
+ } else {
+ setError('Sign in failed. Please try again.');
+ }
+ }
+ };
+
return (
{/* ── Patterned Background ──────────────── */}
@@ -94,6 +141,7 @@ export default function SignInRoute() {
👤
setUsernameFocused(false)}
@@ -110,6 +158,7 @@ export default function SignInRoute() {
🔒
setPasswordFocused(false)}
@@ -142,12 +191,23 @@ export default function SignInRoute() {
accessibilityRole="button"
onPressIn={pressIn}
onPressOut={pressOut}
+ onPress={signInPressed}
+ disabled={signingIn}
style={s.enterBtn}
>
- Sign In →
+ {signingIn ? (
+
+ ) : (
+ Sign In →
+ )}
+ {error && }
+
+ {connectionError && (
+
+ )}
New here?
@@ -157,34 +217,42 @@ export default function SignInRoute() {
{/* ── Dev Quick-Login ───────────────────── */}
-
-
-
- DEV ONLY
-
-
-
- {(['student', 'mentor'] as const).map((role) => (
- mockSignInAs(role)}
- style={({ pressed }) => [
- s.devBtn,
- pressed && s.devBtnPressed,
- ]}
- >
-
- {role === 'student' ? '🎒' : '🎓'}
-
-
- {role === 'student' ? 'Student' : 'Mentor'}
-
-
- ))}
+ {__DEV__ && (
+
+
+
+ DEV ONLY
+
+
+
+ {(['student', 'mentor'] as const).map((role) => (
+ {
+ void mockSignInAs(role).catch(() => {
+ setError(
+ 'Unable to start a test session. Please try again.',
+ );
+ });
+ }}
+ style={({ pressed }) => [
+ s.devBtn,
+ pressed && s.devBtnPressed,
+ ]}
+ >
+
+ {role === 'student' ? '🎒' : '🎓'}
+
+
+ {role === 'student' ? 'Student' : 'Mentor'}
+
+
+ ))}
+
-
+ )}
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 60995a0..4590d13 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -8,6 +8,8 @@ import { QueryProvider } from '@/providers/QueryProvider';
import '@/config/env';
+import ErrorScreen from '@/features/auth/components/error-screen';
+
export default function RootLayout() {
return (
@@ -20,7 +22,7 @@ export default function RootLayout() {
}
function RootNavigator() {
- const { session } = useSession();
+ const { session, restoreSession, signOut } = useSession();
if (session.status === 'bootstrapping') {
return (
@@ -30,9 +32,31 @@ function RootNavigator() {
);
}
+ // If an error occured on launch while checking if a user has a session.
+ if (session.status === 'error') {
+ // Get error message from session
+ const message = session.errorMessage;
+ return (
+
+
+
+ );
+ }
+
return (
-
+
diff --git a/src/features/auth/components/error-screen.tsx b/src/features/auth/components/error-screen.tsx
new file mode 100644
index 0000000..71a75d4
--- /dev/null
+++ b/src/features/auth/components/error-screen.tsx
@@ -0,0 +1,20 @@
+import { Button } from '@/components/ui/Button';
+import { InlineError } from '@/components/feedback/InlineError';
+
+export default function ErrorScreen({
+ message,
+ restoreSession,
+ signOut,
+}: {
+ message: string;
+ restoreSession: () => Promise;
+ signOut: () => Promise;
+}) {
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/src/providers/SessionProvider.tsx b/src/providers/SessionProvider.tsx
index be6dd66..100a672 100644
--- a/src/providers/SessionProvider.tsx
+++ b/src/providers/SessionProvider.tsx
@@ -1,35 +1,60 @@
import {
createContext,
type PropsWithChildren,
+ useCallback,
useContext,
useEffect,
useReducer,
+ useRef,
} from 'react';
import {
initialSessionState,
sessionReducer,
type SessionState,
- type SessionUser,
type UserRole,
} from '@/state/session';
+// Temporary AppError import until API is merged
+import { AppError, tokenStore } from '@/services/storage/TokenStore';
+
+import { useQueryClient } from '@tanstack/react-query';
+import {
+ authGateway,
+ studentToken,
+ mentorToken,
+} from '@/services/auth/AuthGateway';
+import { decodeJWT } from '@/services/auth/decodeJWT';
+import type { AuthGateway } from '@/types/authGateway';
+
type SessionContextValue = {
session: SessionState;
- mockSignInAs: (role: UserRole) => void;
- signOut: () => void;
+ mockSignInAs: (role: UserRole) => Promise;
+ signIn: (username: string, password: string) => Promise;
+ signOut: () => Promise;
+ expiredSession: () => Promise;
+ restoreSession: () => Promise;
};
-const mockUsers: Record = {
- student: {
- id: 'mock-student',
- displayName: 'Mock Student',
- role: 'student',
+const fakeAuthGateway: AuthGateway = {
+ async login(username) {
+ if (username === 'student') return studentToken;
+ if (username === 'mentor') return mentorToken;
+ throw new AppError('UNKNOWN', 'Unknown test account.');
+ },
+ async logout() {
+ try {
+ await tokenStore.deleteToken();
+ } catch (error) {
+ if (error instanceof AppError) {
+ throw error;
+ }
+
+ throw new AppError('UNKNOWN', 'Sign out failed. Please try again.');
+ }
},
- mentor: {
- id: 'mock-mentor',
- displayName: 'Mock Mentor',
- role: 'mentor',
+ async validate(token) {
+ return token === studentToken || token === mentorToken;
},
};
@@ -38,27 +63,213 @@ const SessionContext = createContext(null);
export function SessionProvider({ children }: PropsWithChildren) {
const [session, dispatch] = useReducer(sessionReducer, initialSessionState);
- useEffect(() => {
- dispatch({ type: 'bootstrapCompleted' });
- }, []);
+ // Used to clear cache when signing out.
+ const queryClient = useQueryClient();
+
+ const expiringRef = useRef(false);
+ const expiredSession = useCallback(async () => {
+ if (expiringRef.current) {
+ return;
+ }
+
+ expiringRef.current = true;
+
+ // This ensures if deleteToken fails cancelQueries still gets called
+ try {
+ await queryClient.cancelQueries();
+ queryClient.clear();
+
+ await tokenStore.deleteToken();
+
+ dispatch({
+ type: 'expired',
+ expiredMessage: 'Your session has expired. Please sign in again.',
+ });
+ } catch {
+ dispatch({
+ type: 'error',
+ errorMessage:
+ 'Your session has expired, but we could not sign you out. Please sign out.',
+ });
+ } finally {
+ expiringRef.current = false;
+ }
+ }, [queryClient]);
+
+ const restoreRef = useRef(false);
+
+ const restoreSession = useCallback(async (): Promise => {
+ // This prevents a user from pressing on the retry button multiple times.
+ // Which will cause this function to run multiple times.
+ if (restoreRef.current) {
+ return;
+ }
+
+ restoreRef.current = true;
+
+ dispatch({ type: 'restore' });
+
+ try {
+ const token = await tokenStore.getToken();
+
+ if (token) {
+ // For dev testing
+ const isFakeToken =
+ __DEV__ && (token === studentToken || token === mentorToken);
+ const validToken = isFakeToken
+ ? await fakeAuthGateway.validate(token)
+ : await authGateway.validate(token);
+
+ if (validToken) {
+ const decoded = decodeJWT(token);
+
+ if (
+ typeof decoded.firstName !== 'string' ||
+ typeof decoded.lastName !== 'string' ||
+ typeof decoded.username !== 'string' ||
+ (decoded.role !== 'student' && decoded.role !== 'mentor')
+ ) {
+ throw new Error('Missing user claims');
+ }
+
+ dispatch({
+ type: 'authenticated',
+ user: {
+ username: decoded.username,
+ displayName: decoded.firstName + ' ' + decoded.lastName,
+ role: decoded.role,
+ },
+ });
+ } else {
+ // token is invalid. Session state is expired
+ await expiredSession();
+ }
+ } else {
+ // There is no saved token / previous session so state is anonymous
+ dispatch({ type: 'anonymous' });
+ }
+ } catch (error) {
+ // Show an error state. Ask to refetch, since there may be a valid token.
+ // Error is caused either by secure token get failing, or connection failure when validating.
+ if (error instanceof AppError && error.code === 'SESSION_EXPIRED') {
+ await expiredSession();
+ } else {
+ const message =
+ error instanceof AppError
+ ? error.message
+ : 'Failed to restore session. Please try again.';
+
+ dispatch({
+ type: 'error',
+ errorMessage: message,
+ });
+ }
+ } finally {
+ restoreRef.current = false;
+ }
+ }, [expiredSession]);
+
+ async function mockSignInAs(role: UserRole) {
+ if (!__DEV__) return;
+
+ const token = await fakeAuthGateway.login(role, '');
+
+ const decoded = decodeJWT(token);
+
+ if (
+ typeof decoded.firstName !== 'string' ||
+ typeof decoded.lastName !== 'string' ||
+ typeof decoded.username !== 'string' ||
+ (decoded.role !== 'student' && decoded.role !== 'mentor')
+ ) {
+ throw new Error('Missing user claims');
+ }
+
+ await tokenStore.setToken(token);
- function mockSignInAs(role: UserRole) {
dispatch({
type: 'authenticated',
- user: mockUsers[role],
+ user: {
+ username: decoded.username,
+ displayName: decoded.firstName + ' ' + decoded.lastName,
+ role: decoded.role,
+ },
});
}
- function signOut() {
- dispatch({ type: 'signedOut' });
+ async function signIn(username: string, password: string): Promise {
+ try {
+ const token = await authGateway.login(username, password);
+
+ if (!token) {
+ // Empty token recieved
+ throw new AppError(
+ 'INVALID_RESPONSE',
+ 'The server returned an invalid response.',
+ );
+ }
+
+ const decoded = decodeJWT(token);
+
+ if (
+ typeof decoded.firstName !== 'string' ||
+ typeof decoded.lastName !== 'string' ||
+ typeof decoded.username !== 'string' ||
+ (decoded.role !== 'student' && decoded.role !== 'mentor')
+ ) {
+ throw new Error('Missing user claims');
+ }
+
+ await tokenStore.setToken(token);
+
+ dispatch({
+ type: 'authenticated',
+ user: {
+ username: decoded.username,
+ displayName: decoded.firstName + ' ' + decoded.lastName,
+ role: decoded.role,
+ },
+ });
+ } catch (error) {
+ if (error instanceof AppError) {
+ throw error;
+ } else {
+ throw new AppError('UNKNOWN', 'Sign in failed. Please try again.');
+ }
+ }
+ }
+
+ async function signOut() {
+ try {
+ await queryClient.cancelQueries();
+ queryClient.clear();
+
+ await authGateway.logout();
+
+ dispatch({ type: 'signedOut' });
+ } catch (error) {
+ if (error instanceof AppError) {
+ throw error;
+ } else {
+ throw new AppError('UNKNOWN', 'Sign out failed. Please try again.');
+ }
+ }
}
+ // On launch check if a user has a valid session
+ useEffect(() => {
+ restoreSession();
+ }, [restoreSession]);
+
return (
{children}
diff --git a/src/services/auth/AuthGateway.ts b/src/services/auth/AuthGateway.ts
new file mode 100644
index 0000000..4858a16
--- /dev/null
+++ b/src/services/auth/AuthGateway.ts
@@ -0,0 +1,91 @@
+// Temporary AppError import until API is merged
+import { AppError, tokenStore } from '../storage/TokenStore';
+import { AuthGateway, LoginResponse } from '@/types/authGateway';
+
+export const studentToken =
+ 'eyJ0eXAiOiJKV1QifQ.eyJ1c2VybmFtZSI6Im1vY2stc3R1ZGVudCIsImZpcnN0TmFtZSI6Ik1vY2siLCJsYXN0TmFtZSI6IlN0dWRlbnQiLCJyb2xlIjoic3R1ZGVudCJ9.fake';
+
+export const mentorToken =
+ 'eyJ0eXAiOiJKV1QifQ.eyJ1c2VybmFtZSI6Im1vY2stbWVudG9yIiwiZmlyc3ROYW1lIjoiTW9jayIsImxhc3ROYW1lIjoiTWVudG9yIiwicm9sZSI6Im1lbnRvciJ9.fake';
+
+// Fake apiClient until Brandons API client is merged.
+export const fakeApiClient = {
+ async post(
+ endpoint: string,
+ body: B,
+ options?: { headers?: Record },
+ ): Promise {
+ if (!__DEV__) {
+ throw new AppError('UNKNOWN', 'Sign in is unavailable.');
+ }
+
+ if (endpoint === '/auth/login') {
+ const username = (body as { username?: string })?.username;
+ if (username === 'student') return { token: studentToken } as T;
+ if (username === 'mentor') return { token: mentorToken } as T;
+ throw new AppError('UNKNOWN', 'Invalid username or password.');
+ }
+
+ if (endpoint === '/auth/validate') {
+ const bearer = options?.headers?.Authorization;
+ if (
+ bearer === `Bearer ${studentToken}` ||
+ bearer === `Bearer ${mentorToken}`
+ ) {
+ return null; // Successful 200 with no response body
+ }
+ throw new AppError('SESSION_EXPIRED', 'Your session has expired.', 401);
+ }
+
+ throw new AppError('UNKNOWN', 'Unsupported authentication request.');
+ },
+};
+
+export function createAuthGateway(client: typeof fakeApiClient): AuthGateway {
+ return {
+ async login(username, password) {
+ try {
+ const response: LoginResponse | null = await client.post(
+ '/auth/login',
+ { username, password },
+ );
+
+ return response?.token ?? '';
+ } catch (error) {
+ throw error;
+ }
+ },
+
+ async logout() {
+ try {
+ await tokenStore.deleteToken();
+ } catch (error) {
+ if (error instanceof AppError) {
+ throw error;
+ }
+
+ throw new AppError('UNKNOWN', 'Sign out failed. Please try again.');
+ }
+ },
+
+ async validate(token) {
+ try {
+ await client.post('/auth/validate', undefined, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ return true;
+ } catch (error) {
+ if (
+ error instanceof AppError &&
+ error.code === 'SESSION_EXPIRED' &&
+ error.status === 401
+ ) {
+ return false;
+ }
+ throw error;
+ }
+ },
+ };
+}
+
+export const authGateway = createAuthGateway(fakeApiClient);
diff --git a/src/services/auth/decodeJWT.ts b/src/services/auth/decodeJWT.ts
new file mode 100644
index 0000000..b2e8737
--- /dev/null
+++ b/src/services/auth/decodeJWT.ts
@@ -0,0 +1,20 @@
+export function decodeJWT(token: string): Record {
+ const parts = token.split('.');
+ if (parts.length !== 3 || !parts[1]) throw new Error('Invalid JWT');
+
+ const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
+ const bytes = atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, '='));
+ const json = decodeURIComponent(
+ Array.from(
+ bytes,
+ (char) => `%${char.charCodeAt(0).toString(16).padStart(2, '0')}`,
+ ).join(''),
+ );
+ const payload: unknown = JSON.parse(json);
+
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
+ throw new Error('Invalid JWT payload');
+ }
+
+ return payload as Record;
+}
diff --git a/src/services/storage/TokenStore.ts b/src/services/storage/TokenStore.ts
new file mode 100644
index 0000000..5bc1ef0
--- /dev/null
+++ b/src/services/storage/TokenStore.ts
@@ -0,0 +1,55 @@
+import * as SecureStore from 'expo-secure-store';
+
+// Temporary AppError decloration here until Brandons API code is merged.
+export type AppErrorCode =
+ | 'NETWORK_ERROR'
+ | 'REQUEST_TIMEOUT'
+ | 'REQUEST_CANCEL'
+ | 'INVALID_RESPONSE'
+ | 'SESSION_EXPIRED'
+ | 'PERMISSION_DENIED'
+ | 'SERVER_ERROR'
+ | 'UNKNOWN';
+
+export class AppError extends Error {
+ constructor(
+ public readonly code: AppErrorCode,
+ message: string,
+ public readonly status?: number, // Pass in response.status
+ ) {
+ super(message);
+ this.name = 'AppError';
+ }
+}
+
+const TOKEN_KEY = 'jwt-token';
+
+interface TokenStore {
+ getToken: () => Promise;
+ setToken: (token: string) => Promise;
+ deleteToken: () => Promise;
+}
+
+export const tokenStore: TokenStore = {
+ async getToken() {
+ try {
+ return await SecureStore.getItemAsync(TOKEN_KEY);
+ } catch {
+ throw new AppError('UNKNOWN', 'Failed to get token please retry.');
+ }
+ },
+ async setToken(token: string) {
+ try {
+ await SecureStore.setItemAsync(TOKEN_KEY, token);
+ } catch {
+ throw new AppError('UNKNOWN', 'Failed to set token please try again.');
+ }
+ },
+ async deleteToken() {
+ try {
+ await SecureStore.deleteItemAsync(TOKEN_KEY);
+ } catch {
+ throw new AppError('UNKNOWN', 'Failed to delete token please try again.');
+ }
+ },
+};
diff --git a/src/state/session.ts b/src/state/session.ts
index 40adc05..6787934 100644
--- a/src/state/session.ts
+++ b/src/state/session.ts
@@ -1,7 +1,7 @@
export type UserRole = 'student' | 'mentor';
export type SessionUser = {
- readonly id: string;
+ readonly username: string;
readonly displayName: string;
readonly role: UserRole;
};
@@ -16,6 +16,14 @@ export type SessionState =
| {
readonly status: 'authenticated';
readonly user: SessionUser;
+ }
+ | {
+ readonly status: 'expired';
+ readonly expiredMessage: string;
+ }
+ | {
+ readonly status: 'error';
+ readonly errorMessage: string;
};
export type SessionAction =
@@ -28,6 +36,20 @@ export type SessionAction =
}
| {
readonly type: 'signedOut';
+ }
+ | {
+ readonly type: 'anonymous';
+ }
+ | {
+ readonly type: 'error';
+ readonly errorMessage: string;
+ }
+ | {
+ readonly type: 'restore';
+ }
+ | {
+ readonly type: 'expired';
+ readonly expiredMessage: string;
};
export const initialSessionState: SessionState = {
@@ -48,6 +70,18 @@ export function sessionReducer(
};
case 'signedOut':
return state.status === 'anonymous' ? state : { status: 'anonymous' };
+ case 'anonymous':
+ return { status: 'anonymous' };
+ case 'expired':
+ return { status: 'expired', expiredMessage: action.expiredMessage };
+ case 'error':
+ return {
+ status: 'error',
+ errorMessage: action.errorMessage,
+ };
+ case 'restore':
+ return { status: 'bootstrapping' };
+
default: {
const exhaustiveAction: never = action;
return exhaustiveAction;
diff --git a/src/types/authGateway.ts b/src/types/authGateway.ts
new file mode 100644
index 0000000..bcd7fcd
--- /dev/null
+++ b/src/types/authGateway.ts
@@ -0,0 +1,7 @@
+export interface AuthGateway {
+ login: (username: string, password: string) => Promise;
+ logout: () => Promise;
+ validate: (token: string) => Promise;
+}
+
+export type LoginResponse = { token: string };
diff --git a/test/SessionProvider.test.tsx b/test/SessionProvider.test.tsx
index 60df8d2..8b43396 100644
--- a/test/SessionProvider.test.tsx
+++ b/test/SessionProvider.test.tsx
@@ -6,6 +6,7 @@ import {
} from '@testing-library/react-native';
import { Button, Text, View } from 'react-native';
+import { QueryProvider } from '@/providers/QueryProvider';
import { SessionProvider, useSession } from '@/providers/SessionProvider';
function SessionTestHarness() {
@@ -31,9 +32,11 @@ function SessionTestHarness() {
describe('SessionProvider', () => {
it('finishes bootstrap as anonymous', async () => {
render(
-
-
- ,
+
+
+
+
+ ,
);
await waitFor(() => {
@@ -43,18 +46,22 @@ describe('SessionProvider', () => {
it('supports mocked student sign-in and sign-out', async () => {
render(
-
-
- ,
+
+
+
+
+ ,
);
+ await screen.findByText('anonymous');
+
fireEvent.press(
screen.getByRole('button', {
name: 'Mock student',
}),
);
- expect(screen.getByText('authenticated')).toBeOnTheScreen();
+ expect(await screen.findByText('authenticated')).toBeOnTheScreen();
expect(screen.getByText('student')).toBeOnTheScreen();
@@ -64,22 +71,26 @@ describe('SessionProvider', () => {
}),
);
- expect(screen.getByText('anonymous')).toBeOnTheScreen();
+ await screen.findByText('anonymous');
});
- it('supports mocked mentor sign-in', () => {
+ it('supports mocked mentor sign-in', async () => {
render(
-
-
- ,
+
+
+
+
+ ,
);
+ await screen.findByText('anonymous');
+
fireEvent.press(
screen.getByRole('button', {
name: 'Mock mentor',
}),
);
- expect(screen.getByText('mentor')).toBeOnTheScreen();
+ expect(await screen.findByText('mentor')).toBeOnTheScreen();
});
});
diff --git a/test/authentication.test.tsx b/test/authentication.test.tsx
new file mode 100644
index 0000000..f9dd67a
--- /dev/null
+++ b/test/authentication.test.tsx
@@ -0,0 +1,277 @@
+import { router } from 'expo-router';
+import {
+ act,
+ fireEvent,
+ renderRouter,
+ screen,
+ waitFor,
+} from 'expo-router/testing-library';
+import { render } from '@testing-library/react-native';
+import { QueryClientProvider } from '@tanstack/react-query';
+import { Button, Text, View } from 'react-native';
+
+import { authGateway } from '@/services/auth/AuthGateway';
+import { tokenStore } from '@/services/storage/TokenStore';
+import { createQueryClient } from '@/providers/QueryProvider';
+import { SessionProvider, useSession } from '@/providers/SessionProvider';
+import { renderAnonymous } from './fixtures/renderAnonymous';
+// Temporary AppError import until API is merged
+import { AppError } from '@/services/storage/TokenStore';
+
+const studentToken =
+ 'eyJ0eXAiOiJKV1QifQ.eyJ1c2VybmFtZSI6InRlc3Qtc3R1ZGVudCIsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IlN0dWRlbnQiLCJyb2xlIjoic3R1ZGVudCJ9.fake';
+
+function submitSignIn(username: string, password: string) {
+ fireEvent.changeText(screen.getByTestId('username-input'), username);
+ fireEvent.changeText(screen.getByTestId('password-input'), password);
+ fireEvent.press(screen.getByRole('button', { name: 'Sign in' }));
+}
+
+function MockScreen() {
+ const { session, mockSignInAs, signOut } = useSession();
+
+ return (
+
+ {session.status}
+
+ );
+}
+
+describe('authentication', () => {
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('Successful login stores the token.', async () => {
+ let savedToken: string | null = null;
+ const login = jest
+ .spyOn(authGateway, 'login')
+ .mockResolvedValue(studentToken);
+ const setToken = jest
+ .spyOn(tokenStore, 'setToken')
+ .mockImplementation(async (token) => {
+ savedToken = token;
+ });
+ jest
+ .spyOn(tokenStore, 'getToken')
+ .mockResolvedValueOnce(null)
+ .mockImplementation(async () => savedToken);
+
+ const renderedRouter = renderRouter('./app', { initialUrl: '/sign-in' });
+ await screen.findByText('Welcome back');
+ submitSignIn('User', 'password123');
+
+ await waitFor(() => expect(renderedRouter.getPathname()).toBe('/'));
+ expect(login).toHaveBeenCalledWith('User', 'password123');
+ expect(setToken).toHaveBeenCalledWith(studentToken);
+ expect(await tokenStore.getToken()).toBe(studentToken);
+ });
+
+ it('Invalid credentials show an error.', async () => {
+ const login = jest
+ .spyOn(authGateway, 'login')
+ .mockRejectedValue(
+ new AppError('UNKNOWN', 'Invalid username or password.'),
+ );
+ const renderedRouter = await renderAnonymous('/sign-in');
+ submitSignIn('User', 'wrong-password');
+
+ expect(
+ await screen.findByText('Invalid username or password.'),
+ ).toBeOnTheScreen();
+ expect(renderedRouter.getPathname()).toBe('/sign-in');
+ expect(login).toHaveBeenCalledTimes(1);
+ });
+
+ it.each([
+ ['', ''],
+ [' ', ' '],
+ ['User', ''],
+ ['', 'password123'],
+ ])('Blank fields are rejected (%s, %s).', async (username, password) => {
+ const login = jest.spyOn(authGateway, 'login');
+ const renderedRouter = await renderAnonymous('/sign-in');
+ submitSignIn(username, password);
+
+ expect(
+ await screen.findByText('Please enter your username and password'),
+ ).toBeOnTheScreen();
+ expect(renderedRouter.getPathname()).toBe('/sign-in');
+ expect(login).not.toHaveBeenCalled();
+ });
+
+ it('Connection failure shows a retryable error.', async () => {
+ const login = jest
+ .spyOn(authGateway, 'login')
+ .mockRejectedValueOnce(
+ new AppError('NETWORK_ERROR', 'Unable to connect to the server.'),
+ )
+ .mockResolvedValue(studentToken);
+ const renderedRouter = await renderAnonymous('/sign-in');
+ submitSignIn('User', 'password123');
+
+ expect(
+ await screen.findByText('Unable to connect to the server.'),
+ ).toBeOnTheScreen();
+ expect(renderedRouter.getPathname()).toBe('/sign-in');
+
+ fireEvent.press(screen.getByRole('button', { name: 'Sign in' }));
+ await waitFor(() => expect(renderedRouter.getPathname()).toBe('/'));
+ expect(login).toHaveBeenCalledTimes(2);
+ });
+
+ it('A stored valid token restores the session.', async () => {
+ const getToken = jest
+ .spyOn(tokenStore, 'getToken')
+ .mockResolvedValue(studentToken);
+ const validate = jest
+ .spyOn(authGateway, 'validate')
+ .mockResolvedValue(true);
+ const renderedRouter = renderRouter('./app', { initialUrl: '/sign-in' });
+
+ await waitFor(() => expect(renderedRouter.getPathname()).toBe('/'));
+ expect(screen.getByText('Quick Actions')).toBeOnTheScreen();
+ expect(getToken).toHaveBeenCalled();
+ expect(validate).toHaveBeenCalledWith(studentToken);
+ });
+
+ it('An expired token is deleted.', async () => {
+ let savedToken: string | null = studentToken;
+ jest
+ .spyOn(tokenStore, 'getToken')
+ .mockImplementation(async () => savedToken);
+ const deleteToken = jest
+ .spyOn(tokenStore, 'deleteToken')
+ .mockImplementation(async () => {
+ savedToken = null;
+ });
+ const validate = jest
+ .spyOn(authGateway, 'validate')
+ .mockResolvedValue(false);
+ const renderedRouter = renderRouter('./app', { initialUrl: '/sign-in' });
+
+ expect(
+ await screen.findByText(
+ 'Your session has expired. Please sign in again.',
+ ),
+ ).toBeOnTheScreen();
+ expect(renderedRouter.getPathname()).toBe('/sign-in');
+
+ expect(validate).toHaveBeenCalledWith(studentToken);
+ expect(deleteToken).toHaveBeenCalledTimes(1);
+ expect(await tokenStore.getToken()).toBeNull();
+ });
+
+ it('A validation connection failure does not immediately destroy a potentially valid token.', async () => {
+ const getToken = jest
+ .spyOn(tokenStore, 'getToken')
+ .mockResolvedValue(studentToken);
+ const deleteToken = jest.spyOn(tokenStore, 'deleteToken');
+ const validate = jest
+ .spyOn(authGateway, 'validate')
+ .mockRejectedValueOnce(
+ new AppError('NETWORK_ERROR', 'Unable to connect to the server.'),
+ )
+ .mockResolvedValue(true);
+ const renderedRouter = renderRouter('./app', { initialUrl: '/sign-in' });
+
+ expect(await screen.findByTestId('session-error-screen')).toBeOnTheScreen();
+ expect(deleteToken).not.toHaveBeenCalled();
+ fireEvent.press(screen.getByRole('button', { name: 'Retry' }));
+
+ await waitFor(() => expect(renderedRouter.getPathname()).toBe('/'));
+ expect(getToken).toHaveBeenCalledTimes(2);
+ expect(validate).toHaveBeenCalledTimes(2);
+ expect(deleteToken).not.toHaveBeenCalled();
+ });
+
+ it('Sign Out deletes the token.', async () => {
+ let savedToken: string | null = null;
+ jest.spyOn(authGateway, 'login').mockResolvedValue(studentToken);
+ jest.spyOn(tokenStore, 'setToken').mockImplementation(async (token) => {
+ savedToken = token;
+ });
+ jest
+ .spyOn(tokenStore, 'getToken')
+ .mockResolvedValueOnce(null)
+ .mockImplementation(async () => savedToken);
+ const deleteToken = jest
+ .spyOn(tokenStore, 'deleteToken')
+ .mockImplementation(async () => {
+ savedToken = null;
+ });
+ const renderedRouter = renderRouter('./app', { initialUrl: '/sign-in' });
+ await screen.findByText('Welcome back');
+ submitSignIn('User', 'password123');
+ await waitFor(() => expect(renderedRouter.getPathname()).toBe('/'));
+
+ act(() => router.push('/profile'));
+ await waitFor(() => expect(renderedRouter.getPathname()).toBe('/profile'));
+ fireEvent.press(screen.getByRole('button', { name: 'Sign out' }));
+
+ await waitFor(() => expect(renderedRouter.getPathname()).toBe('/sign-in'));
+ expect(deleteToken).toHaveBeenCalledTimes(1);
+ expect(await tokenStore.getToken()).toBeNull();
+ });
+
+ it('Sign Out clears private cached data.', async () => {
+ const queryClient = createQueryClient();
+ const privateKey = ['student', 'profile'];
+ render(
+
+
+
+
+ ,
+ );
+
+ await screen.findByText('anonymous');
+ fireEvent.press(screen.getByRole('button', { name: 'Mock student' }));
+ expect(await screen.findByText('authenticated')).toBeOnTheScreen();
+ queryClient.setQueryData(privateKey, { name: 'Test Student' });
+ expect(queryClient.getQueryData(privateKey)).toBeDefined();
+ fireEvent.press(screen.getByRole('button', { name: 'Sign out' }));
+
+ await waitFor(() => {
+ expect(screen.getByText('anonymous')).toBeOnTheScreen();
+ expect(queryClient.getQueryData(privateKey)).toBeUndefined();
+ });
+ });
+
+ it('The password is never stored.', async () => {
+ jest.spyOn(authGateway, 'login').mockResolvedValue(studentToken);
+ const setToken = jest
+ .spyOn(tokenStore, 'setToken')
+ .mockResolvedValue(undefined);
+ await renderAnonymous('/sign-in');
+ submitSignIn('User', 'password123');
+
+ await screen.findByText('Quick Actions');
+ expect(setToken).toHaveBeenCalledTimes(1);
+ expect(setToken).toHaveBeenCalledWith(studentToken);
+ expect(setToken).not.toHaveBeenCalledWith('password123');
+ });
+
+ it('Protected screens cannot be reopened after Sign Out.', async () => {
+ jest.spyOn(authGateway, 'login').mockResolvedValue(studentToken);
+ const renderedRouter = await renderAnonymous('/sign-in');
+ submitSignIn('User', 'password123');
+ await waitFor(() => expect(renderedRouter.getPathname()).toBe('/'));
+
+ act(() => router.push('/profile'));
+ await waitFor(() => expect(renderedRouter.getPathname()).toBe('/profile'));
+ fireEvent.press(screen.getByRole('button', { name: 'Sign out' }));
+ await waitFor(() => expect(renderedRouter.getPathname()).toBe('/sign-in'));
+
+ if (router.canGoBack()) {
+ act(() => router.back());
+ await waitFor(() =>
+ expect(renderedRouter.getPathname()).toBe('/sign-in'),
+ );
+ }
+ act(() => router.push('/profile'));
+ await waitFor(() => expect(renderedRouter.getPathname()).toBe('/sign-in'));
+ });
+});
diff --git a/test/fixtures/renderAnonymous.ts b/test/fixtures/renderAnonymous.ts
new file mode 100644
index 0000000..327697f
--- /dev/null
+++ b/test/fixtures/renderAnonymous.ts
@@ -0,0 +1,14 @@
+import { renderRouter, screen, waitFor } from 'expo-router/testing-library';
+import { tokenStore } from '@/services/storage/TokenStore';
+
+export async function renderAnonymous(initialUrl = '/') {
+ jest.spyOn(tokenStore, 'getToken').mockResolvedValue(null);
+
+ const renderedRouter = renderRouter('./app', { initialUrl });
+
+ await waitFor(() => {
+ expect(screen.getByText('Welcome back')).toBeOnTheScreen();
+ });
+
+ return renderedRouter;
+}
diff --git a/test/navigation.test.tsx b/test/navigation.test.tsx
index 6b9bd83..3a9cc70 100644
--- a/test/navigation.test.tsx
+++ b/test/navigation.test.tsx
@@ -7,17 +7,7 @@ import {
waitFor,
} from 'expo-router/testing-library';
-async function renderAnonymous(initialUrl = '/') {
- const renderedRouter = renderRouter('./app', {
- initialUrl,
- });
-
- await waitFor(() => {
- expect(screen.getByText('Welcome back')).toBeOnTheScreen();
- });
-
- return renderedRouter;
-}
+import { renderAnonymous } from './fixtures/renderAnonymous';
describe('session-aware navigation', () => {
it('redirects an anonymous user away from a protected route', async () => {
diff --git a/test/session.test.ts b/test/session.test.ts
index f600561..2d8669a 100644
--- a/test/session.test.ts
+++ b/test/session.test.ts
@@ -5,7 +5,7 @@ import {
} from '@/state/session';
const student: SessionUser = {
- id: 'test-student',
+ username: 'test-student',
displayName: 'Test Student',
role: 'student',
};