Component Testing Auth UI
Learn React Authentication, Authorization, Identity & Permission/ACL - Part 093
Component testing authenticated and permission-aware React UI with deny-by-default rendering, visible/hidden/disabled states, async permission snapshots, forms, tables, menus, step-up, impersonation, and accessible assertions.
Part 093 — Component Testing Auth UI
A permission-aware component can fail in quiet ways.
It may hide an action for the wrong user. It may show a button before permission has loaded. It may submit a disabled-looking field. It may reveal a sensitive row action in the DOM. It may show an explanation that leaks internal policy. It may keep an admin-only menu item after logout because a cache was not reset.
These bugs are rarely caught by snapshot tests.
Authenticated UI must be tested as a user-observable authorization projection.
The server remains the enforcement boundary. The component test does not prove the system is secure. It proves that the React layer obeys the frontend permission contract:
permission snapshot + component props + user interaction => safe visible UI + safe emitted intent
This part focuses on component testing: Can, useCan(), AuthorizedButton, permission-aware forms, row actions, menus, access-request UI, step-up prompts, impersonation banners, and stale/unknown permission states.
1. The actual component testing target
Do not test this:
Does the component call useAuth()?
Do not test this:
Does user.role equal "admin"?
Test this:
Given an exact auth/permission snapshot,
what can the user see,
what can the user interact with,
and what intent can the component emit?
That gives you three component-level contracts.
| Contract | Meaning | Example assertion |
|---|---|---|
| Visibility | Sensitive UI should not be exposed when not allowed | Delete button is absent |
| Interactivity | Visible-but-unavailable UI should not be actionable | Approve button is disabled |
| Intent emission | Component should not emit protected mutation intent when denied | onApprove is not called |
The important nuance: a hidden button is not security. It is exposure control. The test is still valuable because exposure control affects accidental misuse, supportability, and data minimization.
2. Test like a user, not like an implementation inspector
Testing Library's core idea is to query the DOM in ways that resemble how users and assistive technologies perceive the page.
For permission-aware UI, this matters because authorization state is often expressed through accessible controls:
- a button is absent,
- a button is disabled,
- a menu item has an accessible label,
- a denial reason appears as visible text,
- a form field is read-only,
- a banner announces impersonation mode.
Prefer semantic queries.
expect(screen.queryByRole("button", { name: /delete case/i })).not.toBeInTheDocument();
Prefer this over brittle selectors.
expect(container.querySelector(".delete-button")).toBeNull();
Authorization UI is a product behavior. Product behavior should be asserted through user-observable output.
3. Build a realistic auth fixture model
Most weak auth UI tests start with weak fixtures.
Bad fixture:
const user = { role: "admin" };
Better fixture:
export interface TestAuthSnapshot {
status: "anonymous" | "authenticated";
subject?: {
id: string;
displayName: string;
tenantId: string;
actorId?: string;
impersonating?: boolean;
assuranceLevel?: "aal1" | "aal2" | "aal3";
};
permissionSnapshot: {
status: "unknown" | "loading" | "ready" | "stale" | "error";
version: number;
tenantId?: string;
decisions: Record<string, PermissionDecision>;
};
}
export type PermissionDecision =
| { effect: "allow"; reason: "explicit_grant" | "role_permission" | "owner" }
| { effect: "deny"; reason: "missing_permission" | "tenant_mismatch" | "resource_state" }
| { effect: "challenge"; reason: "step_up_required"; requiredAcr: string };
This fixture structure forces tests to model the facts that usually cause auth bugs:
- anonymous vs authenticated,
- tenant identity,
- permission readiness,
- permission version,
- impersonation,
- assurance level,
- challenge vs denial.
A test fixture should not make impossible states too easy.
4. Create named scenarios, not random objects
Reusable scenario builders make auth tests readable.
export const authScenarios = {
anonymous(): TestAuthSnapshot {
return {
status: "anonymous",
permissionSnapshot: {
status: "ready",
version: 1,
decisions: {},
},
};
},
investigatorWithCaseRead(): TestAuthSnapshot {
return {
status: "authenticated",
subject: {
id: "user_inv_1",
displayName: "Ira Investigator",
tenantId: "tenant_a",
assuranceLevel: "aal1",
},
permissionSnapshot: {
status: "ready",
version: 7,
tenantId: "tenant_a",
decisions: {
"case:case_123:read": { effect: "allow", reason: "role_permission" },
"case:case_123:update": { effect: "deny", reason: "missing_permission" },
"case:case_123:close": { effect: "challenge", reason: "step_up_required", requiredAcr: "aal2" },
},
},
};
},
tenantMismatch(): TestAuthSnapshot {
return {
status: "authenticated",
subject: {
id: "user_1",
displayName: "Wrong Tenant User",
tenantId: "tenant_b",
},
permissionSnapshot: {
status: "ready",
version: 9,
tenantId: "tenant_b",
decisions: {
"case:case_123:read": { effect: "deny", reason: "tenant_mismatch" },
},
},
};
},
};
Named scenarios are better than inline fixtures because they communicate the authorization story.
A reviewer should understand the test without reverse-engineering a nested object.
5. Use a renderWithAuth() test harness
Component tests should not repeat provider boilerplate.
import { render } from "@testing-library/react";
import { AuthProvider } from "../auth/AuthProvider";
import { PermissionProvider } from "../auth/PermissionProvider";
export function renderWithAuth(
ui: React.ReactElement,
options: {
auth: TestAuthSnapshot;
},
) {
return render(
<AuthProvider initialSnapshot={options.auth}>
<PermissionProvider initialSnapshot={options.auth.permissionSnapshot}>
{ui}
</PermissionProvider>
</AuthProvider>,
);
}
For more complex apps, include router and query client too.
export function renderAppTest(ui: React.ReactElement, options: TestAppOptions) {
const queryClient = createTestQueryClient();
return render(
<QueryClientProvider client={queryClient}>
<AuthProvider initialSnapshot={options.auth}>
<PermissionProvider initialSnapshot={options.auth.permissionSnapshot}>
{ui}
</PermissionProvider>
</AuthProvider>
</QueryClientProvider>,
);
}
The harness should make safe defaults easy.
Bad default:
renderWithAuth(<DeleteCaseButton />); // implicitly admin
Better default:
renderWithAuth(<DeleteCaseButton />, { auth: authScenarios.anonymous() });
Anonymous or deny-by-default should be the default test posture.
6. Test deny-by-default rendering
The most important permission UI test is not the happy path.
It is the unknown path.
it("does not show destructive action while permission is unknown", () => {
renderWithAuth(<CaseActions caseId="case_123" />, {
auth: {
status: "authenticated",
subject: {
id: "user_1",
displayName: "User",
tenantId: "tenant_a",
},
permissionSnapshot: {
status: "unknown",
version: 0,
decisions: {},
},
},
});
expect(screen.queryByRole("button", { name: /delete case/i })).not.toBeInTheDocument();
});
Also test loading.
it("does not expose restricted action while permission is loading", () => {
renderWithAuth(<CaseActions caseId="case_123" />, {
auth: {
...authScenarios.investigatorWithCaseRead(),
permissionSnapshot: {
status: "loading",
version: 7,
decisions: {},
},
},
});
expect(screen.queryByRole("button", { name: /approve/i })).not.toBeInTheDocument();
});
This protects against the classic bug:
if (!permissionLoaded) return <AdminActions />;
A permission-aware component must fail closed.
7. Test Can as a primitive
If your app has a Can component, test it directly.
function Can({ action, resource, children, fallback }: CanProps) {
const decision = useCan(action, resource);
if (decision.effect === "allow") return <>{children}</>;
return <>{fallback ?? null}</>;
}
Test allowed.
it("renders children when permission allows", () => {
renderWithAuth(
<Can action="read" resource={{ type: "case", id: "case_123" }}>
<span>Case content</span>
</Can>,
{ auth: authScenarios.investigatorWithCaseRead() },
);
expect(screen.getByText("Case content")).toBeInTheDocument();
});
Test denied.
it("renders fallback when permission denies", () => {
renderWithAuth(
<Can
action="update"
resource={{ type: "case", id: "case_123" }}
fallback={<span>You cannot edit this case.</span>}
>
<button>Edit case</button>
</Can>,
{ auth: authScenarios.investigatorWithCaseRead() },
);
expect(screen.queryByRole("button", { name: /edit case/i })).not.toBeInTheDocument();
expect(screen.getByText(/cannot edit/i)).toBeInTheDocument();
});
Test challenge.
it("renders step-up prompt when permission requires challenge", () => {
renderWithAuth(
<Can
action="close"
resource={{ type: "case", id: "case_123" }}
challengeFallback={(decision) => <button>Verify to continue</button>}
>
<button>Close case</button>
</Can>,
{ auth: authScenarios.investigatorWithCaseRead() },
);
expect(screen.queryByRole("button", { name: /close case/i })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /verify to continue/i })).toBeInTheDocument();
});
A challenge is not an allow. A component test should make that distinction impossible to miss.
8. Test hide vs disable vs explain explicitly
Different actions need different denial UI.
| Pattern | Use when | Test expectation |
|---|---|---|
| Hide | User should not know action exists | Element absent |
| Disable | User knows action exists but cannot use it now | Disabled control present |
| Explain | User needs recovery path | Reason text or CTA visible |
| Challenge | User can proceed after step-up | Step-up CTA visible, protected action blocked |
Example hidden action:
it("hides delete action for reader", () => {
renderWithAuth(<CaseActions caseId="case_123" />, {
auth: authScenarios.investigatorWithCaseRead(),
});
expect(screen.queryByRole("button", { name: /delete case/i })).not.toBeInTheDocument();
});
Example disabled action:
it("shows disabled approve action when case state blocks approval", () => {
renderWithAuth(<CaseActions caseId="case_123" />, {
auth: withDecision(authScenarios.investigatorWithCaseRead(), "case:case_123:approve", {
effect: "deny",
reason: "resource_state",
}),
});
expect(screen.getByRole("button", { name: /approve/i })).toBeDisabled();
expect(screen.getByText(/cannot approve a closed case/i)).toBeInTheDocument();
});
Do not let product ambiguity leak into tests. Decide the denial pattern per action and test it.
9. Test that disabled controls do not emit intent
A disabled-looking custom component may still emit onClick if it is not implemented with a real disabled button.
Bad custom component:
<div role="button" aria-disabled="true" onClick={onClick}>
Approve
</div>
Better:
<button type="button" disabled={disabled} onClick={onClick}>
Approve
</button>
Test intent emission.
it("does not emit approve intent when disabled", async () => {
const user = userEvent.setup();
const onApprove = vi.fn();
renderWithAuth(<ApproveButton caseId="case_123" onApprove={onApprove} />, {
auth: withDecision(authScenarios.investigatorWithCaseRead(), "case:case_123:approve", {
effect: "deny",
reason: "resource_state",
}),
});
await user.click(screen.getByRole("button", { name: /approve/i }));
expect(onApprove).not.toHaveBeenCalled();
});
The point is not only visual correctness. The component must not emit protected intent when the frontend already knows it is denied.
Server-side authorization still remains mandatory.
10. Test forms with field-level permission
Field-level permission is where many React auth bugs hide.
A form may show a field as read-only but still submit it. Or hide a field but keep stale state in the payload. Or disable a field and then wonder why the browser did not submit it.
Model fields explicitly.
export type FieldMode = "hidden" | "masked" | "readonly" | "editable" | "requires_step_up";
export interface CaseFormProjection {
fields: {
title: { mode: FieldMode };
severity: { mode: FieldMode };
enforcementNote: { mode: FieldMode };
};
}
Test read-only rendering.
it("renders severity as readonly when user can view but not edit severity", () => {
renderWithAuth(<CaseEditForm caseId="case_123" />, {
auth: authWithFieldModes({ severity: "readonly" }),
});
expect(screen.getByLabelText(/severity/i)).toHaveAttribute("readonly");
});
Test hidden field absence.
it("does not render enforcement note field when hidden", () => {
renderWithAuth(<CaseEditForm caseId="case_123" />, {
auth: authWithFieldModes({ enforcementNote: "hidden" }),
});
expect(screen.queryByLabelText(/enforcement note/i)).not.toBeInTheDocument();
});
Test submit payload.
it("does not submit hidden fields", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
renderWithAuth(<CaseEditForm caseId="case_123" onSubmit={onSubmit} />, {
auth: authWithFieldModes({ enforcementNote: "hidden" }),
});
await user.type(screen.getByLabelText(/title/i), "Updated title");
await user.click(screen.getByRole("button", { name: /save/i }));
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ title: "Updated title" }),
);
expect(onSubmit.mock.calls[0][0]).not.toHaveProperty("enforcementNote");
});
The server must still reject unauthorized field updates. The component test prevents accidental leakage and bad UX.
11. Test table row actions
A table is not one authorization decision. It is many decisions.
export interface CaseRowProjection {
id: string;
title: string;
allowedActions: Array<"view" | "edit" | "assign" | "close" | "delete">;
deniedReasons?: Partial<Record<string, string>>;
}
Test per-row action projection.
it("renders edit action only for editable rows", () => {
renderWithAuth(
<CaseTable
rows={[
{ id: "case_1", title: "Editable", allowedActions: ["view", "edit"] },
{ id: "case_2", title: "Read only", allowedActions: ["view"] },
]}
/>,
{ auth: authScenarios.investigatorWithCaseRead() },
);
const editableRow = screen.getByRole("row", { name: /editable/i });
const readOnlyRow = screen.getByRole("row", { name: /read only/i });
expect(within(editableRow).getByRole("button", { name: /edit/i })).toBeInTheDocument();
expect(within(readOnlyRow).queryByRole("button", { name: /edit/i })).not.toBeInTheDocument();
});
Test bulk action eligibility.
it("disables bulk close when selected rows are not all closeable", async () => {
const user = userEvent.setup();
renderWithAuth(<CaseTable rows={caseRowsMixedCloseEligibility} />, {
auth: authScenarios.investigatorWithCaseRead(),
});
await user.click(screen.getByRole("checkbox", { name: /select editable/i }));
await user.click(screen.getByRole("checkbox", { name: /select read only/i }));
expect(screen.getByRole("button", { name: /close selected/i })).toBeDisabled();
expect(screen.getByText(/1 selected case cannot be closed/i)).toBeInTheDocument();
});
A bulk operation test should always include mixed eligibility. All-allow cases hide the important bugs.
12. Test navigation and command palette authorization
Menus are often forgotten because they are not mutation surfaces. But navigation exposure still matters.
Test sidebar filtering.
it("does not show admin navigation to non-admin user", () => {
renderWithAuth(<AppSidebar />, {
auth: authScenarios.investigatorWithCaseRead(),
});
expect(screen.queryByRole("link", { name: /admin/i })).not.toBeInTheDocument();
});
Test command palette filtering.
it("does not expose restricted commands in command palette", async () => {
const user = userEvent.setup();
renderWithAuth(<CommandPalette />, {
auth: authScenarios.investigatorWithCaseRead(),
});
await user.keyboard("{Control>}k{/Control}");
expect(screen.queryByRole("option", { name: /delete case/i })).not.toBeInTheDocument();
});
Also test direct route separately at router level. Component tests only prove the menu projection.
13. Test access request UI
A denial may have a recovery path.
Example decision:
{
effect: "deny",
reason: "missing_permission",
publicMessage: "You need Case Approver access to approve this case.",
recovery: {
type: "request_access",
target: {
resourceType: "case",
resourceId: "case_123",
action: "approve"
}
}
}
Test the message and CTA.
it("shows access request CTA for recoverable missing permission", async () => {
const user = userEvent.setup();
const onRequestAccess = vi.fn();
renderWithAuth(<ApproveCasePanel caseId="case_123" onRequestAccess={onRequestAccess} />, {
auth: authWithDecision("case:case_123:approve", {
effect: "deny",
reason: "missing_permission",
publicMessage: "You need Case Approver access to approve this case.",
recovery: { type: "request_access" },
}),
});
expect(screen.getByText(/need case approver access/i)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /request access/i }));
expect(onRequestAccess).toHaveBeenCalledWith({
action: "approve",
resourceId: "case_123",
});
});
Do not expose internal policy traces in public UI.
Bad:
Denied by policy case.approve.v3 because relation tuple missing: approver@tenant_a
Better:
You do not have approval access for this case.
14. Test step-up UI
Step-up is not denial.
It means the user is authenticated but needs stronger or fresher authentication for this action.
it("opens step-up challenge instead of submitting sensitive action", async () => {
const user = userEvent.setup();
const onCloseCase = vi.fn();
const onStepUp = vi.fn();
renderWithAuth(
<CloseCaseButton caseId="case_123" onCloseCase={onCloseCase} onStepUp={onStepUp} />,
{
auth: authWithDecision("case:case_123:close", {
effect: "challenge",
reason: "step_up_required",
requiredAcr: "aal2",
}),
},
);
await user.click(screen.getByRole("button", { name: /close case/i }));
expect(onCloseCase).not.toHaveBeenCalled();
expect(onStepUp).toHaveBeenCalledWith({ requiredAcr: "aal2" });
});
The component should not optimistically call the protected mutation and hope the server challenges later. It should treat challenge as a separate transition.
15. Test impersonation UI
Impersonation bugs are dangerous because the UI may show the wrong identity or allow support agents to perform prohibited actions.
Test persistent banner.
it("shows persistent impersonation banner", () => {
renderWithAuth(<AppShell />, {
auth: {
status: "authenticated",
subject: {
id: "customer_1",
displayName: "Customer User",
tenantId: "tenant_a",
actorId: "support_1",
impersonating: true,
},
permissionSnapshot: {
status: "ready",
version: 1,
tenantId: "tenant_a",
decisions: {},
},
},
});
expect(screen.getByRole("status")).toHaveTextContent(/viewing as customer user/i);
expect(screen.getByRole("button", { name: /exit impersonation/i })).toBeInTheDocument();
});
Test restricted sensitive actions.
it("blocks payment action during impersonation", () => {
renderWithAuth(<BillingActions accountId="acct_1" />, {
auth: impersonatingSupportUser(),
});
expect(screen.queryByRole("button", { name: /change payment method/i })).not.toBeInTheDocument();
});
Impersonation UI should be loud. A quiet impersonation mode is an incident waiting to happen.
16. Test stale permission behavior
Permission snapshots can become stale after:
- role change,
- access revocation,
- tenant switch,
- policy deployment,
- object state transition,
- logout in another tab.
Test stale behavior explicitly.
it("fails closed when permission snapshot is stale", () => {
renderWithAuth(<CaseActions caseId="case_123" />, {
auth: {
...authScenarios.investigatorWithCaseRead(),
permissionSnapshot: {
...authScenarios.investigatorWithCaseRead().permissionSnapshot,
status: "stale",
},
},
});
expect(screen.queryByRole("button", { name: /delete case/i })).not.toBeInTheDocument();
expect(screen.getByText(/checking access/i)).toBeInTheDocument();
});
Do not treat stale as allow.
This bug is common:
const canDelete = snapshot.status === "ready" ? checkPermission() : true;
Safe default:
const canDelete = snapshot.status === "ready" && checkPermission().effect === "allow";
17. Test async permission loading without leaking UI
Async permission checks create a temporary unknown state.
The dangerous test is not only "eventually shows button". The important test is "does not show button before permission is known".
it("does not flash admin action before async permission resolves", async () => {
const permissionPromise = createDeferred<PermissionDecision>();
renderWithAuth(
<AsyncPermissionGate
action="delete"
resource={{ type: "case", id: "case_123" }}
loadPermission={() => permissionPromise.promise}
>
<button>Delete case</button>
</AsyncPermissionGate>,
{ auth: authenticatedWithoutPreloadedPermission() },
);
expect(screen.queryByRole("button", { name: /delete case/i })).not.toBeInTheDocument();
permissionPromise.resolve({ effect: "allow", reason: "explicit_grant" });
expect(await screen.findByRole("button", { name: /delete case/i })).toBeInTheDocument();
});
Also test denial after async resolution.
it("keeps action hidden when async permission denies", async () => {
renderWithAuth(<CaseDeleteGate caseId="case_123" />, {
auth: authenticatedWithoutPreloadedPermission(),
});
await waitFor(() => {
expect(screen.queryByRole("button", { name: /delete case/i })).not.toBeInTheDocument();
});
});
In auth UI, loading should never mean temporarily allowed.
18. Test query cache cleanup from the component perspective
Some component tests should verify that logout or tenant switch clears sensitive UI state.
Example:
it("removes cached case title after logout", async () => {
const user = userEvent.setup();
renderAppTest(<CasePage caseId="case_123" />, {
auth: authScenarios.investigatorWithCaseRead(),
initialQueryData: {
["auth", "tenant_a", "case", "case_123"]: {
title: "Sensitive Case Title",
},
},
});
expect(screen.getByText("Sensitive Case Title")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /logout/i }));
expect(screen.queryByText("Sensitive Case Title")).not.toBeInTheDocument();
});
This is not a replacement for integration tests. But it catches common component-level cache leaks.
19. Test analytics and telemetry redaction
Do not let component tests ignore side effects.
If a denial emits telemetry, test that it does not include sensitive data.
it("emits redacted access denied telemetry", () => {
const track = vi.fn();
renderWithAuth(<CaseActions caseId="case_123" analytics={{ track }} />, {
auth: authWithDecision("case:case_123:delete", {
effect: "deny",
reason: "missing_permission",
}),
});
expect(track).toHaveBeenCalledWith("permission_denied_ui", {
action: "delete",
resourceType: "case",
reason: "missing_permission",
});
expect(track).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ resourceTitle: expect.any(String) }),
);
});
Telemetry should help debugging without becoming a second data leak channel.
20. Test accessible denial UI
Authorization UI often uses disabled controls and explanatory text. That can be inaccessible if not wired correctly.
Example:
<button
type="button"
disabled
aria-describedby="approve-denied-reason"
>
Approve case
</button>
<p id="approve-denied-reason">You need Case Approver access.</p>
Test the visible state.
it("associates disabled action with denial reason", () => {
renderWithAuth(<ApproveCaseButton caseId="case_123" />, {
auth: deniedApprovePermission(),
});
const button = screen.getByRole("button", { name: /approve case/i });
expect(button).toBeDisabled();
expect(screen.getByText(/need case approver access/i)).toBeInTheDocument();
});
Avoid inaccessible fake buttons unless the component has a strong reason and matching keyboard behavior.
21. Test no sensitive DOM leftovers
Hiding via CSS is not the same as not rendering.
Bad:
<button style={{ display: canDelete ? "block" : "none" }}>
Delete case
</button>
A query may not see it visually, but the DOM still contains the action.
Test absence:
expect(screen.queryByRole("button", { name: /delete case/i })).not.toBeInTheDocument();
For sensitive fields, also test raw DOM text absence.
expect(document.body).not.toHaveTextContent("Internal enforcement note");
This catches accidental render-then-hide patterns.
22. Test custom permission-aware components in isolation
A design system should have its own auth UI tests.
AuthorizedButton
it("renders denied destructive action according to policy", () => {
renderWithAuth(
<AuthorizedButton
action="delete"
resource={{ type: "case", id: "case_123" }}
deniedBehavior="hidden"
>
Delete case
</AuthorizedButton>,
{ auth: deniedDeletePermission() },
);
expect(screen.queryByRole("button", { name: /delete case/i })).not.toBeInTheDocument();
});
PermissionedField
it("renders masked field without exposing raw value", () => {
renderWithAuth(
<PermissionedField
label="National ID"
value="123456789"
mode="masked"
/>,
{ auth: maskedPiiPermission() },
);
expect(screen.getByText(/national id/i)).toBeInTheDocument();
expect(screen.getByText("••••••789")).toBeInTheDocument();
expect(document.body).not.toHaveTextContent("123456789");
});
PermissionedMenuItem
it("excludes denied menu item from keyboard navigation", async () => {
const user = userEvent.setup();
renderWithAuth(<CaseActionMenu caseId="case_123" />, {
auth: deniedDeletePermission(),
});
await user.click(screen.getByRole("button", { name: /case actions/i }));
expect(screen.queryByRole("menuitem", { name: /delete/i })).not.toBeInTheDocument();
});
Testing design-system primitives creates leverage. Every feature team inherits the same safety behavior.
23. Avoid snapshot-first auth UI testing
Snapshot tests are weak for auth UI because they often approve accidental exposure.
Bad:
expect(container).toMatchSnapshot();
A snapshot will show the delete button, but the reviewer may not notice.
Better:
expect(screen.queryByRole("button", { name: /delete case/i })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /request access/i })).toBeInTheDocument();
Authorization tests should assert exact security-relevant behavior.
Use snapshots only for low-risk visual regression, not for permission correctness.
24. Anti-pattern catalog
Anti-pattern 1: testing roles instead of permissions
expect(mockUser.role).toBe("admin");
This does not test UI behavior.
Anti-pattern 2: defaulting test user to admin
const defaultUser = adminUser();
This hides deny cases.
Anti-pattern 3: allowing during loading
if (isLoadingPermission) return children;
This creates protected action flash.
Anti-pattern 4: CSS hiding sensitive content
<div className={canView ? "visible" : "hidden"}>{secret}</div>
Secret remains in DOM.
Anti-pattern 5: disabled custom div button
<div role="button" aria-disabled="true" onClick={submit}>Submit</div>
May still emit intent.
Anti-pattern 6: testing only allow cases
it("admin sees delete button", () => {});
Deny cases are where authorization bugs live.
25. Component auth test matrix
Use this matrix for important permission-aware components.
| Case | Expected behavior |
|---|---|
| Anonymous | Protected content absent or login CTA shown |
| Authenticated, permission unknown | Protected action absent/loading safe state |
| Authenticated, permission loading | Protected action absent/loading safe state |
| Authenticated, allowed | Action visible and interactive |
| Authenticated, denied recoverable | Action disabled/hidden with safe request-access path |
| Authenticated, denied unrecoverable | Action hidden or generic denial |
| Challenge required | Mutation not emitted; step-up CTA shown |
| Tenant mismatch | Resource UI hidden or forbidden state |
| Stale permission snapshot | Fail closed and revalidate |
| Impersonating actor | Banner visible; prohibited sensitive actions blocked |
| Field hidden | Field and raw value absent from DOM and payload |
| Field readonly | Visible but not editable; payload excludes unauthorized mutation |
| Row action mixed eligibility | Bulk action handles partial denial |
| Logout | Sensitive cached UI removed |
26. Review checklist
Before approving a permission-aware component test suite, check:
- Tests use realistic auth snapshots, not only
rolestrings. - Anonymous/default case fails closed.
- Unknown/loading/stale permission states are tested.
- Deny cases outnumber allow cases for sensitive actions.
- Hidden actions are absent from DOM, not merely CSS-hidden.
- Disabled actions do not emit mutation intent.
- Denial reasons are public-safe.
- Step-up is tested separately from denial.
- Field-level hidden values are absent from DOM and payload.
- Table row action tests include mixed eligibility.
- Navigation/menu tests do not replace route-level tests.
- Impersonation banner and restrictions are tested when relevant.
- Cache cleanup is tested for logout/tenant switch when sensitive data is shown.
- Tests use semantic queries where possible.
- Test harness defaults to anonymous or denied, not admin.
27. Closing mental model
Component auth tests should answer one practical question:
Given what the frontend currently knows, does this component expose only the safe UI and emit only safe intent?
They do not prove backend enforcement.
They do prevent the React layer from becoming misleading, leaky, inconsistent, or accidentally permissive.
That distinction is important. A senior engineer does not confuse UI projection with security enforcement, but still tests UI projection rigorously because humans use the UI to operate the system.
28. References
- Testing Library Queries — priority of accessible/user-observable queries: https://testing-library.com/docs/queries/about/
- Testing Library
ByRolequery docs: https://testing-library.com/docs/queries/byrole/ - Testing Library
user-eventdocs: https://testing-library.com/docs/user-event/intro/ - DOM Testing Library accessibility guidance: https://testing-library.com/docs/dom-testing-library/api-accessibility/
- React conditional rendering: https://react.dev/learn/conditional-rendering
- React hooks reference: https://react.dev/reference/react/hooks
- React
useSyncExternalStore: https://react.dev/reference/react/useSyncExternalStore - OWASP Authorization Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html
- OWASP Error Handling Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Error_Handling_Cheat_Sheet.html
You just completed lesson 93 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.