Skip to content

feat: reject known-bad arguments locally, before the API round-trips#112

Merged
jackparnell merged 2 commits into
mainfrom
feat/argument-validation
Jul 22, 2026
Merged

feat: reject known-bad arguments locally, before the API round-trips#112
jackparnell merged 2 commits into
mainfrom
feat/argument-validation

Conversation

@arch-colony

Copy link
Copy Markdown
Collaborator

Extends the guard family started by _require_uuid and _validate_totp_code: catch, at the call site, the arguments the server is documented (or tested) to reject — so the caller gets a clear local error and no wasted round-trip.

No version bump (per request).

The safety case

Every guard rejects only values the API already refuses, so none can turn away input the server would have accepted. That is asserted two ways for each:

  • unit: the validator accepts every valid value and rejects the specific bad ones;
  • method-level: a bad argument raises before any HTTP request — a mocked transport that must stay assert_not_called(). Validation that fired after the request would be pointless, so the test enforces that it fires before.

Guards added (evidence cited in each docstring)

Guard Methods Contract & evidence
_require_nonempty create_post (title+body), create_comment, update_comment, send_message, search Empty/whitespace → 422 string_too_short. create_post title & body confirmed against the live API (empty → 422 on each); the rest are unambiguous required fields. Does not enforce longer documented minimums (search's 2 chars) — a 1-char value is plausible and the server owns that floor.
_validate_vote_value vote_post, vote_comment {-1, 1} only — no clear-vote semantic; 0, 2, -2 all 422, per tests/integration/test_voting.py. bool rejected (serialises to true, which the server refuses). 1.0 passes (server may coerce; not newly rejected).
_validate_reaction react_post, react_comment Documented 8-key set, with a targeted hint for the exact mistake the docstrings warn about — passing the Unicode emoji instead of its key.
empty-list check vote_poll option_ids=[] isn't None, so it slipped the existing guard and sent {"option_ids": []} (422). Now caught.
period check subscribe_premium {monthly, annual} — the docstring already documents 400 INVALID_INPUT otherwise.

Validators live in client.py and are imported by async_client.py, so sync and async share one source of truth (as they already do for _require_uuid).

Deliberately NOT added — false-validation risk

  • post_type / sort enums — high value, but a drift risk; the codebase already made colony slugs permissive on purpose for exactly this reason.
  • pagination limit/per_page — commonly clamped server-side, not rejected; a local range check would raise on input the API accepts.
  • register(username) — the charset/length pattern is documented nowhere, so any rule would be a guess.

These are noted as follow-ups pending a confirmed reject-vs-accept contract.

Verification

  • 52 new tests; full unit suite 1059 passed, 1 skipped; ruff clean.
  • Mutation-tested: breaking any one validator turns the suite red — verified applied by sha256 before/after (an unapplied mutation reports identically to a survived one), teardown verified. The tests can actually fail.
  • One live probe created zero junk data (both empty-title and empty-body posts were rejected 422).

Extends the family started by `_require_uuid` and `_validate_totp_code`:
catch, at the call site, the arguments the server is documented (or tested)
to reject, so the caller gets a clear local error and no wasted request.

Same philosophy as the existing guards, and it is the whole safety case:
each check rejects ONLY values the API already refuses, so none can turn
away input the server would have accepted. Every guard is covered both as a
unit (accepts all valid, rejects the specific bad) and at the method level
(a bad argument raises BEFORE the transport is called — asserted with a
mock that must stay untouched).

Added guards, each with cited evidence:

- Required non-empty text (`_require_nonempty`) on the hottest paths:
  create_post title+body, create_comment body, update_comment body,
  send_message body, search query. Empty/whitespace-only -> 422
  string_too_short. create_post's title AND body requirement was confirmed
  against the live API (empty -> 422 on each); the others are unambiguous
  required fields. Deliberately does NOT enforce documented longer minimums
  (search's 2 chars) — a 1-char value is a plausible real input and the
  server owns that floor.

- Vote value in {-1, 1} (`_validate_vote_value`) on vote_post/vote_comment.
  The endpoint has no clear-vote semantic: 0, 2, -2 all 422 ("Vote value
  must be 1 or -1"), per tests/integration/test_voting.py. bool is rejected
  even though True == 1, because it serialises to JSON `true`, which the
  server refuses. Floats like 1.0 pass (server may coerce; not newly
  rejected).

- Reaction key in the documented 8-key set (`_validate_reaction`) on
  react_post/react_comment, with a targeted hint for the exact mistake the
  docstrings warn about: passing the Unicode emoji instead of its key.

- vote_poll empty option_ids: `[]` is not None, so it slipped past the
  existing None-guard and sent {"option_ids": []} (a 422). Now caught.

- subscribe_premium period in {monthly, annual} — the docstring already
  documents 400 INVALID_INPUT for anything else.

Validators live in client.py and are imported by async_client.py, so sync
and async share one source of truth (as they do for `_require_uuid`). No
version bump.

Deliberately NOT added, to avoid false validation (rejecting something the
API accepts):
- post_type / sort enums — high value but a drift risk; the codebase
  already made colony slugs permissive on purpose for the same reason.
- pagination limit/per_page — these are commonly clamped server-side rather
  than rejected; a local range check would raise on input the API accepts.
- register(username) — the charset/length pattern is documented nowhere, so
  any local rule would be a guess.
These are noted as follow-ups pending a confirmed reject-vs-accept contract.

52 new tests. Each validator mutation-tested: breaking any one turns the
suite red (verified applied by sha256 before/after, teardown verified), so
the tests can actually fail. Full unit suite: 1059 passed, 1 skipped. ruff
clean.
Local ruff *check* passed but the lint job also runs ruff *format --check*,
which I hadn't run. No logic change — collapses the new validators'
multi-line strings/raises to ruff's canonical single-line form.
@arch-colony
arch-colony force-pushed the feat/argument-validation branch from 0dded31 to bef458d Compare July 22, 2026 09:21
@jackparnell
jackparnell merged commit 520cfe2 into main Jul 22, 2026
6 checks passed
ColonistOne added a commit that referenced this pull request Jul 22, 2026
Follow-up to #113 (agent SSO). Applies the #112 discipline to the two
token-exchange inputs that have a high-confidence, zero-false-positive
local check:

- `audience` is required; empty/whitespace comes back as invalid_target /
  invalid_request (400). Reuses `_require_nonempty`.

- `subject_token`, when passed explicitly, must be a JWT. A `col_...` API
  key is THE mistake this whole endpoint traces back to — #113's error
  mapping exists to surface it, and its own test asserts the server says
  "if you sent a col_... API key". A new `_validate_subject_token` catches
  it locally instead: it rejects the empty and `col_`-prefixed cases and
  names the fix (leave it unset, or call get_auth_token first), and passes
  every other string through. Same spirit as `_validate_totp_code`
  rejecting a base32 *secret*.

`scope` is deliberately NOT validated: scopes are extensible with no
documented closed set, so any local rule would risk rejecting a value the
server accepts — the cardinal sin of this family.

Validation only fires on the explicit-argument paths; the default (own
JWT via get_auth_token) is untouched, and the happy paths #113 established
are covered by tests that assert a valid call still requests.

Applied to sync + async (validators live in client.py, imported by
async_client.py, as with the rest of the family).

13 new tests. Each new guard mutation-tested — removing the col_ check,
the empty check, the audience check, or the subject-token call each turns
the suite red (verified applied by sha256, teardown verified). Full unit
suite 1094 passed; ruff format + check clean; no version bump.
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.

3 participants