fix(mobile): ignore cancelled password completions
Track each native mobile password-unlock operation with a monotonic generation. Cancelling the password prompt immediately invalidates the active operation and releases the busy state.\n\nEvery asynchronous login boundary now verifies that its operation is still current before reporting errors, writing a client token, persisting connection metadata, switching the active runtime, or notifying the connected surface. A stale relay login still closes its unadopted tunnel, while its finally block cannot clear the busy state of a newer password attempt.\n\nAdd a focused regression test that models a deferred password completion after cancellation and verifies it cannot apply the runtime-switch side effect.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import { loadMobileConnections, migrateLegacyInlineTokenRecords, upsertMobileConnection, validateMobileConnectionSession, type MobileRelayConfig } from './mobileConnections';
|
||||
import { createMobilePasswordOperationTracker, loadMobileConnections, migrateLegacyInlineTokenRecords, upsertMobileConnection, validateMobileConnectionSession, type MobileRelayConfig } from './mobileConnections';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalWindow = globalThis.window;
|
||||
@@ -40,6 +40,24 @@ const testRelay: MobileRelayConfig = {
|
||||
};
|
||||
|
||||
describe('mobile connection storage', () => {
|
||||
test('cancellation invalidates an in-flight password completion', async () => {
|
||||
const tracker = createMobilePasswordOperationTracker();
|
||||
const operation = tracker.begin();
|
||||
let resolveLogin: () => void = () => {
|
||||
throw new Error('Login was not started');
|
||||
};
|
||||
let switchedRuntime = false;
|
||||
const completion = new Promise<void>((resolve) => { resolveLogin = resolve; }).then(() => {
|
||||
if (tracker.isCurrent(operation)) switchedRuntime = true;
|
||||
});
|
||||
|
||||
tracker.cancel();
|
||||
resolveLogin();
|
||||
await completion;
|
||||
|
||||
expect(switchedRuntime).toBe(false);
|
||||
});
|
||||
|
||||
test('removes inline tokens only after each secure migration succeeds', async () => {
|
||||
const result = await migrateLegacyInlineTokenRecords([
|
||||
{ id: 'ok', url: 'http://ok.example', clientToken: 'token-ok' },
|
||||
|
||||
@@ -72,6 +72,20 @@ const MOBILE_SECURE_TIMEOUT_MS = 3000;
|
||||
// feels instant instead of hanging for seconds.
|
||||
const MOBILE_FAST_PROBE_TIMEOUT_MS = 2500;
|
||||
|
||||
export const createMobilePasswordOperationTracker = () => {
|
||||
let current = 0;
|
||||
return {
|
||||
begin: (): number => {
|
||||
current += 1;
|
||||
return current;
|
||||
},
|
||||
cancel: (): void => {
|
||||
current += 1;
|
||||
},
|
||||
isCurrent: (operation: number): boolean => operation === current,
|
||||
};
|
||||
};
|
||||
|
||||
export type MobileConnectionMode = 'direct' | 'relay';
|
||||
|
||||
// Persisted relay transport config. This is connection metadata, not a secret
|
||||
@@ -1313,6 +1327,7 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
|
||||
const [pendingConnection, setPendingConnection] = React.useState<MobilePendingConnection | null>(null);
|
||||
const connectionsRef = React.useRef(connections);
|
||||
const busyRef = React.useRef<'connect' | 'password' | 'pairing' | null>(null);
|
||||
const passwordOperationRef = React.useRef(createMobilePasswordOperationTracker());
|
||||
|
||||
const applyConnections = React.useCallback((next: MobileSavedConnection[]) => {
|
||||
connectionsRef.current = next;
|
||||
@@ -1496,6 +1511,8 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
|
||||
if (!pendingConnection || !password.trim() || busyRef.current === 'password') return;
|
||||
setError(null);
|
||||
beginBusy('password');
|
||||
const operation = passwordOperationRef.current.begin();
|
||||
const isCurrentOperation = () => passwordOperationRef.current.isCurrent(operation);
|
||||
const { id, label, candidates } = pendingConnection;
|
||||
// A chosen relay transport owns an open tunnel; close it unless the switch
|
||||
// adopted it as the runtime tunnel.
|
||||
@@ -1506,6 +1523,7 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
|
||||
// tunnel; cookies never cross it, so an issued bearer token is mandatory
|
||||
// there. `issueClientToken` mints the device's token in one round-trip.
|
||||
chosen = await establishLiveTransport(candidates);
|
||||
if (!isCurrentOperation()) return;
|
||||
if (!chosen) {
|
||||
setError(t('mobile.connect.error.unreachable'));
|
||||
return;
|
||||
@@ -1522,12 +1540,14 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
|
||||
const response = chosen.kind === 'relay'
|
||||
? await raceWithTimeout(RELAY_CONNECT_TIMEOUT_MS, chosen.tunnel.fetch('/auth/session', loginInit).catch(() => null))
|
||||
: await requestWithTimeout(`${chosen.url}/auth/session`, loginInit);
|
||||
if (!isCurrentOperation()) return;
|
||||
logConnect('password:done', { ok: response?.ok === true, status: response?.status ?? null });
|
||||
if (!response?.ok) {
|
||||
setError(t('mobile.connect.error.passwordFailed'));
|
||||
return;
|
||||
}
|
||||
const body = await response.json().catch(() => null) as { clientToken?: unknown } | null;
|
||||
if (!isCurrentOperation()) return;
|
||||
const issuedToken = typeof body?.clientToken === 'string' ? body.clientToken.trim() : '';
|
||||
logConnect('password:token', { issued: Boolean(issuedToken) });
|
||||
|
||||
@@ -1548,8 +1568,11 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
|
||||
|
||||
// Persist the token BEFORE switching (no fire-and-forget).
|
||||
if (isCapacitorApp()) {
|
||||
if (!isCurrentOperation()) return;
|
||||
await writeSecureToken(secureTokenKeyOf({ candidates }), issuedToken);
|
||||
if (!isCurrentOperation()) return;
|
||||
}
|
||||
if (!isCurrentOperation()) return;
|
||||
persistMetadata({ id, label, candidates, clientToken: issuedToken });
|
||||
setPendingConnection(null);
|
||||
// A relay transport hands its live login tunnel to the runtime (adopted
|
||||
@@ -1560,20 +1583,24 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
|
||||
{ runtimeKey: secureTokenKeyOf({ candidates }) },
|
||||
);
|
||||
adopted = chosen.kind === 'relay';
|
||||
if (!isCurrentOperation()) return;
|
||||
onConnected();
|
||||
} catch (error) {
|
||||
if (!isCurrentOperation()) return;
|
||||
console.warn('[mobile-connect] password threw', error);
|
||||
setError(t('mobile.connect.error.passwordFailed'));
|
||||
} finally {
|
||||
if (!adopted && chosen?.kind === 'relay') chosen.tunnel.close();
|
||||
endBusy('password');
|
||||
if (isCurrentOperation()) endBusy('password');
|
||||
}
|
||||
}, [beginBusy, endBusy, onConnected, pendingConnection, persistMetadata, t]);
|
||||
|
||||
const cancelPassword = React.useCallback(() => {
|
||||
passwordOperationRef.current.cancel();
|
||||
endBusy('password');
|
||||
setPendingConnection(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
}, [endBusy]);
|
||||
|
||||
const saveConnection = React.useCallback(async (input: MobileConnectInput): Promise<MobileSavedConnection | null> => {
|
||||
setError(null);
|
||||
|
||||
Reference in New Issue
Block a user