| new file mode 100644 |
| index 000000000..d166176c2 |
| --- /dev/null |
| +++ b/internal/console/instancetoken_gate_test.go |
| @@ -0,0 +1,103 @@ |
| +package console |
| + |
| +import ( |
| + "net/http" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +// cliRoute's host check (spec 2026-10-16-instance-bound-tokens §4.1): a |
| +// Self token reaches only the {host} it is bound to, spelled exactly, and |
| +// every other host is the stranger's 404 byte for byte, with the handler |
| +// never run. |
| +func TestCLIRouteSelfHostCheck(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + raw := mintBoundToken(t, fx.st, OpsAccountID, "kass", "a.example.com", testBorn) |
| + hit := 0 |
| + fx.srv.cliRoute("GET /api/cli/test/{account}/{app}/instances/{host}/probe", svcrole.AllowSelf(svcrole.Set{}), |
| + func(w http.ResponseWriter, r *http.Request) { hit++; w.WriteHeader(http.StatusNoContent) }) |
| + want := canonicalNotFound(t) |
| + base := fx.ts.URL + "/api/cli/test/" + fx.sqid + "/kass/instances/" |
| + for _, host := range []string{"b.example.com", "A.example.com", "a.example.com.", "a.example.co", "a.example.com%2e", "nope"} { |
| + st, h, body := rawReq(t, http.MethodGet, base+host+"/probe", raw, "") |
| + assertNotFound(t, "self token on "+host, want, st, h, body) |
| + } |
| + if hit != 0 { |
| + t.Fatalf("handler ran %d times for hosts the token is not bound to", hit) |
| + } |
| + if st, _, body := rawReq(t, http.MethodGet, base+"a.example.com/probe", raw, ""); st != http.StatusNoContent { |
| + t.Fatalf("own host = %d %s", st, body) |
| + } |
| + // Any other caller is untouched by the check: an admin credential has |
| + // no binding, and a person never meets the role gate at all. |
| + admin := serviceToken(t, fx.st, OpsAccountID, "root", "", svcrole.Admin) |
| + if st, _, body := rawReq(t, http.MethodGet, base+"b.example.com/probe", admin, ""); st != http.StatusForbidden { |
| + t.Fatalf("admin at a Self-only door = %d %s, want the role 403", st, body) |
| + } |
| +} |
| + |
| +func TestCLIRoutePanicsOnSelfDoorWithoutHost(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + defer func() { |
| + if recover() == nil { |
| + t.Fatal("a Self door without {host} registered") |
| + } |
| + }() |
| + fx.srv.cliRoute("GET /api/cli/test/{account}/{app}/nohost", svcrole.AllowSelf(svcrole.Set{}), func(http.ResponseWriter, *http.Request) {}) |
| +} |
| + |
| +func TestSelfBindingRefusesAnotherIncarnation(t *testing.T) { |
| + b := selfBinding{Host: "a.example.com", Born: testBorn} |
| + same := &provision.Record{Host: "a.example.com", CreatedAt: time.Unix(0, testBorn)} |
| + reborn := &provision.Record{Host: "a.example.com", CreatedAt: time.Unix(0, testBorn+1)} |
| + other := &provision.Record{Host: "b.example.com", CreatedAt: time.Unix(0, testBorn)} |
| + if b.refuses(same) { |
| + t.Fatal("refused its own incarnation") |
| + } |
| + if !b.refuses(reborn) || !b.refuses(other) || !b.refuses(nil) { |
| + t.Fatal("accepted a replacement, a sibling or no record") |
| + } |
| + if (selfBinding{}).refuses(reborn) || (selfBinding{}).refuses(nil) { |
| + t.Fatal("the zero binding refused") |
| + } |
| + p := Principal{Instance: "a.example.com", InstanceBorn: testBorn} |
| + if bindingOf(p) != b { |
| + t.Fatalf("bindingOf = %+v", bindingOf(p)) |
| + } |
| +} |
| + |
| +// instanceGate's budget is per incarnation for an instance token (spec |
| +// §3.5): tokens for one incarnation share it, so minting more buys nothing, |
| +// while a sibling's and a replacement incarnation's stay their own. |
| +func TestInstanceLimiterIsPerIncarnation(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cli.instances = newRateLimiter(2, cliRateWindow) |
| + fx.srv.cliRoute("GET /api/cli/test/{account}/{app}/instances/{host}/limit", svcrole.AllowSelf(svcrole.Set{}), |
| + func(w http.ResponseWriter, r *http.Request) { |
| + if _, _, _, ok := fx.srv.instanceGate(w, r); ok { |
| + w.WriteHeader(http.StatusNoContent) |
| + } |
| + }) |
| + a1 := mintBoundToken(t, fx.st, OpsAccountID, "kass", "a.example.com", testBorn) |
| + a2 := mintBoundToken(t, fx.st, OpsAccountID, "kass", "a.example.com", testBorn) |
| + reborn := mintBoundToken(t, fx.st, OpsAccountID, "kass", "a.example.com", testBorn+1) |
| + b := mintBoundToken(t, fx.st, OpsAccountID, "kass", "b.example.com", testBorn) |
| + url := func(host string) string { return fx.ts.URL + "/api/cli/test/" + fx.sqid + "/kass/instances/" + host + "/limit" } |
| + for i, tok := range []string{a1, a1} { |
| + if st, _, body := rawReq(t, http.MethodGet, url("a.example.com"), tok, ""); st != http.StatusNoContent { |
| + t.Fatalf("request %d = %d %s", i, st, body) |
| + } |
| + } |
| + if st, _, _ := rawReq(t, http.MethodGet, url("a.example.com"), a2, ""); st != http.StatusTooManyRequests { |
| + t.Fatalf("a second token for the same incarnation got a budget of its own: %d", st) |
| + } |
| + if st, _, body := rawReq(t, http.MethodGet, url("a.example.com"), reborn, ""); st != http.StatusNoContent { |
| + t.Fatalf("the replacement incarnation was charged for the old one's requests: %d %s", st, body) |
| + } |
| + if st, _, body := rawReq(t, http.MethodGet, url("b.example.com"), b, ""); st != http.StatusNoContent { |
| + t.Fatalf("the sibling was charged: %d %s", st, body) |
| + } |
| +} |