| new file mode 100644 |
| index 000000000..7f0f90dd0 |
| --- /dev/null |
| +++ b/internal/console/sidecarsgate_test.go |
| @@ -0,0 +1,193 @@ |
| +package console |
| + |
| +// The sidecar pause (sidecarsgate.go), through the real Server, its router |
| +// and the cliRoute auth stack the rest of apisidecars_test.go uses. The |
| +// properties, in the order they would hurt if lost: with the allowlist set, |
| +// an account it does not name cannot enable or create a sidecar by any |
| +// credential that reaches those doors, and nothing is written to the bucket |
| +// when it is refused; a stranger still gets the byte-identical 404, so the |
| +// gate never confirms an account exists; existing sidecars stay listable, |
| +// wakeable and deletable; a named account is unaffected; and with the |
| +// allowlist unset nothing changes at all. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "slices" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +// sidecarAppKeys is every bucket key under the fixture app's prefix, sorted: the |
| +// refused calls must leave it exactly as it was. |
| +func sidecarAppKeys(t *testing.T, fx *consoleFixture) []string { |
| + t.Helper() |
| + keys, err := fx.rel.List(context.Background(), release.AppPrefix("bab", "titogo")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + slices.Sort(keys) |
| + return keys |
| +} |
| + |
| +func assertSidecarsPaused(t *testing.T, what string, status int, body string) { |
| + t.Helper() |
| + if status != http.StatusForbidden { |
| + t.Fatalf("%s = %d %s, want 403", what, status, body) |
| + } |
| + var got struct{ Error, Code string } |
| + if err := json.Unmarshal([]byte(body), &got); err != nil { |
| + t.Fatalf("%s body %q: %v", what, body, err) |
| + } |
| + if got.Code != "sidecars-paused" || got.Error != sidecarsPausedMsg { |
| + t.Fatalf("%s body = %+v, want code sidecars-paused and the paused sentence", what, got) |
| + } |
| +} |
| + |
| +// TestSidecarsPausedRefusesEnableAndCreate: the fixture's account (bab) is |
| +// not on the list, so enable and create are refused for the member, the |
| +// legacy app token and an instance-role service principal, and the bucket |
| +// is untouched. |
| +func TestSidecarsPausedRefusesEnableAndCreate(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-side-paused") |
| + fx.srv.cfg.SidecarAccounts = []string{"cdef"} |
| + u := sidecarsURL(fx, "bab", "titogo") |
| + |
| + // Enable with no manifest yet: refused, and no manifest appears. |
| + before := sidecarAppKeys(t, fx) |
| + status, _, out := rawReq(t, http.MethodPut, sidecarEnableURL(fx, "bab", "titogo"), member, `{"channel":"beta"}`) |
| + assertSidecarsPaused(t, "paused enable, no manifest", status, out) |
| + if after := sidecarAppKeys(t, fx); !slices.Equal(before, after) { |
| + t.Fatalf("a refused enable changed the bucket: %v -> %v", before, after) |
| + } |
| + |
| + // An operator-written manifest (store mode, not a console door) is |
| + // there; enable still may not rewrite it. |
| + enableSidecars(t, fx, "titogo", titoSidecarManifest()) |
| + manifest, _, err := fx.rel.Get(context.Background(), provision.SidecarManifestKey("bab", "titogo")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + status, _, out = rawReq(t, http.MethodPut, sidecarEnableURL(fx, "bab", "titogo"), member, `{"channel":"beta","args":["evil"]}`) |
| + assertSidecarsPaused(t, "paused enable over a manifest", status, out) |
| + if now, _, err := fx.rel.Get(context.Background(), provision.SidecarManifestKey("bab", "titogo")); err != nil || string(now) != string(manifest) { |
| + t.Fatalf("a refused enable rewrote the manifest: %s -> %s (%v)", manifest, now, err) |
| + } |
| + |
| + // Create, by every credential the create door admits. |
| + appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + svc := serviceToken(t, fx.st, OpsAccountID, "router", "titogo", svcrole.Instance) |
| + before = sidecarAppKeys(t, fx) |
| + for _, c := range []struct{ what, token string }{ |
| + {"member create", member}, |
| + {"app token create", appTok}, |
| + {"instance service principal create", svc}, |
| + } { |
| + status, _, out := rawReq(t, http.MethodPost, u, c.token, `{"name":"bot","region":"ie"}`) |
| + assertSidecarsPaused(t, c.what, status, out) |
| + } |
| + if after := sidecarAppKeys(t, fx); !slices.Equal(before, after) { |
| + t.Fatalf("a refused create changed the bucket: %v -> %v", before, after) |
| + } |
| + if keys, err := fx.rel.List(context.Background(), provision.SidecarsPrefix("bab", "titogo")); err != nil || len(keys) != 0 { |
| + t.Fatalf("refused creates left records %v (%v)", keys, err) |
| + } |
| + |
| + // The gate comes after membership: a stranger and an unknown account |
| + // still get the byte-identical 404, never the 403 that would confirm |
| + // the account exists and is paused. |
| + want := canonicalNotFound(t) |
| + eve := cliToken(t, fx, "eve@keymail.dev", "code-side-paused-eve") |
| + for _, c := range []struct{ what, method, url, token, body string }{ |
| + {"non-member enable", http.MethodPut, sidecarEnableURL(fx, "bab", "titogo"), eve, `{}`}, |
| + {"non-member create", http.MethodPost, u, eve, `{"name":"bot","region":"ie"}`}, |
| + {"unknown account create", http.MethodPost, sidecarsURL(fx, "zzzz", "titogo"), member, `{"name":"bot","region":"ie"}`}, |
| + {"unclaimed app create", http.MethodPost, sidecarsURL(fx, "bab", "ghost-app"), member, `{"name":"bot","region":"ie"}`}, |
| + } { |
| + status, h, out := rawReq(t, c.method, c.url, c.token, c.body) |
| + assertNotFound(t, c.what, want, status, h, out) |
| + } |
| + // And validation still answers first for a member. |
| + if status, _, out := rawReq(t, http.MethodPost, u, member, `{"name":"-nope-","region":"ie"}`); status != http.StatusBadRequest { |
| + t.Fatalf("paused create with a bad name = %d %s, want the 400 first", status, out) |
| + } |
| +} |
| + |
| +// TestSidecarsPausedLeavesExistingSidecarsAlone: a sidecar created before the |
| +// pause is still listed, read, woken and deleted by the paused account, by |
| +// the member and by the instance service principal. |
| +func TestSidecarsPausedLeavesExistingSidecarsAlone(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-side-paused-existing") |
| + enableSidecars(t, fx, "titogo", titoSidecarManifest()) |
| + u := sidecarsURL(fx, "bab", "titogo") |
| + for _, name := range []string{"bot", "bot2"} { |
| + if status, _, out := rawReq(t, http.MethodPost, u, member, `{"name":"`+name+`","region":"ie"}`); status != http.StatusCreated { |
| + t.Fatalf("create %s before the pause = %d %s", name, status, out) |
| + } |
| + } |
| + fx.srv.cfg.SidecarAccounts = []string{"cdef"} |
| + svc := serviceToken(t, fx.st, OpsAccountID, "router", "titogo", svcrole.Instance) |
| + |
| + for _, c := range []struct{ what, method, url string }{ |
| + {"list", http.MethodGet, u}, |
| + {"get", http.MethodGet, u + "/bot"}, |
| + {"wake", http.MethodPost, u + "/bot/wake"}, |
| + } { |
| + if status, _, out := rawReq(t, c.method, c.url, member, ""); status != http.StatusOK { |
| + t.Fatalf("paused %s = %d %s, want 200", c.what, status, out) |
| + } |
| + } |
| + for _, c := range []struct{ name, token string }{{"bot", member}, {"bot2", svc}} { |
| + if status, _, out := rawReq(t, http.MethodDelete, u+"/"+c.name, c.token, ""); status != http.StatusOK { |
| + t.Fatalf("paused delete of %s = %d %s, want 200", c.name, status, out) |
| + } |
| + if _, _, err := fx.rel.Get(context.Background(), provision.SidecarRecordKey("bab", "titogo", c.name)); err == nil { |
| + t.Fatalf("the record for %s survived its delete", c.name) |
| + } |
| + } |
| +} |
| + |
| +// TestSidecarsPauseAllowlistAndUnset: an account the list names, and a |
| +// console with no list at all, enable and create exactly as before. |
| +func TestSidecarsPauseAllowlistAndUnset(t *testing.T) { |
| + for _, c := range []struct { |
| + what string |
| + accts []string |
| + }{ |
| + {"allowlisted", []string{"cdef", "bab"}}, |
| + {"unset", nil}, |
| + } { |
| + t.Run(c.what, func(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-side-allow-"+c.what) |
| + fx.srv.cfg.SidecarAccounts = c.accts |
| + status, _, out := rawReq(t, http.MethodPut, sidecarEnableURL(fx, "bab", "titogo"), member, `{"channel":"stable"}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("enable = %d %s, want 200", status, out) |
| + } |
| + // The pool and regions are operator policy the enable door never |
| + // sets; put them as the operator would so create can place. |
| + enableSidecars(t, fx, "titogo", titoSidecarManifest()) |
| + u := sidecarsURL(fx, "bab", "titogo") |
| + if status, _, out := rawReq(t, http.MethodPost, u, member, `{"name":"bot","region":"ie"}`); status != http.StatusCreated { |
| + t.Fatalf("member create = %d %s, want 201", status, out) |
| + } |
| + svc := serviceToken(t, fx.st, OpsAccountID, "router", "titogo", svcrole.Instance) |
| + if status, _, out := rawReq(t, http.MethodPost, u, svc, `{"name":"bot2","region":"ie"}`); status != http.StatusCreated { |
| + t.Fatalf("service principal create = %d %s, want 201", status, out) |
| + } |
| + for _, name := range []string{"bot", "bot2"} { |
| + if _, err := provision.GetSidecarRecord(context.Background(), fx.rel, "bab", "titogo", name); err != nil { |
| + t.Fatalf("record %s: %v", name, err) |
| + } |
| + } |
| + }) |
| + } |
| +} |