Skip to content

feat: reject truncated UUID identifiers locally instead of returning an opaque 404#95

Merged
ColonistOne merged 2 commits into
mainfrom
feat/uuid-shape-validation
Jul 13, 2026
Merged

feat: reject truncated UUID identifiers locally instead of returning an opaque 404#95
ColonistOne merged 2 commits into
mainfrom
feat/uuid-shape-validation

Conversation

@ColonistOne

Copy link
Copy Markdown
Collaborator

The bug

An id gets printed truncated for display — post["id"][:8] into a log, a table, a code review — and then passed back in as though it were the whole value.

Today that builds a perfectly well-formed request, and the server answers with a bare 404 Not Found. Which reads as "the post was deleted" when the real cause is "you passed eight characters". Those are debugged very differently, and the second one is invisible.

I found this the hard way: I listed some Colony comments with print(c["id"][:8]), then used those 8-character strings as parent_id. Only the server's 404 caught it.

The fix

Every method taking post_id / comment_id / parent_id / user_id / webhook_id / notification_id now rejects a value that is visibly a fragment of a UUID — hex-and-hyphens, 8+ characters, not a whole id:

ValueError: parent_id looks like a truncated UUID: 'a13258d1' (8 chars, expected 36).
The prefix of a UUID is not a UUID -- re-fetch the object and use its full 'id'
rather than completing it by hand.

Names the parameter, both lengths, and what to do instead.

Not a breaking change

The check is deliberately narrow. Opaque placeholders — "p1", "c1", "abc", "post-1" — pass through to the server untouched, exactly as before, so mocked test suites keep working (ours and downstream's).

The 8-character floor is what makes that work. It's the canonical display truncation (id[:8], the git short-hash convention), so it's what real truncation looks like. Below it, a short hex-ish string like "c1" or "abc" is far more plausibly a fixture than a fragment of a real id.

A strict "must be a UUID" check was the first thing I wrote. It broke 144 existing tests — all of them placeholder ids against a mocked transport — and would break every downstream mocked suite on upgrade. That's a real cost for no extra safety: nobody was ever going to mistake "p1" for a real id. A hex prefix, they would.

Full suite: 965 → 986 passed. Zero existing tests modified. Additive only.

A shape check, not an existence check

Stated plainly because it matters, and asserted in a test so it can't quietly get oversold later:

A well-formed UUID that refers to nothing still reaches the server and still returns 404. That is the server's job, and the server is the only party that can do it. A local check can tell you an id is malformed. It can never tell you an id is real.

def test_a_well_formed_but_fabricated_uuid_is_accepted(self):
    fabricated = "a13258d1-1b2f-4a04-bd97-0e1a5e78a5f4"  # valid shape, refers to nothing
    assert _require_uuid(fabricated, "post_id") == fabricated

That test exists to keep the guard honest about what it does not do.

Scope

  • ColonyClient and AsyncColonyClient, 57 methods each, applied symmetrically. async_client imports the helper from client, as it already does for _UUID_RE.
  • Non-string ids (passing a whole response dict instead of its id) raise a ValueError that points at the 'id' field.
  • No version bump. CHANGELOG entry lands under Unreleased — ships whenever the next release goes out.
  • 21 new tests in tests/test_uuid_validation.py. ruff + mypy clean.

Possible follow-up (not in this PR)

The narrow check catches malformed ids. It cannot catch a syntactically perfect but wrong id. The fix for that class is provenance, not shape: opaque PostId / CommentId types constructible only from an API response, so a fabricated id becomes a type error at construction rather than a runtime 404. Happy to open a design issue if there's appetite.

…an opaque 404

Every method taking a post_id / comment_id / parent_id / user_id / webhook_id /
notification_id now rejects a value that is visibly a FRAGMENT of a UUID —
hex-and-hyphens, 8+ chars, not a whole id — with a ValueError naming the
parameter, both lengths, and the fix.

The failure this catches: an id printed truncated for display (post["id"][:8]
into a log, a table, a code review) and then passed back in as though it were
the whole value. Today that builds a perfectly well-formed request, and the
server answers with a bare 404 Not Found — which reads as "the post was deleted"
when the real cause is "you passed eight characters". Those are debugged very
differently, and the second one is invisible. (Found the hard way: I did exactly
this to the Colony API and only the server's 404 caught it.)

NOT a breaking change. The check is deliberately narrow — opaque placeholders
("p1", "c1", "abc", "post-1") pass through to the server untouched, so mocked
test suites keep working. The 8-char floor is the canonical display truncation
(id[:8], the git short-hash convention); below it a short hex-ish string is far
more plausibly a fixture than a fragment of a real id. Full suite: 965 -> 986
passed, zero existing tests touched.

A SHAPE check, not an EXISTENCE check, and deliberately not sold as one: a
well-formed UUID that refers to nothing still reaches the server and still 404s.
That is the server's job and it is the only party that can do it. A local check
can tell you an id is malformed; it can never tell you an id is real. There is a
test asserting precisely that, so the guard cannot quietly get oversold later.

Applied symmetrically to ColonyClient and AsyncColonyClient (57 methods each);
async_client imports the helper from client, as it already does for _UUID_RE.
No version bump — CHANGELOG entry lands under Unreleased for the next release.
@codecov

codecov Bot commented Jul 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

codecov/patch failed: the UUID guard added a line to every id-taking method, and
five async methods — crosspost, pin_post, close_post, reopen_post,
set_post_language — had NO tests at all. Their sync twins are covered; the async
ones never were. The guard line simply made an existing hole visible.

Adds AsyncColonyClient coverage for all five: correct verb and path, crosspost's
optional title omitted when unset, set_post_language's code in the query string,
and a parametrized case asserting the guard fires BEFORE any request is built —
the mock transport raises if it is ever called, so a truncated id provably never
leaves the process.

Uncovered added lines: 5 → 0. Suite 986 → 997 passed.
@ColonistOne ColonistOne merged commit 2d2a9ee into main Jul 13, 2026
7 checks passed
@ColonistOne ColonistOne deleted the feat/uuid-shape-validation branch July 13, 2026 11:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant