package domains import ( "context" "errors" "strconv" "testing" "github.com/carlosframework/platform/internal/release" ) // TestSetClaimPathsRoundTrip: the map written is the map read back. func TestSetClaimPathsRoundTrip(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) if _, _, err := AttachClaim(ctx, s, Claim{ Schema: 1, Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", }); err != nil { t.Fatal(err) } changed, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", map[string]string{"paulca": "go.tito.io"}) if err != nil { t.Fatal(err) } if !changed { t.Fatal("changed = false on the first write of a paths map") } c, err := GetClaim(ctx, s, "ti.to") if err != nil { t.Fatal(err) } if c.Paths["paulca"] != "go.tito.io" { t.Fatalf("paths = %v, want paulca -> go.tito.io", c.Paths) } } // TestSetClaimPathsIsWholesale: publishing is a REPLACE, not a merge. Tito // regenerates the whole map from its own database, so an account it no longer // lists must disappear — that is how a rollback reaches the edge. func TestSetClaimPathsIsWholesale(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) if _, _, err := AttachClaim(ctx, s, Claim{ Schema: 1, Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", }); err != nil { t.Fatal(err) } if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", map[string]string{"paulca": "go.tito.io", "bigcorp": "go.tito.io"}); err != nil { t.Fatal(err) } if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", map[string]string{"paulca": "go.tito.io"}); err != nil { t.Fatal(err) } c, err := GetClaim(ctx, s, "ti.to") if err != nil { t.Fatal(err) } if _, still := c.Paths["bigcorp"]; still { t.Fatal("bigcorp survived a republish that omitted it — publishing must replace, not merge") } } // TestSetClaimPathsEmptyClearsTheMap: the final cutover. When every account // is across, Tito repoints the host's own route and publishes an empty map. func TestSetClaimPathsEmptyClearsTheMap(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) if _, _, err := AttachClaim(ctx, s, Claim{ Schema: 1, Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", }); err != nil { t.Fatal(err) } if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", map[string]string{"paulca": "go.tito.io"}); err != nil { t.Fatal(err) } if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", nil); err != nil { t.Fatal(err) } c, err := GetClaim(ctx, s, "ti.to") if err != nil { t.Fatal(err) } if len(c.Paths) != 0 { t.Fatalf("paths = %v, want empty after publishing an empty map", c.Paths) } } // TestSetClaimPathsIsOwnerGated: a caller who does not hold the claim gets // the same neutral ErrHostUnavailable the attach door uses — no existence // oracle, byte-identical to the answer for a claim that does not exist. func TestSetClaimPathsIsOwnerGated(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) if _, _, err := AttachClaim(ctx, s, Claim{ Schema: 1, Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", }); err != nil { t.Fatal(err) } if _, err := SetClaimPaths(ctx, s, "ti.to", "someone-else", "classic", map[string]string{"paulca": "evil.example"}); !errors.Is(err, ErrHostUnavailable) { t.Fatalf("err = %v, want ErrHostUnavailable for a non-owner", err) } if _, err := SetClaimPaths(ctx, s, "never-claimed.example", "tito", "classic", map[string]string{"a": "b.example.com"}); !errors.Is(err, ErrHostUnavailable) { t.Fatalf("err = %v, want the identical ErrHostUnavailable for an absent claim", err) } } // TestSetClaimPathsRejectsUnnormalisedTarget: a target that is not a // normalised hostname (no interior dot, wrong case, trailing dot, ...) is // refused with ErrBadPathTarget — this is an owned, existing claim, so the // failure can only be about the target shape, not about who is asking or // whether the host exists. // // Asserted through the SENTINEL, not merely as "non-nil and not // ErrHostUnavailable". The API door answers this class as the CALLER's error // (422, naming the entry) rather than as a store failure (502, "try again"), // and errors.Is is the only thing that tells it which it has — so a lost %w // wrap would silently start telling the publishing platform to retry a body // that will fail identically forever. A non-nil check passes either way and // would not catch it. func TestSetClaimPathsRejectsUnnormalisedTarget(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) if _, _, err := AttachClaim(ctx, s, Claim{ Schema: 1, Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", }); err != nil { t.Fatal(err) } // Every shape NormalizeAliasHost turns away, and each one is something a // generated map could plausibly carry. for _, target := range []string{"notahostname", "GO.tito.io", "go.tito.io.", "", "go tito io"} { _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", map[string]string{"paulca": target}) if !errors.Is(err, ErrBadPathTarget) { t.Fatalf("target %q: err = %v, want ErrBadPathTarget", target, err) } if errors.Is(err, ErrHostUnavailable) { t.Fatalf("target %q: err = %v, want the target-shape error, not ErrHostUnavailable", target, err) } // Refused WHOLE: a map with one bad entry stores none of it. if c, err := GetClaim(ctx, s, "ti.to"); err != nil { t.Fatal(err) } else if len(c.Paths) != 0 { t.Fatalf("target %q: claim paths = %v, want none", target, c.Paths) } } } // TestSetClaimPathsUnchangedIsNoWrite: republishing an identical map must not // burn a bucket write. Tito republishes wholesale as a repair, so the no-op // case is the common one. func TestSetClaimPathsUnchangedIsNoWrite(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) if _, _, err := AttachClaim(ctx, s, Claim{ Schema: 1, Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", }); err != nil { t.Fatal(err) } m := map[string]string{"paulca": "go.tito.io"} if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", m); err != nil { t.Fatal(err) } changed, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", m) if err != nil { t.Fatal(err) } if changed { t.Fatal("changed = true for an identical republish — a no-op must not write") } } // TestSetClaimPathsRefusesOversizeMap: the cap is enforced at the door, so an // oversize map is refused whole rather than silently truncated into the // bucket. (The reconcile pass caps again, defensively, for hand-written // claims — see Task 3.) func TestSetClaimPathsRefusesOversizeMap(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) if _, _, err := AttachClaim(ctx, s, Claim{ Schema: 1, Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", }); err != nil { t.Fatal(err) } big := make(map[string]string, MaxPathsPerClaim+1) for i := 0; i <= MaxPathsPerClaim; i++ { big["acct"+strconv.Itoa(i)] = "go.tito.io" } if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", big); !errors.Is(err, ErrTooManyPaths) { t.Fatalf("err = %v, want ErrTooManyPaths", err) } } // TestSetClaimPathsRejectsMalformedPrefixes: a prefix is a bare segment. A // leading slash, an embedded slash, or an empty key would each produce a row // the edge could never match, so they are refused at the door rather than // stored as dead weight. func TestSetClaimPathsRejectsMalformedPrefixes(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) if _, _, err := AttachClaim(ctx, s, Claim{ Schema: 1, Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", }); err != nil { t.Fatal(err) } for _, bad := range []string{"/paulca", "paulca/awesomeconf", "", "with space"} { if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", map[string]string{bad: "go.tito.io"}); !errors.Is(err, ErrBadPathPrefix) { t.Fatalf("prefix %q: err = %v, want ErrBadPathPrefix", bad, err) } } } // TestAttachConvergePreservesPaths: a repoint must not strip the prefix map. func TestAttachConvergePreservesPaths(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) if _, _, err := AttachClaim(ctx, s, Claim{ Schema: 1, Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", }); err != nil { t.Fatal(err) } if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", map[string]string{"paulca": "go.tito.io"}); err != nil { t.Fatal(err) } if _, _, err := AttachClaim(ctx, s, Claim{ Schema: 1, Host: "ti.to", Account: "tito", App: "classic", Route: "ti-to-v2.tito.io", CreatedAt: "2026-09-03T00:00:00Z", }); err != nil { t.Fatal(err) } c, err := GetClaim(ctx, s, "ti.to") if err != nil { t.Fatal(err) } if c.Paths["paulca"] != "go.tito.io" { t.Fatalf("paths = %v, want the map preserved across a converge repoint", c.Paths) } }