Skip to content

Coverage Percentage Is Not a Shield: Lock the Reject Arms

Adityo Guni Waluyo

A Go middleware coverage audit from 27 to 96.7 percent: what httptest arm-by-arm reject-path testing teaches, from JWT to CORS.

TL;DR

A coverage audit showed middleware at 27 percent despite guarding authentication, and the untested paths were all security-critical rejections. Walking arm by arm with Go's httptest added 850 lines across seven files, pushing middleware to 96.7 percent without changing production code. Coverage stops climbing where fakes start lying, so each rejection arm now gets a named test immediately.

That night I scrolled the coverage audit report package by package, and one line stayed on screen: middleware at 27 percent. The package that actually guards authentication. Stranger still, the dark paths were not the busy ones. Expired tokens, missing headers, mismatched roles, none of them had ever been touched by a single test. Those rejection paths ran in production every day; the test suite just never called them.

My first reflex was wrong: I read 27 as a math challenge. Add tests anywhere, chase the leading digits, done. I nearly wrote tests for paths that were already covered so the report would look healthy, while the risky arms stayed empty. The definition itself is plain: coverage measures how many code statements execute while tests run [1]. The number has no idea which paths are dangerous; it only counts.

One Arm, One Test Function

The right work turned out to be duller: walking arm by arm. The audit flagged packages with unhealthy coverage, I took the darkest first, and 850 lines of tests landed across seven new files. Every rejection scenario got its own function built on httptest, Go's standard library for testing HTTP handlers: its ResponseRecorder records mutations of http.ResponseWriter for later inspection, and NewRequestWithContext builds a server-side request without a real socket [2].

The function names double as documentation. TestJWTAuth_ExpiredToken locks the expired-token response, TestRequireRole_MismatchedRole locks the rejection of a role that does not match, TestRequiredUserAuth_AdminTokenRejected closes the hole where an admin token could slip through the regular-user gate, and TestResolveBidang_SourceError locks the path where the data source fails. The CORS package got the same treatment: a mismatched origin must be rejected, wildcards behave per contract, preflight gets the right answer. The Recover middleware was tested to emit a 500 without leaking panic details, and status recording in the logging middleware was tested so the number the client sees is the number that gets logged. In the request package, ParseInt boundaries and the whole VisitorKey surface were closed too, from the normal key to input variations that used to be guesses. That is 850 lines of tests in seven new files, plus one corrected comment and one documentation index. Not one line of production code changed, and that is exactly the point: the security surface already existed; what was missing was a witness.

The Numbers Rose, But Not Evenly

The result: middleware climbed from 27 to 96.7 percent, request from 50 to 100. rbac crawled from 6.3 to 42.1 percent and stopped; the remaining arms need a real RBAC store, not a fake, and forcing the number there would mean testing lies. The database package sat still at 39.4 percent because its wrappers must meet real MySQL. Here is the lesson: the number stops climbing exactly where fakes start lying. If the percentage were the goal, those last two packages failed. If the goal is locked risky arms, the mission is done, and 96.7 is just a sweet side effect. My map for the next round changed too: no longer percent per package, but the list of arms still without a witness, sorted by how often attackers try them.

Security behavior now has named witnesses. Changing a CORS response or loosening a role means arguing with one specific test function, not editing code in a quiet corner. The difference shows up in review: the question is no longer intuition, it is which test has to change.

One small fix rode along: a comment in decodepath.go. The old text implied that values like %252C stay encoded after the middleware. In reality chi already decodes the path once during route matching, so what the middleware receives is already %2C. A wrong comment on a security arm is slow poison: the next reader will write code for a world that does not exist.

The habit I took home: every time I write a new rejection arm, I write its test immediately and call it by name with go test's -run flag [3]; the flag runs only the tests matching the pattern, so the loop stays fast, and the parents it runs along the way are reported too. The percentage can rise afterwards on its own. The mandatory check stays the same: every rejection path has named proof of execution.

Related articles