Testing Access Boundaries
The most important authorization tests
Access boundary tests verify that users cannot see, modify, or delete things they should not. These are the tests that catch IDOR bugs, broken role checks, and org scoping leaks.
IDOR tests
The pattern: log in as user A, try to access user B’s resource.
describe("IDOR prevention", () => {
it("user cannot view another user's note by ID", async () => {
const aliceCookie = await loginAs("[email protected]");
const bobCookie = await loginAs("[email protected]");
// Alice creates a note
const createRes = await authenticatedRequest("/orgs/org-acme/notes", aliceCookie, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title: "Private", body: "Alice only" }),
});
const { id: noteId } = await createRes.json();
// Bob tries to access it (in a user-scoped app, this should fail)
// In an org-scoped app, Bob can access it if he's a member of the same org
// Adjust based on your app's access model
});
it("non-member cannot access org resources", async () => {
const bobCookie = await loginAs("[email protected]");
// Bob is not a member of Globex
const res = await authenticatedRequest("/orgs/org-globex/notes", bobCookie);
assert.strictEqual(res.status, 404, "Non-member should get 404, not 403");
});
it("non-member gets 404, not 403 (no org existence leak)", async () => {
const bobCookie = await loginAs("[email protected]");
// Try a real org Bob is not in
const realOrg = await authenticatedRequest("/orgs/org-globex/notes", bobCookie);
// Try a fake org
const fakeOrg = await authenticatedRequest("/orgs/org-doesnt-exist/notes", bobCookie);
// Both should return 404 — no way to tell which orgs exist
assert.strictEqual(realOrg.status, 404);
assert.strictEqual(fakeOrg.status, 404);
});
}); The third test is subtle: it verifies that real and non-existent orgs return the same status code for non-members. This prevents org enumeration.
Role and permission tests
Test every role against every action:
describe("role-based access", () => {
it("viewer can read notes", async () => {
const carolCookie = await loginAs("[email protected]"); // viewer at Acme
const res = await authenticatedRequest("/orgs/org-acme/notes", carolCookie);
assert.strictEqual(res.status, 200);
});
it("viewer cannot create notes", async () => {
const carolCookie = await loginAs("[email protected]");
const res = await authenticatedRequest("/orgs/org-acme/notes", carolCookie, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title: "Test", body: "Should fail" }),
});
assert.strictEqual(res.status, 403);
});
it("editor can create notes", async () => {
const bobCookie = await loginAs("[email protected]"); // editor at Acme
const res = await authenticatedRequest("/orgs/org-acme/notes", bobCookie, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title: "Bob's Note", body: "Should work" }),
});
assert.strictEqual(res.status, 201);
});
it("editor cannot delete notes", async () => {
const bobCookie = await loginAs("[email protected]");
const res = await authenticatedRequest("/orgs/org-acme/notes/note-1", bobCookie, {
method: "DELETE",
});
assert.strictEqual(res.status, 403);
});
it("owner can delete notes", async () => {
const aliceCookie = await loginAs("[email protected]"); // owner at Acme
// Create a note to delete (don't delete seed data)
const createRes = await authenticatedRequest("/orgs/org-acme/notes", aliceCookie, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title: "Deletable", body: "Will be deleted" }),
});
const { id } = await createRes.json();
const res = await authenticatedRequest(`/orgs/org-acme/notes/${id}`, aliceCookie, {
method: "DELETE",
});
assert.strictEqual(res.status, 200);
});
}); This is a permission matrix test. Each row tests one role against one action. The complete matrix for three roles and three actions (read, create, delete) is nine tests. Write them all.
The access boundary matrix
A useful way to plan these tests:
| Read notes | Create notes | Delete notes | |
|---|---|---|---|
| Owner (Alice) | ✅ 200 | ✅ 201 | ✅ 200 |
| Editor (Bob) | ✅ 200 | ✅ 201 | ❌ 403 |
| Viewer (Carol) | ✅ 200 | ❌ 403 | ❌ 403 |
| Non-member | ❌ 404 | ❌ 404 | ❌ 404 |
Every cell is a test. The matrix makes it obvious what is missing.
Exercises
Exercise 1: Write the full 4×3 matrix of tests (4 access levels × 3 actions). Run them all.
Exercise 2: Add a test for Alice accessing Globex (where she is a viewer). She should be able to read but not create or delete.
Exercise 3: Temporarily remove the user_id check from a route (break IDOR prevention). Run the tests. Which ones fail?
Why do we test that non-members get 404 for both real and non-existent orgs?