test(gui): Vitest coverage for the 2026-05-10/11 GUI batch (Fix 12)

Audit 2026-05-11 Fix 12 closure. The original GUI-batch commit
191384c claimed 'npx tsc --noEmit PASS' but shipped no Vitest
cases for the new surfaces, leaving the regression-prevention
layer wide open. This closure backfills 35 cases across five
files; the next refactor of KeysPage's assign modal that drops
scope_type, or the AuthProvider demo-banner predicate that
gets flipped to !authRequired, surfaces in CI instead of
silently shipping.

What's added:

* web/src/pages/auth/UsersPage.test.tsx (NEW, 8 cases) — pins
  the MED-11 closure's UsersPage flow: active rows render the
  Active status pill, deactivated rows render dimmed with the
  Deactivated <timestamp> status, Deactivate button fires the
  API call after confirm() returns true and is a no-op on
  false, Reactivate button works inversely, provider filter
  narrows the underlying authListUsers call (undefined vs
  provider-id), empty list renders the placeholder, loading
  renders 'Loading users…'.

* web/src/pages/auth/AuthSettingsPage.test.tsx (EXTENDED, +4
  cases) — the pre-existing 2 cases only exercised identity +
  bootstrap status; the runtime-config panel (MED-12 closure)
  had no test. New cases cover: per-key row rendering,
  alphabetical sort (stable for log-scraping correlation),
  empty-value '(empty)' placeholder, 403 rejected query
  silently hides the panel (non-admins shouldn't see the
  shell).

* web/src/pages/auth/KeysPage.test.tsx (EXTENDED, +8 cases) —
  the HIGH-10 GUI half added scope picker + scope_id input +
  expires_at datetime-local to the assign modal but the
  pre-existing test only asserted (actor, role). New cases
  pin the third opts arg shape: global hides scope_id input,
  profile/issuer scope reveal scope_id + mark required,
  trimmed scope_id round-trips into the body, global omits
  scope_id (undefined NOT empty string), empty expires_at
  omits the field, filled expires_at gets :00Z appended for
  RFC3339 promotion, whitespace-only scope_id fires the
  'scope_id is required' typed error WITHOUT calling the
  API, actor-demo-anon row hides both assign and revoke
  affordances.

* web/src/pages/auth/RoleDetailPage.test.tsx (NEW, 9 cases) —
  no test file pre-Fix 12. Pins the MED-8 scope picker for
  AddPermissionForm: global hides scope_id, profile reveals +
  gates the Add button until scope_id is filled, submit POSTs
  {permission, scope_type: profile, scope_id} with whitespace
  trimming, global submit omits scope keys entirely, issuer
  scope path, Add button stays disabled without a permission
  selection. Plus the LOW-11 default-role delete-button hide:
  r-admin renders the role-delete-disabled-tooltip + NO
  role-delete-button, r-auditor same, custom role renders the
  delete button. The DEFAULT_ROLE_IDS set tracking the
  migration-seeded role ids is the load-bearing client-side
  decision so a future drift between migrations and the GUI
  set surfaces here too.

* web/src/components/AuthProvider.test.tsx (NEW, 5 cases) —
  the LOW-1 demo banner had no test for its visibility
  predicate. Pins all four authType branches (none → visible,
  api-key → hidden, oidc → hidden, loading → hidden to avoid
  flash) plus the rejected-getAuthInfo branch: the catch
  treats failure as an old-server-fallback to demo mode (no
  authType mutation, loading flips false), so the banner
  SHOWS — that's the actual behavior, and pinning it prevents
  a future change from silently hiding the banner when the
  /auth/info endpoint is unreachable.

Spec deviations: Phase 6 (Layout.test.tsx users-nav) and
Phase 7 (per-Fix tests for Fixes 03/05/07/09/10) live on those
fixes' own branches — already authored there. Including them
here would have produced merge conflicts.

Verify gate:

* tsc --noEmit — clean
* vitest run touched files — 40/40 pass (8 + 6 + 12 + 9 + 5,
  including the 2 + 4 + 4 pre-existing cases in the extended
  AuthSettingsPage + KeysPage files)
* full suite (162 tests across 15 files) green — no regression
  from the panel-mount-in-existing-page setup or the new
  mocked-module entries.

Refs cowork/auth-bundles-fixes-2026-05-11/12-test-vitest-gui-coverage.md.
This commit is contained in:
shankar0123
2026-05-11 12:18:08 +00:00
parent b8fac59200
commit dfdba5b260
6 changed files with 864 additions and 3 deletions
+134
View File
@@ -0,0 +1,134 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
// =============================================================================
// Audit 2026-05-11 Fix 12 — AuthProvider demo-mode banner regression coverage.
//
// The LOW-1 closure added a sticky red banner that renders when the
// server reports `auth_type=none`. Pre-fix-12 there was no test pinning
// the visibility-condition contract, so a future refactor could silently
// flip the predicate (e.g. swap `authType === 'none'` for `!authRequired`
// — looks equivalent but treats backwards-compat fallback the same as
// demo mode). This block pins:
// - auth_type='none' → banner visible (data-testid="demo-mode-banner").
// - auth_type='api-key' → banner absent.
// - auth_type='oidc' → banner absent.
// - getAuthInfo still in flight → banner absent (avoid the flash where
// the page momentarily shows it before the fetch resolves).
// - getAuthInfo rejected → banner absent (the catch branch keeps the
// default authType='none' state in raw values, but loading→true→false
// transitions complete; the banner predicate is `authType==='none' &&
// !loading` and the rejection path doesn't mutate authType, so the
// state lingers at 'none'. That looks like a footgun BUT the rejection
// catch comment "assume no auth required (server may be old version)"
// means downstream code treats this as anonymous — so the banner
// SHOULD render. This test pins the actual behavior, not the spec's
// assumption.)
// =============================================================================
vi.mock('../api/client', () => ({
getAuthInfo: vi.fn(),
checkAuth: vi.fn(),
setApiKey: vi.fn(),
logout: vi.fn(),
}));
import AuthProvider from './AuthProvider';
import * as client from '../api/client';
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
describe('AuthProvider — LOW-1 demo-mode banner', () => {
it('renders the banner when getAuthInfo reports auth_type=none', async () => {
vi.mocked(client.getAuthInfo).mockResolvedValue({
auth_type: 'none',
required: false,
});
render(
<AuthProvider>
<div>child</div>
</AuthProvider>,
);
await waitFor(() => screen.getByTestId('demo-mode-banner'));
expect(screen.getByTestId('demo-mode-banner').textContent)
.toContain('Demo mode active');
expect(screen.getByTestId('demo-mode-banner').getAttribute('role'))
.toBe('alert');
});
it('hides the banner when getAuthInfo reports auth_type=api-key', async () => {
vi.mocked(client.getAuthInfo).mockResolvedValue({
auth_type: 'api-key',
required: true,
});
render(
<AuthProvider>
<div data-testid="child">child</div>
</AuthProvider>,
);
// Wait for the auth-info fetch to complete (children render after
// the provider's loading state flips), then assert no banner.
await waitFor(() => screen.getByTestId('child'));
expect(screen.queryByTestId('demo-mode-banner')).toBeNull();
});
it('hides the banner when getAuthInfo reports auth_type=oidc', async () => {
vi.mocked(client.getAuthInfo).mockResolvedValue({
auth_type: 'oidc',
required: true,
});
render(
<AuthProvider>
<div data-testid="child">child</div>
</AuthProvider>,
);
await waitFor(() => screen.getByTestId('child'));
expect(screen.queryByTestId('demo-mode-banner')).toBeNull();
});
it('hides the banner while loading (no flash before fetch resolves)', () => {
// Never-resolving promise so loading stays true. The banner's
// predicate is `authType === 'none' && !loading`, so the
// synchronous render must NOT show the banner.
vi.mocked(client.getAuthInfo).mockReturnValue(new Promise(() => {}));
render(
<AuthProvider>
<div data-testid="child">child</div>
</AuthProvider>,
);
// Children render eagerly; banner is gated on !loading so it
// shouldn't show up on the initial paint.
expect(screen.queryByTestId('demo-mode-banner')).toBeNull();
expect(screen.getByTestId('child')).toBeInTheDocument();
});
it('shows the banner when getAuthInfo rejects (fallback treats as anonymous demo mode)', async () => {
// The catch branch in AuthProvider's mount effect treats a failed
// /auth/info call as "assume no auth required (server may be old
// version)". authType state stays at its default 'none' value and
// loading flips to false in the finally clause, so the banner's
// predicate fires. This pins that fallback behavior — a future
// change that resets authType to something else on error would
// surface as a test failure.
vi.mocked(client.getAuthInfo).mockRejectedValue(new Error('network'));
render(
<AuthProvider>
<div data-testid="child">child</div>
</AuthProvider>,
);
await waitFor(() => screen.getByTestId('demo-mode-banner'));
});
});