Files
orca/src/main/runtime/mobile-pairing-userdata-path.test.ts
T
NeilandOrca 46646d7ff1 chore(lint): upgrade oxlint to 1.71 + enable 7 new rules (autofixed backlog) (#6841)
* chore(lint): upgrade oxlint to 1.71 and enable 7 new rules

Upgrade oxlint 1.67.0 -> 1.71.0 (1.72 was blocked by the repo's 3-day
minimum-release-age supply-chain guard; nothing here needs it). The
bump is a no-op on the existing config.

Enable 3 error rules (backlog autofixed to zero in this commit) and
4 warn rules (surface signal without gating CI):

error (autofixed, behavior-preserving):
- unicorn/prefer-node-protocol        (~1531 sites: bare builtin -> node:)
- typescript/no-import-type-side-effects (~36: all-inline-type -> import type)
- unicorn/no-array-reverse            (19: copy-then-reverse -> toReversed)

warn (real signal, current fires are test-only/correct):
- unicorn/no-array-fill-with-reference-type  (aliasing footgun guard)
- typescript/no-unsafe-function-type         (bans bare Function type)
- unicorn/prefer-array-flat-map              (map().flat() -> flatMap())
- unicorn/prefer-regexp-test                 (.match() in bool ctx -> .test())

mobile/.oxlintrc.json extends root, so it inherits all 7; the autofix
ran from root and covered mobile/ too.

Verification (all green): oxlint 0 errors (root+mobile+aux configs),
oxfmt clean, typecheck (node+cli+web), vitest 22795 passed / 0 failed,
builds (electron-vite + web + cli) succeed. node: rewrites confirmed to
skip embedded SSH/CLI string payloads (AST-only); all toReversed sites
verified to operate on fresh copies or write-once locals.

* chore(lint): bump mobile oxlint to 1.71 so inherited rules parse

mobile/ is a standalone pnpm project pinning its own oxlint@1.67, which
lacks unicorn/no-array-fill-with-reference-type (needs >=1.70). Since
mobile/.oxlintrc.json extends the root config, mobile CI's 'cd mobile &&
oxlint' failed to parse the new rule. Bump mobile to match root (1.71).

Verified in mobile/: oxlint 0 errors, oxfmt --check clean, tsc --noEmit
pass, vitest 978 passed / 0 failed.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-06-29 22:38:29 -07:00

215 lines
9.0 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
// Import from the production source of truth so a filename rename can't silently
// pass these tests against stale names.
import { DEVICE_REGISTRY_FILENAME, E2EE_KEYPAIR_FILENAME } from './mobile-pairing-files'
// Mutable userData the electron mock resolves. We flip it mid-test to simulate
// app.setName('Orca') changing how app.getPath('userData') resolves (e.g. from
// lowercase 'orca' to uppercase 'Orca' on a case-sensitive filesystem) — the
// divergence that drops paired devices. We use two genuinely distinct directory
// names rather than case variants so the assertion is deterministic regardless
// of whether the test host's filesystem is case-sensitive.
const appState = { userData: '' }
vi.mock('electron', () => ({
app: { getPath: () => appState.userData },
safeStorage: {
isEncryptionAvailable: () => false,
encryptString: (plaintext: string) => Buffer.from(plaintext, 'utf-8'),
decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8')
}
}))
describe('mobile pairing userData path stability', () => {
let root: string
// The path persistence captures early, before app.setName().
let canonicalDir: string
// The path app.getPath('userData') resolves to after app.setName() — a
// distinct directory standing in for the post-rename resolution.
let lateDir: string
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'orca-pairing-path-'))
canonicalDir = join(root, 'userdata-early')
lateDir = join(root, 'userdata-late')
mkdirSync(canonicalDir, { recursive: true })
mkdirSync(lateDir, { recursive: true })
vi.resetModules()
})
afterEach(() => {
rmSync(root, { recursive: true, force: true })
vi.resetModules()
})
it('keeps returning the path captured before app.setName changes resolution', async () => {
appState.userData = canonicalDir
const { initDataPath, getCanonicalUserDataPath } = await import('../persistence')
initDataPath()
// app.setName('Orca') happens later in startup, changing late resolution.
appState.userData = lateDir
expect(getCanonicalUserDataPath()).toBe(canonicalDir)
const { app } = await import('electron')
expect(getCanonicalUserDataPath()).not.toBe(app.getPath('userData'))
})
it('writes DeviceRegistry + E2EE keypair under the canonical path, not the late one', async () => {
appState.userData = canonicalDir
const { initDataPath, getCanonicalUserDataPath } = await import('../persistence')
initDataPath()
appState.userData = lateDir // app.setName('Orca') has run by the time the runtime starts
const { DeviceRegistry } = await import('./device-registry')
const { loadOrCreateE2EEKeypair } = await import('./e2ee-keypair')
// Mirrors OrcaRuntimeRpcServer.start(): both read from the same userDataPath.
const registry = new DeviceRegistry(getCanonicalUserDataPath())
registry.addDevice('iPhone')
loadOrCreateE2EEKeypair(getCanonicalUserDataPath())
// Pairing credentials land beside orca-data.json so they survive restarts/updates.
expect(existsSync(join(canonicalDir, DEVICE_REGISTRY_FILENAME))).toBe(true)
expect(existsSync(join(canonicalDir, E2EE_KEYPAIR_FILENAME))).toBe(true)
// The bug being guarded: the late path would have captured these instead.
expect(existsSync(join(lateDir, DEVICE_REGISTRY_FILENAME))).toBe(false)
expect(existsSync(join(lateDir, E2EE_KEYPAIR_FILENAME))).toBe(false)
})
it('migrates existing mobile pairing files from the late path as an all-or-nothing pair', async () => {
appState.userData = canonicalDir
const {
initDataPath,
getCanonicalUserDataPath,
migrateMobilePairingDataToCanonicalUserDataPath
} = await import('../persistence')
initDataPath()
appState.userData = lateDir
const lateDevices = JSON.stringify([
{
deviceId: 'late-phone',
name: 'iPhone',
token: 'late-token',
scope: 'mobile',
pairedAt: 1,
lastSeenAt: 2
}
])
const lateKeypair = JSON.stringify({
v: 1,
publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64'),
secretKeyB64: Buffer.from(new Uint8Array(32).fill(2)).toString('base64')
})
writeFileSync(join(lateDir, DEVICE_REGISTRY_FILENAME), lateDevices)
writeFileSync(join(lateDir, E2EE_KEYPAIR_FILENAME), lateKeypair)
migrateMobilePairingDataToCanonicalUserDataPath(appState.userData)
expect(readFileSync(join(canonicalDir, DEVICE_REGISTRY_FILENAME), 'utf-8')).toBe(lateDevices)
expect(readFileSync(join(canonicalDir, E2EE_KEYPAIR_FILENAME), 'utf-8')).toBe(lateKeypair)
const { DeviceRegistry } = await import('./device-registry')
const registry = new DeviceRegistry(getCanonicalUserDataPath())
expect(registry.getDevice('late-phone')?.token).toBe('late-token')
writeFileSync(join(lateDir, DEVICE_REGISTRY_FILENAME), JSON.stringify([]))
migrateMobilePairingDataToCanonicalUserDataPath(appState.userData)
expect(readFileSync(join(canonicalDir, DEVICE_REGISTRY_FILENAME), 'utf-8')).toBe(lateDevices)
})
it('skips legacy migration when only part of the canonical credential pair exists', async () => {
appState.userData = canonicalDir
const { initDataPath, migrateMobilePairingDataToCanonicalUserDataPath } =
await import('../persistence')
initDataPath()
appState.userData = lateDir
const lateDevices = JSON.stringify([
{
deviceId: 'late-phone',
name: 'iPhone',
token: 'late-token',
scope: 'mobile',
pairedAt: 1,
lastSeenAt: 2
}
])
const lateKeypair = JSON.stringify({
v: 1,
publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64'),
secretKeyB64: Buffer.from(new Uint8Array(32).fill(2)).toString('base64')
})
const canonicalKeypair = JSON.stringify({
v: 1,
publicKeyB64: Buffer.from(new Uint8Array(32).fill(3)).toString('base64'),
secretKeyB64: Buffer.from(new Uint8Array(32).fill(4)).toString('base64')
})
writeFileSync(join(lateDir, DEVICE_REGISTRY_FILENAME), lateDevices)
writeFileSync(join(lateDir, E2EE_KEYPAIR_FILENAME), lateKeypair)
writeFileSync(join(canonicalDir, E2EE_KEYPAIR_FILENAME), canonicalKeypair)
migrateMobilePairingDataToCanonicalUserDataPath(appState.userData)
expect(existsSync(join(canonicalDir, DEVICE_REGISTRY_FILENAME))).toBe(false)
expect(readFileSync(join(canonicalDir, E2EE_KEYPAIR_FILENAME), 'utf-8')).toBe(canonicalKeypair)
})
it('no-ops when the source path equals the canonical path (no rename happened)', async () => {
// Case-insensitive filesystems (macOS/Windows) resolve both paths to the same
// dir, so migration must be a clean no-op rather than copy a file onto itself.
appState.userData = canonicalDir
const { initDataPath, migrateMobilePairingDataToCanonicalUserDataPath } =
await import('../persistence')
initDataPath()
const devices = JSON.stringify([
{ deviceId: 'phone', name: 'iPhone', token: 't', scope: 'mobile', pairedAt: 1, lastSeenAt: 2 }
])
writeFileSync(join(canonicalDir, DEVICE_REGISTRY_FILENAME), devices)
expect(() => migrateMobilePairingDataToCanonicalUserDataPath(canonicalDir)).not.toThrow()
expect(readFileSync(join(canonicalDir, DEVICE_REGISTRY_FILENAME), 'utf-8')).toBe(devices)
})
it('no-ops on a fresh install with no legacy pairing files to migrate', async () => {
appState.userData = canonicalDir
const { initDataPath, migrateMobilePairingDataToCanonicalUserDataPath } =
await import('../persistence')
initDataPath()
appState.userData = lateDir
expect(() => migrateMobilePairingDataToCanonicalUserDataPath(appState.userData)).not.toThrow()
expect(existsSync(join(canonicalDir, DEVICE_REGISTRY_FILENAME))).toBe(false)
expect(existsSync(join(canonicalDir, E2EE_KEYPAIR_FILENAME))).toBe(false)
})
it('a previously paired device is still found after a restart on the canonical path', async () => {
// First launch: pair a device while userData resolves to the canonical path.
appState.userData = canonicalDir
{
const { initDataPath, getCanonicalUserDataPath } = await import('../persistence')
initDataPath()
appState.userData = lateDir
const { DeviceRegistry } = await import('./device-registry')
new DeviceRegistry(getCanonicalUserDataPath()).addDevice('iPhone')
}
// Second launch (e.g. after an update): fresh module state, path captured again.
vi.resetModules()
appState.userData = canonicalDir
const { initDataPath, getCanonicalUserDataPath } = await import('../persistence')
initDataPath()
appState.userData = lateDir
const { DeviceRegistry } = await import('./device-registry')
const registry = new DeviceRegistry(getCanonicalUserDataPath())
expect(registry.listDevices().map((d) => d.name)).toContain('iPhone')
})
})