access-control-safety-testing
Use when writing or reviewing the must-fail fixture for a gate that bans direct role or permission membership checks — an ESLint `no-restricted-syntax` selector, a dependency-cruiser rule, or a hand-r
SKILL.mdplugins/react-frontend-sdlc/skills/access-control-safety-testing/SKILL.md
GitHubAccess Control Safety Testing
Profile keys consumed
make.lint_eslintmake.lint_depsmake.lintmake.test_unit_client
Every gate invocation goes through the profile's make target map. A null value for one of these
keys means the capability is absent in this repository: skip the dependent step with a recorded
note instead of improvising a raw host command.
Overview
A gate that forbids reading a principal's roles directly is only as strong as the fixture proving it
fires. JavaScript offers at least fourteen ways to ask "is this role in this array"; a fixture built
around .includes() leaves the other thirteen enforced by nothing, which is worse than no gate
because it manufactures confidence.
When to use
- Adding or reviewing a lint selector that bans direct
roles/permissionsarray access. - Writing the must-fail fixture that pins such a selector, or auditing an existing one.
- A reviewer reports that a role check "slipped past" a gate that has a passing test.
- Choosing between a regex or AST selector for a membership-check ban.
- Not for: gates unrelated to membership checks, or runtime authorization on a server — this is about static enforcement of a client-side convention, which is never the authorization boundary.
Applicability by repository shape
- React SPA shape (feature modules under the source root, a bootable app, an aggregate CI
target): yes — the rules themselves run under the targets mapped by
make.lint_eslint/make.lint_deps, and the must-fail fixture harnesses that pin them (scripts/ci/eslint-gate-fixtures.mjswithtests/unit/tooling/eslint-gate-fixtures.test.ts,scripts/ci/depcruise-rule-fixtures.mjswithtests/unit/tooling/depcruise-rules.test.ts) run in the unit suite mapped bymake.test_unit_client. - Next.js app shape (routed pages, no aggregate duplication gate): partial — the same
no-restricted-syntaxand dependency-cruiser gates run under the targets mapped bymake.lint/make.lint_deps, but there is no fixture rot-guard harness, so the fixture and its assertions have to be written alongside the rule. - Component-library shape (Storybook-first, no bootable app, published package): partial —
eslint.config.mjsand.dependency-cruiser.jscarry the same rule kinds under the targets mapped bymake.lint/make.lint_deps; again no fixture harness ships with the repository.
The fourteen spellings
Every one of these has to appear in the fixture and be rejected:
roles.includes(ROLES.admin)roles.some((r) => r === ROLES.admin)roles.every((r) => r === ROLES.admin)roles.find((r) => r === ROLES.admin)roles.findIndex((r) => r === ROLES.admin) !== -1roles.findLast((r) => r === ROLES.admin) !== undefinedroles.findLastIndex((r) => r === ROLES.admin) !== -1roles.indexOf(ROLES.admin) !== -1roles.lastIndexOf(ROLES.admin) !== -1roles.filter((r) => r === ROLES.admin).length > 0roles.at(0) === ROLES.adminroles[0] === ROLES.adminnew Set(roles).has(ROLES.admin)roles[0] === 'admin'— the hardcoded literal, which no token-based selector catches
findLast, findLastIndex and lastIndexOf are the three most often missed; they are ordinary
Array.prototype members and read exactly like their forward counterparts.
Procedure
- Write the selector against the AST, not the raw file text. A regex over source cannot tell an executable call from the same characters inside a comment or a string literal, so it either fails open or fires on prose.
- Put all fourteen spellings in one fixture file and run it through the real resolved config, the
way the rule runs in CI — the targets mapped by
make.lint_eslint/make.lint_deps, and the fixture harness undermake.test_unit_client— not through a hand-built config object that may not match. - Assert the finding count equals the number of seeded violations, so an over-broad selector that also flags unrelated lines is visible.
- Assert the resolved severity is
error. A downgrade towarnnever fails a lint run, so the gate keeps reporting while enforcing nothing, and no fixture that only checks "a finding exists" notices. - Keep the selector comment, the fixture, and the documentation of the banned list as one stated three-way contract; when a spelling is added, all three change together.
Common mistakes
- Fixture covers
.includes()only — seed all fourteen spellings in the same file. - Fixture asserts "at least one finding" — assert the exact count so over-broad selectors surface.
- Rule left at
warn— resolve the severity in the test and assert it iserror. - Regex over source text — an AST selector is the only way to skip comments and string literals.
- Banned list documented in one place only — the selector, the fixture, and the docs drift apart.
- Treating the passing gate as authorization — it is a convention gate; the upstream API service still has to reject an unauthorized request.