| new file mode 100644 |
| index 000000000..62e24fa72 |
| --- /dev/null |
| +++ b/internal/console/instancetoken_doors_test.go |
| @@ -0,0 +1,258 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "regexp" |
| + "sort" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +// The six doors a Self principal may pass (spec |
| +// 2026-10-16-instance-bound-tokens §4.2), in that order: the record, its |
| +// PATCH and move status first, the three update settings doors after. |
| +var selfDoors = []string{ |
| + "GET /api/cli/apps/{account}/{app}/instances/{host}", |
| + "PATCH /api/cli/apps/{account}/{app}/instances/{host}", |
| + "GET /api/cli/apps/{account}/{app}/instances/{host}/move", |
| + "GET /api/cli/apps/{account}/{app}/instances/{host}/updates", |
| + "PUT /api/cli/apps/{account}/{app}/instances/{host}/updates", |
| + "POST /api/cli/apps/{account}/{app}/instances/{host}/updates/install", |
| +} |
| + |
| +// selfDoorsOpen is the part of selfDoors registered so far: the update |
| +// settings doors open together with their binding checks (plan Task 5). |
| +var selfDoorsOpen = selfDoors[:3] |
| + |
| +func TestSelfRoleDoors(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + want := map[string]bool{} |
| + for _, d := range selfDoorsOpen { |
| + want[d] = true |
| + } |
| + for pattern, set := range fx.srv.cliRoles { |
| + if set.Permits(svcrole.Self) != want[pattern] { |
| + t.Errorf("%s: admits Self = %v, want %v", pattern, set.Permits(svcrole.Self), want[pattern]) |
| + } |
| + } |
| + for _, d := range selfDoorsOpen { |
| + if _, ok := fx.srv.cliRoles[d]; !ok { |
| + t.Errorf("%s is not registered", d) |
| + } |
| + } |
| +} |
| + |
| +// selfWorld is two sibling records of one app and a Self token bound to the |
| +// first. The records are upstream-backed services, so a PATCH of the probe |
| +// is a valid change a member could make. |
| +type selfWorld struct { |
| + fx *consoleFixture |
| + member string |
| + tok string |
| + a, b provision.Record |
| +} |
| + |
| +var otherWildcard = regexp.MustCompile(`\{[a-z_]+(\.\.\.)?\}`) |
| + |
| +const ( |
| + selfHostA = "a.example.com" |
| + selfHostB = "b.example.com" |
| +) |
| + |
| +func newSelfWorld(t *testing.T) *selfWorld { |
| + t.Helper() |
| + fx, member := instanceFixture(t, "code-self-world") |
| + w := &selfWorld{fx: fx, member: member} |
| + w.a = w.create(t, selfHostA, "service") |
| + w.b = w.create(t, selfHostB, "service") |
| + w.tok = mintBoundToken(t, fx.st, OpsAccountID, "titogo", selfHostA, w.a.CreatedAt.UnixNano()) |
| + return w |
| +} |
| + |
| +func (w *selfWorld) create(t *testing.T, host, kind string) provision.Record { |
| + t.Helper() |
| + rec := provision.Record{Schema: 1, Host: host, Slug: strings.SplitN(host, ".", 2)[0], App: "titogo", |
| + Account: release.OpsAccount, Pool: "tito", Region: "ie", Channel: "stable", Kind: kind, |
| + CreatedAt: time.Now().UTC(), CreatedBy: "alice@keymail.dev"} |
| + if kind == "service" { |
| + rec.Addrs = []string{"10.0.0.1:8090", "10.0.0.2:8090"} |
| + } |
| + if err := provision.CreateRecord(context.Background(), w.fx.rel, rec); err != nil { |
| + t.Fatalf("CreateRecord(%s): %v", host, err) |
| + } |
| + got, err := provision.GetRecord(context.Background(), w.fx.rel, release.OpsAccount, "titogo", host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return *got |
| +} |
| + |
| +func (w *selfWorld) etag(t *testing.T, host string) string { |
| + t.Helper() |
| + _, etag, err := provision.GetRecordETag(context.Background(), w.fx.rel, release.OpsAccount, "titogo", host) |
| + if err != nil { |
| + t.Fatalf("GetRecordETag(%s): %v", host, err) |
| + } |
| + return etag |
| +} |
| + |
| +// fill turns a registered pattern into a request URL, wildcards filled with |
| +// things that exist where they can (the closed-door walk's replacer). |
| +func (w *selfWorld) fill(pattern, host string) (method, url string) { |
| + method, path, _ := strings.Cut(pattern, " ") |
| + path = strings.NewReplacer( |
| + "{account}", w.fx.sqid, |
| + "{app}", "titogo", |
| + "{fleet}", "edge", |
| + "{label}", "box1", |
| + "{name}", "hub", |
| + "{host}", host, |
| + "{id}", "deadbeefdeadbeef", |
| + "{level}", "0", |
| + "{span}", "1-1", |
| + "{version}", "v1", |
| + "{ledger}", "main", |
| + "{key}", "k", |
| + ).Replace(path) |
| + // Any other wildcard gets a plain segment: a Self token is refused at |
| + // the role gate before any handler reads one. |
| + path = otherWildcard.ReplaceAllString(path, "x") |
| + return method, w.fx.ts.URL + path |
| +} |
| + |
| +// TestSelfTokenContainment walks every CLI door. Outside the six, a Self |
| +// token is refused at the role gate, even for its own host. At the six, a |
| +// sibling or an unknown host is the stranger's 404, and neither record moves. |
| +func TestSelfTokenContainment(t *testing.T) { |
| + w := newSelfWorld(t) |
| + self := map[string]bool{} |
| + for _, d := range selfDoorsOpen { |
| + self[d] = true |
| + } |
| + patterns := make([]string, 0, len(w.fx.srv.cliRoles)) |
| + for p := range w.fx.srv.cliRoles { |
| + patterns = append(patterns, p) |
| + } |
| + sort.Strings(patterns) |
| + want := canonicalNotFound(t) |
| + beforeA, beforeB := w.etag(t, selfHostA), w.etag(t, selfHostB) |
| + for _, pattern := range patterns { |
| + if self[pattern] { |
| + for _, host := range []string{selfHostB, "c.example.com"} { |
| + method, url := w.fill(pattern, host) |
| + st, h, body := rawReq(t, method, url, w.tok, `{"probe":null}`) |
| + assertNotFound(t, pattern+" on "+host, want, st, h, body) |
| + } |
| + continue |
| + } |
| + method, url := w.fill(pattern, selfHostA) |
| + if strings.Contains(url, "{") { |
| + t.Fatalf("%s has a wildcard this test does not fill: %s", pattern, url) |
| + } |
| + body := "" |
| + if method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch { |
| + body = `{}` |
| + } |
| + if st, _, out := rawReq(t, method, url, w.tok, body); st != http.StatusForbidden { |
| + t.Errorf("%s with a Self token = %d %s, want the role 403", pattern, st, out) |
| + } |
| + } |
| + if w.etag(t, selfHostA) != beforeA || w.etag(t, selfHostB) != beforeB { |
| + t.Fatal("a refused request changed a record") |
| + } |
| +} |
| + |
| +func TestSelfTokenReadsAndPatchesItself(t *testing.T) { |
| + w := newSelfWorld(t) |
| + base := instancesURL(w.fx, w.fx.sqid, "titogo") + "/" + selfHostA |
| + st, _, body := rawReq(t, http.MethodGet, base, w.tok, "") |
| + if st != http.StatusOK || !strings.Contains(body, `"host":"`+selfHostA+`"`) { |
| + t.Fatalf("GET own record = %d %s", st, body) |
| + } |
| + if st, _, body := rawReq(t, http.MethodPatch, base, w.tok, `{"probe":{"path":"/healthz","status":200}}`); st != http.StatusOK { |
| + t.Fatalf("PATCH own probe = %d %s", st, body) |
| + } |
| + got, err := provision.GetRecord(context.Background(), w.fx.rel, release.OpsAccount, "titogo", selfHostA) |
| + if err != nil || got.Health == nil || got.Health.Path != "/healthz" { |
| + t.Fatalf("probe not written: %+v, %v", got, err) |
| + } |
| + // Its move status answers what a member's does. |
| + mst, _, _ := rawReq(t, http.MethodGet, base+"/move", w.member, "") |
| + if st, _, body := rawReq(t, http.MethodGet, base+"/move", w.tok, ""); st != mst { |
| + t.Fatalf("move status = %d %s, a member gets %d", st, body, mst) |
| + } |
| +} |
| + |
| +// PATCH from Self takes addrs and probe only (spec §4.4.1): environment |
| +// alone would bind the instance to a sibling's bundle and its secrets. |
| +func TestSelfPatchRefusesOtherFields(t *testing.T) { |
| + w := newSelfWorld(t) |
| + base := instancesURL(w.fx, w.fx.sqid, "titogo") + "/" + selfHostA |
| + before := w.etag(t, selfHostA) |
| + for _, body := range []string{ |
| + `{"environment":"other"}`, |
| + `{"environment":""}`, |
| + `{"steering":"latency"}`, |
| + `{"gate":"correct-horse-9"}`, |
| + `{"probe":null,"environment":"other"}`, |
| + } { |
| + st, _, out := rawReq(t, http.MethodPatch, base, w.tok, body) |
| + if st != http.StatusForbidden || !strings.Contains(out, selfPatchFieldsRefused) { |
| + t.Errorf("PATCH %s = %d %s, want 403 with the Self refusal", body, st, out) |
| + } |
| + } |
| + if w.etag(t, selfHostA) != before { |
| + t.Fatal("a refused PATCH changed the record") |
| + } |
| + // A member may still send them: the refusal is the Self principal's. |
| + if st, _, out := rawReq(t, http.MethodPatch, base, w.member, `{"steering":""}`); st != http.StatusOK { |
| + t.Fatalf("member PATCH steering = %d %s", st, out) |
| + } |
| +} |
| + |
| +// A token is bound to one incarnation (spec §3.4): delete the record and |
| +// create the host again, and the old token is the stranger's 404 on every |
| +// door, including move status for a re-created route of another kind, |
| +// whose kind refusal would otherwise describe the replacement. |
| +func TestSelfTokenDiesWithItsIncarnation(t *testing.T) { |
| + w := newSelfWorld(t) |
| + ctx := context.Background() |
| + if err := provision.DeleteRecord(ctx, w.fx.rel, release.OpsAccount, "titogo", selfHostA); err != nil { |
| + t.Fatal(err) |
| + } |
| + time.Sleep(time.Millisecond) |
| + w.create(t, selfHostA, "static") |
| + want := canonicalNotFound(t) |
| + base := instancesURL(w.fx, w.fx.sqid, "titogo") + "/" + selfHostA |
| + for _, c := range []struct{ method, url, body string }{ |
| + {http.MethodGet, base, ""}, |
| + {http.MethodPatch, base, `{"probe":null}`}, |
| + {http.MethodGet, base + "/move", ""}, |
| + } { |
| + st, h, body := rawReq(t, c.method, c.url, w.tok, c.body) |
| + assertNotFound(t, c.method+" "+c.url+" after re-creation", want, st, h, body) |
| + } |
| + // The replacement's own member access is untouched. |
| + if st, _, body := rawReq(t, http.MethodGet, base, w.member, ""); st != http.StatusOK { |
| + t.Fatalf("member GET of the replacement = %d %s", st, body) |
| + } |
| +} |
| + |
| +func TestAppWideCredentialsStillRefusedAtUpdateDoors(t *testing.T) { |
| + w := newSelfWorld(t) |
| + for _, role := range []svcrole.Role{svcrole.Admin, svcrole.Instance} { |
| + tok := serviceToken(t, w.fx.st, OpsAccountID, "c-"+string(role), "titogo", role) |
| + for _, d := range selfDoors[3:] { |
| + method, url := w.fill(d, selfHostA) |
| + if st, _, body := rawReq(t, method, url, tok, `{}`); st != http.StatusForbidden { |
| + t.Errorf("%s as %s = %d %s, want 403", d, role, st, body) |
| + } |
| + } |
| + } |
| +} |