package host import ( "context" "strings" "testing" "time" "github.com/carlosframework/platform/internal/release" ) func TestRefusalWatchAlertsOncePerRecord(t *testing.T) { bake := t.TempDir() now := time.Date(2026, 10, 6, 9, 31, 0, 0, time.UTC) if err := release.WriteRefusal(bake, release.Refusal{Host: "console.example.com", App: "console", Version: "v2", RefusedAt: now.Add(-time.Minute), Reason: "exited: status=2"}); err != nil { t.Fatal(err) } var posts []string sinks := newAlertSinks("https://hook.example", func(_ string, b []byte) error { posts = append(posts, string(b)); return nil }, "", nil, &alertCounters{}) w := &refusalWatch{} got := w.tick(context.Background(), bake, sinks, now) w.tick(context.Background(), bake, sinks, now.Add(time.Minute)) if len(posts) != 1 || !strings.Contains(posts[0], `"release_refused"`) || !strings.Contains(posts[0], "v2") { t.Fatalf("posts = %v, want exactly one release_refused alert", posts) } if len(got) != 1 || got[0].Host != "console.example.com" { t.Fatalf("status rows = %+v", got) } } func TestRefusalWatchSkipsOldRecordsAtStartup(t *testing.T) { bake := t.TempDir() now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) if err := release.WriteRefusal(bake, release.Refusal{Host: "a.example.com", Version: "v2", RefusedAt: now.Add(-2 * time.Hour)}); err != nil { t.Fatal(err) } var posts int sinks := newAlertSinks("https://hook.example", func(string, []byte) error { posts++; return nil }, "", nil, &alertCounters{}) (&refusalWatch{}).tick(context.Background(), bake, sinks, now) if posts != 0 { t.Fatalf("an agent restart re-alerted a refusal from two hours ago") } } // Final review F6 and ledger Task 8: SNS and the webhook tell a recovered // rollback from one whose restored build did not come up either, and the // status row carries the failure for `status --live`. func TestRefusalWatchReportsTheRecoveryOutcome(t *testing.T) { now := time.Date(2026, 10, 6, 9, 31, 0, 0, time.UTC) for _, c := range []struct { name string rec release.Refusal subject string inMessage string inBody string rowErr string }{ {"recovered", release.Refusal{RestoredVersion: "v1", Recovered: true}, "console.example.com: v2 did not start, rolled back to v1", "Retry with: carlos promote --retry v2", `"recovered":true`, ""}, {"failed recovery", release.Refusal{RestoredVersion: "v1", RecoveryError: "start console.service: exit status 1"}, "console.example.com: v2 did not start and the rollback did not come up either", "The rollback did not come up either: start console.service: exit status 1", `"recovery_error":"start console.service: exit status 1"`, "start console.service: exit status 1"}, } { t.Run(c.name, func(t *testing.T) { bake := t.TempDir() r := c.rec r.Host, r.App, r.Version, r.RefusedAt, r.Reason = "console.example.com", "console", "v2", now.Add(-time.Minute), "exited: status=2" if err := release.WriteRefusal(bake, r); err != nil { t.Fatal(err) } var posts []string pub := &fakeSNS{} sinks := newAlertSinks("https://hook.example", func(_ string, b []byte) error { posts = append(posts, string(b)); return nil }, testTopicARN, pub, &alertCounters{}) rows := (&refusalWatch{}).tick(context.Background(), bake, sinks, now) if len(pub.in) != 1 || *pub.in[0].Subject != c.subject { t.Fatalf("sns = %+v, want one publish with subject %q", pub.in, c.subject) } if !strings.Contains(*pub.in[0].Message, c.inMessage) { t.Fatalf("sns message = %q, want %q in it", *pub.in[0].Message, c.inMessage) } if len(posts) != 1 || !strings.Contains(posts[0], c.inBody) { t.Fatalf("webhook = %v, want %s in the body", posts, c.inBody) } if len(rows) != 1 || rows[0].RecoveryError != c.rowErr { t.Fatalf("status rows = %+v, want recovery_error %q", rows, c.rowErr) } }) } } // D2's refusal is not a rollback: the subject says so and the message points // at a NEW version shipped with carlos ship --program-change (the refused // version's manifest is immutable), never --retry. The webhook keeps its envelope kind and // carries the record's as refusal_kind; the status row carries it too. func TestRefusalWatchReportsAWrongProgram(t *testing.T) { bake := t.TempDir() now := time.Date(2026, 10, 6, 9, 31, 0, 0, time.UTC) rec := release.Refusal{Host: "console.example.com", App: "console", Version: "v2", RefusedAt: now.Add(-time.Minute), Reason: "wrong program: example.com/carlos", Kind: release.RefusalWrongProgram, Recovered: true, RestoredVersion: "v1"} if err := release.WriteRefusal(bake, rec); err != nil { t.Fatal(err) } subject, msg := formatReleaseAlert(rec) if subject != "console.example.com: v2 refused, wrong program (still on v1)" { t.Fatalf("subject = %q", subject) } if !strings.Contains(msg, "Ship a new version with carlos ship --program-change and promote it, if the change is intended") || strings.Contains(msg, "--retry") { t.Fatalf("message = %q", msg) } var posts []string sinks := newAlertSinks("https://hook.example", func(_ string, b []byte) error { posts = append(posts, string(b)); return nil }, "", nil, &alertCounters{}) rows := (&refusalWatch{}).tick(context.Background(), bake, sinks, now) if len(posts) != 1 || !strings.Contains(posts[0], `"kind":"release_refused"`) || !strings.Contains(posts[0], `"refusal_kind":"wrong-program"`) { t.Fatalf("posts = %v", posts) } if len(rows) != 1 || rows[0].Kind != release.RefusalWrongProgram { t.Fatalf("rows = %+v", rows) } } // D3's staged-bytes refusal is not a rollback either (plan 3 ruling 3): the // box never left its old build. The alert says the staged bytes do not match // dev's proof, names the build the box is still on, and asks for the exact // bytes dev proved; never --retry, never a rollback. func TestRefusalWatchReportsANotProven(t *testing.T) { bake := t.TempDir() now := time.Date(2026, 10, 6, 9, 31, 0, 0, time.UTC) rec := release.Refusal{Host: "console.example.com", App: "console", Version: "v2", RefusedAt: now.Add(-time.Minute), Reason: "not proven: staged sha256 aaaa, dev proved bbbb", Kind: release.RefusalNotProven, Recovered: true, RestoredVersion: "v1"} if err := release.WriteRefusal(bake, rec); err != nil { t.Fatal(err) } subject, msg := formatReleaseAlert(rec) if subject != "console.example.com: v2 refused, staged bytes do not match dev's proof (still on v1)" { t.Fatalf("subject = %q", subject) } for _, want := range []string{"staged sha256 aaaa, dev proved bbbb", "still on v1", "Roll the exact bytes dev proved"} { if !strings.Contains(msg, want) { t.Fatalf("message = %q, want %q in it", msg, want) } } for _, never := range []string{"--retry", "rolled back", "rollback"} { if strings.Contains(msg, never) || strings.Contains(subject, never) { t.Fatalf("subject %q, message %q: %q has no place in a not-proven alert", subject, msg, never) } } var posts []string sinks := newAlertSinks("https://hook.example", func(_ string, b []byte) error { posts = append(posts, string(b)); return nil }, "", nil, &alertCounters{}) rows := (&refusalWatch{}).tick(context.Background(), bake, sinks, now) if len(posts) != 1 || !strings.Contains(posts[0], `"refusal_kind":"not-proven"`) { t.Fatalf("posts = %v", posts) } if len(rows) != 1 || rows[0].Kind != release.RefusalNotProven || rows[0].RecoveryError != "" { t.Fatalf("rows = %+v", rows) } } // Final review finding 3: held refusals moved to their own namespace, and // status and alerts still surface them as they did when they shared // adoption's directory: one row each, beside an adoption refusal for the // same host, and one alert each, keyed apart from adoption's. func TestRefusalWatchReadsHeldRefusals(t *testing.T) { bake := t.TempDir() now := time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC) host := "held.example.com" if err := release.WriteRefusal(bake, release.Refusal{Host: host, App: "console", Version: "v1", RefusedAt: now.Add(-time.Minute), Reason: "exited: status=2"}); err != nil { t.Fatal(err) } if err := release.WriteRefusal(release.HeldRefusalBase(bake), release.Refusal{Host: host, App: "console", Version: "v2", RefusedAt: now.Add(-2 * time.Minute), Reason: "not proven: staged sha256 bb", Kind: release.RefusalNotProven, Recovered: true, MoveOp: "mv_abcdefghijklmnopqrstuvwxyz"}); err != nil { t.Fatal(err) } var posts []string sinks := newAlertSinks("https://hook.example", func(_ string, b []byte) error { posts = append(posts, string(b)); return nil }, "", nil, &alertCounters{}) w := &refusalWatch{} rows := w.tick(context.Background(), bake, sinks, now) w.tick(context.Background(), bake, sinks, now.Add(time.Minute)) var versions []string for _, r := range rows { versions = append(versions, r.Host+"@"+r.Version) } if strings.Join(versions, ",") != host+"@v1,"+host+"@v2" { t.Fatalf("status rows %v, want the adoption refusal and the held one", versions) } if len(posts) != 2 { t.Fatalf("%d alerts, want one per record: %v", len(posts), posts) } }