The root ceremony uploaded the new package before the new code was claimed, and the hub overwrites the current package when the sealed secrets are unchanged: a customer who started the ceremony again and walked away kept a written-down code that opened nothing. Json mode (the FELHOM_ESCROW argv, unchanged) now returns the package (CeremonyOutput v2, PendingUpload); the daemon uploads it at POST /escrow/ceremony/claim before handing out the code. A failed upload is a 502 with the code still claimable; TTL and a new start wait for an in-flight claim upload; no uploader is a 503. Text mode still uploads at once. No sudoers change. Tests TestEscrowCeremony_R978_* (localapi) and the cmd-side defer/delivery/wiring tests, red-proved.
113 lines
4.5 KiB
Go
113 lines
4.5 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"go/ast"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/escrow"
|
|
)
|
|
|
|
// R-978, the root half: the controller-driven ceremony (json mode, the FELHOM_ESCROW argv with
|
|
// `--upload`) must hand its package back instead of sending it — the daemon sends it at the claim.
|
|
//
|
|
// COMPANION RED-PROOF (observed 2026-10-18): set `deferUpload: false` in escrowOptsFromCLI → this fails
|
|
// with "json mode must defer the upload (R-978): got deferUpload=false", and the delivery test's json leg
|
|
// then sends the package at create time ("put called 1 times"). Restored.
|
|
func TestEscrowOptsFromCLI_JSONModeDefersUpload(t *testing.T) {
|
|
// The FELHOM_ESCROW argv: --selftest=escrow-create --upload --output=json.
|
|
if o := escrowOptsFromCLI("", false, false, true, "", true); !o.deferUpload || !o.upload {
|
|
t.Fatalf("json mode must defer the upload (R-978): got deferUpload=%v upload=%v", o.deferUpload, o.upload)
|
|
}
|
|
// Text mode (an operator at a terminal) keeps the immediate upload; no --upload means no upload at all.
|
|
if o := escrowOptsFromCLI("", false, false, true, "", false); o.deferUpload {
|
|
t.Fatal("text mode must upload at once")
|
|
}
|
|
if o := escrowOptsFromCLI("", false, false, false, "", true); o.deferUpload {
|
|
t.Fatal("without --upload there is nothing to defer")
|
|
}
|
|
}
|
|
|
|
func TestDeliverEscrowPackage_JSONModeSendsNothing(t *testing.T) {
|
|
cfg := config.Config{}
|
|
cfg.Hub.URL, cfg.Hub.HostID, cfg.Hub.APIKey = "https://hub.invalid", "host-1", "k"
|
|
res := escrow.CreateResult{Blob: []byte("ciphertext"), KeyFingerprint: "f2:87", Posture: escrow.PostureZeroKnowledge, IdentityBlob: []byte("id")}
|
|
|
|
for _, jsonMode := range []bool{true, false} {
|
|
calls := 0
|
|
var sent []byte
|
|
put := func(_ context.Context, _ config.Config, body []byte) error { calls++; sent = body; return nil }
|
|
o := escrowOptsFromCLI("", false, false, true, "", jsonMode)
|
|
pending, uploaded, err := deliverEscrowPackage(context.Background(), cfg, res, "abc", o.deferUpload, put)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if jsonMode {
|
|
if calls != 0 || uploaded {
|
|
t.Fatalf("json mode: put called %d times (uploaded=%v), want 0 — the package must wait for the claim", calls, uploaded)
|
|
}
|
|
var m map[string]any
|
|
if json.Unmarshal(pending, &m) != nil || m["blob_b64"] == "" || m["identity_blob_b64"] == "" || m["restic_pw_sha256"] != "abc" {
|
|
t.Fatalf("json mode: the pending package is not the hub upload body: %s", pending)
|
|
}
|
|
} else if calls != 1 || !uploaded || pending != nil || len(sent) == 0 {
|
|
t.Fatalf("text mode: put called %d times, uploaded=%v, pending=%q — want one immediate upload", calls, uploaded, pending)
|
|
}
|
|
}
|
|
|
|
// A hub that is not configured fails in both modes (as the pre-R-978 upload did).
|
|
if _, _, err := deliverEscrowPackage(context.Background(), config.Config{}, res, "", true, nil); err == nil {
|
|
t.Fatal("an unconfigured hub must fail the ceremony even when the upload is deferred")
|
|
}
|
|
}
|
|
|
|
// R-978 wiring (the built-but-never-wired class): runDaemon's localapi.EscrowCeremonyConfig carries an
|
|
// Upload that calls putEscrowBody. Without it every claim of a new package answers 503 and no customer
|
|
// can finish a ceremony.
|
|
//
|
|
// COMPANION RED-PROOF (observed 2026-10-18): delete the `Upload:` line in runDaemon → this fails with
|
|
// "runDaemon's EscrowCeremonyConfig has no Upload calling putEscrowBody". Restored.
|
|
func TestR978_CeremonyUploadIsWiredIntoTheDaemon(t *testing.T) {
|
|
_, f := parseMain(t)
|
|
if !callsWithin(f, "main")["runDaemon"] {
|
|
t.Fatal("main → runDaemon is broken — the path this test asserts is not the live one")
|
|
}
|
|
wired := false
|
|
for _, d := range f.Decls {
|
|
fd, ok := d.(*ast.FuncDecl)
|
|
if !ok || fd.Name == nil || fd.Name.Name != "runDaemon" || fd.Body == nil {
|
|
continue
|
|
}
|
|
ast.Inspect(fd.Body, func(n ast.Node) bool {
|
|
cl, ok := n.(*ast.CompositeLit)
|
|
if !ok {
|
|
return true
|
|
}
|
|
sel, ok := cl.Type.(*ast.SelectorExpr)
|
|
if !ok || sel.Sel.Name != "EscrowCeremonyConfig" {
|
|
return true
|
|
}
|
|
for _, el := range cl.Elts {
|
|
if kv, ok := el.(*ast.KeyValueExpr); ok {
|
|
if k, ok := kv.Key.(*ast.Ident); ok && k.Name == "Upload" {
|
|
ast.Inspect(kv.Value, func(n ast.Node) bool {
|
|
if ce, ok := n.(*ast.CallExpr); ok {
|
|
if id, ok := ce.Fun.(*ast.Ident); ok && id.Name == "putEscrowBody" {
|
|
wired = true
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
if !wired {
|
|
t.Fatal("runDaemon's EscrowCeremonyConfig has no Upload calling putEscrowBody")
|
|
}
|
|
}
|