go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/resultdb/internal/services/recorder/create_artifact_test.go (about) 1 // Copyright 2020 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package recorder 16 17 import ( 18 "context" 19 "io" 20 "net/http" 21 "net/http/httptest" 22 "net/url" 23 "strconv" 24 "strings" 25 "testing" 26 "time" 27 28 "github.com/golang/protobuf/proto" 29 "google.golang.org/genproto/googleapis/bytestream" 30 "google.golang.org/grpc" 31 "google.golang.org/grpc/codes" 32 "google.golang.org/grpc/status" 33 34 "go.chromium.org/luci/common/tsmon" 35 "go.chromium.org/luci/server/router" 36 37 "go.chromium.org/luci/resultdb/internal/invocations" 38 "go.chromium.org/luci/resultdb/internal/spanutil" 39 "go.chromium.org/luci/resultdb/internal/testutil" 40 "go.chromium.org/luci/resultdb/internal/testutil/insert" 41 pb "go.chromium.org/luci/resultdb/proto/v1" 42 43 . "github.com/smartystreets/goconvey/convey" 44 ) 45 46 type fakeWriter struct { 47 grpc.ClientStream // implements the rest of ByteStream_WriteClient. 48 49 requestsToAccept int 50 requests []*bytestream.WriteRequest 51 52 res *bytestream.WriteResponse 53 resErr error 54 } 55 56 func (*fakeWriter) CloseSend() error { 57 return nil 58 } 59 60 func (w *fakeWriter) CloseAndRecv() (*bytestream.WriteResponse, error) { 61 if w.res != nil || w.resErr != nil { 62 return w.res, w.resErr 63 } 64 65 res := &bytestream.WriteResponse{} 66 for _, req := range w.requests { 67 res.CommittedSize += int64(len(req.Data)) 68 } 69 return res, nil 70 } 71 72 func (w *fakeWriter) Send(req *bytestream.WriteRequest) error { 73 w.requests = append(w.requests, proto.Clone(req).(*bytestream.WriteRequest)) 74 if len(w.requests) >= w.requestsToAccept { 75 return io.EOF 76 } 77 return nil 78 } 79 80 func TestCreateArtifact(t *testing.T) { 81 // metric field values for Artifact table 82 artMFVs := []any{string(spanutil.Artifacts), string(spanutil.Inserted), insert.TestRealm} 83 84 Convey(`CreateArtifact`, t, func() { 85 ctx := testutil.SpannerTestContext(t) 86 ctx, _ = tsmon.WithDummyInMemory(ctx) 87 store := tsmon.Store(ctx) 88 89 w := &fakeWriter{} 90 writerCreated := false 91 ach := &artifactCreationHandler{ 92 RBEInstance: "projects/example/instances/artifacts", 93 NewCASWriter: func(context.Context) (bytestream.ByteStream_WriteClient, error) { 94 So(writerCreated, ShouldBeFalse) 95 writerCreated = true 96 return w, nil 97 }, 98 MaxArtifactContentStreamLength: 1024, 99 } 100 101 art := "invocations/inv/artifacts/a" 102 tok, err := invocationTokenKind.Generate(ctx, []byte("inv"), nil, time.Hour) 103 So(err, ShouldBeNil) 104 105 send := func(artifact, hash string, size int64, updateToken, content, contentType string) *httptest.ResponseRecorder { 106 rec := httptest.NewRecorder() 107 108 // Create a URL whose EscapePath() returns `artifact`. 109 u := &url.URL{RawPath: "/" + artifact} 110 var err error 111 u.Path, err = url.PathUnescape(artifact) 112 So(err, ShouldBeNil) 113 114 c := &router.Context{ 115 Request: (&http.Request{ 116 Header: http.Header{}, 117 URL: u, 118 Body: io.NopCloser(strings.NewReader(content)), 119 }).WithContext(ctx), 120 Writer: rec, 121 } 122 if hash != "" { 123 c.Request.Header.Set(artifactContentHashHeaderKey, hash) 124 } 125 if size != -1 { 126 c.Request.Header.Set(artifactContentSizeHeaderKey, strconv.FormatInt(size, 10)) 127 } 128 if updateToken != "" { 129 c.Request.Header.Set(updateTokenHeaderKey, updateToken) 130 } 131 if contentType != "" { 132 c.Request.Header.Set(artifactContentTypeHeaderKey, contentType) 133 } 134 ach.Handle(c) 135 return rec 136 } 137 138 Convey(`Verify request`, func() { 139 Convey(`Artifact name is malformed`, func() { 140 rec := send("artifact", "", 0, "", "", "") 141 So(rec.Code, ShouldEqual, http.StatusBadRequest) 142 So(rec.Body.String(), ShouldContainSubstring, "bad artifact name: does not match") 143 }) 144 145 Convey(`Hash is missing`, func() { 146 rec := send(art, "", 0, "", "", "") 147 So(rec.Code, ShouldEqual, http.StatusBadRequest) 148 So(rec.Body.String(), ShouldContainSubstring, "Content-Hash header is missing") 149 }) 150 151 Convey(`Hash is malformed`, func() { 152 rec := send(art, "a", 0, "", "", "") 153 So(rec.Code, ShouldEqual, http.StatusBadRequest) 154 So(rec.Body.String(), ShouldContainSubstring, "Content-Hash header value does not match") 155 }) 156 157 Convey(`Size is missing`, func() { 158 rec := send(art, "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", -1, "", "", "") 159 So(rec.Code, ShouldEqual, http.StatusBadRequest) 160 So(rec.Body.String(), ShouldContainSubstring, "Content-Length header is missing") 161 }) 162 163 Convey(`Size is negative`, func() { 164 rec := send(art, "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", -100, "", "", "") 165 So(rec.Code, ShouldEqual, http.StatusBadRequest) 166 So(rec.Body.String(), ShouldContainSubstring, "Content-Length header must be a value between 0 and 1024") 167 }) 168 169 Convey(`Size is too large`, func() { 170 rec := send(art, "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 1<<30, "", "", "") 171 So(rec.Code, ShouldEqual, http.StatusBadRequest) 172 So(rec.Body.String(), ShouldContainSubstring, "Content-Length header must be a value between 0 and 1024") 173 }) 174 175 Convey(`Update token is missing`, func() { 176 rec := send(art, "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 0, "", "", "") 177 So(rec.Code, ShouldEqual, http.StatusUnauthorized) 178 So(rec.Body.String(), ShouldContainSubstring, "Update-Token header is missing") 179 }) 180 181 Convey(`Update token is invalid`, func() { 182 rec := send(art, "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 0, "x", "", "") 183 So(rec.Code, ShouldEqual, http.StatusForbidden) 184 So(rec.Body.String(), ShouldContainSubstring, "invalid Update-Token header value") 185 }) 186 187 // RowCount metric should have no changes from any of the above Convey()s. 188 So(store.Get(ctx, spanutil.RowCounter, time.Time{}, artMFVs), ShouldBeNil) 189 }) 190 191 Convey(`Verify state`, func() { 192 Convey(`Invocation does not exist`, func() { 193 rec := send(art, "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 0, tok, "", "") 194 So(rec.Code, ShouldEqual, http.StatusNotFound) 195 So(rec.Body.String(), ShouldContainSubstring, "invocations/inv not found") 196 }) 197 198 Convey(`Invocation is finalized`, func() { 199 testutil.MustApply(ctx, insert.Invocation("inv", pb.Invocation_FINALIZED, nil)) 200 rec := send(art, "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 0, tok, "", "") 201 So(rec.Code, ShouldEqual, http.StatusBadRequest) 202 So(rec.Body.String(), ShouldContainSubstring, "invocations/inv is not active") 203 }) 204 205 Convey(`Same artifact exists`, func() { 206 testutil.MustApply(ctx, 207 insert.Invocation("inv", pb.Invocation_ACTIVE, nil), 208 spanutil.InsertMap("Artifacts", map[string]any{ 209 "InvocationId": invocations.ID("inv"), 210 "ParentId": "", 211 "ArtifactId": "a", 212 "RBECASHash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 213 "Size": 0, 214 }), 215 ) 216 rec := send(art, "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 0, tok, "", "") 217 So(rec.Code, ShouldEqual, http.StatusNoContent) 218 }) 219 220 Convey(`Different artifact exists`, func() { 221 testutil.MustApply(ctx, 222 insert.Invocation("inv", pb.Invocation_ACTIVE, nil), 223 spanutil.InsertMap("Artifacts", map[string]any{ 224 "InvocationId": invocations.ID("inv"), 225 "ParentId": "", 226 "ArtifactId": "a", 227 "RBECASHash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b851", 228 "Size": 1, 229 }), 230 ) 231 rec := send(art, "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 0, tok, "", "") 232 So(rec.Code, ShouldEqual, http.StatusConflict) 233 }) 234 235 // RowCount metric should have no changes from any of the above Convey()s. 236 So(store.Get(ctx, spanutil.RowCounter, time.Time{}, artMFVs), ShouldBeNil) 237 }) 238 239 Convey(`Write to CAS`, func() { 240 testutil.MustApply(ctx, insert.Invocation("inv", pb.Invocation_ACTIVE, nil)) 241 w.requestsToAccept = 1 242 243 casSend := func() *httptest.ResponseRecorder { 244 return send(art, "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", 5, tok, "hello", "") 245 } 246 247 // Skip due to https://crbug.com/1110755 248 SkipConvey(`Single request`, func() { 249 rec := casSend() 250 So(rec.Code, ShouldEqual, http.StatusNoContent) 251 So(w.requests, ShouldHaveLength, 1) 252 So(w.requests[0].ResourceName, ShouldEqual, "projects/example/instances/artifacts/uploads/0194fdc2-fa2f-fcc0-41d3-ff12045b73c8/blobs/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824/5") 253 So(string(w.requests[0].Data), ShouldEqual, "hello") 254 }) 255 256 Convey(`Chunked`, func() { 257 w.requestsToAccept = 2 258 ach.bufSize = 3 259 rec := casSend() 260 So(rec.Code, ShouldEqual, http.StatusNoContent) 261 So(w.requests, ShouldHaveLength, 2) 262 So(string(w.requests[0].Data), ShouldEqual, "hel") 263 So(string(w.requests[1].Data), ShouldEqual, "lo") 264 So(store.Get(ctx, spanutil.RowCounter, time.Time{}, artMFVs), ShouldEqual, 1) 265 }) 266 267 Convey(`Invalid digest`, func() { 268 w.resErr = status.Errorf(codes.InvalidArgument, "wrong hash") 269 rec := casSend() 270 So(rec.Code, ShouldEqual, http.StatusBadRequest) 271 So(rec.Body.String(), ShouldEqual, "Content-Hash and/or Content-Length do not match the request body\n") 272 So(store.Get(ctx, spanutil.RowCounter, time.Time{}, artMFVs), ShouldBeNil) 273 }) 274 275 Convey(`RBE-CAS stops request early`, func() { 276 w.requestsToAccept = 1 277 w.res = &bytestream.WriteResponse{CommittedSize: 5} // blob already exists 278 ach.bufSize = 1 279 rec := casSend() 280 So(rec.Code, ShouldEqual, http.StatusNoContent) 281 // Must not continue requests after receiving io.EOF in the first 282 // request. 283 So(w.requests, ShouldHaveLength, 1) 284 So(store.Get(ctx, spanutil.RowCounter, time.Time{}, artMFVs), ShouldEqual, 1) 285 }) 286 287 }) 288 289 Convey(`Verify digest`, func() { 290 testutil.MustApply(ctx, insert.Invocation("inv", pb.Invocation_ACTIVE, nil)) 291 w.requestsToAccept = 1 292 293 Convey(`Hash`, func() { 294 rec := send(art, "sha256:baaaaada5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", 5, tok, "hello", "") 295 So(rec.Code, ShouldEqual, http.StatusBadRequest) 296 So( 297 rec.Body.String(), 298 ShouldEqual, 299 `Content-Hash header value "sha256:baaaaada5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" `+ 300 `does not match the hash of the request body, "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"`+ 301 "\n") 302 }) 303 304 Convey(`Size`, func() { 305 // Satisfy the RBE-level verification. 306 w.res = &bytestream.WriteResponse{CommittedSize: 100} 307 308 rec := send(art, "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", 100, tok, "hello", "") 309 So(rec.Code, ShouldEqual, http.StatusBadRequest) 310 So(rec.Body.String(), ShouldEqual, "Content-Length header value 100 does not match the length of the request body, 5\n") 311 }) 312 313 // RowCount metric should have no changes from any of the above Convey()s. 314 So(store.Get(ctx, spanutil.RowCounter, time.Time{}, artMFVs), ShouldBeNil) 315 }) 316 317 Convey(`e2e`, func() { 318 testutil.MustApply(ctx, insert.Invocation("inv", pb.Invocation_ACTIVE, nil)) 319 320 res := send(art, "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", 5, tok, "hello", "text/plain") 321 So(res.Code, ShouldEqual, http.StatusNoContent) 322 323 var actualSize int64 324 var actualHash string 325 var actualContentType string 326 testutil.MustReadRow(ctx, "Artifacts", invocations.ID("inv").Key("", "a"), map[string]any{ 327 "Size": &actualSize, 328 "RBECASHash": &actualHash, 329 "ContentType": &actualContentType, 330 }) 331 So(actualSize, ShouldEqual, 5) 332 So(actualHash, ShouldEqual, "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824") 333 So(actualContentType, ShouldEqual, "text/plain") 334 So(store.Get(ctx, spanutil.RowCounter, time.Time{}, artMFVs), ShouldEqual, 1) 335 }) 336 }) 337 }