Skip to content

Commit 8b71308

Browse files
itcmsgrclaude
andcommitted
test(v1.100 PR-26-code-D): add 5 dispatcher-level evidence-failure semantics tests (auditor checkpoint)
Auditor focused-audit on 849b372 flagged that PR-26-code-D's Step D introduces a real operator-visible terminal transition: StateRestoreExecuted + evidence write failure → StateRestoreDegraded The 10 unit tests already covered the writer + builder + recording invariants but did NOT pin the dispatcher-level downgrade semantics. This commit adds 5 dispatcher-level tests to close that gap. Tests added: cmd/nftban-installer/restore_decide_test.go 1. PR26D_ExecutedPlusEvidenceFail_DowngradesToDegraded fake deps return StateRestoreExecuted; writeFailExec wrapper forces evidence WriteFileAtomic to fail. Asserts: - sf.State == StateRestoreDegraded (downgrade fires) - exit code == StateRestoreDegraded.ExitCode() - sf.State != StateRestoreExecuted (no false claim) Note: sf.FailureReason stays empty by design (Transition only populates FailureReason on .IsFailed() states; Degraded is success-with-warnings). The downgrade reason surfaces via log.Result, which is the authoritative operator channel for Degraded outcomes. 2. PR26D_FailedExecutionPlusEvidenceFail_TerminalPreserved fake.mutateErr forces FailedExecution; writeFailExec forces evidence-write failure. Asserts: - sf.State == StateRestoreFailedExecution (terminal preserved) - exit == StateRestoreFailedExecution.ExitCode() Evidence failure is warning-only on non-Executed terminals. 3. PR26D_FailedVerificationPlusEvidenceFail_TerminalPreserved fake.activeRet=false forces inline-verify SafeToRemove=false → FailedVerification; writeFailExec forces evidence-write fail. Asserts terminal + exit code unchanged from FailedVerification. 4. PR26D_ExecutedPlusEvidenceOk_PreservesExecuted Plain MockExecutor (writes succeed). Asserts: - sf.State == StateRestoreExecuted (no downgrade on clean write) - exit == StateRestoreExecuted.ExitCode() - exactly one file written under restoreEvidenceDir - no writes outside restoreEvidenceDir 5. PR26D_NoUpdateHistoryWrite_FileScan File-scan against restore_decide.go. Strips line-leading // per §46.1; asserts no production-code reference to writeHistory( or update-history.json. Pins the §19.2 layer-4 invariant stays untouched after PR-26-code-D adds Step D. writeFailExec wrapper (test-only): Wraps *executor.MockExecutor and overrides only WriteFileAtomic to fail. Avoids changing the production MockExecutor; uses the same composition pattern as flakyCSFActiveExec (introduced in PR-25 4B-3-csf for analogous test purposes). Verified on lab2 (Ubuntu 24.04, go1.22.2): - go build ./... clean - go test ./cmd/nftban-installer/... PASS - 5 new TestRunRestoreExecutionFromProceed_PR26D_* / TestDispatcher_PR26D_* tests all PASS - go test -race -count=1 cmd + restore + state PASS - existing PR-25 + PR-26-code-A/B/C tests still PASS No production code change. No CI workflow change. No contract amendment needed. Restore semantics from §48.6 lock + §19.2 layer-4 invariant are both now structurally pinned by tests. Awaiting auditor sign-off + push signal. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 849b372 commit 8b71308

1 file changed

Lines changed: 207 additions & 0 deletions

File tree

cmd/nftban-installer/restore_decide_test.go

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -948,3 +948,210 @@ func TestProductionMutationDep_4B3pre_NoSetterMethods(t *testing.T) {
948948
}
949949
}
950950
}
951+
952+
// =============================================================================
953+
// =============================================================================
954+
// PR-26-code-D — dispatcher evidence-record semantics tests
955+
//
956+
// Per auditor checkpoint on 849b372a: the dispatcher's new Step D
957+
// changes terminal behavior on a successful StateRestoreExecuted
958+
// when the evidence writer fails (downgrade to StateRestoreDegraded).
959+
// These 5 tests pin that semantic delta + the parallel preserved-
960+
// terminal cases on failure-path execution.
961+
// =============================================================================
962+
// =============================================================================
963+
964+
// writeFailExec wraps a MockExecutor and forces every WriteFileAtomic
965+
// to fail with a simulated error. Used by the evidence-failure tests
966+
// to exercise the dispatcher's Step D failure handling without
967+
// changing MockExecutor itself.
968+
type writeFailExec struct {
969+
*executor.MockExecutor
970+
}
971+
972+
func (w *writeFailExec) WriteFileAtomic(_ string, _ []byte, _ os.FileMode) error {
973+
return errors.New("simulated WriteFileAtomic failure for evidence-write")
974+
}
975+
976+
// =============================================================================
977+
// PR-26-code-D test #1: Execute returns StateRestoreExecuted +
978+
// evidence writer fails → dispatcher persists StateRestoreDegraded;
979+
// exit code is the Degraded code; no Executed claim.
980+
// =============================================================================
981+
982+
func TestRunRestoreExecutionFromProceed_PR26D_ExecutedPlusEvidenceFail_DowngradesToDegraded(t *testing.T) {
983+
fake := &fakeDispatcherDeps{
984+
preflightOK: true,
985+
activeRet: true,
986+
authorityRet: uninstall.AuthorityExternal,
987+
safeRet: true,
988+
}
989+
withFakeDeps(t, fake)
990+
991+
sf := newTestStateFile(t)
992+
log := newTestLogger(t)
993+
dr, in, rec, panel := procRecordedPriorFixture()
994+
995+
exec := &writeFailExec{MockExecutor: executor.NewMockExecutor()}
996+
exit := runRestoreExecutionFromProceed(context.Background(), exec, sf, log, dr, in, rec, panel)
997+
998+
if sf.State != state.StateRestoreDegraded {
999+
t.Errorf("State = %q; want StateRestoreDegraded (evidence-write failure must downgrade Executed)", sf.State)
1000+
}
1001+
if exit != state.StateRestoreDegraded.ExitCode() {
1002+
t.Errorf("exit = %d; want %d (Degraded exit code)", exit, state.StateRestoreDegraded.ExitCode())
1003+
}
1004+
if sf.State == state.StateRestoreExecuted {
1005+
t.Errorf("dispatcher claimed StateRestoreExecuted despite evidence-write failure")
1006+
}
1007+
// Note: sf.FailureReason is only populated by Transition when
1008+
// newState.IsFailed() is true (state/file.go:118). StateRestoreDegraded
1009+
// is intentionally NOT a failed state (it is success-with-warnings),
1010+
// so FailureReason stays empty. The evidence-write failure surfaces
1011+
// via the operator-facing log.Result line ("COMPLETED with warnings
1012+
// — restore executed but evidence-write failed: …") which is the
1013+
// authoritative operator channel for Degraded outcomes.
1014+
}
1015+
1016+
// =============================================================================
1017+
// PR-26-code-D test #2: Execute returns StateRestoreFailedExecution +
1018+
// evidence writer fails → original FailedExecution terminal preserved;
1019+
// evidence failure is warning-only.
1020+
// =============================================================================
1021+
1022+
func TestRunRestoreExecutionFromProceed_PR26D_FailedExecutionPlusEvidenceFail_TerminalPreserved(t *testing.T) {
1023+
fake := &fakeDispatcherDeps{
1024+
preflightOK: true,
1025+
mutateErr: errors.New("simulated mutation failure"),
1026+
}
1027+
withFakeDeps(t, fake)
1028+
1029+
sf := newTestStateFile(t)
1030+
log := newTestLogger(t)
1031+
dr, in, rec, panel := procRecordedPriorFixture()
1032+
1033+
exec := &writeFailExec{MockExecutor: executor.NewMockExecutor()}
1034+
exit := runRestoreExecutionFromProceed(context.Background(), exec, sf, log, dr, in, rec, panel)
1035+
1036+
// Original FailedExecution terminal preserved — evidence failure
1037+
// does NOT downgrade a non-Executed terminal.
1038+
if sf.State != state.StateRestoreFailedExecution {
1039+
t.Errorf("State = %q; want StateRestoreFailedExecution (terminal must be preserved on non-Executed paths)", sf.State)
1040+
}
1041+
if exit != state.StateRestoreFailedExecution.ExitCode() {
1042+
t.Errorf("exit = %d; want %d", exit, state.StateRestoreFailedExecution.ExitCode())
1043+
}
1044+
}
1045+
1046+
// =============================================================================
1047+
// PR-26-code-D test #3: Execute returns StateRestoreFailedVerification +
1048+
// evidence writer fails → original FailedVerification terminal
1049+
// preserved; evidence failure is warning-only.
1050+
// =============================================================================
1051+
1052+
func TestRunRestoreExecutionFromProceed_PR26D_FailedVerificationPlusEvidenceFail_TerminalPreserved(t *testing.T) {
1053+
fake := &fakeDispatcherDeps{
1054+
preflightOK: true,
1055+
activeRet: false, // inline-verify assertion 1 returns false → SafeToRemove false
1056+
authorityRet: uninstall.AuthorityExternal,
1057+
safeRet: true,
1058+
}
1059+
withFakeDeps(t, fake)
1060+
1061+
sf := newTestStateFile(t)
1062+
log := newTestLogger(t)
1063+
dr, in, rec, panel := procRecordedPriorFixture()
1064+
1065+
exec := &writeFailExec{MockExecutor: executor.NewMockExecutor()}
1066+
exit := runRestoreExecutionFromProceed(context.Background(), exec, sf, log, dr, in, rec, panel)
1067+
1068+
if sf.State != state.StateRestoreFailedVerification {
1069+
t.Errorf("State = %q; want StateRestoreFailedVerification (terminal must be preserved on non-Executed paths)", sf.State)
1070+
}
1071+
if exit != state.StateRestoreFailedVerification.ExitCode() {
1072+
t.Errorf("exit = %d; want %d", exit, state.StateRestoreFailedVerification.ExitCode())
1073+
}
1074+
}
1075+
1076+
// =============================================================================
1077+
// PR-26-code-D test #4: Execute returns StateRestoreExecuted +
1078+
// evidence writer succeeds → StateRestoreExecuted preserved; evidence
1079+
// file written under restoreEvidenceDir.
1080+
// =============================================================================
1081+
1082+
func TestRunRestoreExecutionFromProceed_PR26D_ExecutedPlusEvidenceOk_PreservesExecuted(t *testing.T) {
1083+
fake := &fakeDispatcherDeps{
1084+
preflightOK: true,
1085+
activeRet: true,
1086+
authorityRet: uninstall.AuthorityExternal,
1087+
safeRet: true,
1088+
}
1089+
withFakeDeps(t, fake)
1090+
1091+
sf := newTestStateFile(t)
1092+
log := newTestLogger(t)
1093+
dr, in, rec, panel := procRecordedPriorFixture()
1094+
1095+
exec := executor.NewMockExecutor()
1096+
exit := runRestoreExecutionFromProceed(context.Background(), exec, sf, log, dr, in, rec, panel)
1097+
1098+
if sf.State != state.StateRestoreExecuted {
1099+
t.Errorf("State = %q; want StateRestoreExecuted (clean evidence-write must preserve terminal)", sf.State)
1100+
}
1101+
if exit != state.StateRestoreExecuted.ExitCode() {
1102+
t.Errorf("exit = %d; want %d", exit, state.StateRestoreExecuted.ExitCode())
1103+
}
1104+
// Exactly one evidence file under restoreEvidenceDir.
1105+
count := 0
1106+
for path := range exec.WrittenFiles {
1107+
if strings.HasPrefix(path, restoreEvidenceDir+"/") {
1108+
count++
1109+
}
1110+
}
1111+
if count != 1 {
1112+
t.Errorf("evidence file count under %s = %d; want 1", restoreEvidenceDir, count)
1113+
}
1114+
// No write outside restoreEvidenceDir from the dispatcher path
1115+
// (the fake deps don't write anything; the only file the
1116+
// dispatcher writes itself is the evidence record).
1117+
for path := range exec.WrittenFiles {
1118+
if !strings.HasPrefix(path, restoreEvidenceDir+"/") {
1119+
t.Errorf("dispatcher wrote unexpected path: %s", path)
1120+
}
1121+
}
1122+
}
1123+
1124+
// =============================================================================
1125+
// PR-26-code-D test #5: Dispatcher path does NOT write update-history.
1126+
// File-scan against restore_decide.go pins the §19.2 layer-4 invariant
1127+
// stays untouched by the new Step D evidence record.
1128+
// =============================================================================
1129+
1130+
func TestDispatcher_PR26D_NoUpdateHistoryWrite_FileScan(t *testing.T) {
1131+
body, err := os.ReadFile("restore_decide.go")
1132+
if err != nil {
1133+
t.Fatalf("read restore_decide.go: %v", err)
1134+
}
1135+
src := string(body)
1136+
1137+
// Strip line-leading // comments per §46.1 discipline.
1138+
var prodLines []string
1139+
for _, line := range strings.Split(src, "\n") {
1140+
trimmed := strings.TrimLeft(line, " \t")
1141+
if strings.HasPrefix(trimmed, "//") {
1142+
continue
1143+
}
1144+
prodLines = append(prodLines, line)
1145+
}
1146+
prodSrc := strings.Join(prodLines, "\n")
1147+
1148+
forbidden := []string{
1149+
"writeHistory(",
1150+
"update-history.json",
1151+
}
1152+
for _, pat := range forbidden {
1153+
if strings.Contains(prodSrc, pat) {
1154+
t.Errorf("restore_decide.go references %q (§19.2 layer-4 invariant breached — restore mode must NOT write update-history)", pat)
1155+
}
1156+
}
1157+
}

0 commit comments

Comments
 (0)