Skip to content

fix(simulation): a control test result was whatever string the client sent - #417

Merged
0xmanhnv merged 1 commit into
developfrom
fix/control-test-result-unvalidated
Aug 4, 2026
Merged

fix(simulation): a control test result was whatever string the client sent#417
0xmanhnv merged 1 commit into
developfrom
fix/control-test-result-unvalidated

Conversation

@0xmanhnv

@0xmanhnv 0xmanhnv commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

There was no layer between a request field and the control_tests.status column.

// handler -> service
ct.RecordResult(simulation.ControlTestStatus(input.Status), ...)   // raw cast, no check

// entity
func (c *ControlTest) RecordResult(status ControlTestStatus, ...) {
    c.status = status                                              // no check

And unlike compensating_controls, the control_tests table has no CHECK constraint on status. I checked:

select pg_get_constraintdef(oid) from pg_constraint
 where conrelid=control_tests::regclass and contype=c;
(no rows)

Why it matters — the failure is silent

A control recorded as "Pass" or "passed" stores happily, then matches neither status === pass nor fail in the Control Testing pages own summary. A control that was tested reads as neither passed nor failed. No error anywhere; the compliance number is just wrong.

This is the same shape as the compensating-controls bug (a vocabulary mismatch between what the client sends and what the platform understands) — except that one failed loudly with a 500, because the database refused it. Here the database accepts anything, so it fails quietly instead. Quieter is worse.

The fix

RecordResult returns an error for an unknown status and leaves the entity untouched — a rejected test must not leave a lastTestedAt behind implying a test happened.

AllControlTestStatuses() is the single list, with a test that fails if a constant is declared without being added to it. Otherwise a new status would be silently unusable and the failure would look like a client bug.

One thing worth flagging about the change itself

The caller compiled fine while ignoring the new return value. Go allows that for a method call used as a statement, and --new-from-rev linting would not have flagged it either, since simulation.go:273 is not a line this PR would otherwise touch. So the build passing proved nothing here — the caller is updated explicitly.

Verification

Rejection cases cover the realistic near-misses, not just nonsense: "Pass", "passed", "PARTIAL", "not-applicable", "", "untested ". Each asserts the error and that the entity was not mutated.

  • GOWORK=off go build ./... — ok
  • GOWORK=off go test ./... against app_test — all green
  • GOWORK=off make lint-ci — clean

Not addressed here — but found, and it is the other half

The Control Testing page has no way to record a result at all. useRecordControlTestResult and useDeleteControlTest exist in use-simulation-api.ts; the page imports neither (only useCreateControlTest, line 279). The row renders status and last_tested_at and offers no action.

So a control test created through the UI stays untested forever, last_tested_at shows Never, and the pages own passed/failed/untested summary is permanently 100% untested. control_tests has 0 rows on the live database.

That is a ui change and gets its own PR. This one makes sure that when the call does arrive, a typo cannot become a stored status.

… sent

RecordResult cast the incoming string straight into the entity and persisted it:

    ct.RecordResult(simulation.ControlTestStatus(input.Status), ...)   // no check
    func (c *ControlTest) RecordResult(status ControlTestStatus, ...) {
        c.status = status                                              // no check

Nothing else validated it either. Unlike compensating_controls, the
control_tests table has NO CHECK constraint on status, so there was no layer
between a request field and the column.

The failure is quiet, which is what makes it worth fixing. A control recorded
as "Pass" or "passed" stores happily and then matches neither `status ===
'pass'` nor `'fail'` in the Control Testing page's own summary — so a control
that WAS tested reads as neither passed nor failed. Nobody gets an error;
the compliance number is just wrong.

RecordResult now returns an error for an unknown status and leaves the entity
untouched, so a rejected test does not leave a lastTestedAt behind implying a
test happened.

Note the caller compiled fine while ignoring the new return value — Go allows
that for a method call used as a statement, and `--new-from-rev` linting would
not flag an unchanged line. It is updated explicitly rather than left to a
linter that had no reason to look.

AllControlTestStatuses is the single list, with a test that fails if a constant
is declared without being added to it — otherwise a new status would be
silently unusable and the failure would look like a client bug.

Not addressed here: the Control Testing page has no way to record a result at
all. useRecordControlTestResult and useDeleteControlTest exist in
use-simulation-api.ts and the page imports neither, so a control test created
through the UI stays untested forever and the page's own passed/failed summary
is always 100% untested. That is a ui change and gets its own PR; this one makes
sure that when the call does arrive, a typo cannot become a stored status.
@0xmanhnv
0xmanhnv merged commit 1048b45 into develop Aug 4, 2026
17 checks passed
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