Files
qbit-mobile-scanners/e2e/tests/admin-processes.spec.js
james.maesandOpenAI GPT-6 5dd3cd78c4 feat: add device sessions and record file integration
Add native device profiles, presence and location, session activity and screen viewing, and record/bulk session termination. Include the optional Record Files bridge and isolated HTTP contract evidence; final authorization-guard adoption and production qualification remain documented gates.

Co-Authored-By: OpenAI GPT-6 (Codex) <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-31 04:12:11 -05:00

184 lines
9.6 KiB
JavaScript

// Copyright (c) 2026 Kingsrook, LLC
// SPDX-License-Identifier: Apache-2.0
import { randomUUID } from 'node:crypto';
import { test, expect, request as requestFactory } from '@playwright/test';
const handheldRequests = new Set();
test.afterEach(async () => {
await Promise.all([...handheldRequests].map((request) => request.dispose()));
handheldRequests.clear();
});
async function loginApi(request, account) {
const password = process.env[account === 'admin' ? 'MOBILE_DEMO_ADMIN_PASSWORD' : 'MOBILE_DEMO_SCANNER_PASSWORD'];
const response = await request.post('/qqq/v1/manageSession', {
headers: { Authorization: `Basic ${Buffer.from(`${account}:${password}`).toString('base64')}` },
data: {},
});
expect(response.status()).toBe(200);
}
async function enroll(name) {
// A real handheld has its own core login, even when multiple devices share a user.
const handheld = await requestFactory.newContext({ baseURL: `http://127.0.0.1:${process.env.MOBILE_E2E_PORT}` });
handheldRequests.add(handheld);
await loginApi(handheld, 'scanner');
const response = await handheld.post('/qqq/v1/qbit/mobile-scanners/devices/enroll', {
data: { installationId: randomUUID(), displayName: name, manufacturer: 'Test', model: 'Browser fixture', osVersion: 'test', appVersion: 'test' },
});
expect(response.ok()).toBeTruthy();
return { ...await response.json(), request: handheld };
}
async function loginBrowser(page) {
// Authenticate through the real login endpoint without recording password-fill steps.
await loginApi(page.request, 'admin');
// QQQ only attempts HttpOnly-cookie resume when its non-secret sign-in hint exists.
// The real server session still supplies the identity and permission checks.
await page.addInitScript(() => localStorage.setItem('qqq.sessionHint', '1'));
await page.goto('/app/');
await expect(page.getByRole('button', { name: 'Account: Device Administrator' })).toBeVisible();
}
async function status(device) {
const response = await device.request.get('/qqq/v1/qbit/mobile-scanners/devices/current', {
headers: { 'X-QQQ-Device-Id': device.deviceId, 'X-QQQ-Device-Token': device.deviceToken },
});
expect(response.status()).toBe(200);
return (await response.json()).status;
}
async function chooseAction(page, label, record = false) {
await page.getByRole('button', { name: record ? 'Record actions menu' : 'Actions', exact: true }).click();
await page.getByRole('menuitem', { name: label, exact: true }).click();
}
async function confirmSelected(page, devices, result) {
const confirmation = page.locator('[data-qqq-id="process-step-input"]');
for (const device of devices) await expect(confirmation).toContainText(device.deviceId);
await confirmation.locator('[data-qqq-id="button-next"]').click();
await expect(page.locator('[data-qqq-id="process-step-result"]')).toContainText(result);
}
test('approve and revoke from a single record show results and persist the selected device', async ({ page, request }) => {
await loginApi(request, 'scanner');
const device = await enroll(`Single ${randomUUID()}`);
await loginBrowser(page);
for (const [action, before, after, result] of [
['Approve Device', 'PENDING', 'APPROVED', '1 device approved.'],
['Revoke Device', 'APPROVED', 'REVOKED', '1 device revoked.'],
]) {
await page.goto(`/app/mobileDevice/${device.deviceId}/`);
await chooseAction(page, action, true);
await expect.poll(() => status(device)).toBe(before);
await confirmSelected(page, [device], result);
await expect.poll(() => status(device)).toBe(after);
await page.getByRole('button', { name: 'Return', exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/app/mobileDevice/${device.deviceId}/?$`));
await expect(page.getByRole('tabpanel', { name: 'Overview' })).toContainText(after);
}
});
test('bulk checked rows approve and revoke only selected devices', async ({ page, request }) => {
await loginApi(request, 'scanner');
const devices = [await enroll(`Bulk A ${randomUUID()}`), await enroll(`Bulk B ${randomUUID()}`)];
const untouched = await enroll(`Untouched ${randomUUID()}`);
await loginBrowser(page);
for (const [action, before, after, result] of [
['Approve Device', 'PENDING', 'APPROVED', '2 devices approved.'],
['Revoke Device', 'APPROVED', 'REVOKED', '2 devices revoked.'],
]) {
await page.goto('/app/mobileDevice/');
for (const device of devices) await page.getByRole('checkbox', { name: `Select ${device.deviceId}`, exact: true }).check();
await expect(page.getByRole('region', { name: 'Bulk actions' })).toContainText('2 records');
await chooseAction(page, action);
for (const device of devices) await expect.poll(() => status(device)).toBe(before);
await confirmSelected(page, devices, result);
for (const device of devices) await expect.poll(() => status(device)).toBe(after);
await expect.poll(() => status(untouched)).toBe('PENDING');
}
});
test('standalone admin processes accept an explicit device ID', async ({ page, request }) => {
await loginApi(request, 'scanner');
const device = await enroll(`Manual ${randomUUID()}`);
await loginBrowser(page);
for (const [process, after, result] of [
['mobileDeviceApprove', 'APPROVED', '1 device approved.'],
['mobileDeviceRevoke', 'REVOKED', '1 device revoked.'],
]) {
await page.goto(`/app/${process}/`);
await page.getByRole('textbox', { name: 'Device IDs', exact: true }).fill(device.deviceId);
await page.locator('[data-qqq-id="button-next"]').click();
await expect(page.locator('[data-qqq-id="process-step-result"]')).toContainText(result);
await expect.poll(() => status(device)).toBe(after);
}
});
test('cancel from record confirmation leaves device pending', async ({ page, request }) => {
await loginApi(request, 'scanner');
const device = await enroll(`Cancel ${randomUUID()}`);
await loginBrowser(page);
await page.goto(`/app/mobileDevice/${device.deviceId}/`);
await chooseAction(page, 'Approve Device', true);
await expect(page.locator('[data-qqq-id="process-step-input"]')).toContainText(device.deviceId);
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
await page.getByRole('dialog', { name: 'Cancel Process?' }).getByRole('button', { name: 'Cancel Process', exact: true }).click();
await expect.poll(() => status(device)).toBe('PENDING');
});
test('full query selection approves and revokes through the filter-based bulk path', async ({ page, request }) => {
await loginApi(request, 'scanner');
const devices = [await enroll(`All A ${randomUUID()}`), await enroll(`All B ${randomUUID()}`)];
await loginBrowser(page);
for (const [action, after, verb] of [
['Approve Device', 'APPROVED', 'approved'],
['Revoke Device', 'REVOKED', 'revoked'],
]) {
await page.goto('/app/mobileDevice/');
await page.getByRole('button', { name: 'Selection', exact: true }).click();
await page.getByRole('menuitem', { name: /^Full query result/ }).click();
await chooseAction(page, action);
const confirmation = page.locator('[data-qqq-id="process-step-input"]');
for (const device of devices) await expect(confirmation).toContainText(device.deviceId);
await confirmation.locator('[data-qqq-id="button-next"]').click();
await expect(page.locator('[data-qqq-id="process-step-result"]')).toContainText(new RegExp(`\\d+ devices ${verb}\\.`));
for (const device of devices) await expect.poll(() => status(device)).toBe(after);
}
});
test('first-N bulk selection changes exactly the requested subset', async ({ page, request }) => {
await loginApi(request, 'scanner');
await enroll(`Subset A ${randomUUID()}`);
await enroll(`Subset B ${randomUUID()}`);
await enroll(`Subset C ${randomUUID()}`);
await loginBrowser(page);
for (const [action, after, result] of [
['Approve Device', 'APPROVED', '2 devices approved.'],
['Revoke Device', 'REVOKED', '2 devices revoked.'],
]) {
const beforeResponse = await page.request.post('/qqq/v1/table/mobileDevice/query', { data: { filter: { limit: 100 } } });
expect(beforeResponse.status()).toBe(200);
const before = new Map((await beforeResponse.json()).records.map((record) => [record.values.id, record.values.status]));
await page.goto('/app/mobileDevice/');
await page.getByRole('button', { name: 'Selection', exact: true }).click();
await page.getByRole('menuitem', { name: 'Subset of the query result...' }).click();
await page.getByRole('spinbutton', { name: 'How many records do you want to select?' }).fill('2');
await page.getByRole('button', { name: 'OK', exact: true }).click();
await chooseAction(page, action);
const confirmation = page.locator('[data-qqq-id="process-step-input"]');
await expect(confirmation).toContainText('2 device(s) selected');
const summary = await confirmation.innerText();
const selected = [...before.keys()].filter((id) => summary.includes(id));
expect(selected).toHaveLength(2);
await confirmation.locator('[data-qqq-id="button-next"]').click();
await expect(page.locator('[data-qqq-id="process-step-result"]')).toContainText(result);
const afterResponse = await page.request.post('/qqq/v1/table/mobileDevice/query', { data: { filter: { limit: 100 } } });
expect(afterResponse.status()).toBe(200);
for (const record of (await afterResponse.json()).records) {
expect(record.values.status).toBe(selected.includes(record.values.id) ? after : before.get(record.values.id));
}
}
});