Skip to content

← Writing

engineering

Testing Authorization Policies Like the Boundary They Are

· Jerwin Arnado · 8 min read ·

The RBAC vs ABAC post ended with a caveat that deserves its own post: test policies like the security boundary they are. Most test suites are thorough about the happy path and vague about authorization — and the asymmetry is exactly backwards. A rendering bug ships a broken page; a policy bug ships a breach. “Users could see other departments’ records for three weeks” is not a changelog entry, it’s a disclosure email.

The good news: authorization is the easiest security property to test, because a policy is a pure function — attributes in, boolean out. Here’s the discipline, in Pest.

The policy under test

Carrying over the hybrid policy from part one — role for the capability class, attributes for the row:

class PostPolicy
{
    public function update(User $user, Post $post): bool
    {
        return $user->can('posts.update')
            && $user->department_id === $post->department_id
            && $post->status !== PostStatus::Archived
            && $user->isCurrentlyEmployed();
    }
}

Four conditions. That’s not one test — that’s a grid: each condition needs at least one case where it alone flips the answer to “no.”

Unit tests: table-driven over the attribute grid

Pest datasets are built for this. One test body, one named row per business rule:

function pair(array $user = [], array $post = []): array
{
    $dept = Department::factory()->create();

    return [
        User::factory()->editor()->create(['department_id' => $dept->id, ...$user]),
        Post::factory()->create(['department_id' => $dept->id, ...$post]),
    ];
}

it('decides post updates across the attribute grid', function (callable $scenario, bool $allowed) {
    [$user, $post] = $scenario();

    expect((new PostPolicy)->update($user, $post))->toBe($allowed);
})->with([
    'own department, live post, employed' => [fn () => pair(), true],
    'other department'                    => [fn () => tap(pair(), fn (&$p) =>
                                                 $p[1]->update(['department_id' => Department::factory()->create()->id])), false],
    'archived post'                       => [fn () => pair(post: ['status' => PostStatus::Archived]), false],
    'contract ended yesterday'            => [fn () => pair(user: ['contract_ends_at' => now()->subDay()]), false],
    'missing the capability'              => [fn () => tap(pair(), fn ($p) =>
                                                 $p[0]->revokePermissionTo('posts.update')), false],
]);

Two things matter more than the syntax. Every row is named after the business rule, so a failure reads as “contract ended yesterday → FAILED” — the requirement doc and the test report are the same artifact. And there’s exactly one green row: with default-deny, the happy path is the special case, and everything else must independently force a no. When someone refactors isCurrentlyEmployed() and the contractor row goes green, the suite catches what a code review skims past.

Feature tests: prove the 403 is wired

A unit-green policy protects nothing if the controller forgot to call it. That’s a depressingly common bug — the policy exists, the test passes, and the endpoint never asks. So a second, thinner layer hits the HTTP surface:

it('forbids updating another department\'s post over HTTP', function () {
    $post     = Post::factory()->create();
    $outsider = User::factory()->editor()->create();   // different department by default

    $this->actingAs($outsider)
        ->putJson(route('posts.update', $post), ['title' => 'nope'])
        ->assertForbidden();

    expect($post->fresh()->title)->not->toBe('nope');
});

it('rejects guests outright', function () {
    $post = Post::factory()->create();

    $this->putJson(route('posts.update', $post), ['title' => 'nope'])
        ->assertUnauthorized();   // 401 — authn, before authz even runs
});

You don’t repeat the whole grid here — the unit layer owns the combinatorics. The feature layer answers exactly two questions: is the policy connected to this route, and does a denial actually prevent the write? That final fresh() assertion has caught real bugs: a controller that returned 403 after saving.

Test the bypass — it’s the sharpest knife

Part one used Gate::before for the admin override. It’s also the easiest way to destroy your entire authorization layer with one character:

// Correct: null means "no opinion, continue to the policy"
Gate::before(fn (User $user) => $user->hasRole('admin') ? true : null);

// Catastrophe: false short-circuits — every policy in the app is now dead code
Gate::before(fn (User $user) => $user->hasRole('admin'));

That second version returns false for every non-admin, and Gate::before treats any non-null return as final — no policy ever runs again. All your policy unit tests still pass, because they call the policy directly. Only a through-the-gate test catches it:

it('lets admins through the gate without a policy hit', function () {
    $admin = User::factory()->admin()->create();
    $post  = Post::factory()->create();   // not the admin's department

    expect($admin->can('update', $post))->toBeTrue();
});

it('does not short-circuit non-admins before the policy', function () {
    [$user, $post] = pair();   // the grid's one green row

    expect($user->can('update', $post))->toBeTrue();   // via Gate, not the policy class
});

If someone fat-fingers the before callback, the second test goes red instantly. Cheap insurance against the most expensive one-liner in Laravel.

Caveats and best practices

  • Spend your rows on denials. Default-deny means the “no” cases carry the security weight. A suite that’s 80% happy-path authorization tests is testing the wrong thing.
  • Never mock the policy. Mocking PostPolicy in a feature test asserts that your mock works. The entire point is exercising the real decision against real attributes.
  • Factories are the test’s vocabulary. States like ->editor(), ->admin(), ->contractorEndingYesterday() keep the grid readable. If building a denial case takes ten lines of setup, that’s friction that stops the next person adding one.
  • Every authorization incident becomes a permanent row. Post-mortem action item, every time: the exact attribute combination that leaked gets a named dataset entry. Regression tests are how the same hole never opens twice.
  • Keep the grid at the unit layer. Policy-direct tests are milliseconds; through-HTTP tests are not. Combinatorics below, wiring above — the suite stays fast enough that nobody’s tempted to skip it, which is the same argument as the pre-commit hooks post.

Conclusion

Unit    → the grid: one named row per rule, one green row, policy called directly
Feature → the wiring: 403 on the route, denial actually blocks the write
Gate    → the bypass: before() returns null, admins pass, non-admins still hit policy
Ritual  → every authz incident becomes a permanent dataset row

A policy is the cheapest security control you’ll ever test — a pure function with a boolean out. Name the rows after the rules, keep one path green, prove the 403 is wired, and put a tripwire on the admin bypass. Next in the series: what happens to all of this when one database serves many customers.