hub v0.161.0: passphrase keys only until the box uses its own (box-config route; R-1159, D19); ep0 namespaces without a prune job alarmed, tenantsync v1.3.0 creates the job (R-1165)
gates / gates (push) Successful in 7m47s
gates / gates (push) Successful in 7m47s
This commit is contained in:
1 parent
224fe41e06
commit
b6a911c3f9
16 files changed
+802
-35
No files matched your search
+19
-28
@@ -408,6 +408,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/config/"):
|
||||
customerID := strings.TrimPrefix(path, "/config/")
|
||||
h.handleConfigRetrieve(w, r, customerID)
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/box-config/"):
|
||||
h.handleBoxConfig(w, r, strings.TrimPrefix(path, "/box-config/"))
|
||||
case r.Method == http.MethodPost && strings.HasPrefix(path, "/offsite/consume-password/"):
|
||||
customerID := strings.TrimPrefix(path, "/offsite/consume-password/")
|
||||
h.handleOffsiteConsumePassword(w, r, customerID)
|
||||
@@ -1239,6 +1241,11 @@ func (h *Handler) handleHostEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
h.noteReenrollOverExistingHost(cc, existing, time.Now())
|
||||
// R-1159: once the box uses its own key, the passphrase no longer re-serves the host key.
|
||||
if closed, why := h.passphraseKeysClosed(req.CustomerID); closed {
|
||||
h.refusePassphraseKeys(w, r, req.CustomerID, "POST /host-enroll", why)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"host_id": existing.HostID, "api_key": existing.APIKey})
|
||||
@@ -2824,6 +2831,10 @@ func (h *Handler) handleRecovery(w http.ResponseWriter, r *http.Request, custome
|
||||
http.Error(w, "Unauthorized: invalid password", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if closed, why := h.passphraseKeysClosed(customerID); closed {
|
||||
h.refusePassphraseKeys(w, r, customerID, "GET /recovery", why)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate controller.yaml. The claim state is baked read-only (no issue on the DR path — a
|
||||
// recovered box whose settings.json is gone re-gates on the EXISTING hash; the reset flow
|
||||
@@ -2897,36 +2908,16 @@ func (h *Handler) handleConfigRetrieve(w http.ResponseWriter, r *http.Request, c
|
||||
return
|
||||
}
|
||||
|
||||
if h.templateProvider == nil {
|
||||
http.Error(w, "Config generation not available", http.StatusServiceUnavailable)
|
||||
// R-1159: the passphrase gives keys only until the box uses its own (passphrase_scope.go).
|
||||
if closed, why := h.passphraseKeysClosed(customerID); closed {
|
||||
h.refusePassphraseKeys(w, r, customerID, "GET /config", why)
|
||||
return
|
||||
}
|
||||
|
||||
// Customer-claim arc (v0.50.0): the REAL config pull (Day-0 installer / controller refresh) is
|
||||
// the Day-0 claim entry point — issue + email the first code here (idempotent), and bake the
|
||||
// active hash into the generated web.claim_code_hash so the box is gated from FIRST boot.
|
||||
// (The operator-UI preview deliberately does NOT issue — it only bakes an existing hash.)
|
||||
var claimState *store.ClaimState
|
||||
if h.claimEngine != nil {
|
||||
var cerr error
|
||||
claimState, cerr = h.claimEngine.EnsureIssued(cfg)
|
||||
if cerr != nil {
|
||||
// Loud but non-fatal: the config is still served; if a hash was stored the gate is armed
|
||||
// and the operator resends the email from the customer page.
|
||||
h.logger.Printf("[WARN] claim issue for %s on config retrieve: %v", customerID, cerr)
|
||||
}
|
||||
}
|
||||
|
||||
yamlOutput, err := configgen.Generate(h.templateProvider.Template(), cfg, claimState)
|
||||
if err != nil {
|
||||
h.logger.Printf("[ERROR] Failed to generate config for %s: %v", customerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Printf("[INFO] Config downloaded for customer %s", customerID)
|
||||
w.Header().Set("Content-Type", "text/yaml; charset=utf-8")
|
||||
w.Write([]byte(yamlOutput))
|
||||
// Customer-claim arc (v0.50.0): the REAL config pull (Day-0 installer / controller refresh) is the Day-0 claim
|
||||
// entry point — serveGeneratedConfig issues + emails the first code (idempotent) and bakes the active hash into
|
||||
// the generated web.claim_code_hash so the box is gated from FIRST boot. (The operator-UI preview deliberately
|
||||
// does NOT issue — it only bakes an existing hash.)
|
||||
h.serveGeneratedConfig(w, cfg, "retrieval passphrase")
|
||||
}
|
||||
|
||||
// artifactManifestResponse is the wire shape the host-bootstrap script consumes: the operator-vouched
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/semver"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// R-1159 (hub v0.161.0, operator decision D19 = „box key first, then lock", 2026-10-19) — what the household's
|
||||
// retrieval passphrase may still get.
|
||||
//
|
||||
// MEASURED 2026-10-19 (audits/day-2026-10-19b/P2/passphrase-reach.txt): the passphrase alone returned the controller's
|
||||
// hub key (GET /config, GET /recovery) and the existing host's key (POST /host-enroll), and through them every off-site
|
||||
// key route and the host routes. The passphrase is needed by a NEW box: the installer (preflight, its download login,
|
||||
// enroll), and the new guest's first boot (bootstrap.json → GET /config, which is how the box gets its own key). And,
|
||||
// until controller 0.334.0, by every LIVE box on each config change (report.ConfigRefresher → GET /config).
|
||||
//
|
||||
// THE RULE. The passphrase gives keys only until the box has a key of its own and uses it:
|
||||
// - no host record → open (a first install; a reinstall after the operator deleted the old host);
|
||||
// - a host record, and no controller report received since the record was made → open (the install in progress,
|
||||
// its --resume, the new guest's first boot);
|
||||
// - a host record, and a controller report received since it was made from a controller that pulls its config with
|
||||
// its OWN key (boxConfigMinController and newer, GET /box-config) → CLOSED: /config and /recovery answer 409 with
|
||||
// no config, /host-enroll answers 409 with no key, and the operator gets passphrase_keys_refused (warning, mailed).
|
||||
// A reinstall then needs the operator's delete first — the rule since R-1154.
|
||||
// - a controller OLDER than boxConfigMinController still refreshes with the passphrase, so it stays open for it
|
||||
// (the lock arms itself box by box as the floor moves; no ordering between the hub and controller releases).
|
||||
//
|
||||
// GET /box-config/{id} — the box's own config pull, authenticated with the controller's hub key (self-scoped, or the
|
||||
// global key), the same generated controller.yaml as GET /config.
|
||||
//
|
||||
// Pinned by r1159_passphrase_scope_test.go.
|
||||
|
||||
const (
|
||||
// boxConfigMinController is the first controller that refreshes its config with its own key (GET /box-config).
|
||||
boxConfigMinController = "0.334.0"
|
||||
|
||||
eventPassphraseKeysRefused = "passphrase_keys_refused"
|
||||
)
|
||||
|
||||
// passphraseKeysClosed applies THE RULE above. A failed lookup is closed (fail-safe: a key is a key).
|
||||
func (h *Handler) passphraseKeysClosed(customerID string) (closed bool, why string) {
|
||||
host, err := h.store.GetHostByCustomer(customerID)
|
||||
if err != nil {
|
||||
return true, "the host lookup failed: " + err.Error()
|
||||
}
|
||||
if host == nil {
|
||||
return false, "no host record"
|
||||
}
|
||||
found, at, ver, err := h.store.LatestControllerReport(customerID)
|
||||
if err != nil {
|
||||
return true, "the controller report lookup failed: " + err.Error()
|
||||
}
|
||||
if !found || at.Before(host.CreatedAt) {
|
||||
return false, "the box's controller has not reported since host " + host.HostID + " was made"
|
||||
}
|
||||
if !semver.Valid(ver) || semver.Compare(ver, boxConfigMinController) < 0 {
|
||||
return false, "the box's controller (" + ver + ") still refreshes its config with the passphrase"
|
||||
}
|
||||
return true, "host " + host.HostID + " is live: its controller " + ver + " reported at " + at.UTC().Format("2006-01-02 15:04 UTC") +
|
||||
" and pulls its config with its own key"
|
||||
}
|
||||
|
||||
// refusePassphraseKeys answers 409 with a plain sentence (no config, no key) and tells the operator.
|
||||
func (h *Handler) refusePassphraseKeys(w http.ResponseWriter, r *http.Request, customerID, route, why string) {
|
||||
h.logger.Printf("[WARN] %s for %s with the retrieval passphrase REFUSED (R-1159): %s — visitor %s", route, customerID, why, r.RemoteAddr)
|
||||
msg := "Someone with the household's retrieval passphrase asked " + route + " for the box keys of " + customerID +
|
||||
"; refused because " + why + ". If this is a reinstall: delete the old host (Hosts page), then run the installer again. " +
|
||||
"If nobody in the household did this, the passphrase may be known to someone else."
|
||||
details, _ := json.Marshal(map[string]string{"route": route, "reason": why})
|
||||
if _, err := h.store.SaveEvent(customerID, eventPassphraseKeysRefused, "warning", msg, string(details), "hub"); err != nil {
|
||||
h.logger.Printf("[WARN] save %s for %s: %v", eventPassphraseKeysRefused, customerID, err)
|
||||
} else if h.dispatcher != nil {
|
||||
go h.dispatcher.ProcessEvent(customerID, eventPassphraseKeysRefused, "warning", msg, string(details), "hub")
|
||||
}
|
||||
http.Error(w, "Conflict: this customer already has a live box, which uses its own key. The passphrase gives no keys now. "+
|
||||
"For a reinstall the operator deletes the old host first.", http.StatusConflict)
|
||||
}
|
||||
|
||||
// handleBoxConfig — GET /box-config/{id}: the box's own config pull with its controller key (controller ≥ 0.334.0).
|
||||
func (h *Handler) handleBoxConfig(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||
authCustomerID, isGlobal, ok := h.checkAuthCustomer(r)
|
||||
if !ok || (!isGlobal && authCustomerID != customerID) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
cfg, err := h.store.GetCustomerConfig(customerID)
|
||||
if err != nil {
|
||||
h.logger.Printf("[ERROR] box-config: customer lookup failed for %s: %v", customerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if cfg == nil {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if cfg.SecretsUnreadable {
|
||||
h.logger.Printf("[ERROR] customer %s: sealed secrets do not open (OFFSITE_SECRET_KEY)", cfg.CustomerID)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.serveGeneratedConfig(w, cfg, "box key")
|
||||
}
|
||||
|
||||
// serveGeneratedConfig writes the generated controller.yaml (GET /config and GET /box-config). The claim code is
|
||||
// issued here (idempotent) — the real config pull is the Day-0 claim entry point.
|
||||
func (h *Handler) serveGeneratedConfig(w http.ResponseWriter, cfg *store.CustomerConfig, by string) {
|
||||
if h.templateProvider == nil {
|
||||
http.Error(w, "Config generation not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var claimState *store.ClaimState
|
||||
if h.claimEngine != nil {
|
||||
var cerr error
|
||||
claimState, cerr = h.claimEngine.EnsureIssued(cfg)
|
||||
if cerr != nil {
|
||||
// Loud but non-fatal: the config is still served; if a hash was stored the gate is armed
|
||||
// and the operator resends the email from the customer page.
|
||||
h.logger.Printf("[WARN] claim issue for %s on config retrieve: %v", cfg.CustomerID, cerr)
|
||||
}
|
||||
}
|
||||
start := time.Now()
|
||||
yamlOutput, err := configgen.Generate(h.templateProvider.Template(), cfg, claimState)
|
||||
if err != nil {
|
||||
h.logger.Printf("[ERROR] Failed to generate config for %s: %v", cfg.CustomerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.logger.Printf("[INFO] Config downloaded for customer %s (by %s, %s)", cfg.CustomerID, by, time.Since(start).Round(time.Millisecond))
|
||||
w.Header().Set("Content-Type", "text/yaml; charset=utf-8")
|
||||
w.Write([]byte(yamlOutput))
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// boxConfigMinController must be the controller release that pulls its config with its own key (GET /box-config). If
|
||||
// it named an earlier one, a box on that controller would be refused its passphrase config refresh while it still
|
||||
// needs it (its config changes would stop arriving). Read from the controller's CHANGELOG beside this repo (the
|
||||
// workspace layout; a CI clone has no sibling, and says so).
|
||||
func TestR1159_BoxConfigMinControllerIsTheReleaseThatPullsWithItsKey(t *testing.T) {
|
||||
p := filepath.Join("..", "..", "..", "..", "felhom-controller", "CHANGELOG.md")
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Skipf("NOT CHECKED: the controller clone is not beside this repo (%v) — the workspace run checks it", err)
|
||||
}
|
||||
m := regexp.MustCompile(`(?m)^## v` + regexp.QuoteMeta(boxConfigMinController) + ` .*$`).FindString(string(b))
|
||||
if m == "" {
|
||||
t.Fatalf("the controller CHANGELOG has no `## v%s` heading — boxConfigMinController names a release that does not exist", boxConfigMinController)
|
||||
}
|
||||
if !strings.Contains(m, "box-config") && !strings.Contains(m, "own key") {
|
||||
t.Fatalf("controller v%s's heading does not say it pulls its config with its own key: %s", boxConfigMinController, m)
|
||||
}
|
||||
// And no EARLIER release may claim it (then the constant would be too high, and the lock would arm late).
|
||||
for _, h := range regexp.MustCompile(`(?m)^## v(\d+\.\d+\.\d+) .*$`).FindAllStringSubmatch(string(b), -1) {
|
||||
if h[1] != boxConfigMinController && strings.Contains(h[0], "box-config") {
|
||||
t.Errorf("controller v%s also names box-config: %s", h[1], h[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,7 @@ var apiRoutes = []apiRoute{
|
||||
{Method: "GET", Prefix: "/customers/", Auth: authGlobal},
|
||||
{Method: "GET", Prefix: "/recovery/", Auth: authPassphrase},
|
||||
{Method: "GET", Prefix: "/config/", Auth: authPassphrase},
|
||||
{Method: "GET", Prefix: "/box-config/", Auth: authCustomer},
|
||||
{Method: "POST", Prefix: "/offsite/consume-password/", Auth: authCustomer},
|
||||
{Method: "POST", Prefix: "/offsite/register-key/", Auth: authCustomer},
|
||||
{Method: "POST", Prefix: "/offsite/confirm-key/", Auth: authCustomer},
|
||||
|
||||
Reference in new issue
Block a user