FifeRouter

4 September 2026 · testing architecture

Read-only by construction, asserted by walking the route table

A test that iterates the application's own routes and fails if any admin endpoint accepts anything but GET. The value is not the assertion — it is what it does to the next conversation.

def test_the_console_exposes_no_write_endpoints(self, client, as_operator):
    import app as app_module

    for route in app_module.app.routes:
        path = getattr(route, "path", "")
        if path.startswith("/api/admin"):
            assert set(getattr(route, "methods", set())) <= {"GET", "HEAD"}, path

Eight lines. It walks the running application's routing table and asserts that nothing under /api/admin accepts a method that changes anything.

Why not a review convention

"The console is read-only" written in a document is a promise. It holds while the person who wrote it is reviewing pull requests and decays afterwards, in the usual way: not through disagreement, but through a series of individually reasonable exceptions.

Each one is genuinely reasonable, too. Send this campaign — you already have the subscriber list on screen. Adjust this balance — you can see the ledger. Revoke this key — you are looking at the key. In every case the data is right there and the write is one endpoint away.

The property does not die from a bad decision. It dies from four good ones.

What the test actually does

It does not forbid a write endpoint. It makes adding one produce a red build.

That converts an implicit slide into an explicit act: somebody has to see the failure, understand what it is protecting, and either delete the test or make the case for the exception. Both of those are conversations, and the conversation is the mechanism. A convention has no moment where it insists on being discussed.

Why route introspection rather than a list

The obvious version enumerates the endpoints:

ADMIN_ENDPOINTS = ["/api/admin/overview", "/api/admin/users", ...]

Which has the failure mode this codebase has now hit three times with a different list: the enumeration and the reality are two descriptions of the same thing, kept in step by memory. Adding an endpoint and forgetting to add it here produces a test that passes and checks nothing.

Asking the application removes the second description. A new route is covered the moment it exists, without anybody remembering.

The same reasoning replaced a hardcoded TRUNCATE list with a query against pg_tables, after that list went stale twice in a day.

What "safe to leave open" buys

The console reads API-key metadata, balances, email addresses and consent records. Read-only means it can sit open on a second monitor without being a thing that might get clicked.

It also means an operator session that leaks — a shared laptop, a forgotten logout — is a disclosure and not a destruction. Bad, recoverable, and a different incident from one where somebody's balance was adjusted.

And it means the console needs no audit log. There is nothing to audit, because nothing there can change anything. An audit log is what you build when a surface can act; making the surface unable to act is cheaper and harder to get wrong than recording what it did.

The endpoints that will eventually want to exist

Sending a campaign is the obvious one. The mailing list is built, consent is recorded, and there is nowhere to press send.

That is deliberate: a send button is the most destructive control in the product and should not exist before there is somewhere properly guarded to put it. When it arrives it will be the moment this property ends, which is why the test exists — so that moment is chosen rather than arrived at.


← All posts