package host import ( "context" "crypto/ed25519" "crypto/sha256" "encoding/hex" "encoding/json" "strings" "testing" "time" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostevidence" "github.com/carlosframework/platform/internal/hostguard" "github.com/carlosframework/platform/internal/release" ) func TestPlan11ReleaseNeedsExactSignedRootStop(t *testing.T) { t.Parallel() for _, bad := range []string{"", "unsigned", "wrong-stamp", "retirement", "wrong-enrollment", "wrong-session", "session-zero", "zero-stale", "zero-as-stopped", "positive-as-no-process"} { t.Run(bad, func(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) root := ed25519.NewKeyFromSeed(make([]byte, 32)) boxKey := ed25519.NewKeyFromSeed([]byte(strings.Repeat("b", 32))) d := hostactors.Directory{Store: s, PublicKey: root.Public().(ed25519.PublicKey), Bucket: "bucket"} en := strings.Repeat("a", 32) roster := hostactors.Roster{V: 1, Revision: 1, DeploymentBucket: "bucket", Boxes: map[string]hostactors.Box{"box": {Enrollment: en, BoxID: "box", Label: "box", Namespace: "platform", Region: "ie", Pool: "default", State: "active", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-box"}, EvidenceSHA256: strings.Repeat("d", 64), CensusPublicKey: boxKey.Public().(ed25519.PublicKey)}}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}} wire, _ := release.SignRootEnvelope(release.RootSigner(root), roster) if _, err := s.Put(ctx, hostactors.Key, wire, ""); err != nil { t.Fatal(err) } host := "shop.example" created := time.Date(2026, 10, 9, 0, 0, 0, 0, time.UTC) inc, _ := hostclaim.Incarnation("bab", "jam", host, created) claim := hostclaim.Claim{V: 2, Gen: 1, Host: host, Holder: &hostclaim.Holder{Family: "instance", Key: "apps/bab/jam/instances/" + host + ".json", Account: "bab", App: "jam", Incarnation: inc, CreatedAt: created, Location: "instances/" + host + "/" + inc, Stamp: 7, Session: 7, WriterBox: "box"}, Op: hostclaim.Op{ID: "reloc", Kind: hostclaim.KindRelocating, Phase: "releasing", Exec: "box", Intent: map[string]string{"source_box": "box", "source_enrollment": en, "region": "ie"}}, Obligations: []hostclaim.Obligation{{ID: "relocation-release", Kind: "relocation-release", Op: "reloc", Box: "box", Region: "ie", Owner: "region:ie", Retirement: "install-7"}}} if bad == "session-zero" || strings.HasPrefix(bad, "zero-") { claim.Holder.Session = 0 claim.Holder.WriterBox = "" } raw, _ := json.Marshal(claim) key, _ := hostclaim.Key(host) tag, err := s.Put(ctx, key, raw, "") if err != nil { t.Fatal(err) } obs := hostclaim.Observed{Host: host, Claim: &claim, ETag: tag} if err := hostclaim.CompleteObligation("reloc", "relocation-release")(&claim); err == nil { t.Fatal("generic chore completion discharged release") } receipt := StopReceipt{V: 1, ID: "stop-1", Kind: "stopped", Host: host, Incarnation: inc, InstallationStamp: 7, WriterSession: 7, OperationID: "reloc", Phase: "releasing", ObligationID: "relocation-release", Box: "box", Enrollment: en, Region: "ie", EvidenceAt: time.Now(), RequestLimitNS: int64(120 * time.Second), ClaimGen: 1, FenceID: "fence", InventorySHA256: strings.Repeat("e", 64), LocalJournalSHA256: strings.Repeat("f", 64), ProducerDrainDigest: strings.Repeat("a", 64), CgroupIdentities: []string{"root-role"}} if claim.Holder.Session == 0 || bad == "positive-as-no-process" { receipt.WriterSession = claim.Holder.Session receipt.Kind = StopReceiptNoProcess receipt.FenceID = "" receipt.ProducerDrainDigest = "" receipt.ClaimETag = tag } switch bad { case "zero-stale": receipt.ClaimETag = "old-etag" case "zero-as-stopped": receipt.Kind = "stopped" case "wrong-stamp": receipt.InstallationStamp++ case "retirement": receipt.ObligationID = "park-local" case "wrong-enrollment": receipt.Enrollment = strings.Repeat("f", 32) case "wrong-session": receipt.WriterSession++ } body, _ := json.Marshal(receipt) receiptKey := stopEvidenceKey(host, "reloc", "box", "stop-1") sum := sha256.Sum256(body) if bad == "unsigned" { _, err = s.Put(ctx, receiptKey, body, "") } else { err = hostevidence.Publish(ctx, s, boxKey, receiptKey, body) } if err != nil { t.Fatal(err) } got, err := (RelocationReleaseReceipts{Store: s, Directory: d}).Complete(ctx, obs, receiptKey, hex.EncodeToString(sum[:])) if bad != "" && bad != "session-zero" { if err == nil { t.Fatal("invalid release receipt accepted") } return } if err != nil { t.Fatal(err) } if len(got.Claim.Obligations) != 0 || got.Claim.Holder.Stamp != 7 || got.Claim.Op.Phase != "releasing" { t.Fatal("release completion changed authority") } replayed, err := (RelocationReleaseReceipts{Store: s, Directory: d}).Complete(ctx, got, receiptKey, hex.EncodeToString(sum[:])) if err != nil || replayed.ETag != got.ETag { t.Fatal("release replay changed claim", err) } }) } } func TestPlan11RootReleaseRequiresItsStopOnlyObligation(t *testing.T) { c, req, _ := claimStopFixture(t) o := c.Claims.(stopClaimReader).obs o.Claim.Op.Kind = hostclaim.KindRelocating o.Claim.Op.Phase = "releasing" o.Claim.Op.Intent = map[string]string{"region": "ie", "source_box": "box-1"} req.Phase = "releasing" req.Region = "ie" req.ObligationID = "relocation-release" target := hostguard.FenceTarget{Host: req.Host, Incarnation: req.Incarnation, Stamp: req.InstallationStamp, Session: req.WriterSession, Op: req.OperationID, Phase: req.Phase, Box: req.Box, Region: req.Region, ObligationID: req.ObligationID} if hostguard.StopAuthorityMatches(o.Claim, target) { t.Fatal("release accepted without obligation") } o.Claim.Obligations = []hostclaim.Obligation{{ID: "relocation-release", Kind: "relocation-release", Op: req.OperationID, Region: "ie", Box: req.Box, Owner: "region:ie", Retirement: "install"}} if !hostguard.StopAuthorityMatches(o.Claim, target) { t.Fatal("bound release refused") } o.Claim.Obligations[0].Park = &hostclaim.ParkScope{} if hostguard.StopAuthorityMatches(o.Claim, target) { t.Fatal("release with park scope accepted") } }