Files
felhom.eu/hub/internal/api/r1154_reenroll_test.go
T
admin 5121536f10 hub v0.159.0: a refused whole-guest backup has its own alarm (R-1150); a reinstall re-issues the claim code and the off-site login when the old host is stale or off (R-1154)
R-1150: the agent's localapi:backup-cadence-cap is a separate per-host state in
HostCapabilityChecker: alone it raises whole_guest_backup_refused (warning, plain text,
agent reason in details) and whole_guest_backup_refusal_cleared (info); other critical
caps keep agent_capability_degraded/_recovered unchanged. New types operator-only and
hub-origin only (not in allowedEventTypes: the suspected controller must not post them).
agent_capability_degraded/_recovered join operatorOnlyEvents (the checker's "never the
customer" claim was configuration only).

R-1154: handleHostEnroll's reuse path re-issues (claim code + off-site login) when no
controller wait is open and either deliberate_off_at is held or both the host report and
the controller report are older than alerting.stale_threshold (wired from main); once per
silence (host_reenroll_reissued record, also written at mint); a live box gets nothing
re-issued and the skip is recorded (host_reenroll_not_reissued). 05 section 8 [DESIGN].

Tests red-proved; go build/vet/test green.
2026-10-19 03:18:34 +02:00

332 lines
13 KiB
Go

package api
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/intent"
"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 re-issues the claim code and
// the off-site login when the old box is gone (silent past the stale threshold on BOTH report streams, or switched
// off on purpose), and NEVER while the box is live. 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 // offsite 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 and a counting off-site re-issuer.
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)
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.exec(t, `UPDATE hosts SET created_at = datetime('now', '-30 days') WHERE host_id = ?`, r1154Host)
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 must hold on EVERY branch: 200, the same
// host id and key.
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)
}
}
// reissued reports what the enroll did: claim generation moved, mails sent, off-site calls.
func (f *r1154Fixture) assertReissued(t *testing.T, want bool) {
t.Helper()
gen := f.generation(t)
if want {
if gen != f.gen0+1 || f.mail.sends != 1 || f.offsite != 1 {
t.Fatalf("want re-issued once: claim generation %d→%d, claim mails %d, off-site re-issues %d", f.gen0, gen, f.mail.sends, f.offsite)
}
return
}
if gen != f.gen0 || f.mail.sends != 0 || f.offsite != 0 {
t.Fatalf("want NOT re-issued: claim generation %d→%d, claim mails %d, off-site re-issues %d", 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
}
// The old box silent on both streams past the threshold → the enroll re-issues both, keeps the record, records it.
//
// COMPANION RED-PROOF (observed 2026-10-19): with the pre-fix reuse path restored (the existing host returned at
// once, reissueOnReuseIfReinstall not called) this fails with
//
// r1154_reenroll_test.go: want re-issued once: claim generation 1→1, claim mails 0, off-site re-issues 0
//
// Restored.
func TestR1154_StaleOldHostIsReissued(t *testing.T) {
f := newR1154(t)
f.hostReportedAgo(t, 2*time.Hour)
f.controllerReportedAgo(t, 2*time.Hour)
f.enroll(t)
f.assertReissued(t, true)
if f.eventAt(t, eventHostReenrollReissued).IsZero() {
t.Error("host_reenroll_reissued not recorded")
}
if !f.eventAt(t, "claim_reissued_reenroll").After(time.Time{}) {
t.Error("claim_reissued_reenroll (the claim leg's own event) not recorded")
}
}
// Switched off on purpose minutes ago (both report clocks still fresh) → re-issued: the box said it was going down.
//
// COMPANION RED-PROOF (observed 2026-10-19): with the deliberate-off branch removed from decideReenroll this fails
// with
//
// r1154_reenroll_test.go: want re-issued once: claim generation 1→1, claim mails 0, off-site re-issues 0
//
// Restored.
func TestR1154_DeliberatelyOffOldHostIsReissued(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)
f.assertReissued(t, true)
}
// A LIVE box enrolling again (an installer --resume after its agent started, a repair re-run) keeps every
// credential: nothing re-issued, and the skip is recorded so a too-early reinstall is visible. Three ways to be
// live, each alone: the host report, the controller report (the codes live in the controller), the wait connection.
//
// COMPANION RED-PROOF (observed 2026-10-19): with decideReenroll replaced by an unconditional re-issue on the reuse
// path this fails with
//
// r1154_reenroll_test.go: host report fresh: want NOT re-issued: claim generation 1→2, claim mails 1, off-site re-issues 1
//
// (and the same for the other two cases). Restored.
func TestR1154_LiveHostIsNotReissued(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 gen := f.generation(t); gen != f.gen0 || f.mail.sends != 0 || f.offsite != 0 {
t.Fatalf("%s: want NOT re-issued: claim generation %d→%d, claim mails %d, off-site re-issues %d",
c.name, f.gen0, gen, f.mail.sends, f.offsite)
}
if f.eventAt(t, eventHostReenrollNotReissued).IsZero() {
t.Errorf("%s: the live skip was not recorded (host_reenroll_not_reissued)", c.name)
}
if !f.eventAt(t, eventHostReenrollReissued).IsZero() {
t.Errorf("%s: a re-issue record was written for a live box", c.name)
}
})
}
}
// Once per silence: an installer --resume of the same reinstall enrolls again before the new agent's first report
// — the old box is still "silent", and the re-issues must not run twice (the claim engine's single-shot rule).
// Then the new agent reports, goes silent again past the threshold (the next reinstall): re-issued again.
//
// COMPANION RED-PROOF (observed 2026-10-19): with the once-per-silence check removed from decideReenroll this fails
// with
//
// r1154_reenroll_test.go: the --resume re-issued again: claim generation 1→3, off-site re-issues 2
//
// Restored.
func TestR1154_ResumeDoesNotReissueTwice(t *testing.T) {
f := newR1154(t)
f.hostReportedAgo(t, 2*time.Hour)
f.controllerReportedAgo(t, 2*time.Hour)
f.enroll(t)
f.enroll(t) // the --resume
if gen := f.generation(t); gen != f.gen0+1 || f.offsite != 1 {
t.Fatalf("the --resume re-issued again: claim generation %d→%d, off-site re-issues %d", f.gen0, gen, f.offsite)
}
// The record is backdated so the next report is strictly after it (second-granularity clocks).
f.exec(t, `UPDATE events SET created_at = datetime('now', '-3 hours') WHERE event_type = ?`, eventHostReenrollReissued)
f.exec(t, `UPDATE hosts SET last_report_at = datetime('now', '-2 hours', '-30 minutes') WHERE host_id = ?`, r1154Host)
f.enroll(t)
if gen := f.generation(t); gen != f.gen0+2 || f.offsite != 2 {
t.Fatalf("a later reinstall (the box reported after the last re-issue, then went silent) must re-issue: claim generation %d→%d, off-site re-issues %d",
f.gen0, gen, f.offsite)
}
}
// Edges the code shows. A host record that never reported: a provisional record (operator /admin/hosts, no
// re-issue ran for it) → re-issued, like the mint it stands in for; a record the enroll itself minted (the
// re-issues ran at mint) → not again on the --resume. Not wired (no threshold) → nothing re-issued, the
// pre-v0.159.0 behaviour.
//
// COMPANION RED-PROOF (observed 2026-10-19): with the pre-fix reuse path restored, „provisional never reported"
// fails with „want re-issued once: claim generation 1→1, claim mails 0, off-site re-issues 0"; with the
// once-per-silence check removed, „minted by the enroll" fails with „off-site re-issues = 2, want 1"; with an
// unconditional re-issue, „threshold not wired" fails with „want NOT re-issued: claim generation 1→2, claim mails
// 1, off-site re-issues 1". Restored.
func TestR1154_NeverReportedAndUnwiredEdges(t *testing.T) {
t.Run("provisional 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)
f.assertReissued(t, true)
})
t.Run("minted by the enroll, never reported", func(t *testing.T) {
h, st, _ := newTestHandler(t)
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"})
h.SetStaleThreshold(45 * time.Minute)
calls := 0
h.SetOffsiteReissuer(func(context.Context, string) error { calls++; return nil })
if rr := doEnroll(h, "c1", "pass-phrase"); rr.Code != http.StatusCreated {
t.Fatalf("first enroll = %d", rr.Code)
}
if rr := doEnroll(h, "c1", "pass-phrase"); rr.Code != http.StatusOK {
t.Fatalf("resume enroll = %d", rr.Code)
}
if calls != 1 {
t.Fatalf("off-site re-issues = %d, want 1 (at the mint only, not again on the --resume)", calls)
}
})
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)
f.assertReissued(t, false)
})
}
// Both R-1154 event types: operator-only, and hub-origin only — a box able to POST host_reenroll_reissued could
// plant the once-per-silence record and block the re-issue of its own reinstall.
func TestR1154_EventsAreOperatorOnlyAndHubOrigin(t *testing.T) {
for _, et := range []string{eventHostReenrollReissued, eventHostReenrollNotReissued} {
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)
}
}
}