package api import ( "database/sql" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "gitea.dooplex.hu/admin/felhom-hub/internal/store" "gopkg.in/yaml.v3" ) // R-1159 (operator decision D19, 2026-10-19): the household's retrieval passphrase gives keys only until the box uses // its own. The measurement of 2026-10-19 (audits/day-2026-10-19b/P2/) turned into the regression: the same calls, now // with the answer each must get. type r1159Tpl struct{} func (r1159Tpl) Template() string { return "hub: {}\n" } type r1159Fixture struct { h *Handler st *store.Store db *sql.DB } func newR1159(t *testing.T) *r1159Fixture { t.Helper() h, st, path := newTestHandler(t) h.templateProvider = r1159Tpl{} if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey-1159", RetrievalPassword: "owner-pass"}); err != nil { t.Fatal(err) } if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c2", APIKey: "ckey-other", RetrievalPassword: "other-pass"}); err != nil { t.Fatal(err) } db, err := sql.Open("sqlite", path) if err != nil { t.Fatal(err) } t.Cleanup(func() { db.Close() }) return &r1159Fixture{h: h, st: st, db: db} } func (f *r1159Fixture) 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 *r1159Fixture) host(t *testing.T) { t.Helper() if err := f.st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY-1159"}); err != nil { t.Fatal(err) } f.exec(t, `UPDATE hosts SET created_at = datetime('now', '-1 hour') WHERE host_id = 'h1'`) } // report stores a controller report `ago` SQLite modifier old (e.g. "-5 minutes") naming version. func (f *r1159Fixture) report(t *testing.T, version, ago string) { t.Helper() if err := f.st.SaveReport("c1", []byte(`{"customer_id":"c1","controller_version":"`+version+`"}`)); err != nil { t.Fatal(err) } f.exec(t, `UPDATE reports SET received_at = datetime('now', ?) WHERE id = (SELECT max(id) FROM reports)`, ago) } func (f *r1159Fixture) refusals(t *testing.T) int { t.Helper() var n int if err := f.db.QueryRow(`SELECT count(*) FROM events WHERE customer_id='c1' AND event_type=?`, eventPassphraseKeysRefused).Scan(&n); err != nil { t.Fatal(err) } return n } // keysByPassphrase makes the three passphrase calls that ever returned a key and reports, per route, the status and // whether a key came back. func (f *r1159Fixture) keysByPassphrase(t *testing.T) map[string][2]any { t.Helper() out := map[string][2]any{} rr := r879Get(f.h, "/config/c1", "owner-pass") var y map[string]any _ = yaml.Unmarshal(rr.Body.Bytes(), &y) key := "" if hm, ok := y["hub"].(map[string]any); ok { key, _ = hm["api_key"].(string) } out["config"] = [2]any{rr.Code, key == "ckey-1159"} rr = r879Get(f.h, "/recovery/c1", "owner-pass") out["recovery"] = [2]any{rr.Code, strings.Contains(rr.Body.String(), "ckey-1159")} er := doEnroll(f.h, "c1", "owner-pass") var e enrollResp _ = json.Unmarshal(er.Body.Bytes(), &e) out["enroll"] = [2]any{er.Code, e.APIKey != ""} return out } // THE RULE, case by case. // // COMPANION RED-PROOF (observed 2026-10-19): with passphraseKeysClosed returning false always (the pre-fix shape) the // "live box on 0.334.0" case fails with // // r1159_passphrase_scope_test.go: live box on 0.334.0: config → [200 true], want 409 and no key // // Restored. func TestR1159_PassphraseGivesKeysOnlyUntilTheBoxUsesItsOwn(t *testing.T) { open := func(t *testing.T, name string, f *r1159Fixture) { t.Helper() got := f.keysByPassphrase(t) if got["config"] != [2]any{200, true} || got["recovery"] != [2]any{200, true} { t.Errorf("%s: config → %v, recovery → %v, want 200 with the key (the box still needs the passphrase)", name, got["config"], got["recovery"]) } if c := got["enroll"][0]; c != 200 && c != 201 { t.Errorf("%s: enroll → %v, want 200/201 with a key", name, got["enroll"]) } if f.refusals(t) != 0 { t.Errorf("%s: a refusal event was raised for an allowed call", name) } } t.Run("no host record (a first install)", func(t *testing.T) { f := newR1159(t) open(t, "no host", f) }) t.Run("host made, its controller has not reported (the install, its first boot)", func(t *testing.T) { f := newR1159(t) f.host(t) open(t, "host, no report", f) }) t.Run("only reports from BEFORE the host record (the old box, a reinstall after the delete)", func(t *testing.T) { f := newR1159(t) f.report(t, "0.334.0", "-3 hours") f.host(t) open(t, "older report", f) }) t.Run("live box on an older controller (still refreshes with the passphrase)", func(t *testing.T) { f := newR1159(t) f.host(t) f.report(t, "0.333.0", "-5 minutes") open(t, "0.333.0", f) }) t.Run("live box on 0.334.0", func(t *testing.T) { f := newR1159(t) f.host(t) f.report(t, "0.334.0", "-5 minutes") got := f.keysByPassphrase(t) for _, route := range []string{"config", "recovery", "enroll"} { if got[route] != [2]any{http.StatusConflict, false} { t.Errorf("live box on 0.334.0: %s → %v, want 409 and no key", route, got[route]) } } if n := f.refusals(t); n != 3 { t.Errorf("want one passphrase_keys_refused per refused call (3), got %d", n) } rr := r879Get(f.h, "/config/c1", "owner-pass") if !strings.Contains(rr.Body.String(), "deletes the old host first") || strings.Contains(rr.Body.String(), "ckey-1159") { t.Errorf("the refusal must say what to do and carry no key: %q", rr.Body.String()) } // A wrong passphrase is still a plain 401, never the live-box answer (it must not tell a guesser anything). if c := r879Get(f.h, "/config/c1", "nope").Code; c != http.StatusUnauthorized { t.Errorf("a wrong passphrase on a live box → %d, want 401", c) } }) } // GET /box-config/{id}: the box's own pull, with its controller key only. func TestR1159_BoxPullsItsConfigWithItsOwnKey(t *testing.T) { f := newR1159(t) f.host(t) f.report(t, "0.334.0", "-5 minutes") get := func(id, bearer string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodGet, "/api/v1/box-config/"+id, nil) if bearer != "" { req.Header.Set("Authorization", "Bearer "+bearer) } rr := httptest.NewRecorder() f.h.ServeHTTP(rr, req) return rr } if rr := get("c1", "ckey-1159"); rr.Code != 200 || !strings.Contains(rr.Body.String(), "ckey-1159") { t.Fatalf("own key → %d, want 200 with the config", rr.Code) } for name, c := range map[string]struct{ id, key string }{ "no key": {"c1", ""}, "a wrong key": {"c1", "nope"}, "another household's key": {"c1", "ckey-other"}, "the passphrase as a bearer": {"c1", "owner-pass"}, } { if rr := get(c.id, c.key); rr.Code != http.StatusUnauthorized || strings.Contains(rr.Body.String(), "ckey-1159") { t.Errorf("%s → %d, want 401 and no config", name, rr.Code) } } if f.refusals(t) != 0 { t.Error("the box's own pull raised a refusal event") } }