From 238ff63d1dbcb326cd33c5e6a90f66a27701f04e Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Fri, 3 Jul 2026 21:35:30 -0700 Subject: [PATCH 0001/1298] test(web): cover + harden src/lib/activityIcons.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix inherited-prototype lookup bug in getActivityVisual: a bare REGISTRY[key] truthy check resolved Object.prototype members (toString, constructor, __proto__, valueOf, hasOwnProperty, …) as if they were ActivityVisuals, returning a Function that crashed consumers reading .icon off it. Restrict lookups to own keys and widen the param to string | null | undefined to match the existing nullish guard. Add a comprehensive Vitest suite (15 cases, 101 assertions) covering exact matches, multi-level prefix walking, missing-intermediate skips, empty/whitespace/nullish/malformed input, whitespace trimming, the inherited-key regression, and the result contract + lookup purity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- web/src/lib/activityIcons.test.ts | 195 ++++++++++++++++++++++++++++++ web/src/lib/activityIcons.ts | 29 ++++- 2 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 web/src/lib/activityIcons.test.ts diff --git a/web/src/lib/activityIcons.test.ts b/web/src/lib/activityIcons.test.ts new file mode 100644 index 0000000000..58595afe5e --- /dev/null +++ b/web/src/lib/activityIcons.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect } from 'vitest' +import { getActivityVisual, type ActivityVisual } from './activityIcons' +import { Icons } from '@/lib/icons' + +const FALLBACK_KEY = 'activity.action.unknown' + +/** Runtime guard proving a value is a real ActivityVisual (not an inherited member). */ +function isVisual(v: ActivityVisual): boolean { + return ( + !!v && + typeof v.i18nKey === 'string' && + typeof v.fallback === 'string' && + typeof v.color === 'string' && + v.icon != null + ) +} + +describe('getActivityVisual — exact matches', () => { + it('resolves a leaf vehicle command (wake) to its full visual', () => { + const v = getActivityVisual('vehicle.command.wake') + expect(v.icon).toBe(Icons.power) + expect(v.color).toBe('text-amber-300') + expect(v.i18nKey).toBe('activity.action.vehicleCommandWake') + expect(v.fallback).toBe('Wake vehicle') + }) + + it('resolves lock and unlock to distinct icons + keys', () => { + expect(getActivityVisual('vehicle.command.lock').icon).toBe(Icons.locked) + expect(getActivityVisual('vehicle.command.lock').i18nKey).toBe( + 'activity.action.vehicleCommandLock', + ) + expect(getActivityVisual('vehicle.command.unlock').icon).toBe(Icons.unlocked) + expect(getActivityVisual('vehicle.command.unlock').i18nKey).toBe( + 'activity.action.vehicleCommandUnlock', + ) + }) + + it('resolves representative entries across every domain group', () => { + expect(getActivityVisual('settings.update').i18nKey).toBe('activity.action.settingsUpdate') + expect(getActivityVisual('alert.rule.create').icon).toBe(Icons.notificationsAdd) + expect(getActivityVisual('automation.create').i18nKey).toBe('activity.action.automationCreate') + expect(getActivityVisual('dashboard.layout.save').icon).toBe(Icons.layoutGrid) + expect(getActivityVisual('data_export.create').icon).toBe(Icons.download) + expect(getActivityVisual('api_key.create').icon).toBe(Icons.key) + expect(getActivityVisual('auth.login').i18nKey).toBe('activity.action.authLogin') + expect(getActivityVisual('auth.logout').fallback).toBe('Signed out') + }) +}) + +describe('getActivityVisual — prefix fallback walking', () => { + it('falls back from an unknown leaf to the longest known prefix', () => { + // vehicle.command.wake.extra → vehicle.command.wake (longest own prefix) + expect(getActivityVisual('vehicle.command.wake.extra').i18nKey).toBe( + 'activity.action.vehicleCommandWake', + ) + // vehicle.command. → vehicle.command + expect(getActivityVisual('vehicle.command.zap').i18nKey).toBe('activity.action.vehicleCommand') + }) + + it('skips a missing intermediate prefix and keeps walking down', () => { + // alert.rule.* leaf verbs exist, but "alert.rule" itself is NOT a key, so an + // unknown verb must walk past it down to the registered "alert" domain. + const v = getActivityVisual('alert.rule.silence') + expect(v.i18nKey).toBe('activity.action.alert') + expect(v.icon).toBe(Icons.notifications) + }) + + it('resolves single-segment domain fallbacks', () => { + expect(getActivityVisual('settings.theme').i18nKey).toBe('activity.action.settings') + expect(getActivityVisual('api_key.rotate').i18nKey).toBe('activity.action.apiKey') + expect(getActivityVisual('automation.pause').i18nKey).toBe('activity.action.automation') + expect(getActivityVisual('data_export.download').i18nKey).toBe('activity.action.dataExport') + expect(getActivityVisual('dashboard.reset').i18nKey).toBe('activity.action.dashboard') + }) +}) + +describe('getActivityVisual — fallback for unknown / empty input', () => { + it('returns the generic fallback for a wholly unknown action', () => { + const v = getActivityVisual('totally.unknown.action') + expect(v.icon).toBe(Icons.history) + expect(v.i18nKey).toBe(FALLBACK_KEY) + expect(v.fallback).toBe('Activity') + }) + + it('returns the fallback for a single unknown segment', () => { + expect(getActivityVisual('nonexistent').i18nKey).toBe(FALLBACK_KEY) + }) + + it('returns the fallback for empty, whitespace-only and nullish input', () => { + expect(getActivityVisual('').i18nKey).toBe(FALLBACK_KEY) + expect(getActivityVisual(' ').i18nKey).toBe(FALLBACK_KEY) + expect(getActivityVisual(null).i18nKey).toBe(FALLBACK_KEY) + expect(getActivityVisual(undefined).i18nKey).toBe(FALLBACK_KEY) + }) + + it('does not treat malformed dotted input as a match', () => { + expect(getActivityVisual('.vehicle.command.').i18nKey).toBe(FALLBACK_KEY) + expect(getActivityVisual('...').i18nKey).toBe(FALLBACK_KEY) + }) +}) + +describe('getActivityVisual — trims surrounding whitespace', () => { + it('matches an action padded with spaces, tabs and newlines', () => { + expect(getActivityVisual(' vehicle.command.wake ').i18nKey).toBe( + 'activity.action.vehicleCommandWake', + ) + expect(getActivityVisual('\tsettings.update\n').i18nKey).toBe('activity.action.settingsUpdate') + }) +}) + +describe('getActivityVisual — inherited Object.prototype keys are never matches (regression)', () => { + // A plain object literal inherits toString/constructor/hasOwnProperty/… . A + // naive `REGISTRY[key]` truthy check would return those inherited members as + // if they were ActivityVisuals, and consumers reading `.icon` off a Function + // would render `` and crash. All of these MUST hit FALLBACK. + const inherited = [ + 'toString', + 'constructor', + 'hasOwnProperty', + 'valueOf', + 'isPrototypeOf', + 'propertyIsEnumerable', + 'toLocaleString', + '__proto__', + ] + + it.each(inherited)('resolves "%s" to the safe fallback, not an inherited member', (key) => { + const v = getActivityVisual(key) + expect(v.i18nKey).toBe(FALLBACK_KEY) + expect(v.icon).toBe(Icons.history) + expect(isVisual(v)).toBe(true) + expect(typeof v.icon).not.toBe('undefined') + }) + + it('does not match an inherited name used as a prefix segment either', () => { + expect(getActivityVisual('toString.wake').i18nKey).toBe(FALLBACK_KEY) + expect(getActivityVisual('constructor.create').i18nKey).toBe(FALLBACK_KEY) + expect(getActivityVisual('hasOwnProperty.update').icon).toBe(Icons.history) + }) +}) + +describe('getActivityVisual — result contract & purity', () => { + const KNOWN_ACTIONS = [ + 'vehicle.command', + 'vehicle.command.wake', + 'vehicle.command.honk', + 'vehicle.command.flash', + 'vehicle.command.lock', + 'vehicle.command.unlock', + 'vehicle.command.climate', + 'vehicle.command.charge', + 'settings.update', + 'settings', + 'alert.rule.create', + 'alert.rule.update', + 'alert.rule.delete', + 'alert', + 'automation.create', + 'automation.update', + 'automation.delete', + 'automation', + 'dashboard.layout.save', + 'dashboard', + 'data_export.create', + 'data_export', + 'api_key.create', + 'api_key.update', + 'api_key.delete', + 'api_key', + 'auth.login', + 'auth.logout', + 'auth', + ] + + it('every registered action yields a well-formed ActivityVisual', () => { + for (const action of KNOWN_ACTIONS) { + const v = getActivityVisual(action) + expect(isVisual(v)).toBe(true) + expect(v.i18nKey.startsWith('activity.action.')).toBe(true) + expect(v.color).toMatch(/^text-/) + expect(v.fallback.length).toBeGreaterThan(0) + } + }) + + it('is a pure lookup — identical input yields the same singleton reference', () => { + expect(getActivityVisual('auth.login')).toBe(getActivityVisual('auth.login')) + // Every unknown collapses onto one shared FALLBACK object. + expect(getActivityVisual('x.y.z')).toBe(getActivityVisual('q.w.e')) + }) + + it('exposes exactly the ActivityVisual shape (type contract)', () => { + const v: ActivityVisual = getActivityVisual('auth.login') + expect(Object.keys(v).sort()).toEqual(['color', 'fallback', 'i18nKey', 'icon']) + }) +}) diff --git a/web/src/lib/activityIcons.ts b/web/src/lib/activityIcons.ts index 63073f8adc..b61f6b2e90 100644 --- a/web/src/lib/activityIcons.ts +++ b/web/src/lib/activityIcons.ts @@ -223,20 +223,41 @@ const FALLBACK: ActivityVisual = { fallback: 'Activity', }; +/** + * Own-property lookup into REGISTRY. + * + * REGISTRY is a plain object literal, so it inherits `Object.prototype` + * members (`toString`, `constructor`, `hasOwnProperty`, `valueOf`, + * `__proto__`, …). A bare `REGISTRY[key]` truthy check would resolve those + * inherited functions for any action that happens to share one of those + * names — returning a `Function`/`Object.prototype` masquerading as an + * `ActivityVisual` and crashing consumers that read `.icon`/`.color` off it + * (React renders ``). Restricting lookups to the registry's own + * keys makes such actions fall through to FALLBACK like any other unknown. + */ +function lookup(key: string): ActivityVisual | undefined { + return Object.prototype.hasOwnProperty.call(REGISTRY, key) + ? REGISTRY[key] + : undefined; +} + /** * Resolves an action string to its visual descriptor, falling back to * progressively shorter prefixes. `vehicle.command.wake` matches first; * if absent, `vehicle.command`, then `vehicle`, then the generic fallback. */ -export function getActivityVisual(action: string): ActivityVisual { +export function getActivityVisual(action: string | null | undefined): ActivityVisual { if (!action) return FALLBACK; const normalized = action.trim(); - if (REGISTRY[normalized]) return REGISTRY[normalized]; + if (!normalized) return FALLBACK; + + const exact = lookup(normalized); + if (exact) return exact; const parts = normalized.split('.'); for (let i = parts.length - 1; i > 0; i--) { - const prefix = parts.slice(0, i).join('.'); - if (REGISTRY[prefix]) return REGISTRY[prefix]; + const match = lookup(parts.slice(0, i).join('.')); + if (match) return match; } return FALLBACK; } From 4f1cacd782d200691d8876c4064be46aeff4953f Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Fri, 3 Jul 2026 21:55:54 -0700 Subject: [PATCH 0002/1298] test(web): cover + harden src/features/admin/pages/APIKeysPage.tsx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a comprehensive Vitest + Testing Library suite (8 cases) covering the loaded, empty, error, and loading branches plus the create/revoke/delete interactions, and fix the KPI band so it shows the '—' placeholder instead of fabricated 0s when the query errors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../features/admin/pages/APIKeysPage.test.tsx | 318 ++++++++++++++++++ web/src/features/admin/pages/APIKeysPage.tsx | 11 +- 2 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 web/src/features/admin/pages/APIKeysPage.test.tsx diff --git a/web/src/features/admin/pages/APIKeysPage.test.tsx b/web/src/features/admin/pages/APIKeysPage.test.tsx new file mode 100644 index 0000000000..938b327b62 --- /dev/null +++ b/web/src/features/admin/pages/APIKeysPage.test.tsx @@ -0,0 +1,318 @@ +/** + * APIKeysPage contract tests. + * + * The page fans a single `useApiKeys()` query out into three data surfaces + * (KPI band, key inventory, access-levels panel) plus create / revoke / delete + * mutations. These tests exercise every branch and interaction: + * + * 1. Loaded — KPI counts, key cards, count caption, access-levels + guidance. + * 2. Empty — EmptyStates render and the KPI band shows a truthful `0`. + * 3. Error — BOTH data panels surface AND the KPI band shows + * the `'—'` placeholder rather than a fabricated `0` (regression + * guard for the KPI error-state fix). + * 4. Loading — skeletons render while the query is pending. + * 5. Create — the dialog opens, cancels, and on submit POSTs the trimmed + * name + permission then reveals the one-time secret. + * 6. Revoke — an active key POSTs `/api-keys/:id/revoke`. + * 7. Delete — a key opens a confirm dialog naming it, then DELETEs on confirm + * and closes the dialog. + * + * Network is driven entirely through the mocked `@/api/client` `request` + * (the same seam RbacMatrixPage / FleetTelemetryCoveragePage use) so nothing + * touches the real network. `isApiError` is preserved from the real module so + * falls to its generic network branch for a plain Error. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, waitFor, within, fireEvent } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import type { ReactNode } from 'react'; + +vi.mock('react-i18next', async () => { + const actual = + await vi.importActual('react-i18next'); + return { + ...actual, + useTranslation: () => ({ + t: (key: string, fallbackOrOpts?: unknown, opts?: unknown) => { + if (typeof fallbackOrOpts === 'string') { + if (opts && typeof opts === 'object') { + const o = opts as Record; + return fallbackOrOpts.replace(/{{(\w+)}}/g, (_, name) => + name in o ? String(o[name]) : `{{${name}}}`, + ); + } + return fallbackOrOpts; + } + if (fallbackOrOpts && typeof fallbackOrOpts === 'object') { + const o = fallbackOrOpts as Record; + if (typeof o.defaultValue === 'string') return o.defaultValue; + } + return key; + }, + i18n: { language: 'en', changeLanguage: vi.fn() }, + }), + Trans: ({ children }: { children?: ReactNode }) => <>{children}, + }; +}); + +vi.mock('@/api/client', async () => { + const actual = await vi.importActual('@/api/client'); + return { + ...actual, + request: vi.fn(), + }; +}); + +import { request } from '@/api/client'; +import { ToastProvider } from '@/components/feedback/Toast'; +import APIKeysPage from './APIKeysPage'; +import type { APIKey } from '@/types/admin'; + +const mockedRequest = request as unknown as ReturnType; + +function makeKey(overrides: Partial = {}): APIKey { + return { + id: 'k1', + name: 'Falcon', + keyPrefix: 'sk_live_falcon', + permissions: 'read', + createdAt: '2026-01-01T00:00:00Z', + lastUsedAt: '2026-06-01T00:00:00Z', + expiresAt: null, + ...overrides, + }; +} + +/** Three keys spanning every summary axis: 3 total / 2 active / 1 expired / 1 admin. */ +function threeKeys(): APIKey[] { + return [ + makeKey({ id: 'k1', name: 'Falcon', permissions: 'read' }), + makeKey({ id: 'k2', name: 'Nova', permissions: 'admin' }), + makeKey({ + id: 'k3', + name: 'Roadster', + permissions: 'read-write', + expiresAt: '2020-01-01T00:00:00Z', // in the past → expired + }), + ]; +} + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; +} +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +/** Route the single `request` mock by path + method so mutations + refetch work. */ +function installRequest( + keys: APIKey[], + created: (APIKey & { key: string }) | null = null, +) { + mockedRequest.mockImplementation((path: string, opts?: { method?: string }) => { + const method = opts?.method ?? 'GET'; + if (path === '/api-keys' && method === 'GET') return Promise.resolve(keys); + if (path === '/api-keys' && method === 'POST') { + return Promise.resolve( + created ?? { ...makeKey({ id: 'new', name: 'CI Bot' }), key: 'sk_live_secret_value' }, + ); + } + if (method === 'POST' && /^\/api-keys\/[^/]+\/revoke$/.test(path)) { + return Promise.resolve(undefined); + } + if (method === 'DELETE' && /^\/api-keys\/[^/]+$/.test(path)) { + return Promise.resolve(undefined); + } + return Promise.reject(new Error(`unexpected request ${method} ${path}`)); + }); +} + +function renderPage() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + + + + + + , + ); +} + +/** The MetricCard root text ("