Testing Authorisation Levels

8 min read

Authentication answers who is this? — authorisation answers what are they allowed to do? They sound similar; they're tested completely differently. A working login is one test. A working permission system needs one test per (role × endpoint × action) combination, and the bugs the matrix catches are the worst kind: an admin endpoint silently exposed to viewers, or worse, a user reading another user's data through a path traversal that passed authentication just fine. The API Testing Masterclass lesson on authorisation testing covered the threat model; this lesson is how to encode it as a tight, data-driven Rest Assured suite.

The permission matrix

Every secured API has an implicit permission matrix — which roles can do what to which endpoints. Make it explicit:

Role/admin/users/users/me/users/{others}/products (public)
admin200200200200
user403200403200
viewer403200403200
guest401401401200

Each cell is a test case. Twelve roles-endpoints-statuses, twelve assertions. TestNG's @DataProvider is what turns this from a wall of copy-paste into ten lines.

A data-driven role × endpoint test

import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
 
public class AuthorisationMatrixTest {
 
    @DataProvider(name = "roleAccessData")
    public Object[][] roleAccessData() {
        return new Object[][] {
            // { email, password, endpoint, expectedStatus }
            { "admin@test.com",  "AdminPass",  "/admin/users", 200 },
            { "user@test.com",   "UserPass",   "/admin/users", 403 },
            { "viewer@test.com", "ViewerPass", "/admin/users", 403 },
            { "admin@test.com",  "AdminPass",  "/users/me",    200 },
            { "user@test.com",   "UserPass",   "/users/me",    200 },
            { "viewer@test.com", "ViewerPass", "/users/me",    200 },
            { "admin@test.com",  "AdminPass",  "/products",    200 },
            { "user@test.com",   "UserPass",   "/products",    200 },
            { "viewer@test.com", "ViewerPass", "/products",    200 },
        };
    }
 
    @Test(dataProvider = "roleAccessData")
    public void testRoleAccess(String email, String password,
                               String endpoint, int expectedStatus) {
        String token = getTokenForUser(email, password);
 
        given()
            .auth().oauth2(token)
        .when()
            .get(endpoint)
        .then()
            .statusCode(expectedStatus);
    }
}

Add a row for every cell of your matrix. TestNG produces one test result per row, with a clear name like testRoleAccess(viewer@test.com, ViewerPass, /admin/users, 403). When a regression accidentally opens /admin/users to viewers, the failing test name tells you exactly which cell broke.

Horizontal privilege escalation — the easy-to-miss class

The matrix above tests vertical privilege boundaries (a viewer can't reach admin endpoints). The other half of the threat model is horizontal: a user reading another user's data, even though the endpoint itself is one they're allowed to call.

@Test
public void userCannotAccessAnotherUsersOrders() {
    // User 1 tries to read user 2's orders
    String userOneToken = getTokenForUser("user1@test.com", "Pass1");
 
    given()
        .auth().oauth2(userOneToken)
        .pathParam("userId", 2)
    .when()
        .get("/users/{userId}/orders")
    .then()
        .statusCode(anyOf(is(403), is(404)));
    // 403 (forbidden) or 404 (pretend it doesn't exist) — both are acceptable.
    // 200 is a CRITICAL bug.
}
 
@Test
public void userCannotDeleteAnotherUsersResource() {
    String userOneToken = getTokenForUser("user1@test.com", "Pass1");
 
    given()
        .auth().oauth2(userOneToken)
        .pathParam("orderId", 999)   // belongs to user 2
    .when()
        .delete("/orders/{orderId}")
    .then()
        .statusCode(anyOf(is(403), is(404)));
}
 
@Test
public void userCanModifyOnlyOwnProfile() {
    String userOneToken = getTokenForUser("user1@test.com", "Pass1");
 
    // Should succeed — own profile
    given().auth().oauth2(userOneToken)
        .contentType(ContentType.JSON)
        .body(Map.of("displayName", "Updated"))
    .when()
        .put("/users/1/profile")
    .then().statusCode(200);
 
    // Should be rejected — someone else's profile
    given().auth().oauth2(userOneToken)
        .contentType(ContentType.JSON)
        .body(Map.of("displayName", "Hacked"))
    .when()
        .put("/users/2/profile")
    .then().statusCode(anyOf(is(403), is(404)));
}

Horizontal escalation is the bug that ships to prod most often, because a quick read of the controller code shows "yes, the user is logged in," and the ownership check is somewhere else (or missing). One line per resource catches this.

The unauthenticated case

Every secured endpoint should reject requests with no token at all:

@Test
public void protectedEndpointsRequireAuth() {
    String[] protectedEndpoints = {
        "/users/me", "/admin/users", "/orders", "/users/me/profile"
    };
 
    for (String endpoint : protectedEndpoints) {
        given()
        .when().get(endpoint)
        .then().statusCode(401);
    }
}
 
@Test
public void publicEndpointsWorkWithoutAuth() {
    given()
    .when().get("/products")
    .then().statusCode(200);
 
    given()
    .when().get("/health")
    .then().statusCode(200);
}

The pair of tests captures the boundary explicitly: these need auth, those don't. If a refactor accidentally moves an endpoint across that boundary, one of the two suites fails immediately.

A reusable token helper

The matrix tests above all call getTokenForUser(email, password). Build it once:

public static String getTokenForUser(String email, String password) {
    return given()
        .contentType(ContentType.JSON)
        .body(Map.of("email", email, "password", password))
    .when()
        .post(System.getenv("AUTH_URL") + "/auth/login")
    .then()
        .statusCode(200)
        .extract().path("token");
}

For multi-role suites, cache them — calling login dozens of times in a parameterised test wastes seconds:

private static final Map<String, String> tokenCache = new ConcurrentHashMap<>();
 
public static String getTokenForUser(String email, String password) {
    return tokenCache.computeIfAbsent(email, e -> doLogin(email, password));
}

The ConcurrentHashMap.computeIfAbsent is thread-safe — important when TestNG runs the data-driven test in parallel.

The matrix at a glance

The shape: one row per role, one column per (endpoint, method) pair, expected status code in each cell. 20 cells = 20 test cases. A @DataProvider of 20 rows, one shared @Test, and the entire access-control surface is regression-tested in seconds.

Asserting the body on rejected requests

A 403 with no body is correct but uninformative. Best-in-class APIs return structured error responses; assert on them:

@Test
public void viewerCannotPostUserGetsStructuredError() {
    String viewerToken = getTokenForUser("viewer@test.com", "ViewerPass");
 
    given()
        .auth().oauth2(viewerToken)
        .contentType(ContentType.JSON)
        .body(Map.of("name", "New User", "email", "new@test.com"))
    .when()
        .post("/users")
    .then()
        .statusCode(403)
        .body("error", equalTo("forbidden"))
        .body("message", containsString("permission"))
        .body("required_role", equalTo("admin"));
}

The body assertions prove the error contract — what the client will see and rely on. If the API team changes the error shape, this test fails first.

Don't forget: rate limits and account lockout

Two adjacent classes of test that often fall to the auth-team suite:

@Test
public void repeatedFailedLoginsLockAccount() {
    for (int i = 0; i < 5; i++) {
        given()
            .contentType(ContentType.JSON)
            .body(Map.of("email", "user@test.com", "password", "wrong"))
        .when()
            .post("/auth/login")
        .then()
            .statusCode(401);
    }
 
    // 6th attempt — locked
    given()
        .contentType(ContentType.JSON)
        .body(Map.of("email", "user@test.com", "password", "wrong"))
    .when()
        .post("/auth/login")
    .then()
        .statusCode(429)   // or 403 — depends on API
        .body("error", containsString("locked"));
}

Caveat: account-lockout tests have side effects (the test account is locked when the test ends). Either run them against a dedicated test account that an @AfterMethod resets, or coordinate with the team that owns the account.

⚠️ Common mistakes

  • Only testing the happy path. A test class with userCanReadOwnProfile but no userCannotReadOthersProfile is half a test suite. The presence of permitted access proves nothing about denied access — write both, or write the matrix.
  • Asserting only the status code on a 403. A 403 returned via throw new RuntimeException("oh no") is technically a 403, but the body is a stack trace and the contract is broken. Assert at least the error code or message, especially on rejection paths the client UI displays to users.
  • Ignoring 401-vs-403. They mean different things. 401 = "I don't know who you are" (no/invalid token). 403 = "I know who you are, but you're not allowed." A test that accepts anyOf(is(401), is(403)) lets a real bug through — a missing-auth-filter regression that downgrades 401s to 403s (or vice versa) goes unnoticed.

🎯 Practice task

Build a small permission matrix against an API you control (or stub one if not). 30–40 minutes.

  1. Write the matrix down on paper or in a spreadsheet. Three roles × three endpoints × expected status. This is the source of truth — the test data follows.
  2. Create AuthorisationMatrixTest with a @DataProvider matching your matrix. One @Test consuming the data provider.
  3. Build getTokenForUser(email, password) with a ConcurrentHashMap cache. Confirm running the suite produces only one login per role, not one per matrix row.
  4. Add the unauthenticated boundary. Two tests: protectedEndpointsRequireAuth() (loops through 3 endpoints, asserts 401 on each) and publicEndpointsAccessibleWithoutAuth() (loops through 2 public endpoints).
  5. Horizontal privilege test. Pick one resource (orders, profiles, comments) where users own data. Write userCannotAccessAnotherUsersResource() — log in as user 1, attempt to GET user 2's resource, assert anyOf(is(403), is(404)).
  6. Body assertions on rejection. Pick one of your 403 cases and add .body("error", ...) and .body("message", ...) assertions matching whatever shape your API returns.
  7. Force a regression. Comment out the auth check on one endpoint (in a dev environment!) and re-run. Confirm at least one matrix row fails with a clear name. Restore.
  8. Stretch: add account-lockout tests. Pick a dedicated test account, write the loop that exhausts attempts, and add a @BeforeMethod or @AfterClass that resets the lockout server-side (or coordinate with your auth team for a reset endpoint). Document that this test class needs special teardown.

That's authentication and authorisation covered. Chapter 5 turns inward to the test code itself: serialising and deserialising request and response bodies as Java objects, the POJO patterns, Lombok shortcuts, and how typed models pay off across the suite.

// tip to track lessons you complete and pick up where you left off across devices.