feat(robot): connect provisioning hotspots in app
This commit is contained in:
1 parent
abecd5f344
commit
c1326a2980
17 files changed
+1950
-11
No files matched your search
@@ -44,6 +44,23 @@ function setup(
|
||||
options: {
|
||||
guidedHotspotBinding?: boolean;
|
||||
openExternal?: (url: string) => Promise<void>;
|
||||
robotHotspot?: {
|
||||
scan: () => Promise<{
|
||||
platform: 'windows' | 'macos';
|
||||
hotspots: Array<{
|
||||
candidateId: string;
|
||||
ssid: string;
|
||||
signalPercent: number;
|
||||
connected: boolean;
|
||||
}>;
|
||||
}>;
|
||||
connect: (candidateId: string) => Promise<{
|
||||
connected: true;
|
||||
candidateId: string;
|
||||
ssid: string;
|
||||
}>;
|
||||
clear: () => void;
|
||||
};
|
||||
} = {},
|
||||
) {
|
||||
const getAccessToken = vi.fn().mockResolvedValue('secret-token');
|
||||
@@ -263,6 +280,183 @@ describe('AI hardware Host API route', () => {
|
||||
expect(getAccessToken).not.toHaveBeenCalled();
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scans and connects Robot hotspots locally before credentials or upstream access', async () => {
|
||||
const robotHotspot = {
|
||||
scan: vi.fn().mockResolvedValue({
|
||||
platform: 'windows' as const,
|
||||
hotspots: [{
|
||||
candidateId: 'candidate_123',
|
||||
ssid: 'Xiaozhi-Desk',
|
||||
signalPercent: 78,
|
||||
connected: false,
|
||||
nativeInterface: 'must-not-pass',
|
||||
}],
|
||||
}),
|
||||
connect: vi.fn().mockResolvedValue({
|
||||
connected: true as const,
|
||||
candidateId: 'candidate_123',
|
||||
ssid: 'Xiaozhi-Desk',
|
||||
profile: 'must-not-pass',
|
||||
}),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
const { handler, fetchImpl, getAccessToken } = setup(undefined, { robotHotspot });
|
||||
|
||||
const scanned = await invoke(
|
||||
handler,
|
||||
'POST',
|
||||
'/api/works/ai-hardware/provisioning-hotspots/scan',
|
||||
{},
|
||||
);
|
||||
const connected = await invoke(
|
||||
handler,
|
||||
'POST',
|
||||
'/api/works/ai-hardware/provisioning-hotspots/connect',
|
||||
{ candidate_id: 'candidate_123' },
|
||||
);
|
||||
|
||||
expect(scanned.payload).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
platform: 'windows',
|
||||
hotspots: [{
|
||||
candidate_id: 'candidate_123',
|
||||
ssid: 'Xiaozhi-Desk',
|
||||
signal_percent: 78,
|
||||
connected: false,
|
||||
}],
|
||||
},
|
||||
});
|
||||
expect(connected.payload).toEqual({
|
||||
success: true,
|
||||
data: { connected: true, candidate_id: 'candidate_123', ssid: 'Xiaozhi-Desk' },
|
||||
});
|
||||
expect(robotHotspot.connect).toHaveBeenCalledWith('candidate_123');
|
||||
expect(getAccessToken).not.toHaveBeenCalled();
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects hotspot queries, missing or expanded bodies, and invalid candidate IDs locally', async () => {
|
||||
const robotHotspot = {
|
||||
scan: vi.fn().mockResolvedValue({ platform: 'windows' as const, hotspots: [] }),
|
||||
connect: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
const { handler, fetchImpl, getAccessToken } = setup(undefined, { robotHotspot });
|
||||
const requests = [
|
||||
invoke(handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/scan?refresh=1', {}),
|
||||
invoke(handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/scan'),
|
||||
invoke(handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/scan', { extra: true }),
|
||||
invoke(handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/connect?retry=1', { candidate_id: 'candidate_123' }),
|
||||
invoke(handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/connect', {}),
|
||||
invoke(handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/connect', { candidate_id: '../ssid' }),
|
||||
invoke(handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/connect', {
|
||||
candidate_id: 'a'.repeat(129),
|
||||
}),
|
||||
invoke(handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/connect', {
|
||||
candidate_id: 'candidate_123', ssid: 'Xiaozhi-Evil',
|
||||
}),
|
||||
];
|
||||
|
||||
for (const result of await Promise.all(requests)) {
|
||||
expect(result.payload).toMatchObject({
|
||||
success: false,
|
||||
status: 400,
|
||||
code: 'AI_HARDWARE_INVALID_REQUEST',
|
||||
});
|
||||
}
|
||||
expect(robotHotspot.scan).not.toHaveBeenCalled();
|
||||
expect(robotHotspot.connect).not.toHaveBeenCalled();
|
||||
expect(getAccessToken).not.toHaveBeenCalled();
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables both local hotspot operations with the guided provisioning capability', async () => {
|
||||
const robotHotspot = {
|
||||
scan: vi.fn().mockResolvedValue({ platform: 'windows' as const, hotspots: [] }),
|
||||
connect: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
const disabled = setup(undefined, { guidedHotspotBinding: false, robotHotspot });
|
||||
|
||||
for (const result of await Promise.all([
|
||||
invoke(disabled.handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/scan', {}),
|
||||
invoke(disabled.handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/connect', {
|
||||
candidate_id: 'candidate_123',
|
||||
}),
|
||||
])) {
|
||||
expect(result.payload).toMatchObject({
|
||||
success: false,
|
||||
status: 403,
|
||||
code: 'AI_HARDWARE_PROVISIONING_DISABLED',
|
||||
});
|
||||
}
|
||||
expect(robotHotspot.scan).not.toHaveBeenCalled();
|
||||
expect(robotHotspot.connect).not.toHaveBeenCalled();
|
||||
expect(disabled.getAccessToken).not.toHaveBeenCalled();
|
||||
expect(disabled.fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unsupported', 501, 'AI_HARDWARE_HOTSPOT_UNSUPPORTED'],
|
||||
['permission_denied', 403, 'AI_HARDWARE_HOTSPOT_PERMISSION_DENIED'],
|
||||
['busy', 409, 'AI_HARDWARE_HOTSPOT_BUSY'],
|
||||
['candidate_expired', 409, 'AI_HARDWARE_HOTSPOT_CANDIDATE_EXPIRED'],
|
||||
['scan_failed', 502, 'AI_HARDWARE_HOTSPOT_SCAN_FAILED'],
|
||||
])('projects native hotspot scan error %s safely', async (nativeCode, status, code) => {
|
||||
const nativeError = Object.assign(new Error('native interface and location secret'), {
|
||||
code: nativeCode,
|
||||
});
|
||||
const robotHotspot = {
|
||||
scan: vi.fn().mockRejectedValue(nativeError),
|
||||
connect: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
const local = setup(undefined, { robotHotspot });
|
||||
const result = await invoke(
|
||||
local.handler,
|
||||
'POST',
|
||||
'/api/works/ai-hardware/provisioning-hotspots/scan',
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.payload).toMatchObject({ success: false, status, code, retryable: false });
|
||||
expect(JSON.stringify(result.payload)).not.toContain('native interface');
|
||||
expect(local.getAccessToken).not.toHaveBeenCalled();
|
||||
expect(local.fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('projects native connect failures safely', async () => {
|
||||
const robotHotspot = {
|
||||
scan: vi.fn().mockResolvedValue({ platform: 'macos' as const, hotspots: [] }),
|
||||
connect: vi.fn().mockRejectedValue(Object.assign(new Error('native profile secret'), {
|
||||
code: 'connect_failed',
|
||||
})),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
const local = setup(undefined, { robotHotspot });
|
||||
const result = await invoke(
|
||||
local.handler,
|
||||
'POST',
|
||||
'/api/works/ai-hardware/provisioning-hotspots/connect',
|
||||
{ candidate_id: 'candidate_123' },
|
||||
);
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.payload).toEqual({
|
||||
success: false,
|
||||
status: 502,
|
||||
code: 'AI_HARDWARE_HOTSPOT_CONNECT_FAILED',
|
||||
error: 'The Robot hotspot could not be connected',
|
||||
retryable: false,
|
||||
});
|
||||
expect(JSON.stringify(result.payload)).not.toContain('native profile secret');
|
||||
expect(local.getAccessToken).not.toHaveBeenCalled();
|
||||
expect(local.fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('proxies only the fixed catalog query and projects its safe DTO', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({
|
||||
schema_version: 1,
|
||||
|
||||
Reference in new issue
Block a user