c484aa204e
The fleet half of R-39. An ep0 credential re-issue re-keys the SECRET of an existing token, so token_id, fingerprint, datastore and namespace all come back byte-identical. The agent re-applies on the descriptor's CONTENT HASH, so a re-issue was invisible to a converged box: it short-circuited, never consumed the fresh secret, and served a revoked credential while reporting `applied` — the N100 failure of 2026-07-18. host_pbs_secrets gains a monotonic per-host `generation`, advanced by every fresh MINT and by nothing else, stamped into the descriptor as `secret_generation`. That is now the only field a re-key moves, and it is what re-arms the agent. DEVIATION FROM SPEC, deliberate: the brief said to return "the new row's id (int64) … no schema change". There is no row id — host_pbs_secrets is keyed by host_id and UPSERTed last-write-wins, so a new row never exists, and created_at collides for two mints in the same second. An additive counter column is the only monotonic source; it uses the repo's existing idempotent ALTER-TABLE idiom. RestageHostPBSSecret deliberately does NOT advance it: a re-stage re-arms the SAME secret, the descriptor content genuinely has not changed, and a bump would cause a pointless agent refetch loop (that method's own contract says so). Also corrects a comment that asserted the re-issue refreshes the descriptor "with the NEW token_id/fingerprint". That is false for a re-key, and believing it is why the descriptor was never expected to be identical in the first place. omitempty is load-bearing: a zero generation must not start emitting a new key into every pre-existing descriptor, which would itself be a fleet-wide spurious re-apply. Compatibility: agents below 0.91.0 drop the unknown JSON key and behave exactly as today — inert, not breaking (Scenario C). Tests: store-level monotonicity + per-host isolation + restage-leaves-it-alone; descriptor byte-change, omitempty, and sibling-key round-trip; and a FLOW-level test driving ReissuePBSDR against a fake that models a real re-key. Red-proof run at the assertion level (not the compiler): commenting out the stamp makes the flow test fail with both byte-identical blocks printed.
80 lines
3.2 KiB
Go
80 lines
3.2 KiB
Go
package api
|
|
|
|
// PBS DR SLICE 1 — the agent-facing consume-once endpoint. The contract: 200 with the secret
|
|
// EXACTLY once per stored value, 404 after (and when nothing is stored), 403 on a foreign
|
|
// host's key WITHOUT burning the secret, 401 unauthenticated.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
|
)
|
|
|
|
func TestConsumePBSToken_OnceThen404(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
|
|
if _, err := st.SaveHostPBSSecret("h1", "tok-secret-1"); err != nil {
|
|
t.Fatalf("seed secret: %v", err)
|
|
}
|
|
|
|
rr := do(h, http.MethodPost, "/hosts/h1/pbs/consume-token", "HKEY", "")
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("first consume = %d, want 200 (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
var resp map[string]string
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("response parse: %v", err)
|
|
}
|
|
if resp["token_secret"] != "tok-secret-1" {
|
|
t.Errorf("token_secret = %q, want tok-secret-1", resp["token_secret"])
|
|
}
|
|
|
|
// Consume-once: the second fetch MUST 404. (Red-proof: drop the consumed_at UPDATE in
|
|
// store.ConsumeHostPBSSecret → this returns 200 with the secret again → FAIL.)
|
|
rr = do(h, http.MethodPost, "/hosts/h1/pbs/consume-token", "HKEY", "")
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("second consume = %d (%s), want 404 — single-use broken", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestConsumePBSToken_ForeignKeyForbiddenAndSecretSurvives(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
|
|
st.UpsertHost(&store.Host{HostID: "h2", CustomerID: "c2", APIKey: "HKEY2"})
|
|
if _, err := st.SaveHostPBSSecret("h1", "tok-secret-1"); err != nil {
|
|
t.Fatalf("seed secret: %v", err)
|
|
}
|
|
|
|
// h2's key against h1's path → 403, and the attempt must NOT consume h1's secret.
|
|
rr := do(h, http.MethodPost, "/hosts/h1/pbs/consume-token", "HKEY2", "")
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Fatalf("foreign-key consume = %d, want 403", rr.Code)
|
|
}
|
|
rr = do(h, http.MethodPost, "/hosts/h1/pbs/consume-token", "HKEY", "")
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("own consume after foreign 403 = %d, want 200 — the 403 burned the secret", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestConsumePBSToken_AuthMatrix(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
|
|
_, _ = st.SaveHostPBSSecret("h1", "tok-secret-1")
|
|
if rr := do(h, http.MethodPost, "/hosts/h1/pbs/consume-token", "", ""); rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("unauthenticated = %d, want 401", rr.Code)
|
|
}
|
|
if rr := do(h, http.MethodPost, "/hosts/h1/pbs/consume-token", "bogus", ""); rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("bogus key = %d, want 401", rr.Code)
|
|
}
|
|
// The global key may consume on a host's behalf (operator recovery path).
|
|
if rr := do(h, http.MethodPost, "/hosts/h1/pbs/consume-token", globalKey, ""); rr.Code != http.StatusOK {
|
|
t.Errorf("global key = %d, want 200", rr.Code)
|
|
}
|
|
// Nothing stored (just consumed above) → 404, not an error leak.
|
|
if rr := do(h, http.MethodPost, "/hosts/h1/pbs/consume-token", "HKEY", ""); rr.Code != http.StatusNotFound {
|
|
t.Errorf("post-consume = %d, want 404", rr.Code)
|
|
}
|
|
}
|