Files
felhom.eu/hub/internal/web/pbsdr_test.go
T
admin 91cabdde1b
gates / gates (push) Successful in 7s
hub v0.93.0: the retention keeps the key it was built to keep (R-198) + three honesty fixes (R-197, R-192, R-196)
R-198 — host_escrow_superseded shipped with `blob` (the K-escrow / PBS datastore key) and
identity_blob was added to host_escrow LATER, never here. The offsite restic REPOSITORY
password lives in identity_blob. So demoteCurrentEscrowTx -- whose own comment calls it "THE
ONE escrow row-copy routine" -- retained the whole-guest key and silently dropped the off-site
data key, which is the secret the retention was built to preserve. And because the copy happens
as the new blob overwrites the old, the destroying act was the ESCROW CEREMONY: the exact thing
a rebuilt box tells its customer to run, on a card promising in Hungarian that the old backups
stay recoverable. Both demo boxes crossed that line on 2026-08-04.

  - identity_blob added to the table (CREATE + additive ALTER) and carried in the shared copy
    routine, so BOTH callers are fixed at once: re-escrow and host-delete demotion.
  - ListSupersededEscrow reads it back; store.HostEscrow gains IdentityBlob.
  - CountCurrentEscrowWithIdentity is the census of who the fix protects.
  - Nothing is backfillable: pre-v0.93.0 retained rows have no blob and their sources are gone.
  - Tests assert the CONSEQUENCE (a retained row can still yield a repo password), which is why
    the pre-existing retention test stayed green for two months asserting the mechanism.

R-197 — SaveHostEscrow returns the hash it replaced; the escrow PUT raises
offsite_repo_key_changed (warning, operator-only, edge-triggered) when both hashes are known and
differ. No hash value travels. Severity chosen for the world v0.93.0 creates: with the identity
blob retained, a changed key is "this history now depends on an older recovery code", not a loss.

R-192 (half) — the stuck alert now reports the two shapes it actually covers, burned and
regressed, each stating its own measurement; the regressed text withdraws the Re-issue
recommendation. Every self-heal refusal leaves a notification_log row with its reason. The
guard's logic is unchanged; its 500-oldest-reports scoping stays OPEN and the window is named in
the alert text so the limitation travels with the number. offsite_delivery_stuck and
offsite_credential_restaged are added to operatorOnlyEvents -- neither was registered and neither
has a customerMessages entry, which is not a block.

R-196 — five comments (not the three the spec expected) claimed ReissueCredentials rotates the
restic repo password. It resets the PROVIDER password and cannot touch the repo password, which
is generated on the box. All five corrected; the staleness mark documented as precautionary. The
BEHAVIOUR stays open.

Not in this release: R-199, R-200, R-201 remain open -- the chain that hands the key back is
still unassembled. Part 5 hit its gate; the orphan card is untouched (R-202).
2026-08-04 12:56:58 +02:00

585 lines
23 KiB
Go

package web
// PBS DR SLICE 1 — the provisioning flow behind the config form, against a FAKE tenancy
// provisioner (no SSH in CI). The load-bearing contracts: descriptor lands in the HOST
// desired_json + generation bump; the secret is stored consume-once and appears NOWHERE else
// (ConfigJSON, desired-state, logs); every failure is fail-closed (no descriptor, no bump, no
// secret); an already-provisioned re-save is a pure no-op (no re-key, no second secret, no
// spurious bump); re-issue rotates the secret + bumps.
import (
"bytes"
"context"
"database/sql"
"errors"
"log"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
"gitea.dooplex.hu/admin/felhom-hub/internal/tenantsync"
)
type fakeTenancy struct {
provisionCalls int
reissueCalls int
deprovisionCalls int
err error // both ops fail with this
provisionErr error // Provision-only failure (the F-14 token_exists shape: reissue still works)
deprovisionErr error // Deprovision-only failure (RESET partial-failure red-proof)
deprovisionExisted bool // what Deprovision reports (namespace existed / was destroyed)
secret string
}
func (f *fakeTenancy) result(customerID string) *tenantsync.Result {
return &tenantsync.Result{
TokenID: "felhom@pbs!" + customerID,
TokenSecret: f.secret,
Fingerprint: "aa:bb:cc",
Datastore: "felhom-offsite",
Namespace: customerID,
}
}
func (f *fakeTenancy) Provision(ctx context.Context, customerID string) (*tenantsync.Result, error) {
f.provisionCalls++
if f.err != nil {
return nil, f.err
}
if f.provisionErr != nil {
return nil, f.provisionErr
}
return f.result(customerID), nil
}
func (f *fakeTenancy) Reissue(ctx context.Context, customerID string) (*tenantsync.Result, error) {
f.reissueCalls++
if f.err != nil {
return nil, f.err
}
return f.result(customerID), nil
}
func (f *fakeTenancy) Deprovision(ctx context.Context, customerID string) (bool, error) {
f.deprovisionCalls++
if f.deprovisionErr != nil {
return false, f.deprovisionErr
}
if f.err != nil {
return false, f.err
}
return f.deprovisionExisted, nil
}
// newPBSDRServer builds a server + store with the full provisioning preconditions satisfied:
// customer config, enrolled host, WG endpoint record, bound WG peer. The logger is captured so
// tests can grep-assert the secret never reaches it.
func newPBSDRServer(t *testing.T, fake *fakeTenancy) (*Server, *store.Store, *bytes.Buffer) {
t.Helper()
s, st := newTestServer(t)
logBuf := &bytes.Buffer{}
s.logger = log.New(logBuf, "", 0)
if fake != nil {
s.SetTenantSync(fake)
}
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: "peti", APIKey: "capi", RetrievalPassword: "pw",
}); err != nil {
t.Fatalf("seed config: %v", err)
}
if err := st.UpsertHost(&store.Host{HostID: "peti-01", CustomerID: "peti", APIKey: "hapi"}); err != nil {
t.Fatalf("seed host: %v", err)
}
if err := st.SetWGEndpoint(&store.WGEndpoint{
EndpointID: "ep0", DNSName: "ep0.felhom.eu", WGPort: 443,
ServerPubkey: "SPK", TunnelSubnet: "10.77.0.0/24", PBSTunnelIP: "10.77.0.1",
}); err != nil {
t.Fatalf("seed endpoint: %v", err)
}
if _, _, err := st.RegisterWGPeerForHost("peti-01", "PETIPUBKEY"); err != nil {
t.Fatalf("seed wg peer: %v", err)
}
return s, st, logBuf
}
// postUpdate drives the REAL handler pipeline (handleConfigUpdate → applyPBSDR → save).
func postUpdate(t *testing.T, s *Server, form url.Values) *httptest.ResponseRecorder {
t.Helper()
// v0.48.0 edit-a: the update handler now enforces the form's required fields server-side —
// supply them like every real submission does (the browser form marks both `required`).
if form.Get("customer_name") == "" {
form.Set("customer_name", "Peti")
}
if form.Get("domain") == "" {
form.Set("domain", "peti.example")
}
req := httptest.NewRequest("POST", "/configs/peti", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
s.handleConfigUpdate(rr, req, "peti")
return rr
}
func hostState(t *testing.T, st *store.Store) (desc *pbsDRDescriptor, desiredJSON string, gen int64) {
t.Helper()
h, err := st.GetHost("peti-01")
if err != nil || h == nil {
t.Fatalf("host read: %v", err)
}
return readPBSDR(h.DesiredJSON), h.DesiredJSON, h.DesiredGeneration
}
func TestPBSDR_ProvisionHappyPath(t *testing.T) {
fake := &fakeTenancy{secret: "SUPER-SECRET-TOKEN"}
s, st, logBuf := newPBSDRServer(t, fake)
rr := postUpdate(t, s, url.Values{"dr_tier": {"on"}, "pbsdr_storage_id": {"felhom-pbs"}})
if rr.Code != 303 {
t.Fatalf("save = %d (%s), want 303", rr.Code, rr.Body.String())
}
if fake.provisionCalls != 1 {
t.Errorf("provision calls = %d, want 1", fake.provisionCalls)
}
desc, desiredJSON, gen := hostState(t, st)
if desc == nil {
t.Fatalf("no pbs_dr descriptor in desired_json: %s", desiredJSON)
}
if !desc.Enabled || desc.StorageID != "felhom-pbs" || desc.PBSTunnelIP != "10.77.0.1" ||
desc.Datastore != "felhom-offsite" || desc.Namespace != "peti" ||
desc.TokenID != "felhom@pbs!peti" || desc.Fingerprint != "aa:bb:cc" {
t.Errorf("descriptor wrong: %+v", desc)
}
if gen != 1 {
t.Errorf("desired_generation = %d, want 1 (exactly one bump)", gen)
}
// The secret: stored consume-once for the HOST…
got, err := st.ConsumeHostPBSSecret("peti-01")
if err != nil || got != "SUPER-SECRET-TOKEN" {
t.Fatalf("consume = (%q, %v), want the stored secret", got, err)
}
// …and NOWHERE else: not in the desired-state, not in ConfigJSON, not in any log line.
if strings.Contains(desiredJSON, "SUPER-SECRET-TOKEN") {
t.Error("secret leaked into desired_json")
}
cfg, _ := st.GetCustomerConfig("peti")
if strings.Contains(cfg.ConfigJSON, "SUPER-SECRET-TOKEN") || strings.Contains(cfg.ConfigJSON, "pbs_dr") {
t.Errorf("ConfigJSON must carry neither the secret nor the descriptor: %s", cfg.ConfigJSON)
}
if strings.Contains(logBuf.String(), "SUPER-SECRET-TOKEN") {
t.Error("secret leaked into the hub log")
}
}
func TestPBSDR_ResaveIsNoOp(t *testing.T) {
fake := &fakeTenancy{secret: "SUPER-SECRET-TOKEN"}
s, st, _ := newPBSDRServer(t, fake)
postUpdate(t, s, url.Values{"dr_tier": {"on"}})
if _, err := st.ConsumeHostPBSSecret("peti-01"); err != nil {
t.Fatalf("first secret consume: %v", err)
}
// Idempotent re-save: NO re-key, NO second secret row, NO spurious generation bump.
// (Red-proof: dropping the already-provisioned short-circuit in applyPBSDR re-runs
// Provision → calls=2 + a fresh consumable secret → FAIL.)
rr := postUpdate(t, s, url.Values{"dr_tier": {"on"}})
if rr.Code != 303 {
t.Fatalf("re-save = %d, want 303 (already-provisioned is success-no-op)", rr.Code)
}
if fake.provisionCalls != 1 {
t.Errorf("provision calls after re-save = %d, want 1 (token must not rotate)", fake.provisionCalls)
}
if _, err := st.ConsumeHostPBSSecret("peti-01"); err != sql.ErrNoRows {
t.Errorf("re-save created a fresh secret (consume err %v, want ErrNoRows)", err)
}
if _, _, gen := hostState(t, st); gen != 1 {
t.Errorf("generation after re-save = %d, want 1 (no spurious bump)", gen)
}
}
// v0.51.0 (DR-tier-by-default): an UNMET PRECONDITION is an honest waiting stage, not a
// save-blocking error — the flag is stored and the cascade converges later. A REAL provisioning
// failure stays fail-closed. Red-proof partner for the coupling: drop the WG-peer stage check in
// pbsdrProvisionAtom → "no WG peer waits" fails (provision would be reached).
func TestPBSDR_FailClosed(t *testing.T) {
t.Run("no WG peer waits (stage, not error)", func(t *testing.T) {
fake := &fakeTenancy{secret: "S"}
s, st, logBuf := newPBSDRServer(t, fake)
// Kill the precondition: the host has never registered a WG key.
if err := st.RemoveWGPeer("PETIPUBKEY"); err != nil {
t.Fatalf("remove seed peer: %v", err)
}
rr := postUpdate(t, s, url.Values{"dr_tier": {"on"}})
if rr.Code != 303 {
t.Fatalf("save without WG peer = %d (%s), want 303 — the flag stores, the cascade waits", rr.Code, rr.Body.String())
}
if fake.provisionCalls != 0 {
t.Errorf("provision reached despite missing peer (%d calls)", fake.provisionCalls)
}
h, _ := st.GetHost("peti-01")
if readPBSDR(h.DesiredJSON) != nil || h.DesiredGeneration != 0 {
t.Errorf("descriptor written while waiting: desc=%v gen=%d", readPBSDR(h.DesiredJSON), h.DesiredGeneration)
}
// The flag itself IS saved (the intent survives the wait).
cfg, _ := st.GetCustomerConfig("peti")
if !cfg.DRTier {
t.Error("DR flag not stored while the cascade waits")
}
if !strings.Contains(logBuf.String(), "has not reported a WG key yet") {
t.Error("the waiting stage (guard wording) was not logged")
}
})
t.Run("tenantsync error", func(t *testing.T) {
fake := &fakeTenancy{err: errors.New("ssh boom")}
s, st, _ := newPBSDRServer(t, fake)
rr := postUpdate(t, s, url.Values{"dr_tier": {"on"}})
if rr.Code != 502 {
t.Fatalf("save with tenantsync error = %d, want 502", rr.Code)
}
desc, _, gen := hostState(t, st)
if desc != nil || gen != 0 {
t.Errorf("fail-closed violated: desc=%+v gen=%d", desc, gen)
}
if _, err := st.ConsumeHostPBSSecret("peti-01"); err != sql.ErrNoRows {
t.Errorf("a secret exists after a failed provision (err %v)", err)
}
// Fail-closed also means the config save itself was aborted.
cfg, _ := st.GetCustomerConfig("peti")
if cfg.CustomerName == "half-saved" {
t.Error("config row saved despite provisioning failure")
}
})
t.Run("token_exists points at re-issue", func(t *testing.T) {
fake := &fakeTenancy{err: tenantsync.ErrTokenExists}
s, st, _ := newPBSDRServer(t, fake)
rr := postUpdate(t, s, url.Values{"dr_tier": {"on"}})
if rr.Code != 502 || !strings.Contains(rr.Body.String(), "Re-issue") {
t.Fatalf("token_exists = %d (%s), want 502 mentioning Re-issue", rr.Code, rr.Body.String())
}
if desc, _, gen := hostState(t, st); desc != nil || gen != 0 {
t.Errorf("state written on token_exists: desc=%+v gen=%d", desc, gen)
}
_ = st
})
t.Run("not configured waits (stage, not error)", func(t *testing.T) {
s, st, _ := newPBSDRServer(t, nil) // no tenantsync
rr := postUpdate(t, s, url.Values{"dr_tier": {"on"}})
if rr.Code != 303 {
t.Fatalf("save without tenantsync = %d, want 303 — the flag stores, provisioning waits", rr.Code)
}
if desc, _, gen := hostState(t, st); desc != nil || gen != 0 {
t.Errorf("state written without a provisioner: desc=%+v gen=%d", desc, gen)
}
})
}
// Scenario A (hands-free cascade): flag ON while the WG peer is missing → the save waits; the
// host's WG registration then fires PBSDRAutoProvision and the descriptor lands with ZERO
// operator steps. Red-proof partner: unhook the atom from PBSDRAutoProvision (early return) →
// this fails at "descriptor after WG registration".
func TestPBSDR_AutoProvisionOnWGRegistration(t *testing.T) {
fake := &fakeTenancy{secret: "AUTO-SECRET"}
s, st, logBuf := newPBSDRServer(t, fake)
if err := st.RemoveWGPeer("PETIPUBKEY"); err != nil {
t.Fatalf("remove seed peer: %v", err)
}
// 1. Flag ON, peer missing → stored intent, no descriptor.
postUpdate(t, s, url.Values{"dr_tier": {"on"}})
if desc, _, _ := hostState(t, st); desc != nil {
t.Fatalf("descriptor exists before the WG peer: %+v", desc)
}
// 2. The agent registers its WG key (what the api handler does) → the hook fires.
if _, _, err := st.RegisterWGPeerForHost("peti-01", "PETIPUBKEY"); err != nil {
t.Fatalf("register peer: %v", err)
}
s.PBSDRAutoProvision(context.Background(), "peti")
desc, desiredJSON, _ := hostState(t, st)
if desc == nil || !desc.Enabled || desc.Namespace != "peti" {
t.Fatalf("descriptor after WG registration = %+v (json %s), want provisioned", desc, desiredJSON)
}
if fake.provisionCalls != 1 {
t.Errorf("provision calls = %d, want 1", fake.provisionCalls)
}
if got, err := st.ConsumeHostPBSSecret("peti-01"); err != nil || got != "AUTO-SECRET" {
t.Fatalf("consume-once secret after auto-provision = (%q, %v)", got, err)
}
if strings.Contains(logBuf.String(), "AUTO-SECRET") {
t.Error("secret leaked into the hub log")
}
// 3. Idempotent: a second hook firing (re-registration) must not re-key.
s.PBSDRAutoProvision(context.Background(), "peti")
if fake.provisionCalls != 1 {
t.Errorf("second hook firing re-provisioned (%d calls)", fake.provisionCalls)
}
// 4. Flag OFF → the hook never provisions (scenario B: off is off).
fake2 := &fakeTenancy{secret: "S2"}
s2, st2, _ := newPBSDRServer(t, fake2)
cfg, _ := st2.GetCustomerConfig("peti")
cfg.DRTier = false
if err := st2.SaveCustomerConfig(cfg); err != nil {
t.Fatalf("save flag-off config: %v", err)
}
s2.PBSDRAutoProvision(context.Background(), "peti")
if fake2.provisionCalls != 0 {
t.Errorf("hook provisioned with the DR flag OFF (%d calls)", fake2.provisionCalls)
}
}
func TestPBSDR_DisableKeepsTenancy(t *testing.T) {
fake := &fakeTenancy{secret: "S"}
s, st, _ := newPBSDRServer(t, fake)
postUpdate(t, s, url.Values{"dr_tier": {"on"}})
// Unchecked box → descriptor enabled:false, coords kept, ONE bump; no endpoint mutation.
rr := postUpdate(t, s, url.Values{})
if rr.Code != 303 {
t.Fatalf("disable save = %d, want 303", rr.Code)
}
desc, _, gen := hostState(t, st)
if desc == nil || desc.Enabled || desc.Namespace != "peti" || desc.TokenID == "" {
t.Fatalf("disable must keep coords with enabled=false: %+v", desc)
}
if gen != 2 {
t.Errorf("generation = %d, want 2 (enable + disable)", gen)
}
// A second disabled save is a pure no-op.
postUpdate(t, s, url.Values{})
if _, _, gen := hostState(t, st); gen != 2 {
t.Errorf("second disabled save bumped generation to %d", gen)
}
if fake.provisionCalls != 1 || fake.reissueCalls != 0 {
t.Errorf("endpoint touched on disable: provision=%d reissue=%d", fake.provisionCalls, fake.reissueCalls)
}
}
func TestPBSDR_StorageIDChangeUpdatesDescriptorOnly(t *testing.T) {
fake := &fakeTenancy{secret: "S"}
s, st, _ := newPBSDRServer(t, fake)
postUpdate(t, s, url.Values{"dr_tier": {"on"}})
st.ConsumeHostPBSSecret("peti-01") // spend the provision secret
rr := postUpdate(t, s, url.Values{"dr_tier": {"on"}, "pbsdr_storage_id": {"felhom-offsite"}})
if rr.Code != 303 {
t.Fatalf("storage-id change = %d, want 303", rr.Code)
}
desc, _, gen := hostState(t, st)
if desc.StorageID != "felhom-offsite" || !desc.Enabled {
t.Errorf("descriptor not updated: %+v", desc)
}
if gen != 2 {
t.Errorf("generation = %d, want 2", gen)
}
if fake.provisionCalls != 1 {
t.Errorf("a descriptor edit re-provisioned the tenancy (%d calls)", fake.provisionCalls)
}
if _, err := st.ConsumeHostPBSSecret("peti-01"); err != sql.ErrNoRows {
t.Errorf("a descriptor edit staged a new secret (err %v)", err)
}
}
// F-14 scenario A (v0.53.0, operator ruling 2026-07-13): a tenancy orphaned by an ESCROW-ACKED
// host delete → the enable path auto-re-issues via the EXISTING tenantsync re-issue op, writes
// the audit event, and provisioning proceeds to a full descriptor + consume-once secret.
// RED-PROOF (Part 1): dropping the provenance write from DeleteHost's tx → the gate finds no
// record → this test fails with the 502 refusal.
func TestPBSDR_F14AutoReissueOnAckedDeletion(t *testing.T) {
fake := &fakeTenancy{provisionErr: tenantsync.ErrTokenExists, secret: "REISSUED-SECRET"}
s, st, logBuf := newPBSDRServer(t, fake)
// The F-14 history: the tenancy's owning host was deleted through the escrow-ack flow
// (real DeleteHost, real escrow row — no hand-set provenance).
if err := st.UpsertHost(&store.Host{HostID: "peti-00-dead", CustomerID: "peti", APIKey: "oldkey"}); err != nil {
t.Fatal(err)
}
if _, _, err := st.SaveHostEscrow("peti-00-dead", []byte("opaque"), "fp", "posture", "2026-07-01T00:00:00Z", ""); err != nil {
t.Fatal(err)
}
if err := st.DeleteHost("peti-00-dead", true); err != nil {
t.Fatalf("escrow-ack delete: %v", err)
}
rr := postUpdate(t, s, url.Values{"dr_tier": {"on"}})
if rr.Code != 303 {
t.Fatalf("enable with acked deletion record = %d (%s), want 303 (auto re-issue proceeds)", rr.Code, rr.Body.String())
}
if fake.provisionCalls != 1 || fake.reissueCalls != 1 {
t.Errorf("calls = provision %d / reissue %d, want 1/1 (the EXISTING re-issue op, once)", fake.provisionCalls, fake.reissueCalls)
}
// Full effect: descriptor landed, generation bumped, fresh consume-once secret staged.
desc, desiredJSON, gen := hostState(t, st)
if desc == nil || !desc.Enabled || desc.Namespace != "peti" || desc.TokenID != "felhom@pbs!peti" {
t.Fatalf("descriptor after auto re-issue = %+v (json %s)", desc, desiredJSON)
}
if gen != 1 {
t.Errorf("generation = %d, want 1", gen)
}
if got, err := st.ConsumeHostPBSSecret("peti-01"); err != nil || got != "REISSUED-SECRET" {
t.Fatalf("consume-once secret = (%q, %v), want the re-issued secret", got, err)
}
// The audit line: a stored hub-source event carrying the operator note.
events, err := st.GetRecentEvents("peti", 10)
if err != nil {
t.Fatalf("events: %v", err)
}
var audit *store.Event
for i := range events {
if events[i].EventType == "pbsdr_auto_reissue" {
audit = &events[i]
}
}
if audit == nil {
t.Fatal("no pbsdr_auto_reissue audit event stored")
}
if audit.Source != "hub" || !strings.Contains(audit.Message, "Previous key destroyed (acknowledged deletion)") {
t.Errorf("audit event = %+v, want hub-source with the operator note", audit)
}
if !strings.Contains(audit.DetailsJSON, "peti-00-dead") {
t.Errorf("audit details lack the deleted host: %s", audit.DetailsJSON)
}
if strings.Contains(logBuf.String(), "REISSUED-SECRET") {
t.Error("secret leaked into the hub log")
}
}
// F-14 scenario B (the never-silently-re-key law): a surviving tenancy WITHOUT an acked
// deletion record — none at all, or the latest one un-acked — keeps the current refusal and
// records ZERO re-issue calls (the exact non-effect).
// RED-PROOF (§10 B): removing the record check in pbsdrProvisionAtom (auto-reissue
// unconditionally) → the zero-reissue assertions here fail.
func TestPBSDR_F14NoRecordNeverRekeys(t *testing.T) {
assertRefused := func(t *testing.T, fake *fakeTenancy, s *Server, st *store.Store) {
t.Helper()
rr := postUpdate(t, s, url.Values{"dr_tier": {"on"}})
if rr.Code != 502 || !strings.Contains(rr.Body.String(), "Re-issue PBS credentials") {
t.Fatalf("save = %d (%s), want the byte-unchanged 502 refusal", rr.Code, rr.Body.String())
}
if fake.reissueCalls != 0 {
t.Errorf("reissue calls = %d, want 0 — SILENT RE-KEY", fake.reissueCalls)
}
if desc, _, gen := hostState(t, st); desc != nil || gen != 0 {
t.Errorf("state written on refusal: desc=%+v gen=%d", desc, gen)
}
if _, err := st.ConsumeHostPBSSecret("peti-01"); err != sql.ErrNoRows {
t.Errorf("a secret was staged on refusal (err %v)", err)
}
}
t.Run("no deletion record at all", func(t *testing.T) {
fake := &fakeTenancy{provisionErr: tenantsync.ErrTokenExists, secret: "S"}
s, st, _ := newPBSDRServer(t, fake)
assertRefused(t, fake, s, st)
})
t.Run("latest record un-acked", func(t *testing.T) {
fake := &fakeTenancy{provisionErr: tenantsync.ErrTokenExists, secret: "S"}
s, st, _ := newPBSDRServer(t, fake)
// A deletion happened, but NOT through the escrow-ack flow (no escrow row → acked=false).
if err := st.UpsertHost(&store.Host{HostID: "peti-00-dead", CustomerID: "peti", APIKey: "oldkey"}); err != nil {
t.Fatal(err)
}
if err := st.DeleteHost("peti-00-dead", true); err != nil {
t.Fatal(err)
}
assertRefused(t, fake, s, st)
})
}
func TestPBSDR_Reissue(t *testing.T) {
fake := &fakeTenancy{secret: "OLD-SECRET"}
s, st, logBuf := newPBSDRServer(t, fake)
postUpdate(t, s, url.Values{"dr_tier": {"on"}})
st.ConsumeHostPBSSecret("peti-01") // agent already consumed; the dead-end scenario
fake.secret = "FRESH-SECRET"
req := httptest.NewRequest("POST", "/configs/peti/pbsdr-reissue", nil)
rr := httptest.NewRecorder()
s.handlePBSDRReissue(rr, req, "peti")
if rr.Code != 303 {
t.Fatalf("reissue = %d (%s), want 303", rr.Code, rr.Body.String())
}
if fake.reissueCalls != 1 {
t.Errorf("reissue calls = %d, want 1", fake.reissueCalls)
}
got, err := st.ConsumeHostPBSSecret("peti-01")
if err != nil || got != "FRESH-SECRET" {
t.Fatalf("fresh secret consume = (%q, %v)", got, err)
}
if _, err := st.ConsumeHostPBSSecret("peti-01"); err != sql.ErrNoRows {
t.Error("the old secret path survived the re-issue")
}
if _, _, gen := hostState(t, st); gen != 2 {
t.Errorf("generation = %d, want 2 (provision + reissue)", gen)
}
if strings.Contains(logBuf.String(), "FRESH-SECRET") || strings.Contains(logBuf.String(), "OLD-SECRET") {
t.Error("secret leaked into the hub log")
}
}
func TestPBSDR_ReissueRequiresProvisionedState(t *testing.T) {
fake := &fakeTenancy{secret: "S"}
s, _, _ := newPBSDRServer(t, fake)
req := httptest.NewRequest("POST", "/configs/peti/pbsdr-reissue", nil)
rr := httptest.NewRecorder()
s.handlePBSDRReissue(rr, req, "peti")
if rr.Code != 400 {
t.Fatalf("reissue without a provisioned tier = %d, want 400", rr.Code)
}
if fake.reissueCalls != 0 {
t.Errorf("endpoint touched without a descriptor (%d calls)", fake.reissueCalls)
}
}
// The form render leg: the section reflects the provisioned descriptor (checkbox + namespace line)
// and the fresh-form default storage id.
func TestPBSDR_FormRendersState(t *testing.T) {
fake := &fakeTenancy{secret: "S"}
s, _, _ := newPBSDRServer(t, fake)
postUpdate(t, s, url.Values{"dr_tier": {"on"}})
// v0.48.0 edit-a: the standalone GET edit page is a redirect now — the form renders embedded
// in the customer page's Edit tab (the same config_form_body sub-template), so assert there.
req := httptest.NewRequest("GET", "/customers/peti", nil)
rr := httptest.NewRecorder()
s.handleCustomerUnified(rr, req, "peti")
out := rr.Body.String()
if !strings.Contains(out, `name="dr_tier" checked`) {
t.Error("DR-tier checkbox not checked after provisioning")
}
if !strings.Contains(out, "descriptor provisioned (namespace peti, token felhom@pbs!peti)") {
t.Error("provisioned cascade stage line missing")
}
// The cascade (v0.51.0 scenario D): all four stages render as done on a fully-applied box.
if strings.Count(out, `class="badge badge-neutral">waiting</span>`) > 1 {
// escrow may still be waiting in this harness; every other stage must be done
t.Errorf("more than the escrow stage still waiting:\n%s", out[strings.Index(out, "DR tier (PBS, ep0)"):][:1200])
}
if !strings.Contains(out, "pbsdr-reissue") {
t.Error("re-issue button missing")
}
if strings.Contains(out, "S\"") && strings.Contains(out, "token_secret") {
t.Error("secret-ish content rendered")
}
// Deadline check on the detached-ctx behavior is out of scope here; the view path must
// stay read-only — a render must not have touched provisioning.
if fake.provisionCalls != 1 {
t.Errorf("rendering the form called provision (%d calls)", fake.provisionCalls)
}
_ = time.Second
}