Files
felhom.eu/hub/internal/api/r1159_passphrase_scope_test.go
T

199 lines
6.8 KiB
Go

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")
}
}