package signedjobs import ( "context" "errors" "sync" "testing" "time" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" "gitea.dooplex.hu/admin/felhom-agent/internal/reconcile" ) // outcomeQueue is a jobsQueue that also has the optional half (OutcomeSource), as *hub.Client does. type outcomeQueue struct { jobsQueue omu sync.Mutex outcomes map[string]hub.JobOutcome } func (q *outcomeQueue) CompleteJobWithOutcome(ctx context.Context, jobID string, o hub.JobOutcome) error { q.omu.Lock() if q.outcomes == nil { q.outcomes = map[string]hub.JobOutcome{} } q.outcomes[jobID] = o q.omu.Unlock() return q.jobsQueue.CompleteJob(ctx, jobID) } // R-1383 (D78): a signed job that is refused or fails was cleared from the hub's queue exactly like one that ran — // „an agent update never arrives” left one journal line on the box. The runner now says how each job ended, through // the REAL gate and verifier: the stored effect is the outcome the hub receives with the clearing call. // The two clock reasons are the ones the ruling is about: a box with a wrong clock refuses every job. // COMPANION RED-PROOFS (observed, felhom.eu audits/night-build-2026-11-14/B/redproof-agent-job-outcome.txt). func TestRunner_TellsTheHubHowEachJobEnded(t *testing.T) { s := newTestSigner(t) _, _, exec := newRealGateRunner(t, s) base, _, _ := newRealGateRunner(t, s) src := &outcomeQueue{} r := NewRunner(src, base.gate, exec, testHost, quiet()) now := time.Now().UTC() src.add(mintJob(t, s, "ok", testHost, "", "ops-1", wipeParamsJSON, now, now.Add(time.Hour))) src.add(mintJob(t, s, "expired", testHost, "", "ops-1", wipeParamsJSON, now.Add(-3*time.Hour), now.Add(-2*time.Hour))) src.add(mintJob(t, s, "early", testHost, "", "ops-1", wipeParamsJSON, now.Add(2*time.Hour), now.Add(3*time.Hour))) src.add(mintJob(t, s, "other-box", "some-other-host", "", "ops-1", wipeParamsJSON, now, now.Add(time.Hour))) src.add(corruptSig(t, mintJob(t, s, "forged", testHost, "", "ops-1", wipeParamsJSON, now, now.Add(time.Hour)))) src.add(hub.JobWire{JobID: "garbage", BlobB64: "!!!not-base64!!!"}) if _, err := r.RunOnce(context.Background()); err != nil { t.Fatal(err) } // and a job that is accepted and whose run fails exec.err = errors.New("the wrapper refused") src.add(mintJob(t, s, "run-fails", testHost, "", "ops-1", wipeParamsJSON, now, now.Add(time.Hour))) if _, err := r.RunOnce(context.Background()); err != nil { t.Fatal(err) } want := map[string]hub.JobOutcome{ "ok": {Outcome: "done", Op: "storage_wipe"}, "expired": {Outcome: "rejected", Reason: "expired", Op: "storage_wipe"}, "early": {Outcome: "rejected", Reason: "not_yet_valid", Op: "storage_wipe"}, "other-box": {Outcome: "rejected", Reason: "target", Op: "storage_wipe"}, "forged": {Outcome: "rejected", Reason: "malformed", Op: "storage_wipe"}, // corruptSig breaks the armour itself "garbage": {Outcome: "malformed", Reason: "malformed"}, "run-fails": {Outcome: "failed", Reason: "execution_failed", Op: "storage_wipe"}, } for id, w := range want { if got, ok := src.outcomes[id]; !ok || got != w { t.Errorf("job %q: the hub was told %+v (told=%v), want %+v", id, got, ok, w) } } if len(src.outcomes) != len(want) { t.Errorf("outcomes for %d jobs, want %d: %+v", len(src.outcomes), len(want), src.outcomes) } if exec.count() != 2 { t.Errorf("executor ran %d times, want 2 (ok and run-fails only)", exec.count()) } } // Every code reasonCode can return is on the closed list the hub holds a sentence for. func TestReasonCode_ClosedList(t *testing.T) { known := map[string]bool{} for _, c := range ReasonCodes { known[c] = true } for _, d := range []reconcile.Decision{ {}, {Reason: reconcile.ReasonRejected, Err: errors.New("something new")}, {Reason: reconcile.ReasonRoleDenied}, {Reason: reconcile.ReasonBindingMismatch}, {Reason: reconcile.ReasonPendingSignature}, } { if c := reasonCode(d); !known[c] { t.Errorf("reasonCode(%+v) = %q, not on ReasonCodes", d, c) } } if reasonCode(reconcile.Decision{Reason: reconcile.ReasonRejected, Err: errors.New("x")}) != "other" { t.Error("an unknown refusal must be told as „other”, never as the error's own text") } if !known["execution_failed"] || !known["malformed"] { t.Error("the two codes the runner sets itself are not on the list") } }