Skip to content

Commit 98ff05d

Browse files
committed
fix(ios): reject extra reset-keychain arguments and add live-tested keychain fixture
settings reset-keychain clear <extra-arg> silently dropped the extra argument in both the CLI reader and the direct-daemon parser, so a caller expecting per-app scoping could get a whole-simulator wipe without any signal something was off. Reject it instead in both places, with tests proving no settings mutation happens. Also add a small keychain-backed "auth" fixture to the test-app's automation lab (expo-secure-store) so the settings reset-keychain guarantee has a real regression surface: authenticate, verify the credential survives clear-app-state and a plain relaunch, then verify reset-keychain actually clears it. Validated live against a disposable iOS simulator.
1 parent 3237128 commit 98ff05d

8 files changed

Lines changed: 95 additions & 2 deletions

File tree

examples/test-app/app.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ module.exports = {
2020
buildCacheProvider: { plugin: 'expo-build-disk-cache' },
2121
plugins: [
2222
'expo-router',
23+
'expo-secure-store',
2324
[
2425
'expo-audio',
2526
{

examples/test-app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"expo-linking": "56.0.14",
2121
"expo-modules-core": "56.0.17",
2222
"expo-router": "~56.2.11",
23+
"expo-secure-store": "~56.0.4",
2324
"expo-status-bar": "~56.0.4",
2425
"react": "19.2.3",
2526
"react-dom": "19.2.3",

examples/test-app/pnpm-lock.yaml

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/test-app/src/screens/AutomationLabScreen.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,17 @@ import {
1414
} from 'react-native';
1515
import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from 'expo-audio';
1616
import { requireOptionalNativeModule } from 'expo-modules-core';
17+
import * as SecureStore from 'expo-secure-store';
1718

1819
import { ActionButton, ScreenTitle, SectionCard } from '../components';
1920
import { useAppColors, type AppColors } from '../theme';
2021

22+
// A fixed key/value pair standing in for a real login token: this screen only
23+
// needs to prove keychain-backed state survives `clear-app-state` but not
24+
// `reset-keychain`, not to model an actual auth flow.
25+
const KEYCHAIN_AUTH_KEY = 'automation-keychain-auth-token';
26+
const KEYCHAIN_AUTH_VALUE = 'demo-auth-token';
27+
2128
type PushBroadcastLabModule = {
2229
lastPushBroadcast(): string;
2330
};
@@ -45,6 +52,7 @@ export function AutomationLabScreen(props: {
4552
const [microphonePermission, setMicrophonePermission] = useState('checking');
4653
const [lastPushBroadcast, setLastPushBroadcast] = useState('none');
4754
const [sheetVisible, setSheetVisible] = useState(false);
55+
const [keychainAuthStatus, setKeychainAuthStatus] = useState('checking');
4856
const permissionReadGeneration = useRef(0);
4957
const windowMode = dimensions.width > dimensions.height ? 'landscape' : 'portrait';
5058

@@ -125,6 +133,26 @@ export function AutomationLabScreen(props: {
125133
setLastPushBroadcast(pushBroadcastLab?.lastPushBroadcast() ?? 'unavailable');
126134
}
127135

136+
useEffect(() => {
137+
let mounted = true;
138+
void SecureStore.getItemAsync(KEYCHAIN_AUTH_KEY)
139+
.then((value) => {
140+
if (mounted)
141+
setKeychainAuthStatus(value === KEYCHAIN_AUTH_VALUE ? 'signed-in' : 'signed-out');
142+
})
143+
.catch(() => {
144+
if (mounted) setKeychainAuthStatus('error');
145+
});
146+
return () => {
147+
mounted = false;
148+
};
149+
}, []);
150+
151+
async function signInWithKeychain() {
152+
await SecureStore.setItemAsync(KEYCHAIN_AUTH_KEY, KEYCHAIN_AUTH_VALUE);
153+
setKeychainAuthStatus('signed-in');
154+
}
155+
128156
return (
129157
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
130158
<ScreenTitle
@@ -235,6 +263,19 @@ export function AutomationLabScreen(props: {
235263
/>
236264
</SectionCard>
237265

266+
<SectionCard title="Keychain-backed auth">
267+
<ActionButton
268+
label="Sign in (write keychain)"
269+
onPress={() => void signInWithKeychain()}
270+
testID="automation-keychain-signin"
271+
/>
272+
<StateRow
273+
label="Auth status"
274+
testID="automation-keychain-status"
275+
value={keychainAuthStatus}
276+
/>
277+
</SectionCard>
278+
238279
<SectionCard title="Android push broadcast">
239280
<ActionButton
240281
kind="secondary"

src/__tests__/cli-grammar.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,17 @@ test('settings grammar owns positional parsing for CLI commands', () => {
205205
assert.equal(resetKeychain.setting, 'reset-keychain');
206206
assert.equal(resetKeychain.state, 'clear');
207207
});
208+
209+
test('settings reset-keychain rejects an extra app argument instead of dropping it', () => {
210+
assert.throws(
211+
() =>
212+
readInputFromCli('settings', ['reset-keychain', 'clear', 'com.example.app'], {
213+
...BASE_FLAGS,
214+
platform: 'ios',
215+
}),
216+
(err: any) => {
217+
assert.equal(err.code, 'INVALID_ARGS');
218+
return true;
219+
},
220+
);
221+
});

src/commands/capture/settings.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ function readSettingsOptionsFromPositionals(
107107
const app = state === 'clear' ? positionals[2] : state;
108108
return { ...base, setting, state: 'clear', app };
109109
}
110-
if (setting === 'reset-keychain' && state === 'clear') {
110+
if (setting === 'reset-keychain' && state === 'clear' && positionals.length === 2) {
111111
return { ...base, setting, state };
112112
}
113113
throw new AppError('INVALID_ARGS', 'Invalid settings arguments.');

src/daemon/handlers/__tests__/snapshot-settings-handler.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,27 @@ test('settings reset-keychain dispatches without an app id or active app session
158158
});
159159
});
160160

161+
test('settings reset-keychain rejects an extra app argument instead of dropping it', async () => {
162+
const sessionStore = makeSessionStore();
163+
const sessionName = 'ios-reset-keychain-extra-arg';
164+
sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice));
165+
166+
const response = await handleSnapshotCommands({
167+
req: snapshotRequest(sessionName, 'settings', {
168+
positionals: ['reset-keychain', 'clear', 'com.example.app'],
169+
}),
170+
sessionName,
171+
logPath: '/tmp/daemon.log',
172+
sessionStore,
173+
});
174+
175+
expect(response?.ok).toBe(false);
176+
if (response?.ok === false) {
177+
expect(response.error.code).toBe('INVALID_ARGS');
178+
}
179+
expect(fixtureSettingsMutations).toHaveLength(0);
180+
});
181+
161182
test('settings usage hint documents canonical faceid states', async () => {
162183
const sessionStore = makeSessionStore();
163184
const response = await handleSnapshotCommands({

src/daemon/handlers/snapshot-settings.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,10 @@ export function parseSettingsArgs(
6464
!setting ||
6565
!state ||
6666
(setting === 'permission' && !permissionTarget) ||
67-
(setting === 'location' && state === 'set' && (!req.positionals?.[2] || !req.positionals?.[3]))
67+
(setting === 'location' &&
68+
state === 'set' &&
69+
(!req.positionals?.[2] || !req.positionals?.[3])) ||
70+
(setting === 'reset-keychain' && req.positionals?.[2] !== undefined)
6871
) {
6972
return errorResponse('INVALID_ARGS', SETTINGS_INVALID_ARGS_MESSAGE);
7073
}

0 commit comments

Comments
 (0)