Skip to content

Commit df8999e

Browse files
committed
fix(accessibility): reject unknown audit types before they reach the device
1 parent d48d9dd commit df8999e

2 files changed

Lines changed: 110 additions & 4 deletions

File tree

src/services/ios/accessibility-audit/index.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,14 @@ export class AccessibilityAuditService {
117117
*/
118118
private settingTypes: Map<string, number | undefined> | undefined;
119119

120+
/**
121+
* Supported audit types, read once per connection.
122+
*
123+
* Like the setting catalogue this is fixed for the device, so validating a
124+
* name costs one round trip per connection rather than one per audit.
125+
*/
126+
private auditTypeNames: Set<string> | undefined;
127+
120128
private constructor(private readonly transport: AxAuditDtxTransport) {}
121129

122130
/**
@@ -264,6 +272,7 @@ export class AccessibilityAuditService {
264272
: undefined;
265273

266274
try {
275+
await this.assertKnownAuditTypes(auditTypes, options);
267276
if (options.targetPid !== undefined) {
268277
// Narrows the audit to one process; omitted, the daemon uses the
269278
// foreground app.
@@ -287,6 +296,27 @@ export class AccessibilityAuditService {
287296
}
288297
}
289298

299+
/**
300+
* Rejects audit types the device does not implement.
301+
*
302+
* An unrecognised name makes the daemon return neither issues nor a
303+
* completion, and that connection can never run another audit — so the name
304+
* must never reach the device.
305+
*/
306+
private async assertKnownAuditTypes(auditTypes: string[], options?: InvokeOptions): Promise<void> {
307+
if (auditTypes.length === 0) {
308+
return;
309+
}
310+
this.auditTypeNames ??= new Set(await this.getSupportedAuditTypes(options));
311+
const unknown = auditTypes.filter((auditType) => !this.auditTypeNames?.has(auditType));
312+
if (unknown.length > 0) {
313+
throw new Error(
314+
`Unknown audit type(s) ${unknown.map((auditType) => JSON.stringify(auditType)).join(', ')}; ` +
315+
`the device supports: ${[...(this.auditTypeNames ?? [])].join(', ')}`,
316+
);
317+
}
318+
}
319+
290320
/**
291321
* Returns the element the device's accessibility focus is currently on.
292322
*

test/integration/accessibility-audit.spec.ts

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import assert from 'node:assert/strict';
2-
import {after, before, describe, it} from 'node:test';
2+
import {type TestContext, after, before, describe, it} from 'node:test';
33

44
import {type AccessibilityAuditService} from '../../src/index.js';
55
import * as Services from '../../src/services.js';
@@ -21,11 +21,20 @@ import {requireDeviceUdid} from './helpers/device.js';
2121
// Generous: the audits alone take ~60s, and the settings tests deliberately
2222
// pace their writes because the device commits them asynchronously.
2323
describe('AccessibilityAuditService', {timeout: 300000}, function () {
24+
/** The selector the setting-write tests depend on. */
25+
const SETTING_WRITE_SELECTOR = 'deviceUpdateAccessibilitySetting:withValue:';
26+
2427
let service: AccessibilityAuditService | null = null;
28+
/** Whether this device's daemon implements setting writes at all. */
29+
let supportsSettingWrites = false;
2530

2631
before(async function () {
2732
const udid = requireDeviceUdid();
2833
service = await Services.startAccessibilityAuditService(udid);
34+
// Gate on what the daemon advertises rather than on an OS version: it
35+
// publishes the selectors it implements, which is both more precise and
36+
// stable across releases.
37+
supportsSettingWrites = (await service.getCapabilities()).includes(SETTING_WRITE_SELECTOR);
2938
});
3039

3140
after(function () {
@@ -174,7 +183,59 @@ describe('AccessibilityAuditService', {timeout: 300000}, function () {
174183
* rather than session-scoped, so running it against a configured device
175184
* would discard real settings.
176185
*/
186+
/**
187+
* An audit type the device does not implement makes the daemon return neither
188+
* issues nor a completion, and that connection can never run another audit —
189+
* so the name must be refused before it reaches the wire. Matching is exact:
190+
* case, surrounding whitespace and a bare prefix all wedge it.
191+
*/
192+
describe('audit type validation', function () {
193+
const POISON: Array<[string, string[]]> = [
194+
['an invented name', ['bogusAuditTypeThatDoesNotExist']],
195+
['the wrong case', ['TESTTYPECONTRAST']],
196+
['an empty string', ['']],
197+
['a bare prefix', ['testType']],
198+
['surrounding whitespace', [' testTypeContrast ']],
199+
];
200+
201+
for (const [label, auditTypes] of POISON) {
202+
it(`rejects ${label} without contacting the device`, async function () {
203+
await assert.rejects(() => service!.runAudit(auditTypes, {timeoutMs: 20000}), /Unknown audit type/);
204+
});
205+
}
206+
207+
it('rejects a valid type mixed with an unknown one', async function () {
208+
const types = await service!.getSupportedAuditTypes();
209+
210+
await assert.rejects(() => service!.runAudit([types[0], 'bogusType'], {timeoutMs: 20000}), /Unknown audit type/);
211+
});
212+
213+
it('still accepts an empty list, which the device reads as every type', async function () {
214+
// Rejecting this would be a regression: the daemon audits everything.
215+
assert.ok(Array.isArray(await service!.runAudit([], {timeoutMs: 60000})));
216+
});
217+
218+
it('leaves the connection usable after a rejected audit', async function (t) {
219+
const types = await service!.getSupportedAuditTypes();
220+
await assert.rejects(() => service!.runAudit(['nopeNotAType'], {timeoutMs: 20000}), /Unknown audit type/);
221+
222+
// Before the guard this call timed out permanently on this connection.
223+
const issues = await service!.runAudit(types, {timeoutMs: 60000});
224+
assert.ok(Array.isArray(issues));
225+
t.diagnostic(`audit after a rejected one returned ${issues.length} issue(s)`);
226+
});
227+
});
228+
177229
describe('accessibility settings', function () {
230+
/** Skips when the daemon does not implement setting writes. */
231+
function skipWithoutSettingWrites(t: TestContext): boolean {
232+
if (!supportsSettingWrites) {
233+
t.skip(`daemon does not implement ${SETTING_WRITE_SELECTOR}`);
234+
return true;
235+
}
236+
return false;
237+
}
238+
178239
/** Polls until `identifier` reads `expected`, since a write settles asynchronously. */
179240
async function waitForSetting(identifier: string, expected: unknown, timeoutMs = 8000): Promise<unknown> {
180241
const deadline = performance.now() + timeoutMs;
@@ -196,6 +257,9 @@ describe('AccessibilityAuditService', {timeout: 300000}, function () {
196257
}
197258

198259
it('quantises a slider value to the device tick marks', async function (t) {
260+
if (skipWithoutSettingWrites(t)) {
261+
return;
262+
}
199263
const settings = await service!.getAccessibilitySettings();
200264
const slider = settings.find((setting) => setting.identifier === 'DYNAMIC_TYPE');
201265
assert.ok(slider, 'DYNAMIC_TYPE should be present');
@@ -217,20 +281,29 @@ describe('AccessibilityAuditService', {timeout: 300000}, function () {
217281
}
218282
});
219283

220-
it('rejects an unknown setting identifier instead of silently doing nothing', async function () {
284+
it('rejects an unknown setting identifier instead of silently doing nothing', async function (t) {
285+
if (skipWithoutSettingWrites(t)) {
286+
return;
287+
}
221288
await assert.rejects(
222289
() => service!.setAccessibilitySetting('NOT_A_REAL_SETTING', true),
223290
/Unknown accessibility setting/,
224291
);
225292
});
226293

227-
it('rejects a value of the wrong kind for the setting', async function () {
294+
it('rejects a value of the wrong kind for the setting', async function (t) {
295+
if (skipWithoutSettingWrites(t)) {
296+
return;
297+
}
228298
// DYNAMIC_TYPE is a slider, GRAYSCALE a toggle.
229299
await assert.rejects(() => service!.setAccessibilitySetting('DYNAMIC_TYPE', true), /slider/);
230300
await assert.rejects(() => service!.setAccessibilitySetting('GRAYSCALE', 0.5 as unknown as boolean), /toggle/);
231301
});
232302

233-
it('rejects an out-of-range slider value without touching the device', async function () {
303+
it('rejects an out-of-range slider value without touching the device', async function (t) {
304+
if (skipWithoutSettingWrites(t)) {
305+
return;
306+
}
234307
const before = await currentValue('DYNAMIC_TYPE');
235308

236309
await assert.rejects(() => service!.setAccessibilitySetting('DYNAMIC_TYPE', -1), /between 0 and 1/);
@@ -269,6 +342,9 @@ describe('AccessibilityAuditService', {timeout: 300000}, function () {
269342

270343
/** Reports why a test is being skipped, or null when it may run. */
271344
async function skipReason(): Promise<string | null> {
345+
if (!supportsSettingWrites) {
346+
return `daemon does not implement ${SETTING_WRITE_SELECTOR}`;
347+
}
272348
if (!RESET_ALLOWED) {
273349
return 'set ALLOW_ACCESSIBILITY_SETTINGS_RESET=1 to run — this permanently resets the accessibility settings on the device';
274350
}

0 commit comments

Comments
 (0)