| 1 | package console |
| 2 | |
| 3 | // The sidecar pause (sidecarsgate.go), through the real Server, its router |
| 4 | // and the cliRoute auth stack the rest of apisidecars_test.go uses. The |
| 5 | // properties, in the order they would hurt if lost: with the allowlist set, |
| 6 | // an account it does not name cannot enable or create a sidecar by any |
| 7 | // credential that reaches those doors, and nothing is written to the bucket |
| 8 | // when it is refused; a stranger still gets the byte-identical 404, so the |
| 9 | // gate never confirms an account exists; existing sidecars stay listable, |
| 10 | // wakeable and deletable; a named account is unaffected; and with the |
| 11 | // allowlist unset nothing changes at all. |
| 12 | |
| 13 | import ( |
| 14 | "context" |
| 15 | "encoding/json" |
| 16 | "net/http" |
| 17 | "slices" |
| 18 | "testing" |
| 19 | "time" |
| 20 | |
| 21 | "github.com/carlosframework/platform/internal/provision" |
| 22 | "github.com/carlosframework/platform/internal/release" |
| 23 | "github.com/carlosframework/platform/internal/svcrole" |
| 24 | ) |
| 25 | |
| 26 | // sidecarAppKeys is every bucket key under the fixture app's prefix, sorted: the |
| 27 | // refused calls must leave it exactly as it was. |
| 28 | func sidecarAppKeys(t *testing.T, fx *consoleFixture) []string { |
| 29 | t.Helper() |
| 30 | keys, err := fx.rel.List(context.Background(), release.AppPrefix("bab", "titogo")) |
| 31 | if err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | slices.Sort(keys) |
| 35 | return keys |
| 36 | } |
| 37 | |
| 38 | func assertSidecarsPaused(t *testing.T, what string, status int, body string) { |
| 39 | t.Helper() |
| 40 | if status != http.StatusForbidden { |
| 41 | t.Fatalf("%s = %d %s, want 403", what, status, body) |
| 42 | } |
| 43 | var got struct{ Error, Code string } |
| 44 | if err := json.Unmarshal([]byte(body), &got); err != nil { |
| 45 | t.Fatalf("%s body %q: %v", what, body, err) |
| 46 | } |
| 47 | if got.Code != "sidecars-paused" || got.Error != sidecarsPausedMsg { |
| 48 | t.Fatalf("%s body = %+v, want code sidecars-paused and the paused sentence", what, got) |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | // TestSidecarsPausedRefusesEnableAndCreate: the fixture's account (bab) is |
| 53 | // not on the list, so enable and create are refused for the member, the |
| 54 | // legacy app token and an instance-role service principal, and the bucket |
| 55 | // is untouched. |
| 56 | func TestSidecarsPausedRefusesEnableAndCreate(t *testing.T) { |
| 57 | fx, member := instanceFixture(t, "code-side-paused") |
| 58 | fx.srv.cfg.SidecarAccounts = []string{"cdef"} |
| 59 | u := sidecarsURL(fx, "bab", "titogo") |
| 60 | |
| 61 | // Enable with no manifest yet: refused, and no manifest appears. |
| 62 | before := sidecarAppKeys(t, fx) |
| 63 | status, _, out := rawReq(t, http.MethodPut, sidecarEnableURL(fx, "bab", "titogo"), member, `{"channel":"beta"}`) |
| 64 | assertSidecarsPaused(t, "paused enable, no manifest", status, out) |
| 65 | if after := sidecarAppKeys(t, fx); !slices.Equal(before, after) { |
| 66 | t.Fatalf("a refused enable changed the bucket: %v -> %v", before, after) |
| 67 | } |
| 68 | |
| 69 | // An operator-written manifest (store mode, not a console door) is |
| 70 | // there; enable still may not rewrite it. |
| 71 | enableSidecars(t, fx, "titogo", titoSidecarManifest()) |
| 72 | manifest, _, err := fx.rel.Get(context.Background(), provision.SidecarManifestKey("bab", "titogo")) |
| 73 | if err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | status, _, out = rawReq(t, http.MethodPut, sidecarEnableURL(fx, "bab", "titogo"), member, `{"channel":"beta","args":["evil"]}`) |
| 77 | assertSidecarsPaused(t, "paused enable over a manifest", status, out) |
| 78 | if now, _, err := fx.rel.Get(context.Background(), provision.SidecarManifestKey("bab", "titogo")); err != nil || string(now) != string(manifest) { |
| 79 | t.Fatalf("a refused enable rewrote the manifest: %s -> %s (%v)", manifest, now, err) |
| 80 | } |
| 81 | |
| 82 | // Create, by every credential the create door admits. |
| 83 | appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now()) |
| 84 | if err != nil { |
| 85 | t.Fatal(err) |
| 86 | } |
| 87 | svc := serviceToken(t, fx.st, OpsAccountID, "router", "titogo", svcrole.Instance) |
| 88 | before = sidecarAppKeys(t, fx) |
| 89 | for _, c := range []struct{ what, token string }{ |
| 90 | {"member create", member}, |
| 91 | {"app token create", appTok}, |
| 92 | {"instance service principal create", svc}, |
| 93 | } { |
| 94 | status, _, out := rawReq(t, http.MethodPost, u, c.token, `{"name":"bot","region":"ie"}`) |
| 95 | assertSidecarsPaused(t, c.what, status, out) |
| 96 | } |
| 97 | if after := sidecarAppKeys(t, fx); !slices.Equal(before, after) { |
| 98 | t.Fatalf("a refused create changed the bucket: %v -> %v", before, after) |
| 99 | } |
| 100 | if keys, err := fx.rel.List(context.Background(), provision.SidecarsPrefix("bab", "titogo")); err != nil || len(keys) != 0 { |
| 101 | t.Fatalf("refused creates left records %v (%v)", keys, err) |
| 102 | } |
| 103 | |
| 104 | // The gate comes after membership: a stranger and an unknown account |
| 105 | // still get the byte-identical 404, never the 403 that would confirm |
| 106 | // the account exists and is paused. |
| 107 | want := canonicalNotFound(t) |
| 108 | eve := cliToken(t, fx, "eve@keymail.dev", "code-side-paused-eve") |
| 109 | for _, c := range []struct{ what, method, url, token, body string }{ |
| 110 | {"non-member enable", http.MethodPut, sidecarEnableURL(fx, "bab", "titogo"), eve, `{}`}, |
| 111 | {"non-member create", http.MethodPost, u, eve, `{"name":"bot","region":"ie"}`}, |
| 112 | {"unknown account create", http.MethodPost, sidecarsURL(fx, "zzzz", "titogo"), member, `{"name":"bot","region":"ie"}`}, |
| 113 | {"unclaimed app create", http.MethodPost, sidecarsURL(fx, "bab", "ghost-app"), member, `{"name":"bot","region":"ie"}`}, |
| 114 | } { |
| 115 | status, h, out := rawReq(t, c.method, c.url, c.token, c.body) |
| 116 | assertNotFound(t, c.what, want, status, h, out) |
| 117 | } |
| 118 | // And validation still answers first for a member. |
| 119 | if status, _, out := rawReq(t, http.MethodPost, u, member, `{"name":"-nope-","region":"ie"}`); status != http.StatusBadRequest { |
| 120 | t.Fatalf("paused create with a bad name = %d %s, want the 400 first", status, out) |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | // TestSidecarsPausedLeavesExistingSidecarsAlone: a sidecar created before the |
| 125 | // pause is still listed, read, woken and deleted by the paused account, by |
| 126 | // the member and by the instance service principal. |
| 127 | func TestSidecarsPausedLeavesExistingSidecarsAlone(t *testing.T) { |
| 128 | fx, member := instanceFixture(t, "code-side-paused-existing") |
| 129 | enableSidecars(t, fx, "titogo", titoSidecarManifest()) |
| 130 | u := sidecarsURL(fx, "bab", "titogo") |
| 131 | for _, name := range []string{"bot", "bot2"} { |
| 132 | if status, _, out := rawReq(t, http.MethodPost, u, member, `{"name":"`+name+`","region":"ie"}`); status != http.StatusCreated { |
| 133 | t.Fatalf("create %s before the pause = %d %s", name, status, out) |
| 134 | } |
| 135 | } |
| 136 | fx.srv.cfg.SidecarAccounts = []string{"cdef"} |
| 137 | svc := serviceToken(t, fx.st, OpsAccountID, "router", "titogo", svcrole.Instance) |
| 138 | |
| 139 | for _, c := range []struct{ what, method, url string }{ |
| 140 | {"list", http.MethodGet, u}, |
| 141 | {"get", http.MethodGet, u + "/bot"}, |
| 142 | {"wake", http.MethodPost, u + "/bot/wake"}, |
| 143 | } { |
| 144 | if status, _, out := rawReq(t, c.method, c.url, member, ""); status != http.StatusOK { |
| 145 | t.Fatalf("paused %s = %d %s, want 200", c.what, status, out) |
| 146 | } |
| 147 | } |
| 148 | for _, c := range []struct{ name, token string }{{"bot", member}, {"bot2", svc}} { |
| 149 | if status, _, out := rawReq(t, http.MethodDelete, u+"/"+c.name, c.token, ""); status != http.StatusOK { |
| 150 | t.Fatalf("paused delete of %s = %d %s, want 200", c.name, status, out) |
| 151 | } |
| 152 | if _, _, err := fx.rel.Get(context.Background(), provision.SidecarRecordKey("bab", "titogo", c.name)); err == nil { |
| 153 | t.Fatalf("the record for %s survived its delete", c.name) |
| 154 | } |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | // TestSidecarsPauseAllowlistAndUnset: an account the list names, and a |
| 159 | // console with no list at all, enable and create exactly as before. |
| 160 | func TestSidecarsPauseAllowlistAndUnset(t *testing.T) { |
| 161 | for _, c := range []struct { |
| 162 | what string |
| 163 | accts []string |
| 164 | }{ |
| 165 | {"allowlisted", []string{"cdef", "bab"}}, |
| 166 | {"unset", nil}, |
| 167 | } { |
| 168 | t.Run(c.what, func(t *testing.T) { |
| 169 | fx, member := instanceFixture(t, "code-side-allow-"+c.what) |
| 170 | fx.srv.cfg.SidecarAccounts = c.accts |
| 171 | status, _, out := rawReq(t, http.MethodPut, sidecarEnableURL(fx, "bab", "titogo"), member, `{"channel":"stable"}`) |
| 172 | if status != http.StatusOK { |
| 173 | t.Fatalf("enable = %d %s, want 200", status, out) |
| 174 | } |
| 175 | // The pool and regions are operator policy the enable door never |
| 176 | // sets; put them as the operator would so create can place. |
| 177 | enableSidecars(t, fx, "titogo", titoSidecarManifest()) |
| 178 | u := sidecarsURL(fx, "bab", "titogo") |
| 179 | if status, _, out := rawReq(t, http.MethodPost, u, member, `{"name":"bot","region":"ie"}`); status != http.StatusCreated { |
| 180 | t.Fatalf("member create = %d %s, want 201", status, out) |
| 181 | } |
| 182 | svc := serviceToken(t, fx.st, OpsAccountID, "router", "titogo", svcrole.Instance) |
| 183 | if status, _, out := rawReq(t, http.MethodPost, u, svc, `{"name":"bot2","region":"ie"}`); status != http.StatusCreated { |
| 184 | t.Fatalf("service principal create = %d %s, want 201", status, out) |
| 185 | } |
| 186 | for _, name := range []string{"bot", "bot2"} { |
| 187 | if _, err := provision.GetSidecarRecord(context.Background(), fx.rel, "bab", "titogo", name); err != nil { |
| 188 | t.Fatalf("record %s: %v", name, err) |
| 189 | } |
| 190 | } |
| 191 | }) |
| 192 | } |
| 193 | } |
| 194 | |