feat(robot): add guided hotspot binding flow
This commit is contained in:
@@ -6,11 +6,13 @@ import {
|
||||
getAiHardwareAgentConfiguration,
|
||||
getAiHardwareAssignment,
|
||||
getAiHardwareOverview,
|
||||
getAiHardwareProvisioningCapabilities,
|
||||
getAiHardwareConfigurationCatalog,
|
||||
updateAiHardwareAgentConfiguration,
|
||||
updateAiHardwareAssignment,
|
||||
createAiHardwareOperationId,
|
||||
recoverAiHardwareCredential,
|
||||
openAiHardwareProvisioningPortal,
|
||||
} from '@/lib/ai-hardware';
|
||||
|
||||
const hostApiFetchMock = vi.hoisted(() => vi.fn());
|
||||
@@ -57,6 +59,37 @@ describe('AI hardware renderer API', () => {
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/ai-hardware', undefined);
|
||||
});
|
||||
|
||||
it('reads the Main-owned provisioning capability and opens only the fixed portal action', async () => {
|
||||
hostApiFetchMock
|
||||
.mockResolvedValueOnce({ success: true, data: { guided_hotspot_binding: true } })
|
||||
.mockResolvedValueOnce({ success: true, data: { opened: true } });
|
||||
|
||||
await expect(getAiHardwareProvisioningCapabilities()).resolves.toEqual({
|
||||
guidedHotspotBinding: true,
|
||||
});
|
||||
await expect(openAiHardwareProvisioningPortal()).resolves.toEqual({ opened: true });
|
||||
expect(hostApiFetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/works/ai-hardware/provisioning-capabilities',
|
||||
undefined,
|
||||
);
|
||||
expect(hostApiFetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/works/ai-hardware/provisioning-portal/open',
|
||||
{ method: 'POST', body: '{}' },
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ guided_hotspot_binding: 'true' },
|
||||
{ guided_hotspot_binding: false, portal_url: 'http://unsafe.example' },
|
||||
])('rejects malformed or expanded provisioning capability data', async (data) => {
|
||||
hostApiFetchMock.mockResolvedValueOnce({ success: true, data });
|
||||
await expect(getAiHardwareProvisioningCapabilities()).rejects.toMatchObject({
|
||||
code: 'AI_HARDWARE_INVALID_RESPONSE',
|
||||
});
|
||||
});
|
||||
|
||||
it('strictly reads the safe configuration catalog and encodes the TTS dependency', async () => {
|
||||
const catalog = {
|
||||
schema_version: 1,
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getAiHardwareOverview: vi.fn(),
|
||||
getAiHardwareProvisioningCapabilities: vi.fn(),
|
||||
openAiHardwareProvisioningPortal: vi.fn(),
|
||||
recoverAiHardwareCredential: vi.fn(),
|
||||
createAiHardwareAgent: vi.fn(),
|
||||
bindAiHardwareDevice: vi.fn(),
|
||||
@@ -88,6 +90,8 @@ async function openConfigurationEditor(): Promise<void> {
|
||||
describe('AI hardware page', () => {
|
||||
beforeEach(() => {
|
||||
api.getAiHardwareOverview.mockResolvedValue(activeOverview);
|
||||
api.getAiHardwareProvisioningCapabilities.mockResolvedValue({ guidedHotspotBinding: false });
|
||||
api.openAiHardwareProvisioningPortal.mockResolvedValue({ opened: true });
|
||||
api.recoverAiHardwareCredential.mockResolvedValue({ status: 'active', agents: [], devices: [] });
|
||||
api.getAiHardwareAgentConfiguration.mockResolvedValue({ data: configuration, revision: 4 });
|
||||
api.getAiHardwareConfigurationCatalog.mockResolvedValue(catalog);
|
||||
@@ -160,11 +164,144 @@ describe('AI hardware page', () => {
|
||||
fireEvent.change(input, { target: { value: secret } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '绑定' }));
|
||||
await waitFor(() => expect(api.bindAiHardwareDevice).toHaveBeenCalledWith(secret, agentOne.id));
|
||||
await waitFor(() => expect(input).toHaveValue(''));
|
||||
await waitFor(() => expect(screen.getByLabelText('6 位激活码')).toHaveValue(''));
|
||||
expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('激活码无效');
|
||||
});
|
||||
|
||||
it('keeps the existing direct-code binding experience when guided provisioning is disabled', async () => {
|
||||
render(<AiHardware />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '绑定设备' }));
|
||||
|
||||
expect(screen.getByRole('heading', { name: '输入 6 位激活码' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '开始引导配网' })).not.toBeInTheDocument();
|
||||
expect(api.openAiHardwareProvisioningPortal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('guides the existing hotspot flow, locks navigation while binding, and avoids online claims', async () => {
|
||||
api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true });
|
||||
const binding = deferred<typeof device>();
|
||||
api.bindAiHardwareDevice.mockReturnValueOnce(binding.promise);
|
||||
render(<AiHardware />);
|
||||
await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '绑定设备' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '开始引导配网' }));
|
||||
expect(screen.getByText(/设备热点没有加密保护/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' }));
|
||||
expect(screen.getByText(/Xiaozhi-\*/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '上一步' }));
|
||||
expect(screen.getByRole('heading', { name: '准备机器人' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '电脑已连接设备热点' }));
|
||||
expect(screen.getByText(/Makelore 不读取或保存/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开设备配网页面' }));
|
||||
await waitFor(() => expect(api.openAiHardwareProvisioningPortal).toHaveBeenCalledOnce());
|
||||
fireEvent.click(screen.getByRole('button', { name: '我已完成设备配网' }));
|
||||
expect(screen.getByText(/不会撤销机器人已经保存的 Wi-Fi 设置/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '电脑已恢复联网' }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText('6 位激活码'), { target: { value: '031425' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '绑定' }));
|
||||
expect(await screen.findByRole('heading', { name: '正在绑定设备' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '取消' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '上一步' })).not.toBeInTheDocument();
|
||||
|
||||
binding.resolve(device);
|
||||
expect(await screen.findByRole('heading', { name: '绑定成功' })).toBeInTheDocument();
|
||||
expect(screen.getByText('绑定成功不代表设备已经上线,请等待机器人完成连接。')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/设备已就绪/)).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '完成' }));
|
||||
await waitFor(() => expect(screen.queryByRole('heading', { name: '绑定成功' })).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('supports every legal guided back transition and the direct-path return to the chooser', async () => {
|
||||
api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true });
|
||||
render(<AiHardware />);
|
||||
await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled());
|
||||
fireEvent.click(await screen.findByRole('button', { name: '绑定设备' }));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '我已有 6 位激活码' }));
|
||||
expect(screen.getByRole('heading', { name: '输入 6 位激活码' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '上一步' }));
|
||||
expect(screen.getByRole('heading', { name: '绑定机器人设备' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '开始引导配网' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '上一步' }));
|
||||
expect(screen.getByRole('heading', { name: '绑定机器人设备' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '开始引导配网' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '上一步' }));
|
||||
expect(screen.getByRole('heading', { name: '准备机器人' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '电脑已连接设备热点' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '上一步' }));
|
||||
expect(screen.getByRole('heading', { name: '连接设备热点' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '电脑已连接设备热点' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开设备配网页面' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '我已完成设备配网' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '上一步' }));
|
||||
expect(screen.getByRole('heading', { name: '配置机器人 Wi-Fi' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '我已完成设备配网' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '电脑已恢复联网' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '上一步' }));
|
||||
expect(screen.getByRole('heading', { name: '恢复电脑网络' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('cancels before portal handoff and starts a fresh chooser state', async () => {
|
||||
api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true });
|
||||
render(<AiHardware />);
|
||||
await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled());
|
||||
fireEvent.click(await screen.findByRole('button', { name: '绑定设备' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '开始引导配网' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '绑定设备' }));
|
||||
expect(screen.getByRole('heading', { name: '绑定机器人设备' })).toBeInTheDocument();
|
||||
expect(api.openAiHardwareProvisioningPortal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a safe portal error and clears guided state when cancelled after portal handoff', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true });
|
||||
api.openAiHardwareProvisioningPortal
|
||||
.mockRejectedValueOnce(new AiHardwareApiError({
|
||||
status: 502,
|
||||
code: 'AI_HARDWARE_PORTAL_OPEN_FAILED',
|
||||
message: 'native secret path',
|
||||
}))
|
||||
.mockResolvedValueOnce({ opened: true });
|
||||
render(<AiHardware />);
|
||||
await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled());
|
||||
fireEvent.click(await screen.findByRole('button', { name: '绑定设备' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '开始引导配网' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '电脑已连接设备热点' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开设备配网页面' }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('无法打开设备配网页面');
|
||||
expect(screen.queryByText(/native secret path/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('http://192.168.4.1/')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制地址' }));
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith('http://192.168.4.1/'));
|
||||
expect(screen.getByRole('status')).toHaveTextContent('固定地址已复制');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开设备配网页面' }));
|
||||
await waitFor(() => expect(api.openAiHardwareProvisioningPortal).toHaveBeenCalledTimes(2));
|
||||
fireEvent.click(screen.getByRole('button', { name: '我已完成设备配网' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '绑定设备' }));
|
||||
expect(screen.getByRole('button', { name: '开始引导配网' })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/不会撤销机器人已经保存的 Wi-Fi 设置/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends cleared nullable configuration fields through clear_fields and never sends null', async () => {
|
||||
render(<AiHardware />);
|
||||
await openConfigurationEditor();
|
||||
@@ -366,6 +503,26 @@ describe('AI hardware page', () => {
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('7 秒后重试');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ai_hardware_device_already_bound', '设备已被绑定'],
|
||||
['ai_hardware_state_conflict', '设备状态已变化'],
|
||||
])('refreshes overview after the binding result %s', async (code, message) => {
|
||||
api.bindAiHardwareDevice.mockRejectedValueOnce(new AiHardwareApiError({
|
||||
status: 409,
|
||||
code,
|
||||
message: 'private upstream state',
|
||||
}));
|
||||
render(<AiHardware />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '绑定设备' }));
|
||||
fireEvent.change(screen.getByLabelText('6 位激活码'), { target: { value: '031425' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '绑定' }));
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(message);
|
||||
await waitFor(() => expect(api.getAiHardwareOverview).toHaveBeenCalledTimes(2));
|
||||
expect(screen.getByRole('heading', { name: '输入 6 位激活码' })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/private upstream state/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reuses the server operation id only when retrying the same binding body', async () => {
|
||||
api.bindAiHardwareDevice
|
||||
.mockRejectedValueOnce(new AiHardwareApiError({
|
||||
@@ -378,8 +535,9 @@ describe('AI hardware page', () => {
|
||||
const input = screen.getByLabelText('6 位激活码');
|
||||
fireEvent.change(input, { target: { value: '031425' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '绑定' }));
|
||||
await waitFor(() => expect(input).toHaveValue(''));
|
||||
fireEvent.change(input, { target: { value: '031425' } });
|
||||
const retryInput = await screen.findByLabelText('6 位激活码');
|
||||
expect(retryInput).toHaveValue('');
|
||||
fireEvent.change(retryInput, { target: { value: '031425' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '绑定' }));
|
||||
await waitFor(() => expect(api.bindAiHardwareDevice).toHaveBeenCalledTimes(2));
|
||||
expect(api.bindAiHardwareDevice.mock.calls[1]).toEqual([
|
||||
@@ -387,6 +545,34 @@ describe('AI hardware page', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('refreshes overview on remount and never reuses a binding operation identity across restart', async () => {
|
||||
const operationId = '123e4567-e89b-42d3-a456-426614174000';
|
||||
api.bindAiHardwareDevice
|
||||
.mockRejectedValueOnce(new AiHardwareApiError({
|
||||
status: 409,
|
||||
code: 'ai_hardware_operation_in_progress',
|
||||
message: 'private',
|
||||
retryable: true,
|
||||
operationId,
|
||||
}))
|
||||
.mockResolvedValueOnce(device);
|
||||
const first = render(<AiHardware />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '绑定设备' }));
|
||||
fireEvent.change(screen.getByLabelText('6 位激活码'), { target: { value: '031425' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '绑定' }));
|
||||
await screen.findByLabelText('6 位激活码');
|
||||
first.unmount();
|
||||
|
||||
render(<AiHardware />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '绑定设备' }));
|
||||
fireEvent.change(screen.getByLabelText('6 位激活码'), { target: { value: '654321' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '绑定' }));
|
||||
|
||||
await waitFor(() => expect(api.bindAiHardwareDevice).toHaveBeenCalledTimes(2));
|
||||
expect(api.bindAiHardwareDevice.mock.calls[1]).toEqual(['654321', agentOne.id]);
|
||||
await waitFor(() => expect(api.getAiHardwareOverview).toHaveBeenCalledTimes(3));
|
||||
});
|
||||
|
||||
it('does not retain an activation code and starts a fresh operation for a different code', async () => {
|
||||
const operationId = '123e4567-e89b-42d3-a456-426614174000';
|
||||
api.bindAiHardwareDevice
|
||||
@@ -401,17 +587,44 @@ describe('AI hardware page', () => {
|
||||
|
||||
fireEvent.change(input, { target: { value: '031425' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '绑定' }));
|
||||
await waitFor(() => expect(input).toHaveValue(''));
|
||||
const nextInput = await screen.findByLabelText('6 位激活码');
|
||||
expect(nextInput).toHaveValue('');
|
||||
expect(screen.queryByText(/031425/)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(input, { target: { value: '654321' } });
|
||||
fireEvent.change(nextInput, { target: { value: '654321' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '绑定' }));
|
||||
await waitFor(() => expect(api.bindAiHardwareDevice).toHaveBeenCalledTimes(2));
|
||||
expect(api.bindAiHardwareDevice.mock.calls[1]).toEqual(['654321', agentOne.id]);
|
||||
await waitFor(() => expect(input).toHaveValue(''));
|
||||
await screen.findByRole('heading', { name: '绑定成功' });
|
||||
expect(screen.queryByText(/031425|654321/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('drops an invalid-code operation identity even if the error is incorrectly marked retryable', async () => {
|
||||
const operationId = '123e4567-e89b-42d3-a456-426614174000';
|
||||
api.bindAiHardwareDevice
|
||||
.mockRejectedValueOnce(new AiHardwareApiError({
|
||||
status: 422,
|
||||
code: 'ai_hardware_activation_code_invalid',
|
||||
message: 'private',
|
||||
retryable: true,
|
||||
operationId,
|
||||
}))
|
||||
.mockResolvedValueOnce(device);
|
||||
render(<AiHardware />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '绑定设备' }));
|
||||
const input = screen.getByLabelText('6 位激活码');
|
||||
|
||||
fireEvent.change(input, { target: { value: '031425' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '绑定' }));
|
||||
const retryInput = await screen.findByLabelText('6 位激活码');
|
||||
expect(retryInput).toHaveValue('');
|
||||
fireEvent.change(retryInput, { target: { value: '031425' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '绑定' }));
|
||||
|
||||
await waitFor(() => expect(api.bindAiHardwareDevice).toHaveBeenCalledTimes(2));
|
||||
expect(api.bindAiHardwareDevice.mock.calls[1]).toEqual(['031425', agentOne.id]);
|
||||
});
|
||||
|
||||
it('keeps an unset chat history unchanged when saving without edits', async () => {
|
||||
api.getAiHardwareAgentConfiguration.mockResolvedValueOnce({
|
||||
data: { ...configuration, chat_history_conf: null },
|
||||
|
||||
@@ -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