Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions apps/mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,33 @@ an App Group, and matching Apple signing entitlements. Those are a separate nati
not treat a successful JavaScript bundle as proof that iOS broadcasting is configured. The PairUX
app config currently enables the `voip` background mode; remove it or add the matching
CallKit/PushKit flow before an App Store submission.

## Zero-credit validation checklist

Session reads and refreshes fail closed for the current app process if secure-store deletion
fails during logout. A failed login does not unblock the old tokens; a successfully committed
login does. This also applies when the auth provider remounts. It is not a guarantee of persistent
deletion across an app restart when the OS storage operation failed; verify that failure mode
and recovery on a device before treating it as a release guarantee.

Run every step below before considering an EAS cloud build. Each one is free and catches a class
of defect that a green cloud build would only package.

1. `pnpm --filter @pairux/shared-types build` — the mobile app compiles against the built types.
2. `pnpm check:mobile` from the repository root — lint, typecheck, the full unit suite, and the
production bundle verifier. Run it uncached (`--force`) when validating a release candidate.
3. Contract check: any change touching `/api` calls must be validated against the actual route
handler in `apps/web/src/app/api/**` — response envelopes are `{ data, error }`, auth expiry
is in seconds, and the join lookup returns its payload without a wrapper. Update
`src/test/fixtures/server-contracts.ts` from the route source, never from memory.
4. Identity check: signaling tests must keep the authenticated user id, the participant row id,
and the SSE `subscriberId` distinct. A test that reuses one id for all three can pass while
the live flow deadlocks.
5. `pnpm --filter @pairux/mobile build -- --no-install --clean` — clean native prebuild, then
`pnpm --filter @pairux/mobile verify:android-screen-share` against the generated project.
6. If a local Android SDK is available: `pnpm mobile:android` on a device/emulator, then smoke
the supported flow with two distinct accounts — login, join-code lookup, join, host offer /
viewer answer, audio/chat, kill-network reconnect, clean leave.
7. Record anything not exercised (real TURN traversal, background/foreground on physical
hardware, iOS ReplayKit) as an explicit device-only gap instead of assuming the build proves
it.
39 changes: 31 additions & 8 deletions apps/mobile/app/(app)/join.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,15 @@ import {
Platform,
} from 'react-native';
import { useRouter } from 'expo-router';
import type { Session } from '@pairux/shared-types';
import { sessionApi } from '@/lib/api/sessions';
import { sessionApi, isScheduledLookup, type JoinLookupResult } from '@/lib/api/sessions';

export default function JoinScreen() {
const router = useRouter();
const [joinCode, setJoinCode] = useState('');
const [displayName, setDisplayName] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [lookupResult, setLookupResult] = useState<Session | null>(null);
const [lookupResult, setLookupResult] = useState<JoinLookupResult | null>(null);
const [lookingUp, setLookingUp] = useState(false);

async function handleLookup() {
Expand All @@ -38,8 +37,11 @@ export default function JoinScreen() {
return;
}

if (result.data?.session) {
setLookupResult(result.data.session);
// The route returns the session (or scheduled meeting) payload directly.
if (result.data) {
setLookupResult(result.data);
} else {
setError('Session not found or has ended');
}
} catch {
setError('Failed to look up session');
Expand All @@ -62,10 +64,18 @@ export default function JoinScreen() {
}

if (result.data) {
// The participant row carries the authoritative session id.
const sessionId: string =
(result.data.session_id as string | undefined) ??
(lookupResult && !isScheduledLookup(lookupResult) ? lookupResult.id : '');
if (!sessionId) {
setError('Joined, but the server did not return a session ID');
return;
}
router.push({
pathname: '/(app)/session/[id]',
params: {
id: lookupResult?.id ?? '',
id: sessionId,
role: 'viewer',
participantId: result.data.id,
},
Expand Down Expand Up @@ -129,11 +139,24 @@ export default function JoinScreen() {
</View>

{/* Lookup result */}
{lookupResult ? (
{lookupResult && isScheduledLookup(lookupResult) ? (
<View className="mb-6 rounded-xl border border-amber-200 bg-amber-50 p-4">
<Text className="font-medium text-amber-800">Scheduled meeting</Text>
<Text className="mt-1 text-sm text-amber-700">{lookupResult.title}</Text>
<Text className="mt-1 text-sm text-amber-600">
Starts {new Date(lookupResult.scheduled_at).toLocaleString()} (
{lookupResult.duration_minutes} min)
</Text>
<Text className="mt-2 text-sm text-amber-600">
This meeting hasn&apos;t started yet. Try again once the host goes live.
</Text>
</View>
) : lookupResult ? (
<View className="mb-6 rounded-xl border border-green-200 bg-green-50 p-4">
<Text className="font-medium text-green-800">Session found</Text>
<Text className="mt-1 text-sm text-green-600">
Status: {lookupResult.status} | Code: {lookupResult.join_code}
Status: {lookupResult.status} | Code: {lookupResult.join_code} | Participants:{' '}
{lookupResult.participant_count}
</Text>

{/* Display name input */}
Expand Down
14 changes: 9 additions & 5 deletions apps/mobile/app/(auth)/signup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default function SignupScreen() {
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [success, setSuccess] = useState<{ needsConfirmation: boolean } | null>(null);

async function handleSignup() {
setError('');
Expand Down Expand Up @@ -62,14 +62,15 @@ export default function SignupScreen() {
const result = await signup({
email: email.trim(),
password,
confirmPassword,
firstName: firstName.trim(),
lastName: lastName.trim(),
});

if (result.error) {
setError(result.error);
} else {
setSuccess(true);
setSuccess({ needsConfirmation: result.needsConfirmation ?? true });
}
} catch {
setError('An unexpected error occurred');
Expand All @@ -87,10 +88,13 @@ export default function SignupScreen() {
className="mb-6 h-16 w-16"
resizeMode="contain"
/>
<Text className="mb-2 text-xl font-bold text-gray-900">Check your email</Text>
<Text className="mb-2 text-xl font-bold text-gray-900">
{success.needsConfirmation ? 'Check your email' : 'Account created'}
</Text>
<Text className="mb-6 text-center text-gray-500">
We&apos;ve sent a confirmation email to {email}. Please verify your email address to
continue.
{success.needsConfirmation
? `We've sent a confirmation email to ${email}. Please verify your email address to continue.`
: 'Your account is ready. You can sign in now.'}
</Text>
<TouchableOpacity
onPress={() => {
Expand Down
219 changes: 219 additions & 0 deletions apps/mobile/src/contexts/AuthContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import { AuthProvider, useAuth } from './AuthContext';
import { isAuthExpired, type StoredAuth } from '@/lib/secure-storage';
import {
readAuthSession as getStoredAuth,
refreshAuthSession,
clearAuthSession,
} from '@/lib/auth-session';
import { authApi } from '@/lib/api/auth';
import { AUTH_USER_ID } from '../test/fixtures/server-contracts';

vi.mock('@/lib/secure-storage');
vi.mock('@/lib/auth-session');
vi.mock('@/lib/api/auth', () => ({
authApi: {
login: vi.fn(),
signup: vi.fn(),
logout: vi.fn(),
getSession: vi.fn(),
},
}));

const storedAuth: StoredAuth = {
accessToken: 'access-token-1',
refreshToken: 'refresh-token-1',
expiresAt: Date.now() + 3600000,
user: { id: AUTH_USER_ID, email: 'user@example.com' },
};

function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}

function renderAuth() {
return renderHook(() => useAuth(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<AuthProvider>{children}</AuthProvider>
),
});
}

describe('AuthContext session restore', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('restores a valid stored session', async () => {
vi.mocked(getStoredAuth).mockResolvedValue(storedAuth);
vi.mocked(isAuthExpired).mockReturnValue(false);

const { result } = renderAuth();

await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.user).toEqual(storedAuth.user);
expect(refreshAuthSession).not.toHaveBeenCalled();
});

it('refreshes an expired stored session instead of forcing a re-login', async () => {
vi.mocked(getStoredAuth).mockResolvedValue(storedAuth);
vi.mocked(isAuthExpired).mockReturnValue(true);
vi.mocked(refreshAuthSession).mockResolvedValue({
auth: { ...storedAuth, accessToken: 'access-token-2' },
});

const { result } = renderAuth();

await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(refreshAuthSession).toHaveBeenCalledTimes(1);
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.user).toEqual(storedAuth.user);
expect(clearAuthSession).not.toHaveBeenCalled();
});

it('signs out only when the server definitively rejects the refresh token', async () => {
vi.mocked(getStoredAuth).mockResolvedValue(storedAuth);
vi.mocked(isAuthExpired).mockReturnValue(true);
vi.mocked(refreshAuthSession).mockResolvedValue({ auth: null, failure: 'rejected' });

const { result } = renderAuth();

await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.isAuthenticated).toBe(false);
expect(clearAuthSession).toHaveBeenCalledTimes(1);
});

it('keeps the stored session when the refresh fails transiently (offline start)', async () => {
vi.mocked(getStoredAuth).mockResolvedValue(storedAuth);
vi.mocked(isAuthExpired).mockReturnValue(true);
vi.mocked(refreshAuthSession).mockResolvedValue({ auth: null, failure: 'transient' });

const { result } = renderAuth();

await waitFor(() => expect(result.current.isLoading).toBe(false));
// A network hiccup is not a logout: the refresh token may still be good
expect(clearAuthSession).not.toHaveBeenCalled();
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.user).toEqual(storedAuth.user);
});

it('leaves auth state alone when the restore refresh was superseded', async () => {
vi.mocked(getStoredAuth).mockResolvedValue(storedAuth);
vi.mocked(isAuthExpired).mockReturnValue(true);
vi.mocked(refreshAuthSession).mockResolvedValue({ auth: null, failure: 'superseded' });

const { result } = renderAuth();

await waitFor(() => expect(result.current.isLoading).toBe(false));
// A concurrent login/logout owns the state; restore must not clear it
expect(clearAuthSession).not.toHaveBeenCalled();
expect(result.current.user).toBeNull();
});

it('stays signed out with no stored session', async () => {
vi.mocked(getStoredAuth).mockResolvedValue(null);

const { result } = renderAuth();

await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.isAuthenticated).toBe(false);
expect(refreshAuthSession).not.toHaveBeenCalled();
});
});

describe('AuthContext lifecycle races', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('finishes loading if a login supersedes restore but fails', async () => {
const restore = deferred<StoredAuth | null>();
vi.mocked(getStoredAuth).mockReturnValueOnce(restore.promise);
vi.mocked(authApi.login).mockResolvedValueOnce({ error: 'Invalid credentials' });
const { result } = renderAuth();
await act(async () => {
await result.current.login('fixture@example.com', 'fixture-password');
});
expect(result.current.isLoading).toBe(false);
await act(async () => {
restore.resolve(null);
});
expect(result.current.isLoading).toBe(false);
expect(result.current.user).toBeNull();
});

it('does not resurrect the user when a login resolves after a logout', async () => {
vi.mocked(getStoredAuth).mockResolvedValue(null);
vi.mocked(authApi.logout).mockResolvedValue(undefined);
const loginGate = deferred<{ data?: StoredAuth; error?: string }>();
vi.mocked(authApi.login).mockReturnValueOnce(loginGate.promise);

const { result } = renderAuth();
await waitFor(() => expect(result.current.isLoading).toBe(false));

let loginPromise!: Promise<{ error?: string }>;
act(() => {
loginPromise = result.current.login('new@example.com', 'password');
});
await act(async () => {
await result.current.logout();
});
await act(async () => {
loginGate.resolve({
data: {
...storedAuth,
user: { id: 'late-user', email: 'late@example.com' },
},
});
await loginPromise;
});

expect(result.current.user).toBeNull();
expect(result.current.isAuthenticated).toBe(false);
});

it('does not let a slow restore overwrite a fresh login', async () => {
const restoreGate = deferred<StoredAuth | null>();
vi.mocked(getStoredAuth).mockReturnValueOnce(restoreGate.promise);
vi.mocked(isAuthExpired).mockReturnValue(false);
vi.mocked(authApi.login).mockResolvedValue({
data: { ...storedAuth, user: { id: 'fresh-user', email: 'fresh@example.com' } },
});

const { result } = renderAuth();
await act(async () => {
await result.current.login('fresh@example.com', 'password');
});
expect(result.current.user?.id).toBe('fresh-user');

await act(async () => {
// The pre-login stored session finally loads — it is stale now
restoreGate.resolve(storedAuth);
});
await waitFor(() => expect(result.current.isLoading).toBe(false));

expect(result.current.user?.id).toBe('fresh-user');
});

it('signs out immediately even while the logout network call is pending', async () => {
vi.mocked(getStoredAuth).mockResolvedValue(storedAuth);
vi.mocked(isAuthExpired).mockReturnValue(false);
vi.mocked(authApi.logout).mockReturnValue(new Promise<void>(() => undefined));

const { result } = renderAuth();
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));

act(() => {
void result.current.logout();
});

expect(result.current.user).toBeNull();
expect(result.current.isAuthenticated).toBe(false);
});
});
Loading
Loading