| 1 | package console |
| 2 | |
| 3 | // A run service at the run doors (service runs spec §§ 1, 2, 5): it starts |
| 4 | // and follows its own runs of the commands it was granted, and nothing else. |
| 5 | |
| 6 | import ( |
| 7 | "context" |
| 8 | "encoding/json" |
| 9 | "fmt" |
| 10 | "go/ast" |
| 11 | "go/parser" |
| 12 | "go/token" |
| 13 | "net/http" |
| 14 | "os" |
| 15 | "slices" |
| 16 | "strings" |
| 17 | "sync" |
| 18 | "testing" |
| 19 | "time" |
| 20 | |
| 21 | "github.com/carlosframework/platform/internal/hostclaim" |
| 22 | "github.com/carlosframework/platform/internal/provision" |
| 23 | "github.com/carlosframework/platform/internal/release" |
| 24 | "github.com/carlosframework/platform/internal/runs" |
| 25 | "github.com/carlosframework/platform/internal/seeds" |
| 26 | "github.com/carlosframework/platform/internal/svcrole" |
| 27 | ) |
| 28 | |
| 29 | const runServiceFor = "paul@example.com" |
| 30 | |
| 31 | // runServiceToken mints a run credential for app in the ops account. The |
| 32 | // generic serviceToken cannot: a run credential needs its commands. |
| 33 | func runServiceToken(t *testing.T, st *Store, name, app string, commands [][]string) string { |
| 34 | t.Helper() |
| 35 | raw, err := st.NewServicePrincipalScoped(OpsAccountID, name, app, svcrole.Run, nil, commands, "paul@keymail.dev", time.Now()) |
| 36 | if err != nil { |
| 37 | t.Fatalf("mint run %s: %v", name, err) |
| 38 | } |
| 39 | return raw |
| 40 | } |
| 41 | |
| 42 | // serviceMeta is a granted command, said to be for runServiceFor. |
| 43 | func serviceMeta() runs.Meta { |
| 44 | return runs.Meta{Argv: []string{"import", "classic", "--slug", "x"}, TimeoutS: 60, OnBehalfOf: runServiceFor} |
| 45 | } |
| 46 | |
| 47 | func codeOf(t *testing.T, body string) string { |
| 48 | t.Helper() |
| 49 | var e struct { |
| 50 | Code *string `json:"code"` |
| 51 | } |
| 52 | if err := json.Unmarshal([]byte(body), &e); err != nil { |
| 53 | t.Fatalf("body %q: %v", body, err) |
| 54 | } |
| 55 | if e.Code == nil { |
| 56 | return "" |
| 57 | } |
| 58 | return *e.Code |
| 59 | } |
| 60 | |
| 61 | // startAs admits one run for bearer and returns its id. |
| 62 | func (fx *runFx) startAs(t *testing.T, bearer string, meta runs.Meta) string { |
| 63 | t.Helper() |
| 64 | status, body := postRun(t, fx.runsURL(runTestHost), bearer, meta, nil) |
| 65 | if status != http.StatusAccepted { |
| 66 | t.Fatalf("create: %d %s", status, body) |
| 67 | } |
| 68 | var c runs.Created |
| 69 | if err := json.Unmarshal([]byte(body), &c); err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | return c.ID |
| 73 | } |
| 74 | |
| 75 | // followAs sends one follow verb ("" is the GET) on id as bearer. |
| 76 | func (fx *runFx) followAs(t *testing.T, bearer, verb, id string) (int, string) { |
| 77 | t.Helper() |
| 78 | u := fx.runsURL(runTestHost) + "/" + id |
| 79 | if verb == "" { |
| 80 | status, _, body := rawReq(t, http.MethodGet, u, bearer, "") |
| 81 | return status, body |
| 82 | } |
| 83 | status, _, body := rawReq(t, http.MethodPost, u+"/"+verb, bearer, `{"last_seq":0}`) |
| 84 | return status, body |
| 85 | } |
| 86 | |
| 87 | // unknownAnswer is what bearer is told for verb on a run id that never |
| 88 | // existed, rewritten to name id: the answer a run bearer may not see must be |
| 89 | // indistinguishable from it. |
| 90 | func (fx *runFx) unknownAnswer(t *testing.T, bearer, verb, id string) (int, string) { |
| 91 | t.Helper() |
| 92 | never := "run_zzzzzzzzzzzzzzzzzzzzzzzzzz" |
| 93 | status, body := fx.followAs(t, bearer, verb, never) |
| 94 | return status, strings.ReplaceAll(body, never, id) |
| 95 | } |
| 96 | |
| 97 | func (fx *runFx) noRunObjects(t *testing.T, what string) { |
| 98 | t.Helper() |
| 99 | if left, _ := fx.rel.List(context.Background(), runs.RunsPrefix(release.OpsAccount, "shop")); len(left) != 0 { |
| 100 | t.Errorf("%s left run objects %q", what, left) |
| 101 | } |
| 102 | if rec, _ := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount, "shop", runTestHost); rec != nil && rec.ActiveRun != nil { |
| 103 | t.Errorf("%s admitted a run: %+v", what, rec.ActiveRun) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | // runDoorURLs is the five run doors, filled for runTestHost and id. |
| 108 | func (fx *runFx) runDoorURLs(id string) []struct{ method, u, body string } { |
| 109 | base := fx.runsURL(runTestHost) |
| 110 | return []struct{ method, u, body string }{ |
| 111 | {http.MethodPost, base, ""}, |
| 112 | {http.MethodGet, base + "/" + id, ""}, |
| 113 | {http.MethodPost, base + "/" + id + "/cancel", `{}`}, |
| 114 | {http.MethodPost, base + "/" + id + "/expire", `{}`}, |
| 115 | {http.MethodPost, base + "/" + id + "/ack", `{"last_seq":0}`}, |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | // TestRunRoleMatrix: the run doors take a run service and refuse every other |
| 120 | // role, Admin included, with the role gate's own 403, which names the role |
| 121 | // that opens them. |
| 122 | func TestRunRoleMatrix(t *testing.T) { |
| 123 | fx := runFixture(t) |
| 124 | runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| 125 | id := "run_aaaaaaaaaaaaaaaaaaaaaaaaaa" |
| 126 | for _, d := range fx.runDoorURLs(id) { |
| 127 | status, _, body := rawReq(t, d.method, d.u, runner, d.body) |
| 128 | if status == http.StatusForbidden || status == http.StatusUnauthorized { |
| 129 | t.Errorf("run service at %s %s = %d %s, want past the role gate", d.method, d.u, status, body) |
| 130 | } |
| 131 | } |
| 132 | for _, r := range svcrole.All() { |
| 133 | if r == svcrole.Run { |
| 134 | continue |
| 135 | } |
| 136 | app, rungs := "shop", []string(nil) |
| 137 | if r == svcrole.Admin { |
| 138 | app = "" // the widest Admin there is |
| 139 | } |
| 140 | if r == svcrole.Deploy { |
| 141 | rungs = []string{"edge"} |
| 142 | } |
| 143 | tok, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "role-"+string(r), app, r, rungs, nil, "paul@keymail.dev", time.Now()) |
| 144 | if err != nil { |
| 145 | t.Fatalf("mint %s: %v", r, err) |
| 146 | } |
| 147 | raw, _ := json.Marshal(map[string]string{"error": fmt.Sprintf("this credential holds role %q; this door needs one of [run]", r)}) |
| 148 | want := string(raw) + "\n" |
| 149 | for _, d := range fx.runDoorURLs(id) { |
| 150 | status, _, body := rawReq(t, d.method, d.u, tok, d.body) |
| 151 | if status != http.StatusForbidden || body != want { |
| 152 | t.Errorf("role %s at %s %s = %d %q, want 403 %q", r, d.method, d.u, status, body, want) |
| 153 | } |
| 154 | } |
| 155 | } |
| 156 | fx.noRunObjects(t, "the role matrix") |
| 157 | } |
| 158 | |
| 159 | func TestRunServiceStartsAGrantedCommand(t *testing.T) { |
| 160 | fx := runFixture(t) |
| 161 | runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| 162 | status, body := postRun(t, fx.runsURL(runTestHost), runner, serviceMeta(), nil) |
| 163 | if status != http.StatusAccepted { |
| 164 | t.Fatalf("create: %d %s", status, body) |
| 165 | } |
| 166 | var c runs.Created |
| 167 | if err := json.Unmarshal([]byte(body), &c); err != nil || !runs.ValidID(c.ID) || c.State != provision.RunQueued { |
| 168 | t.Fatalf("created %+v, %v", c, err) |
| 169 | } |
| 170 | ctx := context.Background() |
| 171 | req, err := runs.ReadRequest(ctx, fx.rel, release.OpsAccount, "shop", c.ID) |
| 172 | if err != nil { |
| 173 | t.Fatal(err) |
| 174 | } |
| 175 | p, ok := fx.st.CLIPrincipal(runner, time.Now()) |
| 176 | if !ok { |
| 177 | t.Fatal("the run credential does not resolve") |
| 178 | } |
| 179 | if req.ByService != "ci" || req.ByCredential != p.CredentialID || req.ByCredential == "" || req.OnBehalfOf != runServiceFor { |
| 180 | t.Fatalf("request by_service=%q by_credential=%q on_behalf_of=%q, want ci, %q, %s", req.ByService, req.ByCredential, req.OnBehalfOf, p.CredentialID, runServiceFor) |
| 181 | } |
| 182 | if want := "service:" + release.OpsAccount + "/ci"; req.By != want { |
| 183 | t.Errorf("request by = %q, want the audit actor %q", req.By, want) |
| 184 | } |
| 185 | rec, err := provision.GetRecord(ctx, fx.rel, release.OpsAccount, "shop", runTestHost) |
| 186 | if err != nil || rec.ActiveRun == nil || rec.ActiveRun.ID != c.ID { |
| 187 | t.Fatalf("record run %+v, %v", rec.ActiveRun, err) |
| 188 | } |
| 189 | // It follows its own run. |
| 190 | if p := fx.pageAs(t, runner, c.ID); p.ID != c.ID || p.State != provision.RunQueued { |
| 191 | t.Fatalf("own page %+v", p) |
| 192 | } |
| 193 | // A scheduled run names nobody. |
| 194 | fx.transition(t, provision.RunExpire(c.ID)) |
| 195 | meta := serviceMeta() |
| 196 | meta.OnBehalfOf = "" |
| 197 | id := fx.startAs(t, runner, meta) |
| 198 | if req, err := runs.ReadRequest(ctx, fx.rel, release.OpsAccount, "shop", id); err != nil || req.OnBehalfOf != "" || req.ByService != "ci" { |
| 199 | t.Fatalf("scheduled request %+v, %v", req, err) |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | func (fx *runFx) pageAs(t *testing.T, bearer, id string) runs.Page { |
| 204 | t.Helper() |
| 205 | status, body := fx.followAs(t, bearer, "", id) |
| 206 | if status != http.StatusOK { |
| 207 | t.Fatalf("GET page: %d %s", status, body) |
| 208 | } |
| 209 | var p runs.Page |
| 210 | if err := json.Unmarshal([]byte(body), &p); err != nil { |
| 211 | t.Fatal(err) |
| 212 | } |
| 213 | return p |
| 214 | } |
| 215 | |
| 216 | func TestRunServiceRefusedAnUngrantedCommand(t *testing.T) { |
| 217 | fx := runFixture(t) |
| 218 | runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| 219 | for _, argv := range [][]string{ |
| 220 | {"db", "shell"}, |
| 221 | {"import"}, // a missing word |
| 222 | {"import", "classicx"}, // a substring of a word |
| 223 | {"Import", "classic"}, // no case folding |
| 224 | {"sync", "import", "classic"}, |
| 225 | } { |
| 226 | meta := serviceMeta() |
| 227 | meta.Argv = argv |
| 228 | status, body := postRun(t, fx.runsURL(runTestHost), runner, meta, nil) |
| 229 | if status != http.StatusForbidden || codeOf(t, body) != "command_not_granted" { |
| 230 | t.Errorf("%q: %d %s, want 403 command_not_granted", argv, status, body) |
| 231 | } |
| 232 | } |
| 233 | fx.noRunObjects(t, "an ungranted command") |
| 234 | // A person runs any command. |
| 235 | meta := okMeta() |
| 236 | meta.Argv = []string{"db", "shell"} |
| 237 | if status, body := postRun(t, fx.runsURL(runTestHost), fx.member, meta, nil); status != http.StatusAccepted { |
| 238 | t.Fatalf("a person's db shell: %d %s", status, body) |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | func TestRunServiceWrongAppRefused(t *testing.T) { |
| 243 | fx := runFixture(t) |
| 244 | if _, err := fx.st.CreateAppIn(OpsAccountID, "other", "paul@keymail.dev", time.Now()); err != nil { |
| 245 | t.Fatal(err) |
| 246 | } |
| 247 | other := runServiceToken(t, fx.st, "ci", "other", importClassic) |
| 248 | want := canonicalNotFound(t) |
| 249 | status, h, body := postRunH(t, fx.runsURL(runTestHost), other, serviceMeta(), nil) |
| 250 | assertNotFound(t, "another app's run service at create", want, status, h, body) |
| 251 | for _, d := range fx.runDoorURLs("run_aaaaaaaaaaaaaaaaaaaaaaaaaa")[1:] { |
| 252 | status, h, body := rawReq(t, d.method, d.u, other, d.body) |
| 253 | assertNotFound(t, "another app's run service at "+d.method+" "+d.u, want, status, h, body) |
| 254 | } |
| 255 | fx.noRunObjects(t, "another app's run service") |
| 256 | } |
| 257 | |
| 258 | func TestRunRevokedServiceRefused(t *testing.T) { |
| 259 | fx := runFixture(t) |
| 260 | runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| 261 | id := fx.startAs(t, runner, serviceMeta()) |
| 262 | if err := fx.st.RevokeServicePrincipal(OpsAccountID, "ci", time.Now()); err != nil { |
| 263 | t.Fatal(err) |
| 264 | } |
| 265 | // The 401 a fabricated token gets, byte for byte. |
| 266 | _, _, want := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps", "not-a-token", "") |
| 267 | if status, got := postRun(t, fx.runsURL(runTestHost), runner, serviceMeta(), nil); status != http.StatusUnauthorized || got != want { |
| 268 | t.Errorf("revoked at create = %d %q, want 401 %q", status, got, want) |
| 269 | } |
| 270 | for _, d := range fx.runDoorURLs(id)[1:] { |
| 271 | status, _, got := rawReq(t, d.method, d.u, runner, d.body) |
| 272 | if status != http.StatusUnauthorized || got != want { |
| 273 | t.Errorf("revoked at %s %s = %d %q, want 401 %q", d.method, d.u, status, got, want) |
| 274 | } |
| 275 | } |
| 276 | } |
| 277 | |
| 278 | func TestRunServiceCannotSeeAPersonsRun(t *testing.T) { |
| 279 | fx := runFixture(t) |
| 280 | runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| 281 | id := fx.startRun(t) |
| 282 | for _, verb := range []string{"", "cancel", "expire", "ack"} { |
| 283 | wantStatus, want := fx.unknownAnswer(t, runner, verb, id) |
| 284 | status, body := fx.followAs(t, runner, verb, id) |
| 285 | if status != wantStatus || body != want || status != http.StatusNotFound { |
| 286 | t.Errorf("service %q on a person's run = %d %q, want the unknown id's %d %q", verb, status, body, wantStatus, want) |
| 287 | } |
| 288 | } |
| 289 | // Nothing the service sent touched the person's run. |
| 290 | if rec, _ := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount, "shop", runTestHost); rec.ActiveRun == nil || rec.ActiveRun.ID != id || rec.ActiveRun.State != provision.RunQueued { |
| 291 | t.Fatalf("record %+v", rec.ActiveRun) |
| 292 | } |
| 293 | // The member still sees it, and sees a service's run too. |
| 294 | if p := fx.page(t, id, 0); p.ID != id { |
| 295 | t.Fatalf("member page %+v", p) |
| 296 | } |
| 297 | fx.transition(t, provision.RunExpire(id)) |
| 298 | sid := fx.startAs(t, runner, serviceMeta()) |
| 299 | if p := fx.page(t, sid, 0); p.ID != sid || p.State != provision.RunQueued { |
| 300 | t.Fatalf("member's page of the service's run %+v", p) |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | func TestRunRemintedNameDoesNotInheritRuns(t *testing.T) { |
| 305 | fx := runFixture(t) |
| 306 | first := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| 307 | id := fx.startAs(t, first, serviceMeta()) |
| 308 | if err := fx.st.RevokeServicePrincipal(OpsAccountID, "ci", time.Now()); err != nil { |
| 309 | t.Fatal(err) |
| 310 | } |
| 311 | second := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| 312 | for _, verb := range []string{"", "cancel", "expire", "ack"} { |
| 313 | wantStatus, want := fx.unknownAnswer(t, second, verb, id) |
| 314 | status, body := fx.followAs(t, second, verb, id) |
| 315 | if status != wantStatus || body != want || status != http.StatusNotFound { |
| 316 | t.Errorf("the reminted name's %q = %d %q, want the unknown id's %d %q", verb, status, body, wantStatus, want) |
| 317 | } |
| 318 | } |
| 319 | // Rotation keeps the credential, and so its runs. |
| 320 | third := runServiceToken(t, fx.st, "rotated", "shop", importClassic) |
| 321 | fx.transition(t, provision.RunExpire(id)) |
| 322 | rid := fx.startAs(t, third, serviceMeta()) |
| 323 | rotated, err := fx.st.RotateServicePrincipal(OpsAccountID, "rotated", time.Now()) |
| 324 | if err != nil { |
| 325 | t.Fatal(err) |
| 326 | } |
| 327 | if p := fx.pageAs(t, rotated, rid); p.ID != rid { |
| 328 | t.Fatalf("the rotated credential's own run %+v", p) |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | func TestRunOpenRefusalRedactedForServices(t *testing.T) { |
| 333 | fx := runFixture(t) |
| 334 | runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| 335 | id := fx.startRun(t) |
| 336 | status, body := postRun(t, fx.runsURL(runTestHost), runner, serviceMeta(), nil) |
| 337 | if status != http.StatusConflict || codeOf(t, body) != "run_open" { |
| 338 | t.Fatalf("service create over a person's run: %d %s", status, body) |
| 339 | } |
| 340 | for _, leak := range []string{id, "alice@keymail.dev", "alice"} { |
| 341 | if strings.Contains(body, leak) { |
| 342 | t.Errorf("redacted refusal %s carries %q", body, leak) |
| 343 | } |
| 344 | } |
| 345 | // Its own open run, it is told about. |
| 346 | fx.transition(t, provision.RunExpire(id)) |
| 347 | own := fx.startAs(t, runner, serviceMeta()) |
| 348 | status, body = postRun(t, fx.runsURL(runTestHost), runner, serviceMeta(), nil) |
| 349 | if status != http.StatusConflict || codeOf(t, body) != "run_open" || !strings.Contains(errorOf(t, body), own) { |
| 350 | t.Fatalf("service create over its own run: %d %s, want run_open naming %s", status, body, own) |
| 351 | } |
| 352 | // Another service's run is redacted too. |
| 353 | other := runServiceToken(t, fx.st, "ci2", "shop", importClassic) |
| 354 | status, body = postRun(t, fx.runsURL(runTestHost), other, serviceMeta(), nil) |
| 355 | if status != http.StatusConflict || codeOf(t, body) != "run_open" || strings.Contains(body, own) || strings.Contains(body, "service:") { |
| 356 | t.Fatalf("service create over another service's run: %d %s", status, body) |
| 357 | } |
| 358 | // The member is told whose it is, in the house line: the service, and |
| 359 | // the person only as the service's statement. |
| 360 | status, body = postRun(t, fx.runsURL(runTestHost), fx.member, okMeta(), nil) |
| 361 | if want := runTestHost + " already has a run open: " + own + ", started by service ci for " + runServiceFor + " (as stated by the service)"; status != http.StatusConflict || errorOf(t, body) != want { |
| 362 | t.Fatalf("member over a service's run: %d %s, want %q", status, body, want) |
| 363 | } |
| 364 | } |
| 365 | |
| 366 | // The admission's own refusal is redacted the same way: the rival arrives |
| 367 | // after the door's checks passed. |
| 368 | func TestRunOpenRefusalAtAdmissionRedactedForServices(t *testing.T) { |
| 369 | fx := runFixture(t) |
| 370 | runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| 371 | fx.srv.cfg.Releases = &rivalStore{Store: fx.rel, t: t} |
| 372 | status, body := postRun(t, fx.runsURL(runTestHost), runner, serviceMeta(), nil) |
| 373 | if status != http.StatusConflict || codeOf(t, body) != "run_open" || strings.Contains(body, "run_aaaaaaaaaaaaaaaaaaaaaaaaaa") || strings.Contains(body, "bob@keymail.dev") { |
| 374 | t.Fatalf("%d %s", status, body) |
| 375 | } |
| 376 | } |
| 377 | |
| 378 | func TestRunPersonMaySetNoOnBehalfOf(t *testing.T) { |
| 379 | fx := runFixture(t) |
| 380 | meta := okMeta() |
| 381 | meta.OnBehalfOf = "bob@keymail.dev" |
| 382 | status, body := postRun(t, fx.runsURL(runTestHost), fx.member, meta, nil) |
| 383 | if status != http.StatusBadRequest || codeOf(t, body) != "on_behalf_of_invalid" { |
| 384 | t.Fatalf("%d %s, want 400 on_behalf_of_invalid", status, body) |
| 385 | } |
| 386 | fx.noRunObjects(t, "a person's on_behalf_of") |
| 387 | } |
| 388 | |
| 389 | func TestRunOnBehalfOfShape(t *testing.T) { |
| 390 | fx := runFixture(t) |
| 391 | runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| 392 | long := strings.Repeat("a", 255-len("@example.com")) + "@example.com" |
| 393 | if len(long) != 255 { |
| 394 | t.Fatalf("long is %d bytes", len(long)) |
| 395 | } |
| 396 | for _, v := range []string{"paul", "paul@", "@example.com", "Paul <paul@example.com>", "paul@example.com, bob@example.com", " paul@example.com", "paul @example.com", "paul@example.com\n", long} { |
| 397 | meta := serviceMeta() |
| 398 | meta.OnBehalfOf = v |
| 399 | status, body := postRun(t, fx.runsURL(runTestHost), runner, meta, nil) |
| 400 | if status != http.StatusBadRequest || codeOf(t, body) != "on_behalf_of_invalid" { |
| 401 | t.Errorf("%q: %d %s, want 400 on_behalf_of_invalid", v, status, body) |
| 402 | } |
| 403 | } |
| 404 | fx.noRunObjects(t, "a malformed on_behalf_of") |
| 405 | // 254 bytes is the most there may be. Membership is never asked. |
| 406 | meta := serviceMeta() |
| 407 | meta.OnBehalfOf = long[1:] |
| 408 | if status, body := postRun(t, fx.runsURL(runTestHost), runner, meta, nil); status != http.StatusAccepted { |
| 409 | t.Fatalf("254 bytes: %d %s", status, body) |
| 410 | } |
| 411 | } |
| 412 | |
| 413 | // TestRunRefusalsCarryCodes: every refusal a run handler writes has a code |
| 414 | // in runRefusalCodes; the shared gates before the handler do not, and keep |
| 415 | // their bodies as every other door gives them. |
| 416 | func TestRunRefusalsCarryCodes(t *testing.T) { |
| 417 | fx := runFixture(t) |
| 418 | ctx := context.Background() |
| 419 | runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| 420 | known := map[string]bool{} |
| 421 | for _, c := range runRefusalCodes { |
| 422 | if known[c] { |
| 423 | t.Errorf("runRefusalCodes names %s twice", c) |
| 424 | } |
| 425 | known[c] = true |
| 426 | } |
| 427 | seen := map[string]bool{} |
| 428 | check := func(what string, status int, body string) { |
| 429 | t.Helper() |
| 430 | if status < 400 { |
| 431 | t.Errorf("%s: %d %s, want a refusal", what, status, body) |
| 432 | return |
| 433 | } |
| 434 | code := codeOf(t, body) |
| 435 | if !known[code] || errorOf(t, body) == "" { |
| 436 | t.Errorf("%s: %d %s, want an error and a code from runRefusalCodes", what, status, body) |
| 437 | } |
| 438 | seen[code] = true |
| 439 | } |
| 440 | checkF := func(what string, f func() (int, string)) { |
| 441 | t.Helper() |
| 442 | status, body := f() |
| 443 | check(what, status, body) |
| 444 | } |
| 445 | post := func(what, host, bearer string, meta runs.Meta) { |
| 446 | t.Helper() |
| 447 | status, body := postRun(t, fx.runsURL(host), bearer, meta, nil) |
| 448 | check(what, status, body) |
| 449 | } |
| 450 | post("no record", "nobody.example.com", fx.member, okMeta()) |
| 451 | post("bad host", "-bad-", fx.member, okMeta()) |
| 452 | putShopRecord(t, fx.consoleFixture, provision.Record{Host: "static.example.com", Kind: "static"}) |
| 453 | post("class", "static.example.com", fx.member, okMeta()) |
| 454 | putShopRecord(t, fx.consoleFixture, provision.Record{Host: "import.example.com", Seed: &provision.SeedSource{Kind: seeds.KindImport, State: seeds.StatePlaced}}) |
| 455 | post("import", "import.example.com", fx.member, okMeta()) |
| 456 | post("meta", runTestHost, fx.member, runs.Meta{TimeoutS: 60}) |
| 457 | post("files", runTestHost, fx.member, runs.Meta{Argv: []string{"a"}, TimeoutS: 60, Files: []runs.MetaFile{{Name: "a", Size: 65 << 20}}}) |
| 458 | malformed := okMeta() |
| 459 | malformed.Files = []runs.MetaFile{{Name: "x.csv", Size: 5}} |
| 460 | post("malformed", runTestHost, fx.member, malformed) |
| 461 | ungranted := serviceMeta() |
| 462 | ungranted.Argv = []string{"db", "shell"} |
| 463 | post("ungranted", runTestHost, runner, ungranted) |
| 464 | person := okMeta() |
| 465 | person.OnBehalfOf = "bob@keymail.dev" |
| 466 | post("person on_behalf_of", runTestHost, fx.member, person) |
| 467 | |
| 468 | id := fx.startRun(t) |
| 469 | post("open", runTestHost, fx.member, okMeta()) |
| 470 | post("open, for a service", runTestHost, runner, serviceMeta()) |
| 471 | checkF("unknown id", func() (int, string) { return fx.followAs(t, fx.member, "", "run_zzzzzzzzzzzzzzzzzzzzzzzzzz") }) |
| 472 | checkF("not a service's run", func() (int, string) { return fx.followAs(t, runner, "", id) }) |
| 473 | checkF("bad after", func() (int, string) { |
| 474 | status, _, body := rawReq(t, http.MethodGet, fx.runsURL(runTestHost)+"/"+id+"?after=x", fx.member, "") |
| 475 | return status, body |
| 476 | }) |
| 477 | checkF("ack before the end", func() (int, string) { return fx.followAs(t, fx.member, "ack", id) }) |
| 478 | checkF("ack malformed", func() (int, string) { |
| 479 | status, _, body := rawReq(t, http.MethodPost, fx.runsURL(runTestHost)+"/"+id+"/ack", fx.member, `not json`) |
| 480 | return status, body |
| 481 | }) |
| 482 | fx.transition(t, provision.RunPick(id, "box-1", "boot-1", time.Now().Add(time.Hour))) |
| 483 | checkF("expire a picked run", func() (int, string) { return fx.followAs(t, fx.member, "expire", id) }) |
| 484 | if err := runs.WriteResult(ctx, fx.rel, release.OpsAccount, "shop", id, runs.Result{Exit: 0, LastSeq: 3}); err != nil { |
| 485 | t.Fatal(err) |
| 486 | } |
| 487 | fx.transition(t, provision.RunFinish(id)) |
| 488 | checkF("cancel an ended run", func() (int, string) { return fx.followAs(t, fx.member, "cancel", id) }) |
| 489 | checkF("ack at the wrong seq", func() (int, string) { return fx.followAs(t, fx.member, "ack", id) }) |
| 490 | fx.transition(t, provision.RunClear(id)) |
| 491 | |
| 492 | if _, err := provision.SetRecordMove(ctx, fx.rel, release.OpsAccount, "shop", runTestHost, provision.MoveRequest("uk", time.Now())); err != nil { |
| 493 | t.Fatal(err) |
| 494 | } |
| 495 | post("moving", runTestHost, fx.member, okMeta()) |
| 496 | |
| 497 | fx.srv.cli.instances = newRateLimiter(1, cliRateWindow) |
| 498 | fx.srv.cli.instances.allow(hashToken(fx.member), time.Now()) |
| 499 | post("budget", runTestHost, fx.member, okMeta()) |
| 500 | fx.srv.cli.runs = newRateLimiter(1, cliRateWindow) |
| 501 | fx.srv.cli.runs.allow(hashToken(fx.member), time.Now()) |
| 502 | checkF("follow budget", func() (int, string) { return fx.followAs(t, fx.member, "", id) }) |
| 503 | fx.srv.cli = newCLILimits() |
| 504 | fx.srv.cfg.HostClaim = true |
| 505 | post("host claim", runTestHost, fx.member, okMeta()) |
| 506 | checkF("host claim follow", func() (int, string) { return fx.followAs(t, fx.member, "", id) }) |
| 507 | fx.srv.cfg.HostClaim = false |
| 508 | |
| 509 | for _, want := range []string{"not_found", "not_runnable", "import_uncommitted", "invalid_request", "files_too_large", "malformed", |
| 510 | "command_not_granted", "on_behalf_of_invalid", "run_open", "run_not_finished", "run_started", "run_ended", "last_seq_mismatch", |
| 511 | "moving", "rate_limited", "host_claim"} { |
| 512 | if !seen[want] { |
| 513 | t.Errorf("no refusal here carried %s; seen %v", want, seen) |
| 514 | } |
| 515 | } |
| 516 | |
| 517 | // The shared gates: no code, and the bodies every door gives. |
| 518 | _, _, unsigned := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps", "not-a-token", "") |
| 519 | if status, body := postRun(t, fx.runsURL(runTestHost), "not-a-token", okMeta(), nil); status != http.StatusUnauthorized || body != unsigned || strings.Contains(body, "code") { |
| 520 | t.Errorf("401 at the run door = %d %q, want %q", status, body, unsigned) |
| 521 | } |
| 522 | publish := serviceToken(t, fx.st, OpsAccountID, "pub", "shop", svcrole.Publish) |
| 523 | if status, body := postRun(t, fx.runsURL(runTestHost), publish, okMeta(), nil); status != http.StatusForbidden || strings.Contains(body, "code") { |
| 524 | t.Errorf("role 403 at the run door = %d %q, want no code", status, body) |
| 525 | } |
| 526 | stranger := cliToken(t, fx.consoleFixture, "mallory@keymail.dev", "code-run-codes-stranger") |
| 527 | status, h, body := postRunH(t, fx.runsURL(runTestHost), stranger, okMeta(), nil) |
| 528 | assertNotFound(t, "stranger", canonicalNotFound(t), status, h, body) |
| 529 | status, h, body = rawReq(t, http.MethodGet, fx.runsURL(runTestHost)+"/"+id, stranger, "") |
| 530 | assertNotFound(t, "stranger follow", canonicalNotFound(t), status, h, body) |
| 531 | } |
| 532 | |
| 533 | // TestRunHandlersWriteOnlyCodedRefusals reads the run handlers' source: no |
| 534 | // refusal there is an uncoded jsonError, and every code is a runCode |
| 535 | // constant named in runRefusalCodes. The two http.NotFound calls are the |
| 536 | // principal check, which answers as the membership gate does. |
| 537 | func TestRunHandlersWriteOnlyCodedRefusals(t *testing.T) { |
| 538 | fset := token.NewFileSet() |
| 539 | consts := map[string]string{} |
| 540 | for _, file := range []string{"apiruns.go", "apirunsfollow.go"} { |
| 541 | f, err := parser.ParseFile(fset, file, nil, 0) |
| 542 | if err != nil { |
| 543 | t.Fatal(err) |
| 544 | } |
| 545 | ast.Inspect(f, func(n ast.Node) bool { |
| 546 | switch n := n.(type) { |
| 547 | case *ast.ValueSpec: |
| 548 | for i, name := range n.Names { |
| 549 | if strings.HasPrefix(name.Name, "runCode") && i < len(n.Values) { |
| 550 | if lit, ok := n.Values[i].(*ast.BasicLit); ok { |
| 551 | consts[name.Name] = strings.Trim(lit.Value, `"`) |
| 552 | } |
| 553 | } |
| 554 | } |
| 555 | case *ast.CallExpr: |
| 556 | id, ok := n.Fun.(*ast.Ident) |
| 557 | if !ok { |
| 558 | return true |
| 559 | } |
| 560 | switch id.Name { |
| 561 | case "jsonError": |
| 562 | t.Errorf("%s: an uncoded jsonError in a run handler", fset.Position(n.Pos())) |
| 563 | case "jsonErrorCode": |
| 564 | if len(n.Args) != 4 { |
| 565 | t.Errorf("%s: jsonErrorCode takes (w, status, code, msg)", fset.Position(n.Pos())) |
| 566 | return true |
| 567 | } |
| 568 | switch a := n.Args[2].(type) { |
| 569 | case *ast.Ident: |
| 570 | if !strings.HasPrefix(a.Name, "runCode") { |
| 571 | t.Errorf("%s: code %s is not a runCode constant", fset.Position(n.Pos()), a.Name) |
| 572 | } |
| 573 | case *ast.CallExpr: |
| 574 | if f, ok := a.Fun.(*ast.Ident); !ok || f.Name != "runMetaRefusalCode" { |
| 575 | t.Errorf("%s: code is computed by something other than runMetaRefusalCode", fset.Position(n.Pos())) |
| 576 | } |
| 577 | default: |
| 578 | t.Errorf("%s: code is not a runCode constant", fset.Position(n.Pos())) |
| 579 | } |
| 580 | } |
| 581 | } |
| 582 | return true |
| 583 | }) |
| 584 | } |
| 585 | if len(consts) != len(runRefusalCodes) { |
| 586 | t.Errorf("%d runCode constants, %d in runRefusalCodes", len(consts), len(runRefusalCodes)) |
| 587 | } |
| 588 | for name, v := range consts { |
| 589 | if !slices.Contains(runRefusalCodes, v) { |
| 590 | t.Errorf("%s = %q is not in runRefusalCodes", name, v) |
| 591 | } |
| 592 | } |
| 593 | for _, msg := range []string{runFilesTooLargeMsg, "anything else"} { |
| 594 | if !slices.Contains(runRefusalCodes, runMetaRefusalCode(msg)) { |
| 595 | t.Errorf("runMetaRefusalCode(%q) is not in runRefusalCodes", msg) |
| 596 | } |
| 597 | } |
| 598 | } |
| 599 | |
| 600 | func TestServiceRunRendersAsTheServices(t *testing.T) { |
| 601 | fx := runFixture(t) |
| 602 | runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| 603 | id := fx.startAs(t, runner, serviceMeta()) |
| 604 | fx.transition(t, provision.RunPick(id, "box-1", "boot-1", time.Now().Add(time.Hour))) |
| 605 | if err := runs.WriteResult(context.Background(), fx.rel, release.OpsAccount, "shop", id, runs.Result{Exit: 0}); err != nil { |
| 606 | t.Fatal(err) |
| 607 | } |
| 608 | fx.pageAs(t, runner, id) // writes the run-end row |
| 609 | line := "run by service ci for " + runServiceFor + " (as stated by the service)" |
| 610 | events, _ := fx.st.Events(OpsAccountID, "shop", 20) |
| 611 | rows := map[string]bool{} |
| 612 | for _, e := range events { |
| 613 | if !strings.HasPrefix(e.Action, "run-") { |
| 614 | continue |
| 615 | } |
| 616 | rows[e.Action] = true |
| 617 | if e.Address != "service:"+release.OpsAccount+"/ci" { |
| 618 | t.Errorf("%s row's actor is %q, want the service", e.Action, e.Address) |
| 619 | } |
| 620 | if !strings.Contains(e.Detail, line) { |
| 621 | t.Errorf("%s row %q does not say %q", e.Action, e.Detail, line) |
| 622 | } |
| 623 | if strings.Count(e.Detail, runServiceFor) != 1 { |
| 624 | t.Errorf("%s row %q names %s outside the service's statement", e.Action, e.Detail, runServiceFor) |
| 625 | } |
| 626 | } |
| 627 | if !rows["run-start"] || !rows["run-end"] { |
| 628 | t.Fatalf("run rows %v in %+v", rows, events) |
| 629 | } |
| 630 | // The console's history. |
| 631 | c := signInPaul(t, fx.consoleFixture) |
| 632 | status, _, page := get(t, c, fx.u("/apps/shop/activity")) |
| 633 | if status != http.StatusOK { |
| 634 | t.Fatalf("activity: %d", status) |
| 635 | } |
| 636 | if !strings.Contains(page, line) { |
| 637 | t.Errorf("the activity page does not say %q", line) |
| 638 | } |
| 639 | if strings.Contains(page, "<td>"+runServiceFor+"</td>") { |
| 640 | t.Errorf("the activity page shows %s as the actor", runServiceFor) |
| 641 | } |
| 642 | // A scheduled run. |
| 643 | fx.transition(t, provision.RunFinish(id)) |
| 644 | fx.transition(t, provision.RunClear(id)) |
| 645 | meta := serviceMeta() |
| 646 | meta.OnBehalfOf = "" |
| 647 | sid := fx.startAs(t, runner, meta) |
| 648 | req, err := runs.ReadRequest(context.Background(), fx.rel, release.OpsAccount, "shop", sid) |
| 649 | if err != nil { |
| 650 | t.Fatal(err) |
| 651 | } |
| 652 | if got := req.ServiceLine(); got != "run by service ci (scheduled)" { |
| 653 | t.Errorf("scheduled line %q", got) |
| 654 | } |
| 655 | // A person's run has no service line, and its rows are as before. |
| 656 | if got := (runs.Request{By: "alice@keymail.dev"}).ServiceLine(); got != "" { |
| 657 | t.Errorf("a person's run has a service line %q", got) |
| 658 | } |
| 659 | } |
| 660 | |
| 661 | // The run's incarnation is the record's when the service created it, so a |
| 662 | // delete and create again leaves it naming the old account (the box refuses |
| 663 | // it, RefusedIncarnation). |
| 664 | func TestRunServiceRunIsBoundToItsIncarnation(t *testing.T) { |
| 665 | fx := runFixture(t) |
| 666 | runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| 667 | id := fx.startAs(t, runner, serviceMeta()) |
| 668 | ctx := context.Background() |
| 669 | rec, err := provision.GetRecord(ctx, fx.rel, release.OpsAccount, "shop", runTestHost) |
| 670 | if err != nil { |
| 671 | t.Fatal(err) |
| 672 | } |
| 673 | req, err := runs.ReadRequest(ctx, fx.rel, release.OpsAccount, "shop", id) |
| 674 | if err != nil { |
| 675 | t.Fatal(err) |
| 676 | } |
| 677 | inc, err := hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt) |
| 678 | if err != nil || req.Incarnation != inc { |
| 679 | t.Fatalf("request incarnation %q, record's %q, %v", req.Incarnation, inc, err) |
| 680 | } |
| 681 | // Deleted, and created again under the same name. |
| 682 | if err := fx.rel.Delete(ctx, provision.RecordKey(release.OpsAccount, "shop", runTestHost)); err != nil { |
| 683 | t.Fatal(err) |
| 684 | } |
| 685 | putShopRecord(t, fx.consoleFixture, provision.Record{Host: runTestHost, Channel: "stable", CreatedAt: rec.CreatedAt.Add(time.Hour)}) |
| 686 | again, _ := provision.GetRecord(ctx, fx.rel, release.OpsAccount, "shop", runTestHost) |
| 687 | if inc2, _ := hostclaim.Incarnation(again.Account, again.App, again.Host, again.CreatedAt); inc2 == req.Incarnation { |
| 688 | t.Fatal("the recreated instance has the run's incarnation") |
| 689 | } |
| 690 | } |
| 691 | |
| 692 | func TestRunServiceRefusedWhileMoving(t *testing.T) { |
| 693 | fx := runFixture(t) |
| 694 | runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| 695 | if _, err := provision.SetRecordMove(context.Background(), fx.rel, release.OpsAccount, "shop", runTestHost, provision.MoveRequest("uk", time.Now())); err != nil { |
| 696 | t.Fatal(err) |
| 697 | } |
| 698 | status, body := postRun(t, fx.runsURL(runTestHost), runner, serviceMeta(), nil) |
| 699 | if status != http.StatusConflict || codeOf(t, body) != "moving" || errorOf(t, body) != runTestHost+" is moving. run the command once the move has finished" { |
| 700 | t.Fatalf("%d %s", status, body) |
| 701 | } |
| 702 | fx.noRunObjectsBut(t, "a moving instance") |
| 703 | } |
| 704 | |
| 705 | // noRunObjectsBut is noRunObjects without the record check, for a record |
| 706 | // that holds a move. |
| 707 | func (fx *runFx) noRunObjectsBut(t *testing.T, what string) { |
| 708 | t.Helper() |
| 709 | if left, _ := fx.rel.List(context.Background(), runs.RunsPrefix(release.OpsAccount, "shop")); len(left) != 0 { |
| 710 | t.Errorf("%s left run objects %q", what, left) |
| 711 | } |
| 712 | } |
| 713 | |
| 714 | // A service's open run holds the instance against a move, as a person's does. |
| 715 | func TestMoveDoorRefusesWhileAServicesRunIsOpen(t *testing.T) { |
| 716 | fx := runFixture(t) |
| 717 | runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| 718 | id := fx.startAs(t, runner, serviceMeta()) |
| 719 | if _, err := fx.rel.Put(context.Background(), "control/edges/uk-box.json", |
| 720 | []byte(`{"v":1,"box":"uk-box","eip":"1.2.3.4","placement":"uk","data_bucket":"data-uk","data_bucket_region":"eu-west-2","hosts":{}}`), ""); err != nil { |
| 721 | t.Fatal(err) |
| 722 | } |
| 723 | u := fx.ts.URL + "/api/cli/apps/bab/shop/instances/" + runTestHost + "/move" |
| 724 | status, _, body := rawReq(t, http.MethodPost, u, fx.member, `{"region":"uk"}`) |
| 725 | if status != http.StatusConflict || !strings.Contains(body, "a run is open on this instance: "+id) { |
| 726 | t.Fatalf("move over a service's run: %d %s", status, body) |
| 727 | } |
| 728 | } |
| 729 | |
| 730 | // One run per instance holds across a person and a service: at once, one is |
| 731 | // admitted and the other refused, and the loser's objects are gone. |
| 732 | func TestRunPersonAndServiceRaceAdmitsOne(t *testing.T) { |
| 733 | fx := runFixture(t) |
| 734 | runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| 735 | var wg sync.WaitGroup |
| 736 | statuses := make([]int, 2) |
| 737 | bodies := make([]string, 2) |
| 738 | for i, c := range []struct { |
| 739 | bearer string |
| 740 | meta runs.Meta |
| 741 | }{{fx.member, okMeta()}, {runner, serviceMeta()}} { |
| 742 | wg.Add(1) |
| 743 | go func() { |
| 744 | defer wg.Done() |
| 745 | statuses[i], bodies[i] = postRun(t, fx.runsURL(runTestHost), c.bearer, c.meta, nil) |
| 746 | }() |
| 747 | } |
| 748 | wg.Wait() |
| 749 | won, lost := 0, 0 |
| 750 | for i := range 2 { |
| 751 | switch statuses[i] { |
| 752 | case http.StatusAccepted: |
| 753 | won++ |
| 754 | case http.StatusConflict: |
| 755 | lost++ |
| 756 | if codeOf(t, bodies[i]) != "run_open" { |
| 757 | t.Errorf("loser said %s", bodies[i]) |
| 758 | } |
| 759 | } |
| 760 | } |
| 761 | if won != 1 || lost != 1 { |
| 762 | t.Fatalf("statuses %v %v", statuses, bodies) |
| 763 | } |
| 764 | prefix := runs.RunsPrefix(release.OpsAccount, "shop") |
| 765 | keys, _ := fx.rel.List(context.Background(), prefix) |
| 766 | owners := map[string]bool{} |
| 767 | for _, k := range keys { |
| 768 | owners[strings.SplitN(strings.TrimPrefix(k, prefix), "/", 2)[0]] = true |
| 769 | } |
| 770 | if len(owners) != 1 { |
| 771 | t.Fatalf("runs with objects %v, want only the winner's", owners) |
| 772 | } |
| 773 | } |
| 774 | |
| 775 | func TestCallerScopeNamesTheKind(t *testing.T) { |
| 776 | person := Principal{Kind: PrincipalUser, Address: "abc"} |
| 777 | svc := Principal{Kind: PrincipalService, CredentialID: "abc"} |
| 778 | if callerScope(person) == callerScope(svc) { |
| 779 | t.Fatalf("a person and a credential with the same string share a scope: %q", callerScope(person)) |
| 780 | } |
| 781 | if callerScope(person) != "user:abc" || callerScope(svc) != "svc:abc" { |
| 782 | t.Fatalf("scopes %q %q", callerScope(person), callerScope(svc)) |
| 783 | } |
| 784 | if callerScope(Principal{Kind: PrincipalService}) != "" || callerScope(Principal{Kind: PrincipalUser}) != "" { |
| 785 | t.Fatal("a principal with no identity has a scope") |
| 786 | } |
| 787 | s := &Server{} |
| 788 | if s.runVisibleTo(Principal{Kind: PrincipalService}, runs.Request{}) { |
| 789 | t.Fatal("a service with no id sees a run with no credential") |
| 790 | } |
| 791 | if !s.runVisibleTo(svc, runs.Request{ByCredential: "abc"}) || s.runVisibleTo(svc, runs.Request{ByCredential: "abd"}) { |
| 792 | t.Fatal("runVisibleTo does not key on the credential id") |
| 793 | } |
| 794 | if !s.runVisibleTo(person, runs.Request{ByCredential: "zzz"}) { |
| 795 | t.Fatal("a member does not see a service's run") |
| 796 | } |
| 797 | } |
| 798 | |
| 799 | // A run credential minted in another account, for an app of the same name, |
| 800 | // is a stranger at this account's run doors: the membership 404, and nothing |
| 801 | // written. |
| 802 | func TestRunServiceOfAnotherAccountRefused(t *testing.T) { |
| 803 | fx := runFixture(t) |
| 804 | const otherID = 4242 |
| 805 | now := time.Now() |
| 806 | if err := fx.st.CreateAccount(otherID, "cdef", "Other", "paul@keymail.dev", now); err != nil { |
| 807 | t.Fatal(err) |
| 808 | } |
| 809 | if _, err := fx.st.CreateAppIn(otherID, "shop", "paul@keymail.dev", now); err != nil { |
| 810 | t.Fatal(err) |
| 811 | } |
| 812 | other, err := fx.st.NewServicePrincipalScoped(otherID, "ci", "shop", svcrole.Run, nil, importClassic, "paul@keymail.dev", now) |
| 813 | if err != nil { |
| 814 | t.Fatal(err) |
| 815 | } |
| 816 | runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| 817 | id := fx.startAs(t, runner, serviceMeta()) |
| 818 | want := canonicalNotFound(t) |
| 819 | for _, d := range fx.runDoorURLs(id) { |
| 820 | status, h, body := rawReq(t, d.method, d.u, other, d.body) |
| 821 | assertNotFound(t, "another account's run service at "+d.method+" "+d.u, want, status, h, body) |
| 822 | } |
| 823 | status, h, body := postRunH(t, fx.runsURL(runTestHost), other, serviceMeta(), nil) |
| 824 | assertNotFound(t, "another account's run service at create", want, status, h, body) |
| 825 | if rec, _ := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount, "shop", runTestHost); rec == nil || rec.ActiveRun == nil || rec.ActiveRun.ID != id || rec.ActiveRun.State != provision.RunQueued { |
| 826 | t.Fatalf("another account's run service changed the run: %+v", rec) |
| 827 | } |
| 828 | } |
| 829 | |
| 830 | // A granted run service on a host-claim console gets the refusal a person |
| 831 | // gets, at every run door. |
| 832 | func TestRunGrantedServiceOnAHostClaimConsoleRefused(t *testing.T) { |
| 833 | fx := runFixture(t) |
| 834 | runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| 835 | id := fx.startAs(t, runner, serviceMeta()) |
| 836 | fx.srv.cfg.HostClaim = true |
| 837 | for _, d := range fx.runDoorURLs(id) { |
| 838 | pStatus, _, pBody := rawReq(t, d.method, d.u, fx.member, d.body) |
| 839 | sStatus, _, sBody := rawReq(t, d.method, d.u, runner, d.body) |
| 840 | if pStatus != http.StatusConflict || codeOf(t, pBody) != runCodeHostClaim || errorOf(t, pBody) != claimRunUnsupportedMsg { |
| 841 | t.Errorf("a person at %s %s = %d %s, want the host-claim refusal", d.method, d.u, pStatus, pBody) |
| 842 | } |
| 843 | if sStatus != pStatus || sBody != pBody { |
| 844 | t.Errorf("the run service at %s %s = %d %q, want what a person gets, %d %q", d.method, d.u, sStatus, sBody, pStatus, pBody) |
| 845 | } |
| 846 | } |
| 847 | status, body := postRun(t, fx.runsURL(runTestHost), runner, serviceMeta(), nil) |
| 848 | if status != http.StatusConflict || errorOf(t, body) != claimRunUnsupportedMsg { |
| 849 | t.Errorf("a granted create on a host-claim console = %d %s", status, body) |
| 850 | } |
| 851 | } |
| 852 | |
| 853 | // Every refusal code a run handler writes is listed in the run API section |
| 854 | // of the CLI page: a caller with no person to read the sentence acts on the |
| 855 | // code, so a code the docs do not name is a code nobody can act on. |
| 856 | func TestRunRefusalCodesAreDocumented(t *testing.T) { |
| 857 | raw, err := os.ReadFile("../../docs/site/cli.md") |
| 858 | if err != nil { |
| 859 | t.Fatal(err) |
| 860 | } |
| 861 | _, section, ok := strings.Cut(string(raw), "\n#### The run API\n") |
| 862 | if !ok { |
| 863 | t.Fatal("docs/site/cli.md has no '#### The run API' section") |
| 864 | } |
| 865 | if i := strings.Index(section, "\n#"); i >= 0 { |
| 866 | section = section[:i] |
| 867 | } |
| 868 | for _, code := range runRefusalCodes { |
| 869 | if !strings.Contains(section, "- `"+code+"`:") { |
| 870 | t.Errorf("the run API docs do not list the code %s", code) |
| 871 | } |
| 872 | } |
| 873 | } |
| 874 | |