diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 02e67af..23aca39 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -52,9 +52,27 @@ jobs:
run: npx license-checker --failOn "GPL-2.0;GPL-3.0;AGPL-3.0"
- name: Security audit
- # Expo's transitive deps (tar, jsdom) frequently trigger high-severity
- # advisories that can't be fixed without breaking Expo upgrades.
- run: npm audit --audit-level=critical
+ # Threshold raised from `critical` to `high` in the 2026-05-21
+ # second-pass audit. The previous `critical`-only gate masked
+ # 20+ high-severity production advisories (undici, tar, node-forge,
+ # @xmldom/xmldom, postcss, picomatch, brace-expansion, fast-uri).
+ # The fix path for all of them is the Expo SDK 52 to 55 upgrade
+ # tracked in the modernization audit — failing CI here is the
+ # forcing function for that upgrade.
+ #
+ # CD / maintenance impact (called out by the post-fix code review):
+ # cd-android.yml, cd-ios.yml, and maintenance.yml all reuse this
+ # workflow via `uses` plus `needs: ci`. While the SDK upgrade is
+ # outstanding, any workflow_dispatch of the CD pipelines and every
+ # Monday scheduled maintenance run will also fail at this step.
+ # That is intentional — releasing on top of a known-vulnerable
+ # transitive tree is exactly what this gate is meant to prevent —
+ # but contributors should know not to treat the red badge as new
+ # breakage.
+ #
+ # If a single transitive advisory genuinely can't be patched,
+ # document the exception inline rather than weakening this gate.
+ run: npm audit --audit-level=high
- name: Lint
run: npm run lint
@@ -62,5 +80,7 @@ jobs:
- name: Test
run: npm test
- - name: Build verification
- run: npx expo export --platform web 2>/dev/null || echo "Web export not configured — skipping"
+ # Web export is a non-goal for the starter (README "Non-Goals"). The
+ # previous `2>/dev/null || echo "skipping"` swallowed real build
+ # failures, so the step was producing a false-green signal. Removed
+ # entirely — `npm test` is the build verification for this project.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7f7acdd..41f302e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,3 +13,33 @@ release feed without duplicating maintenance.
## [Unreleased]
+### Security
+
+- **BREAKING:** `isSessionStillValid` now requires the id_token `iss` claim
+ to be both present AND in the Google allowlist. Previously, an id_token
+ with no `iss` claim bypassed the allowlist via short-circuit evaluation
+ (CWE-345). Surfaced by the 2026-05-21 adversarial second-pass audit.
+- `handleAuthResult` now also rejects a `type:'success'` response whose
+ id_token carries a missing or non-Google `iss`, so the acquisition path
+ enforces the same trust boundary as rehydration (post-fix review).
+
+### Migration
+
+- Sessions persisted under the previous lenient check (legacy installs that
+ may have stored an iss-less token) will be rejected on first launch after
+ upgrade and the user will be redirected to `/login`. The SecureStore
+ blob is purged automatically — no manual cleanup required.
+- The release commit subject uses the `!` SemVer-major marker. Treat the
+ next release tag accordingly.
+
+### Tests / CI
+
+- Test count: 19 → 49. Coverage: 80.2/62.5 → 93+/77+ (statements/branches).
+- `npm audit` threshold raised from `critical` to `high`. CI, CD pipelines
+ (`cd-android.yml`, `cd-ios.yml`), and the weekly `maintenance.yml` job
+ will fail at the audit step until the Expo SDK 52 → 55 upgrade lands.
+ Intentional forcing function; not a CI regression.
+- The `Build verification` step (`npx expo export --platform web 2>/dev/null`)
+ was removed — it swallowed errors and produced a false-green signal.
+ Web export is a non-goal per README.
+
diff --git a/app/(auth)/_layout.js b/app/(auth)/_layout.js
index 40b661f..11d56aa 100644
--- a/app/(auth)/_layout.js
+++ b/app/(auth)/_layout.js
@@ -1,13 +1,37 @@
import { Redirect, Stack } from 'expo-router';
+import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { useAuth } from '../../lib/auth-context';
export default function AuthLayout() {
const { user, loading } = useAuth();
+ // While the SecureStore restore is in flight we don't yet know whether
+ // we should bounce the user to / or let them see /login. Rendering the
+ // login Stack here would flash the sign-in UI to a user who actually
+ // has a valid session — the (app)/_layout spinner doesn't cover this
+ // case because the route IS underneath (auth), not (app). Show our own
+ // spinner. Surfaced by the 2026-05-21 post-fix review.
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
// If already signed in, bounce to the app.
- if (!loading && user) {
+ if (user) {
return ;
}
return ;
}
+
+const styles = StyleSheet.create({
+ center: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: '#fff',
+ },
+});
diff --git a/lib/auth-context.js b/lib/auth-context.js
index ab4ee4d..cc072dc 100644
--- a/lib/auth-context.js
+++ b/lib/auth-context.js
@@ -4,6 +4,7 @@ import {
useContext,
useEffect,
useMemo,
+ useRef,
useState,
} from 'react';
import * as SecureStore from 'expo-secure-store';
@@ -72,6 +73,9 @@ const ALLOWED_ISSUERS = new Set([
* The token came from Google over TLS via PKCE, so this isn't a server-grade
* check — it's the *client*'s self-defense against a stolen device replaying
* an old SecureStore blob. Returns true when the session should be honoured.
+ *
+ * The `iss` claim is REQUIRED, not optional. A token missing `iss` is by
+ * definition not Google-issued, so accepting it would defeat the allowlist.
*/
export function isSessionStillValid(session, now = Date.now()) {
const idToken = session?.tokens?.idToken;
@@ -79,7 +83,11 @@ export function isSessionStillValid(session, now = Date.now()) {
const claims = decodeIdToken(idToken);
if (!claims) return false;
if (typeof claims.exp !== 'number' || claims.exp * 1000 <= now) return false;
- if (claims.iss && !ALLOWED_ISSUERS.has(claims.iss)) return false;
+ // Require iss AND match the allowlist. A missing iss must be rejected:
+ // an `iss`-less token cannot have come from Google's documented OIDC flow.
+ if (typeof claims.iss !== 'string' || !ALLOWED_ISSUERS.has(claims.iss)) {
+ return false;
+ }
return true;
}
@@ -99,7 +107,33 @@ export async function handleAuthResult(response, setSession, deps = {}) {
response.params?.id_token ?? response.authentication?.idToken ?? null;
const accessToken =
response.authentication?.accessToken ?? response.params?.access_token ?? null;
+ // A `type:'success'` response without an id_token cannot establish
+ // identity — the scope requested includes `openid` so Google always
+ // returns one. Treating this as success would persist a `{user:null,...}`
+ // blob to SecureStore that's then deleted on the next mount: a real but
+ // self-healing state-corruption bug surfaced by the 2026-05-21 audit.
+ if (!idToken) {
+ throw new Error(
+ 'Auth success response missing id_token. Refusing to persist a userless session.',
+ );
+ }
const claims = decodeIdToken(idToken);
+ // Enforce the SAME issuer allowlist on acquisition that
+ // isSessionStillValid enforces on rehydration. Asymmetric enforcement
+ // (lax acquire / strict rehydrate) was flagged by the 2026-05-21
+ // post-fix review: the threat model is narrower here (response came
+ // over TLS from Google), but if the token is going to be deleted on
+ // the next mount because iss is missing, persisting it once + flipping
+ // the UI to "signed in" produces a confusing flash. Reject upfront.
+ if (
+ !claims ||
+ typeof claims.iss !== 'string' ||
+ !ALLOWED_ISSUERS.has(claims.iss)
+ ) {
+ throw new Error(
+ 'Auth success response carried an id_token that is not Google-issued (missing or unrecognised iss claim).',
+ );
+ }
const user = userFromClaims(claims);
const session = { user, tokens: { idToken, accessToken } };
await store.setItemAsync(STORAGE_KEY, JSON.stringify(session));
@@ -147,6 +181,9 @@ export function AuthProvider({ children }) {
}
} catch {
// corrupt blob -> treat as signed out
+ // TODO(2nd-pass-audit-2026-05-21): also call deleteItemAsync here
+ // so a corrupt blob is purged rather than re-read on every mount.
+ // Self-healing on next successful sign-in, but explicit is better.
} finally {
if (!cancelled) setLoading(false);
}
@@ -156,9 +193,16 @@ export function AuthProvider({ children }) {
};
}, []);
- // React to the auth response.
+ // React to the auth response. Guard against re-handling the SAME response
+ // object if the effect re-runs (StrictMode double-invoke in dev, or any
+ // upstream re-render that keeps `response` identity-stable). Without this,
+ // a malformed success response would re-throw on every render, repeatedly
+ // setError-ing with no recovery path. Surfaced by the 2026-05-21 review.
+ const handledResponseRef = useRef(null);
useEffect(() => {
if (!response) return;
+ if (handledResponseRef.current === response) return;
+ handledResponseRef.current = response;
handleAuthResult(response, setSession).catch((e) => setError(e));
}, [response]);
diff --git a/scripts/bump-version.js b/scripts/bump-version.js
index 0302317..89a788a 100644
--- a/scripts/bump-version.js
+++ b/scripts/bump-version.js
@@ -8,7 +8,21 @@ if (!valid.includes(type)) {
}
const appConfig = JSON.parse(fs.readFileSync('app.json', 'utf8'));
-const v = appConfig.expo.version.split('.').map(Number);
+const current = appConfig.expo.version;
+
+// Only accept strict MAJOR.MINOR.PATCH numerics. Prerelease/build-metadata
+// (`1.2.3-beta.1`, `1.2.3+sha`) would parse to NaN under the naive split-Map,
+// producing a corrupted `1.2.NaN` write — fail loudly instead.
+if (!/^\d+\.\d+\.\d+$/.test(current)) {
+ console.error(
+ `Refusing to bump: app.json expo.version="${current}" is not strict ` +
+ `MAJOR.MINOR.PATCH. Prerelease/build-metadata is not supported here — ` +
+ `bump manually and commit, or strip suffixes first.`,
+ );
+ process.exit(1);
+}
+
+const v = current.split('.').map(Number);
if (type === 'major') { v[0]++; v[1] = 0; v[2] = 0; }
else if (type === 'minor') { v[1]++; v[2] = 0; }
diff --git a/tests/app.test.js b/tests/app.test.js
index c9fe22c..3b34cf3 100644
--- a/tests/app.test.js
+++ b/tests/app.test.js
@@ -74,8 +74,98 @@ describe('Project structure', () => {
describe('Version bumper', () => {
const bumperPath = path.resolve(__dirname, '..', 'scripts', 'bump-version.js');
+ const { execFileSync } = require('child_process');
+ const os = require('os');
- test('bump script exists', () => {
- expect(fs.existsSync(bumperPath)).toBe(true);
+ // Track sandbox dirs so afterEach can purge them. Without this, each
+ // `npm test` leaks one tmpdir per bumper test — fine on ephemeral CI
+ // runners, slow leak on long-lived self-hosted infra. Flagged by the
+ // 2026-05-21 post-fix review.
+ const sandboxes = [];
+ afterEach(() => {
+ while (sandboxes.length > 0) {
+ const d = sandboxes.pop();
+ try {
+ fs.rmSync(d, { recursive: true, force: true });
+ } catch {
+ // best-effort cleanup; don't fail the suite on a stuck handle
+ }
+ }
+ });
+
+ // Run the bumper against a throwaway sandbox so we don't mutate the real
+ // app.json. The script reads/writes from process.cwd(), so we cd into a
+ // tmpdir seeded with a minimal app.json (and optionally a package.json).
+ function runBumper({ type, appVersion, withPackageJson = false }) {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bump-test-'));
+ sandboxes.push(dir);
+ fs.writeFileSync(
+ path.join(dir, 'app.json'),
+ JSON.stringify({ expo: { version: appVersion } }) + '\n',
+ );
+ if (withPackageJson) {
+ fs.writeFileSync(
+ path.join(dir, 'package.json'),
+ JSON.stringify({ name: 'sandbox', version: appVersion }) + '\n',
+ );
+ }
+ let err = null;
+ let stdout = '';
+ try {
+ stdout = execFileSync(process.execPath, [bumperPath, type], {
+ cwd: dir,
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ } catch (e) {
+ err = e;
+ }
+ const finalApp = JSON.parse(fs.readFileSync(path.join(dir, 'app.json'), 'utf8'));
+ const finalPkg = withPackageJson
+ ? JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'))
+ : null;
+ return { stdout: stdout.trim(), err, app: finalApp, pkg: finalPkg };
+ }
+
+ test('patch bump increments only the patch component', () => {
+ const r = runBumper({ type: 'patch', appVersion: '1.2.3' });
+ expect(r.err).toBeNull();
+ expect(r.app.expo.version).toBe('1.2.4');
+ expect(r.stdout).toBe('1.2.4');
+ });
+
+ test('minor bump zeroes the patch component', () => {
+ const r = runBumper({ type: 'minor', appVersion: '1.2.3' });
+ expect(r.app.expo.version).toBe('1.3.0');
+ });
+
+ test('major bump zeroes minor and patch', () => {
+ const r = runBumper({ type: 'major', appVersion: '1.2.3' });
+ expect(r.app.expo.version).toBe('2.0.0');
+ });
+
+ test('mirrors the new version into package.json when present', () => {
+ const r = runBumper({ type: 'patch', appVersion: '1.2.3', withPackageJson: true });
+ expect(r.pkg.version).toBe('1.2.4');
+ });
+
+ test('rejects prerelease versions instead of writing NaN — regression', () => {
+ // Before the second-pass audit (2026-05-21), `"1.2.3-beta.1".split('.')`
+ // -> `Number('3-beta')` = NaN, producing `1.2.NaN` in app.json.
+ const r = runBumper({ type: 'patch', appVersion: '1.2.3-beta.1' });
+ expect(r.err).not.toBeNull();
+ expect(r.app.expo.version).toBe('1.2.3-beta.1'); // unchanged
+ });
+
+ test('rejects 2-component versions instead of writing NaN — regression', () => {
+ const r = runBumper({ type: 'patch', appVersion: '1.2' });
+ expect(r.err).not.toBeNull();
+ expect(r.app.expo.version).toBe('1.2'); // unchanged
+ });
+
+ test('rejects invalid bump type', () => {
+ const r = runBumper({ type: 'wat', appVersion: '1.2.3' });
+ expect(r.err).not.toBeNull();
+ expect(r.app.expo.version).toBe('1.2.3'); // unchanged
});
});
diff --git a/tests/auth-context.test.js b/tests/auth-context.test.js
index a056510..9f071a0 100644
--- a/tests/auth-context.test.js
+++ b/tests/auth-context.test.js
@@ -26,12 +26,32 @@ jest.mock('expo-web-browser', () => ({
}));
// Prevent `useAuthRequest` from reaching the network / native discovery.
+// Expose promptAsync + response settors so tests can drive the auth pipeline
+// end-to-end (response → useEffect → handleAuthResult → setSession/setError),
+// not just verify the pure handler. The previous mock created a fresh jest.fn
+// on every render, which made it impossible to assert `promptAsync` was
+// actually invoked from signIn — flagged by the 2026-05-21 post-fix review.
+const mockPromptAsync = jest.fn(async () => ({ type: 'cancel' }));
+let mockUseAuthRequestResponse = null;
jest.mock('expo-auth-session/providers/google', () => ({
- useAuthRequest: () => [{ state: 'ready' }, null, jest.fn(async () => ({ type: 'cancel' }))],
+ useAuthRequest: () => [{ state: 'ready' }, mockUseAuthRequestResponse, mockPromptAsync],
}));
-// Env: pretend the web client ID is set.
-process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID = 'test-web-client-id';
+// Stub lib/env so assertGoogleEnv is a no-op for the happy-path suite. The
+// error path (assertGoogleEnv throws) lives in tests/signin-error.test.js
+// where the same module is mocked the other way. The previous
+// `process.env.EXPO_PUBLIC_... = ...` approach worked while no test invoked
+// signIn(), but env.js reads process.env at module-load time — the load
+// order vs. the assignment is fragile under jest-expo's preset hoisting.
+const mockAssertGoogleEnv = jest.fn();
+jest.mock('../lib/env', () => ({
+ googleClientIds: {
+ webClientId: 'test-web-client-id',
+ iosClientId: undefined,
+ androidClientId: undefined,
+ },
+ assertGoogleEnv: mockAssertGoogleEnv,
+}));
// --- Fixture: a Google id_token (not signature-verified, only decoded) ---
// header.payload.signature where payload is base64url(JSON)
@@ -58,9 +78,21 @@ const {
useAuth,
handleAuthResult,
decodeIdToken,
+ isSessionStillValid,
STORAGE_KEY,
} = require('../lib/auth-context');
+// Build a JWT with arbitrary claims for the negative-path probes below.
+// Signature is never verified by this client — only the payload is decoded.
+function makeFakeJwt(claimsOverride) {
+ const payload = Buffer.from(JSON.stringify(claimsOverride))
+ .toString('base64')
+ .replace(/=+$/, '')
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_');
+ return `eyJhbGciOiJSUzI1NiJ9.${payload}.sig`;
+}
+
function Probe({ onUser }) {
const ctx = useAuth();
onUser(ctx);
@@ -72,6 +104,9 @@ beforeEach(() => {
mockSecureStore.getItemAsync.mockClear();
mockSecureStore.setItemAsync.mockClear();
mockSecureStore.deleteItemAsync.mockClear();
+ mockPromptAsync.mockClear();
+ mockAssertGoogleEnv.mockClear();
+ mockUseAuthRequestResponse = null;
});
// -----------------------------------------------------------------------
@@ -91,6 +126,58 @@ describe('decodeIdToken', () => {
});
});
+describe('isSessionStillValid — issuer + expiry boundary', () => {
+ const future = Math.floor(Date.now() / 1000) + 3600;
+ const past = Math.floor(Date.now() / 1000) - 3600;
+
+ test('accepts a fresh Google-issued token', () => {
+ const tok = makeFakeJwt({
+ iss: 'https://accounts.google.com',
+ sub: 'goog-1',
+ exp: future,
+ });
+ expect(isSessionStillValid({ tokens: { idToken: tok } })).toBe(true);
+ });
+
+ test('rejects a token whose iss is missing — regression for the second-pass audit', () => {
+ // Without this check, an attacker who can write to SecureStore (e.g. on a
+ // rooted/jailbroken device or via a shared-keychain entitlement bug) could
+ // craft an iss-less blob and ride the session indefinitely. See
+ // lib/auth-context.js — `iss` is REQUIRED, not optional.
+ const tok = makeFakeJwt({ sub: 'x', exp: future });
+ expect(isSessionStillValid({ tokens: { idToken: tok } })).toBe(false);
+ });
+
+ test('rejects a token issued by a non-Google IdP', () => {
+ const tok = makeFakeJwt({
+ iss: 'https://evil.example.com',
+ sub: 'x',
+ exp: future,
+ });
+ expect(isSessionStillValid({ tokens: { idToken: tok } })).toBe(false);
+ });
+
+ test('rejects an expired token even with a valid iss', () => {
+ const tok = makeFakeJwt({
+ iss: 'https://accounts.google.com',
+ sub: 'x',
+ exp: past,
+ });
+ expect(isSessionStillValid({ tokens: { idToken: tok } })).toBe(false);
+ });
+
+ test('rejects when exp is missing entirely', () => {
+ const tok = makeFakeJwt({ iss: 'https://accounts.google.com', sub: 'x' });
+ expect(isSessionStillValid({ tokens: { idToken: tok } })).toBe(false);
+ });
+
+ test('rejects when no idToken is present', () => {
+ expect(isSessionStillValid({ tokens: {} })).toBe(false);
+ expect(isSessionStillValid({})).toBe(false);
+ expect(isSessionStillValid(null)).toBe(false);
+ });
+});
+
describe('handleAuthResult (pure)', () => {
test('success result populates user + writes SecureStore', async () => {
const setSession = jest.fn();
@@ -133,6 +220,82 @@ describe('handleAuthResult (pure)', () => {
expect(setSession).not.toHaveBeenCalled();
});
+ test('success without id_token throws and does NOT persist — regression', async () => {
+ // Before the second-pass audit (2026-05-21), this path silently wrote
+ // `{user:null,tokens:{idToken:null,accessToken:null}}` to SecureStore.
+ // Self-healing on next mount, but a real state-corruption bug.
+ const setSession = jest.fn();
+ const store = {
+ setItemAsync: jest.fn(),
+ getItemAsync: jest.fn(),
+ deleteItemAsync: jest.fn(),
+ };
+ await expect(
+ handleAuthResult(
+ { type: 'success', params: {}, authentication: {} },
+ setSession,
+ { store },
+ ),
+ ).rejects.toThrow(/missing id_token/);
+ expect(store.setItemAsync).not.toHaveBeenCalled();
+ expect(setSession).not.toHaveBeenCalled();
+ });
+
+ test('success with non-Google iss throws and does NOT persist — regression for acquisition-side asymmetry', async () => {
+ // 2026-05-21 post-fix review: isSessionStillValid now strictly rejects
+ // missing/non-Google iss on rehydration, but the acquisition path used
+ // to persist the same token, flip UI to signed-in, and only reject on
+ // the next cold start. Acquisition must also enforce.
+ const setSession = jest.fn();
+ const store = { setItemAsync: jest.fn(), getItemAsync: jest.fn(), deleteItemAsync: jest.fn() };
+ const evilToken = makeFakeJwt({
+ iss: 'https://evil.example.com',
+ sub: 'x',
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ });
+ await expect(
+ handleAuthResult(
+ { type: 'success', params: { id_token: evilToken } },
+ setSession,
+ { store },
+ ),
+ ).rejects.toThrow(/not Google-issued/);
+ expect(store.setItemAsync).not.toHaveBeenCalled();
+ expect(setSession).not.toHaveBeenCalled();
+ });
+
+ test('success with iss-less id_token throws and does NOT persist', async () => {
+ const setSession = jest.fn();
+ const store = { setItemAsync: jest.fn(), getItemAsync: jest.fn(), deleteItemAsync: jest.fn() };
+ const issLessToken = makeFakeJwt({
+ sub: 'x',
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ });
+ await expect(
+ handleAuthResult(
+ { type: 'success', params: { id_token: issLessToken } },
+ setSession,
+ { store },
+ ),
+ ).rejects.toThrow(/not Google-issued/);
+ expect(store.setItemAsync).not.toHaveBeenCalled();
+ expect(setSession).not.toHaveBeenCalled();
+ });
+
+ // `dismiss` shares the unknown-type fall-through with everything other
+ // than success/error/cancel. We don't have a behavioural change to assert
+ // here — only an explicit pin so a future "throw on unknown type" refactor
+ // is a CONSCIOUS choice rather than a silent regression.
+ test('explicitly: dismiss and other unknown types fall through (no setSession, no persist)', async () => {
+ const setSession = jest.fn();
+ const store = { setItemAsync: jest.fn(), getItemAsync: jest.fn(), deleteItemAsync: jest.fn() };
+ for (const t of ['dismiss', 'opener', 'locked']) {
+ await handleAuthResult({ type: t }, setSession, { store });
+ }
+ expect(setSession).not.toHaveBeenCalled();
+ expect(store.setItemAsync).not.toHaveBeenCalled();
+ });
+
// Mutation-check: if handleAuthResult accidentally skipped persisting the
// session, the assertion below would still pass for user population but this
// extra check fails. Guards against a common regression.
@@ -184,6 +347,50 @@ describe('AuthProvider lifecycle', () => {
expect(captured.user?.email).toBe('restored@example.com');
});
+ test('handleAuthResult throw via useEffect surfaces to context.error (integration)', async () => {
+ // The 2026-05-21 post-fix review flagged that the new throw in
+ // handleAuthResult was only tested by direct invocation — never via
+ // the useEffect → .catch(setError) integration the security claim
+ // depends on. This test feeds a malformed success response through
+ // the mocked useAuthRequest channel and asserts the user-visible
+ // error state.
+ mockUseAuthRequestResponse = {
+ type: 'success',
+ params: {}, // intentionally no id_token
+ authentication: {},
+ };
+ let captured;
+ render(
+
+ (captured = c)} />
+ ,
+ );
+ await waitFor(() => expect(captured.error).toBeInstanceOf(Error));
+ expect(captured.error.message).toMatch(/missing id_token/);
+ expect(captured.user).toBeNull();
+ });
+
+ test('signIn (happy path) actually invokes assertGoogleEnv and promptAsync', async () => {
+ // The prior version only checked `captured.error === null`, which is
+ // the initial state AND the value set by signIn's first line — so the
+ // assertion passed even if signIn was a no-op. Flagged by the
+ // 2026-05-21 post-fix review as a tautological coverage gap. Now we
+ // probe both dependencies (env check + auth prompt) directly.
+ let captured;
+ render(
+
+ (captured = c)} />
+ ,
+ );
+ await waitFor(() => expect(captured.loading).toBe(false));
+ await act(async () => {
+ await captured.signIn();
+ });
+ expect(mockAssertGoogleEnv).toHaveBeenCalledTimes(1);
+ expect(mockPromptAsync).toHaveBeenCalledTimes(1);
+ expect(captured.error).toBeNull();
+ });
+
test('signOut clears user + SecureStore', async () => {
mockMemStore.set(
STORAGE_KEY,
diff --git a/tests/layout.test.js b/tests/layout.test.js
new file mode 100644
index 0000000..f06d915
--- /dev/null
+++ b/tests/layout.test.js
@@ -0,0 +1,95 @@
+// Exercise the route-group gating that README "Currently implemented" lists
+// as the auth boundary. Before the 2026-05-21 second-pass audit, no test
+// rendered (app)/_layout.js — the gating was claimed, not verified.
+//
+// Assertions use `expect.objectContaining` rather than strict deep equality
+// on props, so adding e.g. `` or a
+// React 19 JSX-runtime metadata key won't false-alarm the gate test.
+
+import React from 'react';
+import { render } from '@testing-library/react-native';
+
+const mockRedirect = jest.fn(() => null);
+const mockStack = jest.fn(() => null);
+
+jest.mock('expo-router', () => ({
+ Redirect: (props) => mockRedirect(props),
+ Stack: (props) => mockStack(props),
+}));
+
+// Mock useAuth per-test by re-requiring after redefining the mock factory.
+let mockAuthState;
+jest.mock('../lib/auth-context', () => ({
+ useAuth: () => mockAuthState,
+}));
+
+describe('(app)/_layout — protected route group', () => {
+ beforeEach(() => {
+ mockRedirect.mockClear();
+ mockStack.mockClear();
+ });
+
+ test('while loading, shows the spinner (no redirect, no Stack)', () => {
+ mockAuthState = { user: null, loading: true };
+ const AppLayout = require('../app/(app)/_layout').default;
+ const { UNSAFE_root } = render();
+ expect(mockRedirect).not.toHaveBeenCalled();
+ expect(mockStack).not.toHaveBeenCalled();
+ expect(UNSAFE_root).toBeTruthy();
+ });
+
+ test('when not signed in, redirects to /login (regression for D1 gating claim)', () => {
+ mockAuthState = { user: null, loading: false };
+ const AppLayout = require('../app/(app)/_layout').default;
+ render();
+ expect(mockRedirect).toHaveBeenCalledWith(
+ expect.objectContaining({ href: '/login' }),
+ );
+ expect(mockStack).not.toHaveBeenCalled();
+ });
+
+ test('when signed in, renders the Stack (protected zone is accessible)', () => {
+ mockAuthState = { user: { email: 'a@b.c' }, loading: false };
+ const AppLayout = require('../app/(app)/_layout').default;
+ render();
+ expect(mockStack).toHaveBeenCalled();
+ expect(mockRedirect).not.toHaveBeenCalled();
+ });
+});
+
+describe('(auth)/_layout — bounce when already signed in', () => {
+ beforeEach(() => {
+ mockRedirect.mockClear();
+ mockStack.mockClear();
+ });
+
+ test('while loading, neither Redirect nor Stack fires (own spinner)', () => {
+ // Regression for the 2026-05-21 review: previously this layout rendered
+ // during loading, flashing the login UI before the redirect
+ // when restoring a signed-in session into a /login deep link. Now it
+ // owns its own spinner — the (app)/_layout's spinner doesn't cover
+ // this case because the route IS underneath (auth), not (app).
+ mockAuthState = { user: null, loading: true };
+ const AuthLayout = require('../app/(auth)/_layout').default;
+ render();
+ expect(mockRedirect).not.toHaveBeenCalled();
+ expect(mockStack).not.toHaveBeenCalled();
+ });
+
+ test('when signed in, bounces back to /', () => {
+ mockAuthState = { user: { email: 'a@b.c' }, loading: false };
+ const AuthLayout = require('../app/(auth)/_layout').default;
+ render();
+ expect(mockRedirect).toHaveBeenCalledWith(
+ expect.objectContaining({ href: '/' }),
+ );
+ });
+
+ test('when not signed in, renders the Stack so the login screen appears', () => {
+ mockAuthState = { user: null, loading: false };
+ const AuthLayout = require('../app/(auth)/_layout').default;
+ render();
+ expect(mockRedirect).not.toHaveBeenCalled();
+ expect(mockStack).toHaveBeenCalled();
+ });
+});
diff --git a/tests/signin-error.test.js b/tests/signin-error.test.js
new file mode 100644
index 0000000..5792f5b
--- /dev/null
+++ b/tests/signin-error.test.js
@@ -0,0 +1,73 @@
+// Cover the signIn error path (auth-context.js:174-177) — previously
+// uncovered. The path: assertGoogleEnv() throws → catch → setError(e) → rethrow.
+// Isolated in its own file so we can mock lib/env without disturbing the
+// happy-path fixture in tests/auth-context.test.js.
+
+import React from 'react';
+import { act, render, waitFor } from '@testing-library/react-native';
+import { Text } from 'react-native';
+
+const mockMemStore = new Map();
+const mockSecureStore = {
+ getItemAsync: jest.fn(async (k) => (mockMemStore.has(k) ? mockMemStore.get(k) : null)),
+ setItemAsync: jest.fn(async (k, v) => {
+ mockMemStore.set(k, v);
+ }),
+ deleteItemAsync: jest.fn(async (k) => {
+ mockMemStore.delete(k);
+ }),
+};
+
+jest.mock('expo-secure-store', () => mockSecureStore);
+jest.mock('expo-web-browser', () => ({ maybeCompleteAuthSession: jest.fn() }));
+// promptAsync must return a Promise — the real expo-auth-session surface
+// is Promise. Returning bare undefined (the default
+// jest.fn() shape) was flagged by the 2026-05-21 post-fix review as a
+// faithful-mock gap that would silently let future tests pass against a
+// non-Promise stand-in.
+jest.mock('expo-auth-session/providers/google', () => ({
+ useAuthRequest: () => [
+ { state: 'ready' },
+ null,
+ jest.fn(async () => ({ type: 'cancel' })),
+ ],
+}));
+
+// Replace lib/env with a stub whose assertGoogleEnv always throws — this
+// simulates the "developer forgot to set EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID"
+// situation at the boundary of signIn() rather than at module load.
+jest.mock('../lib/env', () => ({
+ googleClientIds: { webClientId: undefined, iosClientId: undefined, androidClientId: undefined },
+ assertGoogleEnv: () => {
+ throw new Error('Missing EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID. (test stub)');
+ },
+}));
+
+const { AuthProvider, useAuth } = require('../lib/auth-context');
+
+function Probe({ onUser }) {
+ const ctx = useAuth();
+ onUser(ctx);
+ return {ctx.user ? 'user' : 'anon'};
+}
+
+describe('signIn error path — env missing', () => {
+ test('rethrows from signIn AND lights up context.error', async () => {
+ let captured;
+ render(
+
+ (captured = c)} />
+ ,
+ );
+ await waitFor(() => expect(captured.loading).toBe(false));
+
+ await expect(
+ act(async () => {
+ await captured.signIn();
+ }),
+ ).rejects.toThrow(/Missing EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID/);
+
+ await waitFor(() => expect(captured.error).toBeInstanceOf(Error));
+ expect(captured.error.message).toMatch(/Missing EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID/);
+ });
+});