// SPDX-License-Identifier: MIT package resultssvc import ( "bytes" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "net/url" "regexp" "strconv" "strings" "sync/atomic" "testing" "time" "connectrpc.com/connect" ) func TestJobBackendIDAndScope(t *testing.T) { sum := sha256.Sum256([]byte("job-1/2")) if got, want := JobBackendID("job-1", 2), hex.EncodeToString(sum[:])[:32]; got != want { t.Fatalf("JobBackendID = %q, want %q", got, want) } if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(JobBackendID("a:b", 1)) { t.Fatal("not 32 lowercase hex characters") } if JobBackendID("job-1", 1) == JobBackendID("job-1", 2) { t.Fatal("attempts must differ") } if got, want := Scope(42, "job-1", 2), "Actions.Results:42:"+JobBackendID("job-1", 2); got != want { t.Fatalf("Scope = %q, want %q", got, want) } // The toolkit splits the scope on ':' and wants three parts. if n := len(strings.Split(Scope(42, "a:b", 1), ":")); n != 3 { t.Fatalf("scope has %d parts", n) } } // Golden request bodies, as the real upload-artifact v4.6.2 / v7.0.2 and // download-artifact v4.3.0 sent them in the C0 spike. const ( goldenCreateV4 = `{"workflow_run_backend_id":"11111111-aaaa-4000-8000-000000000001","workflow_job_run_backend_id":"build","name":"single","version":4}` goldenCreateV7 = `{"workflow_run_backend_id":"11111111-aaaa-4000-8000-000000000001","workflow_job_run_backend_id":"build","name":"report.html","version":7,"mime_type":"text/html","expires_at":"2026-11-14T12:00:00.000Z"}` goldenFinalize = `{"workflow_run_backend_id":"11111111-aaaa-4000-8000-000000000001","workflow_job_run_backend_id":"build","name":"single","size":"146","hash":"%s"}` goldenListName = `{"workflow_run_backend_id":"11111111-aaaa-4000-8000-000000000001","workflow_job_run_backend_id":"test","name_filter":"single"}` goldenListID = `{"workflow_run_backend_id":"11111111-aaaa-4000-8000-000000000001","workflow_job_run_backend_id":"test","id_filter":"%s"}` goldenListAll = `{"workflow_run_backend_id":"11111111-aaaa-4000-8000-000000000001","workflow_job_run_backend_id":"test"}` goldenSignedURL = `{"workflow_run_backend_id":"11111111-aaaa-4000-8000-000000000001","workflow_job_run_backend_id":"build","name":"single"}` goldenDelete = `{"workflow_run_backend_id":"11111111-aaaa-4000-8000-000000000001","workflow_job_run_backend_id":"build","name":"single"}` goldenBlockList1 = `M2VhNWQ3MWYtZGEyYi00YmQ5LThiNDAtY2Y4MGM3OTBmY2NjMDAwMDAwMDAwMDAw` goldenBlockID1 = "M2VhNWQ3MWYtZGEyYi00YmQ5LThiNDAtY2Y4MGM3OTBmY2NjMDAwMDAwMDAwMDAw" ) func TestTwirpGolden(t *testing.T) { r := newRig(t) build := r.job(7, "build", 1) test := r.job(7, "test", 1) content := bytes.Repeat([]byte("zip!"), 37)[:146] st, b := build.twirp(t, "CreateArtifact", goldenCreateV4) var created map[string]any if st != http.StatusOK || json.Unmarshal(b, &created) != nil { t.Fatalf("create: %d %s", st, b) } if len(created) != 2 || created["ok"] != true { t.Fatalf("create reply: %s", b) } u, err := url.Parse(created["signedUploadUrl"].(string)) if err != nil || u.Scheme != "http" || u.Host != strings.TrimPrefix(build.srv.URL, "http://") || u.Path != BlobPath+"upload/1" || u.Query().Get("se") != strconv.FormatInt(r.clk.now().Add(time.Hour).Unix(), 10) || u.Query().Get("sig") == "" { t.Fatalf("signedUploadUrl: %v", created["signedUploadUrl"]) } res, _ := do(t, http.MethodPut, u.String()+"&comp=block&blockid="+url.QueryEscape(goldenBlockID1), bytes.NewReader(content)) if res.StatusCode != http.StatusCreated { t.Fatalf("block: %d", res.StatusCode) } res, _ = do(t, http.MethodPut, u.String()+"&comp=blocklist", strings.NewReader(goldenBlockList1)) if res.StatusCode != http.StatusCreated { t.Fatalf("blocklist: %d", res.StatusCode) } st, b = build.twirp(t, "FinalizeArtifact", fmt.Sprintf(goldenFinalize, digestOf(content))) if st != http.StatusOK || string(b) != `{"artifactId":"1","ok":true}`+"\n" { t.Fatalf("finalize: %d %s", st, b) } wantItem := `{"workflowRunBackendId":"7","workflowJobRunBackendId":"` + JobBackendID("build", 1) + `","databaseId":"1","name":"single","size":"146","createdAt":"2026-10-15T12:00:00Z","digest":"` + digestOf(content) + `"}` for _, body := range []string{goldenListName, fmt.Sprintf(goldenListID, "1"), strings.Replace(fmt.Sprintf(goldenListID, "1"), `"1"`, "1", 1), goldenListAll} { st, b = test.twirp(t, "ListArtifacts", body) if st != http.StatusOK || string(b) != `{"artifacts":[`+wantItem+`]}`+"\n" { t.Fatalf("list %s: %d %s", body, st, b) } } st, b = test.twirp(t, "ListArtifacts", fmt.Sprintf(goldenListID, "2")) if st != http.StatusOK || string(b) != `{"artifacts":[]}`+"\n" { t.Fatalf("empty list: %d %s", st, b) } // The body names the uploader's job: ignored, the URL is for test's channel. st, b = test.twirp(t, "GetSignedArtifactURL", goldenSignedURL) var signed struct { URL string `json:"signedUrl"` } if st != http.StatusOK || json.Unmarshal(b, &signed) != nil || !strings.HasPrefix(signed.URL, test.srv.URL+BlobPath+"download/1?se=") { t.Fatalf("signed URL: %d %s", st, b) } res, got := do(t, http.MethodGet, signed.URL, nil) if res.StatusCode != http.StatusOK || !bytes.Equal(got, content) { t.Fatalf("download: %d %d bytes", res.StatusCode, len(got)) } st, b = build.twirp(t, "DeleteArtifact", goldenDelete) if st != http.StatusOK || string(b) != `{"artifactId":"1","ok":true}`+"\n" { t.Fatalf("delete: %d %s", st, b) } st, b = test.twirp(t, "GetSignedArtifactURL", goldenSignedURL) if st != http.StatusNotFound || !strings.Contains(string(b), `"code":"not_found"`) { t.Fatalf("signed URL of a deleted artifact: %d %s", st, b) } // v7: mime_type and expires_at reach ermite. st, b = build.twirp(t, "CreateArtifact", goldenCreateV7) if st != http.StatusOK { t.Fatalf("create v7: %d %s", st, b) } r.fake.mu.Lock() a := r.fake.arts[2].a r.fake.mu.Unlock() if a.GetMediaType() != "text/html" || !a.GetExpiresAt().AsTime().Equal(time.Date(2026, 11, 14, 12, 0, 0, 0, time.UTC)) || a.GetJobId() != "build" || a.GetRunId() != 7 { t.Fatalf("created: %v", a) } st, b = build.twirp(t, "CreateArtifact", `{"name":"x","expires_at":"tomorrow"}`) if st != http.StatusBadRequest || !strings.Contains(string(b), `"invalid_argument"`) { t.Fatalf("bad expires_at: %d %s", st, b) } st, b = build.twirp(t, "FinalizeArtifact", `{"name":"x","size":"many"}`) if st != http.StatusBadRequest { t.Fatalf("bad size: %d %s", st, b) } st, b = build.twirp(t, "ListArtifacts", `not json`) if st != http.StatusBadRequest || !strings.Contains(string(b), `"malformed"`) { t.Fatalf("bad JSON: %d %s", st, b) } } func TestTwirpErrorMapping(t *testing.T) { r := newRig(t) j := r.job(1, "job", 1) cases := []struct { code connect.Code msg string status int twirp string want string }{ {connect.CodeInvalidArgument, "bad name", 400, "invalid_argument", "bad name"}, {connect.CodeNotFound, "gone", 404, "not_found", "gone"}, {connect.CodeAlreadyExists, "", 409, "already_exists", msgExists}, {connect.CodeAlreadyExists, "taken", 409, "already_exists", "taken"}, {connect.CodeFailedPrecondition, "job ended", 412, "failed_precondition", "job ended"}, {connect.CodeResourceExhausted, "over the 10 GiB quota", 412, "failed_precondition", "over the 10 GiB quota"}, {connect.CodeUnauthenticated, "token", 401, "unauthenticated", "token"}, {connect.CodePermissionDenied, "denied", 401, "unauthenticated", "denied"}, {connect.CodeUnavailable, "down", 503, "unavailable", "down"}, {connect.CodeDeadlineExceeded, "slow", 503, "unavailable", "slow"}, {connect.CodeInternal, "bug", 503, "unavailable", "bug"}, {connect.CodeUnknown, "what", 503, "unavailable", "what"}, {connect.CodeCanceled, "stop", 503, "unavailable", "stop"}, {connect.CodeUnimplemented, "old", 503, "unavailable", "old"}, } for _, c := range cases { t.Run(c.code.String()+"/"+c.msg, func(t *testing.T) { r.fake.mu.Lock() r.fake.failNext["CreateJobArtifact"] = connect.NewError(c.code, errors.New(c.msg)) r.fake.mu.Unlock() st, b := j.twirp(t, "CreateArtifact", goldenCreateV4) var e map[string]string if err := json.Unmarshal(b, &e); err != nil || st != c.status || e["code"] != c.twirp || e["msg"] != c.want || len(e) != 2 { t.Fatalf("got %d %s, want %d %s %q", st, b, c.status, c.twirp, c.want) } }) } } func TestTwirpAuth(t *testing.T) { r := newRig(t) j := r.job(1, "job", 1) other := r.job(1, "other", 1) nextAttempt := Job{ID: "job", RunID: 1, Attempt: 2} for name, tok := range map[string]string{ "no bearer": "", "garbage": "abc", "other job's token": other.token, "no results scope": r.auth.mint("job", genericRead, time.Hour), "scope of another run": r.auth.mint("job", Scope(2, "job", 1), time.Hour), "scope of next try": r.auth.mint("job", Scope(nextAttempt.RunID, nextAttempt.ID, nextAttempt.Attempt), time.Hour), "scope as a prefix": r.auth.mint("job", Scope(1, "job", 1)+"0", time.Hour), "forged": (&jwtAuth{key: []byte("other"), now: r.clk.now}).mint("job", Scope(1, "job", 1), time.Hour), "expired": r.auth.mint("job", Scope(1, "job", 1), -time.Second), } { st, b := j.twirpWith(t, tok, "ListArtifacts", goldenListAll) if st != http.StatusUnauthorized || !strings.Contains(string(b), `"code":"unauthenticated"`) { t.Errorf("%s: %d %s", name, st, b) } } if st, b := j.twirp(t, "ListArtifacts", goldenListAll); st != http.StatusOK { t.Fatalf("own token: %d %s", st, b) } if st, b := j.twirp(t, "Nope", "{}"); st != http.StatusNotFound || !strings.Contains(string(b), `"bad_route"`) { t.Errorf("unknown method: %d %s", st, b) } res, b := do(t, http.MethodGet, j.srv.URL+TwirpPath+"ListArtifacts", nil) if res.StatusCode != http.StatusMethodNotAllowed || !strings.Contains(string(b), `"bad_route"`) { t.Errorf("GET: %d %s", res.StatusCode, b) } if res, _ := do(t, http.MethodGet, j.srv.URL+"/_apis/artifactcache/cache", nil); res.StatusCode != http.StatusNotFound { t.Errorf("other path: %d", res.StatusCode) } st, b := j.twirp(t, "ListArtifacts", `{"name_filter":"`+strings.Repeat("x", maxTwirpBody)+`"}`) if st != http.StatusBadRequest { t.Errorf("huge body: %d %.100s", st, b) } } func checkBlobError(t *testing.T, what string, res *http.Response, body []byte, status int, code string) { t.Helper() want := `` + code + `` + code + `` if res.StatusCode != status || res.Header.Get("x-ms-error-code") != code || string(body) != want { t.Errorf("%s: %d %s %s, want %d %s", what, res.StatusCode, res.Header.Get("x-ms-error-code"), body, status, code) } } func TestBlobUpload(t *testing.T) { r := newRig(t) j := r.job(1, "job", 1) other := r.job(1, "other", 1) t.Run("blocks", func(t *testing.T) { u := j.create(t, "blocks", "") res, b := do(t, http.MethodPut, u+"&comp=block&blockid="+url.QueryEscape(base64.StdEncoding.EncodeToString([]byte("b1"))), strings.NewReader("hello ")) if res.StatusCode != http.StatusCreated || len(b) != 0 || res.Header.Get("Content-Length") != "0" { t.Fatalf("block: %d %q", res.StatusCode, b) } for _, h := range []string{"ETag", "Last-Modified", "x-ms-request-id", "x-ms-version", "x-ms-request-server-encrypted"} { if res.Header.Get(h) == "" { t.Errorf("no %s", h) } } // A retry of a block replaces it. do(t, http.MethodPut, u+"&comp=block&blockid="+base64.StdEncoding.EncodeToString([]byte("b2")), strings.NewReader("wrong")) do(t, http.MethodPut, u+"&comp=block&blockid="+base64.StdEncoding.EncodeToString([]byte("b2")), strings.NewReader("world")) list := `` + base64.StdEncoding.EncodeToString([]byte("b1")) + `` + base64.StdEncoding.EncodeToString([]byte("b2")) + `` + base64.StdEncoding.EncodeToString([]byte("b1")) + `` if res, b := do(t, http.MethodPut, u+"&comp=BlockList", strings.NewReader(list)); res.StatusCode != http.StatusCreated { t.Fatalf("blocklist: %d %s", res.StatusCode, b) } id, _ := strconv.ParseInt(j.finalize(t, "blocks", []byte("hello worldhello ")), 10, 64) if c, _ := r.fake.content(id); string(c) != "hello worldhello " { t.Fatalf("content %q", c) } }) t.Run("empty block list", func(t *testing.T) { u := j.create(t, "empty", "") if res, b := do(t, http.MethodPut, u+"&comp=blocklist", strings.NewReader(``)); res.StatusCode != http.StatusCreated { t.Fatalf("blocklist: %d %s", res.StatusCode, b) } id, _ := strconv.ParseInt(j.finalize(t, "empty", nil), 10, 64) if c, ok := r.fake.content(id); !ok || len(c) != 0 { t.Fatalf("content %q %v", c, ok) } }) t.Run("put blob", func(t *testing.T) { u := j.create(t, "single-shot", "") if res, b := do(t, http.MethodPut, u, strings.NewReader("all at once")); res.StatusCode != http.StatusCreated { t.Fatalf("put blob: %d %s", res.StatusCode, b) } id, _ := strconv.ParseInt(j.finalize(t, "single-shot", []byte("all at once")), 10, 64) if c, _ := r.fake.content(id); string(c) != "all at once" { t.Fatalf("content %q", c) } u = j.create(t, "single-shot-empty", "") if res, b := do(t, http.MethodPut, u, nil); res.StatusCode != http.StatusCreated { t.Fatalf("empty put blob: %d %s", res.StatusCode, b) } j.finalize(t, "single-shot-empty", nil) }) t.Run("refusals", func(t *testing.T) { u := j.create(t, "refused", "") pu, _ := url.Parse(u) q := pu.Query() blk := "&comp=block&blockid=" + base64.StdEncoding.EncodeToString([]byte("b")) tamper := func(k, v string) string { q2 := url.Values{"se": {q.Get("se")}, "sig": {q.Get("sig")}} q2.Set(k, v) return j.srv.URL + pu.Path + "?" + q2.Encode() } sig := []byte(q.Get("sig")) sig[3] ^= 1 se, _ := strconv.ParseInt(q.Get("se"), 10, 64) for name, target := range map[string]string{ "tampered signature": tamper("sig", string(sig)) + blk, "no signature": j.srv.URL + pu.Path + "?se=" + q.Get("se") + blk, "later expiry": tamper("se", strconv.FormatInt(se+1, 10)) + blk, "another artifact": j.srv.URL + BlobPath + "upload/99?" + q.Encode() + blk, "other job's URL": other.srv.URL + pu.Path + "?" + q.Encode() + blk, "as a download URL": j.srv.URL + BlobPath + "download/" + strings.TrimPrefix(pu.Path, BlobPath+"upload/") + "?" + q.Encode(), } { method := http.MethodPut if strings.Contains(target, "/download/") { method = http.MethodGet } res, b := do(t, method, target, strings.NewReader("x")) checkBlobError(t, name, res, b, http.StatusForbidden, "AuthenticationFailed") } res, b := do(t, http.MethodPut, u+"&comp=block&blockid=%%%", strings.NewReader("x")) checkBlobError(t, "bad block id", res, b, http.StatusBadRequest, "InvalidQueryParameterValue") res, b = do(t, http.MethodPut, u+"&comp=block&blockid="+base64.StdEncoding.EncodeToString(make([]byte, 65)), strings.NewReader("x")) checkBlobError(t, "long block id", res, b, http.StatusBadRequest, "InvalidQueryParameterValue") res, b = do(t, http.MethodPut, u+"&comp=block", strings.NewReader("x")) checkBlobError(t, "no block id", res, b, http.StatusBadRequest, "InvalidQueryParameterValue") res, b = do(t, http.MethodDelete, u, nil) checkBlobError(t, "DELETE", res, b, http.StatusMethodNotAllowed, "UnsupportedHttpVerb") res, b = do(t, http.MethodPut, u+"&comp=appendblock", strings.NewReader("x")) checkBlobError(t, "append block", res, b, http.StatusMethodNotAllowed, "UnsupportedHttpVerb") res, b = do(t, http.MethodPut, u+"&comp=blocklist", strings.NewReader(``+base64.StdEncoding.EncodeToString([]byte("never"))+``)) checkBlobError(t, "unknown block", res, b, http.StatusBadRequest, "InvalidBlockList") // The large bodies go straight to the handler: over the loopback, a // megabyte that the server reads in small parts can take seconds. res, b = direct(t, r.svc.Handler(j.Job), u+"&comp=blocklist", strings.NewReader(``+strings.Repeat(`AA==`, maxBlocks)+`AA==`)) checkBlobError(t, "too many blocks", res, b, http.StatusBadRequest, "InvalidBlockList") res, b = do(t, http.MethodPut, u+"&comp=blocklist", strings.NewReader(``)) checkBlobError(t, "not a block list", res, b, http.StatusBadRequest, "InvalidXmlDocument") res, b = do(t, http.MethodPut, u+"&comp=blocklist", strings.NewReader(`AA==`)) checkBlobError(t, "truncated XML", res, b, http.StatusBadRequest, "InvalidXmlDocument") res, b = direct(t, r.svc.Handler(j.Job), u+"&comp=blocklist", io.MultiReader(strings.NewReader(`