package api import ( "context" "database/sql" "encoding/json" "fmt" "io" "log" "net/http" "os" "path/filepath" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/intent" "gitea.dooplex.hu/admin/felhom-hub/internal/mailhold" "gitea.dooplex.hu/admin/felhom-hub/internal/notify" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // R-1154 (v0.159.0) — a reinstall while the customer's OLD host record still exists. The reuse path NEVER // re-issues the claim code or the off-site login (operator session-only powers — `05` §17.5; the passphrase must // not reach them) and makes the reinstall LOUD: an operator-only warning mailed to the operator. reenroll.go // carries the rule; `05` §8 the design. const r1154Host = "c1-abc123" type r1154Fixture struct { h *Handler st *store.Store db *sql.DB mail *nullMailer offsite int // off-site re-issuer calls gen0 int // the claim generation before the enroll } // newR1154 builds a CLAIMED customer c1 with an existing host record that has reported, the hub threshold at 45 m // (the production value), a claim engine, a counting off-site re-issuer, and a dispatcher behind a MAIL-HOLD // marker (so the operator mail path is observable in notification_log as "held" with no network). func newR1154(t *testing.T) *r1154Fixture { t.Helper() h, st, path := newTestHandler(t) f := &r1154Fixture{h: h, st: st} cc := &store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase", Email: "owner@example.hu", Domain: "example.hu", ConfigJSON: "{}"} if err := st.SaveCustomerConfig(cc); err != nil { t.Fatal(err) } f.mail = withClaimEngine(t, h, st) if _, err := h.claimEngine.EnsureIssued(cc); err != nil { t.Fatalf("EnsureIssued: %v", err) } if _, err := st.MarkClaimed("c1"); err != nil { t.Fatalf("MarkClaimed: %v", err) } h.SetOffsiteReissuer(func(_ context.Context, id string) error { if id == "c1" { f.offsite++ } return nil }) h.SetStaleThreshold(45 * time.Minute) holdDir := t.TempDir() if err := os.WriteFile(filepath.Join(holdDir, mailhold.FileName), nil, 0o600); err != nil { t.Fatal(err) } d := notify.NewDispatcher(st, "test-key-not-used", "from@felhom.eu", "operator@felhom.eu", true, log.New(io.Discard, "", 0)) d.SetMailHold(mailhold.New(holdDir, log.New(io.Discard, "", 0))) h.SetDispatcher(d) if err := st.UpsertHost(&store.Host{HostID: r1154Host, CustomerID: "c1", APIKey: "old-host-key-0123456789"}); err != nil { t.Fatal(err) } if err := st.SaveHostReport(r1154Host, "c1", []byte(`{"host_id":"`+r1154Host+`"}`), store.HostReportDenorm{}); err != nil { t.Fatal(err) } db, err := sql.Open("sqlite", path) if err != nil { t.Fatal(err) } t.Cleanup(func() { db.Close() }) f.db = db f.mail.sends = 0 f.gen0 = f.generation(t) return f } func (f *r1154Fixture) exec(t *testing.T, q string, args ...any) { t.Helper() if _, err := f.db.Exec(q, args...); err != nil { t.Fatalf("%s: %v", q, err) } } func (f *r1154Fixture) hostReportedAgo(t *testing.T, d time.Duration) { f.exec(t, `UPDATE hosts SET last_report_at = datetime('now', ?) WHERE host_id = ?`, fmt.Sprintf("-%d seconds", int(d.Seconds())), r1154Host) } func (f *r1154Fixture) controllerReportedAgo(t *testing.T, d time.Duration) { t.Helper() if err := f.st.SaveReport("c1", []byte(`{"customer_id":"c1"}`)); err != nil { t.Fatal(err) } f.exec(t, `UPDATE reports SET received_at = datetime('now', ?) WHERE customer_id = 'c1'`, fmt.Sprintf("-%d seconds", int(d.Seconds()))) } func (f *r1154Fixture) generation(t *testing.T) int { t.Helper() cs, err := f.st.GetClaim("c1") if err != nil || cs == nil { t.Fatalf("GetClaim: %+v %v", cs, err) } return cs.Generation } // enroll runs the installer's call and checks the reuse contract that holds on EVERY branch: 200, the same host id // and key, one record — and NOTHING re-issued (no claim rotation, no claim mail, no off-site re-issue). func (f *r1154Fixture) enroll(t *testing.T) { t.Helper() rr := doEnroll(f.h, "c1", "pass-phrase") if rr.Code != http.StatusOK { t.Fatalf("enroll = %d, want 200 (reuse), body=%s", rr.Code, rr.Body.String()) } var got enrollResp if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { t.Fatal(err) } if got.HostID != r1154Host || got.APIKey != "old-host-key-0123456789" { t.Fatalf("reuse returned another credential: %s", got.HostID) } if n := countHostsForCustomer(t, f.st, "c1"); n != 1 { t.Fatalf("host rows = %d, want 1 (the record is reused, never duplicated)", n) } if gen := f.generation(t); gen != f.gen0 || f.mail.sends != 0 || f.offsite != 0 { t.Fatalf("the reuse path re-issued: claim generation %d→%d, claim mails %d, off-site re-issues %d — a passphrase must not reach these", f.gen0, gen, f.mail.sends, f.offsite) } } func (f *r1154Fixture) eventAt(t *testing.T, et string) time.Time { t.Helper() at, err := f.st.LastEventAt("c1", et) if err != nil { t.Fatal(err) } return at } // operatorMailed waits (the dispatch is asynchronous) for an operator-channel notification row of eventType. func (f *r1154Fixture) operatorMailed(t *testing.T, eventType string) bool { t.Helper() deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { logs, err := f.st.GetRecentNotifications("c1", 50) if err != nil { t.Fatal(err) } for _, l := range logs { if l.EventType == eventType && l.Channel == "operator" { return true } } time.Sleep(20 * time.Millisecond) } return false } func (f *r1154Fixture) eventMessage(t *testing.T, et string) string { t.Helper() var msg, sev string if err := f.db.QueryRow(`SELECT message, severity FROM events WHERE customer_id='c1' AND event_type=? ORDER BY id DESC LIMIT 1`, et).Scan(&msg, &sev); err != nil { t.Fatalf("event %s: %v", et, err) } return sev + "|" + msg } // The old box silent on both streams past the threshold → nothing re-issued, the operator WARNED by mail with the // instruction. // // COMPANION RED-PROOF (observed 2026-10-19): with the pre-fix reuse path restored (the existing host returned at // once, noteReenrollOverExistingHost not called) this fails with // // r1154_reenroll_test.go: no host_reinstall_over_old_host event — the reinstall is silent again // // and with the first draft's automatic re-issue (reissueOnReenroll called on this branch) it fails with // // r1154_reenroll_test.go: the reuse path re-issued: claim generation 1→2, claim mails 1, off-site re-issues 1 — a passphrase must not reach these // // Restored. func TestR1154_StaleOldHostWarnsTheOperatorAndReissuesNothing(t *testing.T) { f := newR1154(t) f.hostReportedAgo(t, 2*time.Hour) f.controllerReportedAgo(t, 2*time.Hour) f.enroll(t) if f.eventAt(t, eventHostReinstallOverOldHost).IsZero() { t.Fatal("no host_reinstall_over_old_host event — the reinstall is silent again") } got := f.eventMessage(t, eventHostReinstallOverOldHost) for _, want := range []string{"warning|", "A reinstall of c1", r1154Host, "nothing was re-issued", "delete the old host", "re-issue the claim code and the off-site login from the customer page"} { if !strings.Contains(got, want) { t.Errorf("warning text lacks %q: %s", want, got) } } if !f.operatorMailed(t, eventHostReinstallOverOldHost) { t.Error("the operator mail path was not reached (no operator notification row)") } } // Switched off on purpose minutes ago (both report clocks still fresh) → the same warning: the box said it was // going down. Deliberate-off only changes which message the operator reads — it unlocks nothing. // // COMPANION RED-PROOF (observed 2026-10-19): with the deliberate-off branch removed this fails with // // r1154_reenroll_test.go: deliberately off: no host_reinstall_over_old_host event // // Restored. func TestR1154_DeliberatelyOffOldHostWarnsAndReissuesNothing(t *testing.T) { f := newR1154(t) f.hostReportedAgo(t, 3*time.Minute) f.controllerReportedAgo(t, 3*time.Minute) off := time.Now().UTC().Add(-5 * time.Minute).Truncate(time.Second) if _, err := f.st.SetHostDeliberateOff(r1154Host, &off); err != nil { t.Fatal(err) } f.enroll(t) if f.eventAt(t, eventHostReinstallOverOldHost).IsZero() { t.Fatal("deliberately off: no host_reinstall_over_old_host event") } if !strings.Contains(f.eventMessage(t, eventHostReinstallOverOldHost), "switched the box off") { t.Errorf("the warning does not say why: %s", f.eventMessage(t, eventHostReinstallOverOldHost)) } } // A LIVE box enrolling again (an installer --resume after its agent started, a repair re-run) is recorded as info, // NOT mailed, and nothing is re-issued. Three ways to be live, each alone, plus connected-while-off. // // COMPANION RED-PROOF (observed 2026-10-19): with classifyReenroll replaced by an unconditional "gone" this fails // with // // r1154_reenroll_test.go: host report fresh: a live box raised the reinstall warning // // (and the same for the other three cases). Restored. func TestR1154_LiveHostIsRecordedNotWarned(t *testing.T) { cases := []struct { name string setup func(t *testing.T, f *r1154Fixture) }{ {"host report fresh", func(t *testing.T, f *r1154Fixture) { f.hostReportedAgo(t, 2*time.Minute) f.controllerReportedAgo(t, 2*time.Hour) }}, {"controller report fresh, agent silent", func(t *testing.T, f *r1154Fixture) { f.hostReportedAgo(t, 2*time.Hour) f.controllerReportedAgo(t, 5*time.Minute) }}, {"controller connected, both reports old", func(t *testing.T, f *r1154Fixture) { f.hostReportedAgo(t, 2*time.Hour) f.controllerReportedAgo(t, 2*time.Hour) hub := intent.New() f.h.SetIntentHub(hub) hub.MarkWaitStart("c1") }}, {"controller connected, deliberately off", func(t *testing.T, f *r1154Fixture) { f.hostReportedAgo(t, 2*time.Minute) f.controllerReportedAgo(t, 2*time.Minute) off := time.Now().UTC().Add(-time.Minute).Truncate(time.Second) f.st.SetHostDeliberateOff(r1154Host, &off) hub := intent.New() f.h.SetIntentHub(hub) hub.MarkWaitStart("c1") }}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { f := newR1154(t) c.setup(t, f) f.enroll(t) if !f.eventAt(t, eventHostReinstallOverOldHost).IsZero() { t.Fatalf("%s: a live box raised the reinstall warning", c.name) } if f.eventAt(t, eventHostReenrollWhileLive).IsZero() { t.Errorf("%s: the live re-enroll was not recorded (host_reenroll_while_live)", c.name) } if !strings.HasPrefix(f.eventMessage(t, eventHostReenrollWhileLive), "info|") { t.Errorf("%s: the live record must be info (recorded, never mailed): %s", c.name, f.eventMessage(t, eventHostReenrollWhileLive)) } }) } } // Edges the code shows. A host record that never reported (an operator /admin/hosts mint) with no controller // report → the warning; not wired (no threshold) → no event at all, nothing re-issued; and the MINT path (the old // host deleted by the operator) still re-issues exactly as before — the operator's delete is the gate. // // COMPANION RED-PROOF (observed 2026-10-19): with the call removed from the reuse path „never reported" fails with // „never reported: no host_reinstall_over_old_host event"; with an unconditional "gone", „threshold not wired" // fails with „not wired: an event was written". Restored. func TestR1154_NeverReportedUnwiredAndMintEdges(t *testing.T) { t.Run("never reported", func(t *testing.T) { f := newR1154(t) f.exec(t, `UPDATE hosts SET last_report_at = NULL WHERE host_id = ?`, r1154Host) f.enroll(t) if f.eventAt(t, eventHostReinstallOverOldHost).IsZero() { t.Fatal("never reported: no host_reinstall_over_old_host event") } }) t.Run("threshold not wired", func(t *testing.T) { f := newR1154(t) f.h.SetStaleThreshold(0) f.hostReportedAgo(t, 2*time.Hour) f.controllerReportedAgo(t, 2*time.Hour) f.enroll(t) if !f.eventAt(t, eventHostReinstallOverOldHost).IsZero() || !f.eventAt(t, eventHostReenrollWhileLive).IsZero() { t.Fatal("not wired: an event was written") } }) t.Run("old host deleted: the mint path re-issues", func(t *testing.T) { f := newR1154(t) if err := f.st.DeleteHost(r1154Host, false); err != nil { t.Fatalf("DeleteHost: %v", err) } if rr := doEnroll(f.h, "c1", "pass-phrase"); rr.Code != http.StatusCreated { t.Fatalf("enroll after delete = %d, want 201 (mint)", rr.Code) } if gen := f.generation(t); gen != f.gen0+1 || f.mail.sends != 1 || f.offsite != 1 { t.Fatalf("mint after the operator's delete must re-issue: claim generation %d→%d, claim mails %d, off-site re-issues %d", f.gen0, gen, f.mail.sends, f.offsite) } }) } // Both R-1154 event types: operator-only, and hub-origin only (no box can POST them). func TestR1154_EventsAreOperatorOnlyAndHubOrigin(t *testing.T) { for _, et := range []string{eventHostReinstallOverOldHost, eventHostReenrollWhileLive} { if !notify.IsOperatorOnly(et) { t.Errorf("%s is not operator-only", et) } if allowedEventTypes[et] { t.Errorf("%s is in allowedEventTypes — a box could POST it", et) } } }