feat(robot): add guided hotspot binding flow
This commit is contained in:
1 parent
54443232dd
commit
b7a1590ca1
7 files changed
+855
-18
No files matched your search
@@ -39,7 +39,13 @@ function jsonResponse(value: unknown, init: ResponseInit = {}): Response {
|
||||
return new Response(JSON.stringify(value), { ...init, headers });
|
||||
}
|
||||
|
||||
function setup(fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(overview))) {
|
||||
function setup(
|
||||
fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(overview)),
|
||||
options: {
|
||||
guidedHotspotBinding?: boolean;
|
||||
openExternal?: (url: string) => Promise<void>;
|
||||
} = {},
|
||||
) {
|
||||
const getAccessToken = vi.fn().mockResolvedValue('secret-token');
|
||||
const handler = createAiHardwareRouteHandler({
|
||||
fetchImpl,
|
||||
@@ -47,6 +53,7 @@ function setup(fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(
|
||||
apiBaseUrl: 'https://square.example',
|
||||
randomUuid: () => '11111111-1111-4111-8111-111111111111',
|
||||
timeoutMs: 10,
|
||||
...options,
|
||||
});
|
||||
return { handler, fetchImpl, getAccessToken };
|
||||
}
|
||||
@@ -58,6 +65,141 @@ async function invoke(handler: ReturnType<typeof createAiHardwareRouteHandler>,
|
||||
}
|
||||
|
||||
describe('AI hardware Host API route', () => {
|
||||
it('returns the default-off provisioning capability before credentials or upstream access', async () => {
|
||||
const { handler, fetchImpl, getAccessToken } = setup();
|
||||
const result = await invoke(
|
||||
handler,
|
||||
'GET',
|
||||
'/api/works/ai-hardware/provisioning-capabilities',
|
||||
);
|
||||
|
||||
expect(result.payload).toEqual({
|
||||
success: true,
|
||||
data: { guided_hotspot_binding: false },
|
||||
});
|
||||
expect(getAccessToken).not.toHaveBeenCalled();
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects queried, body-bearing, and wrong-method capability requests locally', async () => {
|
||||
const { handler, fetchImpl, getAccessToken } = setup();
|
||||
const queried = await invoke(
|
||||
handler,
|
||||
'GET',
|
||||
'/api/works/ai-hardware/provisioning-capabilities?enabled=1',
|
||||
);
|
||||
const bodyBearing = await invoke(
|
||||
handler,
|
||||
'GET',
|
||||
'/api/works/ai-hardware/provisioning-capabilities',
|
||||
{},
|
||||
);
|
||||
const wrongMethod = await invoke(
|
||||
handler,
|
||||
'POST',
|
||||
'/api/works/ai-hardware/provisioning-capabilities',
|
||||
{},
|
||||
);
|
||||
|
||||
expect(queried.payload).toMatchObject({ code: 'AI_HARDWARE_INVALID_REQUEST' });
|
||||
expect(bodyBearing.payload).toMatchObject({ code: 'AI_HARDWARE_INVALID_REQUEST' });
|
||||
expect(wrongMethod.payload).toMatchObject({ code: 'AI_HARDWARE_ROUTE_NOT_FOUND' });
|
||||
expect(getAccessToken).not.toHaveBeenCalled();
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens only the Main-owned portal when enabled and never reads cloud credentials', async () => {
|
||||
const openExternal = vi.fn().mockResolvedValue(undefined);
|
||||
const { handler, fetchImpl, getAccessToken } = setup(undefined, {
|
||||
guidedHotspotBinding: true,
|
||||
openExternal,
|
||||
});
|
||||
const result = await invoke(
|
||||
handler,
|
||||
'POST',
|
||||
'/api/works/ai-hardware/provisioning-portal/open',
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result.payload).toEqual({ success: true, data: { opened: true } });
|
||||
expect(openExternal).toHaveBeenCalledWith('http://192.168.4.1/');
|
||||
expect(getAccessToken).not.toHaveBeenCalled();
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects disabled, expanded, or queried portal opens before invoking native or cloud dependencies', async () => {
|
||||
const openExternal = vi.fn().mockResolvedValue(undefined);
|
||||
const disabled = setup(undefined, { openExternal });
|
||||
const disabledResult = await invoke(
|
||||
disabled.handler,
|
||||
'POST',
|
||||
'/api/works/ai-hardware/provisioning-portal/open',
|
||||
{},
|
||||
);
|
||||
expect(disabledResult.payload).toMatchObject({
|
||||
success: false,
|
||||
status: 403,
|
||||
code: 'AI_HARDWARE_PROVISIONING_DISABLED',
|
||||
retryable: false,
|
||||
});
|
||||
|
||||
const enabled = setup(undefined, { guidedHotspotBinding: true, openExternal });
|
||||
const expanded = await invoke(
|
||||
enabled.handler,
|
||||
'POST',
|
||||
'/api/works/ai-hardware/provisioning-portal/open',
|
||||
{ url: 'http://unsafe.example' },
|
||||
);
|
||||
const queried = await invoke(
|
||||
enabled.handler,
|
||||
'POST',
|
||||
'/api/works/ai-hardware/provisioning-portal/open?url=unsafe',
|
||||
{},
|
||||
);
|
||||
const missingBody = await invoke(
|
||||
enabled.handler,
|
||||
'POST',
|
||||
'/api/works/ai-hardware/provisioning-portal/open',
|
||||
);
|
||||
const wrongMethod = await invoke(
|
||||
enabled.handler,
|
||||
'GET',
|
||||
'/api/works/ai-hardware/provisioning-portal/open',
|
||||
);
|
||||
expect(expanded.payload).toMatchObject({ code: 'AI_HARDWARE_INVALID_REQUEST' });
|
||||
expect(queried.payload).toMatchObject({ code: 'AI_HARDWARE_INVALID_REQUEST' });
|
||||
expect(missingBody.payload).toMatchObject({ code: 'AI_HARDWARE_INVALID_REQUEST' });
|
||||
expect(wrongMethod.payload).toMatchObject({ code: 'AI_HARDWARE_ROUTE_NOT_FOUND' });
|
||||
expect(openExternal).not.toHaveBeenCalled();
|
||||
expect(disabled.getAccessToken).not.toHaveBeenCalled();
|
||||
expect(enabled.getAccessToken).not.toHaveBeenCalled();
|
||||
expect(disabled.fetchImpl).not.toHaveBeenCalled();
|
||||
expect(enabled.fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redacts native portal opener failures without reading credentials or calling upstream', async () => {
|
||||
const openExternal = vi.fn().mockRejectedValue(new Error('native path secret'));
|
||||
const { handler, fetchImpl, getAccessToken } = setup(undefined, {
|
||||
guidedHotspotBinding: true,
|
||||
openExternal,
|
||||
});
|
||||
const result = await invoke(
|
||||
handler,
|
||||
'POST',
|
||||
'/api/works/ai-hardware/provisioning-portal/open',
|
||||
{},
|
||||
);
|
||||
expect(result.payload).toEqual({
|
||||
success: false,
|
||||
status: 502,
|
||||
code: 'AI_HARDWARE_PORTAL_OPEN_FAILED',
|
||||
error: 'AI hardware provisioning portal could not be opened',
|
||||
retryable: false,
|
||||
});
|
||||
expect(JSON.stringify(result.payload)).not.toContain('native path secret');
|
||||
expect(getAccessToken).not.toHaveBeenCalled();
|
||||
expect(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,
|
||||
@@ -142,6 +284,35 @@ describe('AI hardware Host API route', () => {
|
||||
expect(fetchImpl.mock.calls[0][0]).toBe('https://square.example/api/ai-hardware/device-bindings');
|
||||
});
|
||||
|
||||
it('forces invalid activation codes to non-retryable even when upstream says retryable', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({ detail: {
|
||||
error_code: 'ai_hardware_activation_code_invalid',
|
||||
message: 'secret activation detail',
|
||||
retryable: true,
|
||||
} }, { status: 422 }));
|
||||
const { handler } = setup(fetchImpl);
|
||||
const result = await invoke(
|
||||
handler,
|
||||
'POST',
|
||||
'/api/works/ai-hardware/device-bindings',
|
||||
{
|
||||
activation_code: '123456',
|
||||
agent_id: 'a-1',
|
||||
client_operation_id: '22222222-2222-4222-8222-222222222222',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.payload).toEqual({
|
||||
success: false,
|
||||
status: 422,
|
||||
code: 'ai_hardware_activation_code_invalid',
|
||||
error: 'AI hardware activation code is invalid',
|
||||
retryable: false,
|
||||
operation_id: '22222222-2222-4222-8222-222222222222',
|
||||
});
|
||||
expect(JSON.stringify(result.payload)).not.toContain('secret activation detail');
|
||||
});
|
||||
|
||||
it('validates a strong ETag and returns its numeric revision in the envelope', async () => {
|
||||
const config = {
|
||||
id: 'a-1', name: 'Desk', config_revision: 0,
|
||||
|
||||
Reference in new issue
Block a user