go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cipd/appengine/impl/gs/gs_test.go (about) 1 // Copyright 2017 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 gs 16 17 import ( 18 "context" 19 "encoding/json" 20 "io" 21 "net/http" 22 "net/http/httptest" 23 "net/url" 24 "sync" 25 "testing" 26 "time" 27 28 "go.chromium.org/luci/common/clock" 29 "go.chromium.org/luci/common/clock/testclock" 30 31 . "github.com/smartystreets/goconvey/convey" 32 . "go.chromium.org/luci/common/testing/assertions" 33 ) 34 35 func TestImpl(t *testing.T) { 36 t.Parallel() 37 38 Convey("With mocked service", t, func(c C) { 39 ctx, cl := testclock.UseTime(context.Background(), testclock.TestRecentTimeUTC) 40 cl.SetTimerCallback(func(d time.Duration, t clock.Timer) { cl.Add(d) }) 41 42 type call struct { 43 Method string 44 Path string 45 Query url.Values 46 Range string // value of Range request header 47 Code int 48 Response any 49 Location string // value of Location response header 50 NonJSON bool // if true, do not put alt=json in the expected URL 51 } 52 53 lock := sync.Mutex{} 54 expected := []call{} 55 56 expect := func(c call) { 57 lock.Lock() 58 defer lock.Unlock() 59 if c.Query == nil { 60 c.Query = url.Values{} 61 } 62 if c.Query.Get("alt") == "" && !c.NonJSON { 63 c.Query.Set("alt", "json") 64 } 65 if c.Response == nil { 66 c.Response = map[string]string{"size": "123"} 67 } 68 expected = append(expected, c) 69 } 70 71 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 72 lock.Lock() 73 next := call{Method: "???", Path: "???"} 74 if len(expected) > 0 { 75 next = expected[0] 76 expected = expected[1:] 77 } 78 lock.Unlock() 79 80 q := r.URL.Query() 81 q.Del("prettyPrint") // not really relevant to anything 82 83 c.So(r.Method+" "+r.URL.Path, ShouldEqual, next.Method+" "+next.Path) 84 c.So(q, ShouldResemble, next.Query) 85 c.So(r.Header.Get("Range"), ShouldEqual, next.Range) 86 87 var response []byte 88 var err error 89 90 if next.Response != nil { 91 if str, yep := next.Response.(string); yep { 92 w.Header().Set("Content-Type", "application/octet-stream") 93 response = []byte(str) 94 } else { 95 w.Header().Set("Content-Type", "application/json") 96 response, err = json.Marshal(next.Response) 97 c.So(err, ShouldBeNil) 98 } 99 } 100 101 if next.Location != "" { 102 w.Header().Set("Location", next.Location) 103 } 104 if next.Code != 0 { 105 w.WriteHeader(next.Code) 106 } 107 if next.Response != nil { 108 w.Write(response) 109 } 110 })) 111 defer srv.Close() 112 113 gs := &impl{ 114 ctx: ctx, 115 testingTransport: http.DefaultTransport, 116 testingBasePath: srv.URL, 117 } 118 119 Convey("Size - exists", func() { 120 expect(call{ 121 Method: "GET", 122 Path: "/b/bucket/o/a/b/c", 123 }) 124 s, yes, err := gs.Size(ctx, "/bucket/a/b/c") 125 So(err, ShouldBeNil) 126 So(s, ShouldEqual, 123) 127 So(yes, ShouldBeTrue) 128 }) 129 130 Convey("Size - missing", func() { 131 expect(call{ 132 Method: "GET", 133 Path: "/b/bucket/o/a/b/c", 134 Code: http.StatusNotFound, 135 }) 136 s, yes, err := gs.Size(ctx, "/bucket/a/b/c") 137 So(err, ShouldBeNil) 138 So(s, ShouldEqual, 0) 139 So(yes, ShouldBeFalse) 140 }) 141 142 Convey("Size - error", func() { 143 expect(call{ 144 Method: "GET", 145 Path: "/b/bucket/o/a/b/c", 146 Code: http.StatusForbidden, 147 }) 148 s, yes, err := gs.Size(ctx, "/bucket/a/b/c") 149 So(StatusCode(err), ShouldEqual, http.StatusForbidden) 150 So(s, ShouldEqual, 0) 151 So(yes, ShouldBeFalse) 152 }) 153 154 Convey("Copy unconditional", func() { 155 expect(call{ 156 Method: "POST", 157 Path: "/b/src_bucket/o/src_obj/copyTo/b/dst_bucket/o/dst_obj", 158 }) 159 So(gs.Copy(ctx, "/dst_bucket/dst_obj", -1, "/src_bucket/src_obj", -1), ShouldBeNil) 160 }) 161 162 Convey("Copy conditional", func() { 163 expect(call{ 164 Method: "POST", 165 Path: "/b/src_bucket/o/src_obj/copyTo/b/dst_bucket/o/dst_obj", 166 Query: url.Values{ 167 "ifSourceGenerationMatch": {"1"}, 168 "ifGenerationMatch": {"2"}, 169 }, 170 }) 171 So(gs.Copy(ctx, "/dst_bucket/dst_obj", 2, "/src_bucket/src_obj", 1), ShouldBeNil) 172 }) 173 174 Convey("Copy error", func() { 175 expect(call{ 176 Method: "POST", 177 Path: "/b/src_bucket/o/src_obj/copyTo/b/dst_bucket/o/dst_obj", 178 Code: http.StatusForbidden, 179 }) 180 err := gs.Copy(ctx, "/dst_bucket/dst_obj", -1, "/src_bucket/src_obj", -1) 181 So(StatusCode(err), ShouldEqual, http.StatusForbidden) 182 }) 183 184 Convey("Delete present", func() { 185 expect(call{ 186 Method: "DELETE", 187 Path: "/b/bucket/o/a/b/c", 188 }) 189 So(gs.Delete(ctx, "/bucket/a/b/c"), ShouldBeNil) 190 }) 191 192 Convey("Delete missing", func() { 193 expect(call{ 194 Method: "DELETE", 195 Path: "/b/bucket/o/a/b/c", 196 Code: http.StatusNotFound, 197 }) 198 So(gs.Delete(ctx, "/bucket/a/b/c"), ShouldBeNil) 199 }) 200 201 Convey("Delete error", func() { 202 expect(call{ 203 Method: "DELETE", 204 Path: "/b/bucket/o/a/b/c", 205 Code: http.StatusForbidden, 206 }) 207 So(StatusCode(gs.Delete(ctx, "/bucket/a/b/c")), ShouldEqual, http.StatusForbidden) 208 }) 209 210 Convey("Publish success", func() { 211 expect(call{ 212 Method: "POST", 213 Path: "/b/src_bucket/o/src_obj/copyTo/b/dst_bucket/o/dst_obj", 214 Query: url.Values{ 215 "ifGenerationMatch": {"0"}, 216 "ifSourceGenerationMatch": {"1"}, 217 }, 218 }) 219 So(gs.Publish(ctx, "/dst_bucket/dst_obj", "/src_bucket/src_obj", 1), ShouldBeNil) 220 }) 221 222 Convey("Publish bad precondition on srcGen", func() { 223 expect(call{ 224 Method: "POST", 225 Path: "/b/src_bucket/o/src_obj/copyTo/b/dst_bucket/o/dst_obj", 226 Query: url.Values{ 227 "ifGenerationMatch": {"0"}, 228 "ifSourceGenerationMatch": {"1"}, 229 }, 230 Code: http.StatusPreconditionFailed, 231 }) 232 expect(call{ 233 Method: "GET", 234 Path: "/b/dst_bucket/o/dst_obj", 235 Code: http.StatusNotFound, 236 }) 237 err := gs.Publish(ctx, "/dst_bucket/dst_obj", "/src_bucket/src_obj", 1) 238 So(StatusCode(err), ShouldEqual, http.StatusPreconditionFailed) 239 So(err, ShouldErrLike, "unexpected generation number") 240 }) 241 242 Convey("Publish general error", func() { 243 expect(call{ 244 Method: "POST", 245 Path: "/b/src_bucket/o/src_obj/copyTo/b/dst_bucket/o/dst_obj", 246 Query: url.Values{ 247 "ifGenerationMatch": {"0"}, 248 "ifSourceGenerationMatch": {"1"}, 249 }, 250 Code: http.StatusForbidden, 251 }) 252 err := gs.Publish(ctx, "/dst_bucket/dst_obj", "/src_bucket/src_obj", 1) 253 So(StatusCode(err), ShouldEqual, http.StatusForbidden) 254 }) 255 256 Convey("Publish missing source object", func() { 257 expect(call{ 258 Method: "POST", 259 Path: "/b/src_bucket/o/src_obj/copyTo/b/dst_bucket/o/dst_obj", 260 Query: url.Values{ 261 "ifGenerationMatch": {"0"}, 262 "ifSourceGenerationMatch": {"1"}, 263 }, 264 Code: http.StatusNotFound, 265 }) 266 expect(call{ 267 Method: "GET", 268 Path: "/b/dst_bucket/o/dst_obj", 269 Code: http.StatusNotFound, 270 }) 271 err := gs.Publish(ctx, "/dst_bucket/dst_obj", "/src_bucket/src_obj", 1) 272 So(StatusCode(err), ShouldEqual, http.StatusNotFound) 273 So(err, ShouldErrLike, "the source object is missing") 274 }) 275 276 Convey("Publish already published (failed precondition on dstGen)", func() { 277 expect(call{ 278 Method: "POST", 279 Path: "/b/src_bucket/o/src_obj/copyTo/b/dst_bucket/o/dst_obj", 280 Query: url.Values{ 281 "ifGenerationMatch": {"0"}, 282 "ifSourceGenerationMatch": {"1"}, 283 }, 284 Code: http.StatusPreconditionFailed, 285 }) 286 expect(call{ 287 Method: "GET", 288 Path: "/b/dst_bucket/o/dst_obj", 289 }) 290 So(gs.Publish(ctx, "/dst_bucket/dst_obj", "/src_bucket/src_obj", 1), ShouldBeNil) 291 }) 292 293 Convey("Publish already published, only srcDst precondition", func() { 294 expect(call{ 295 Method: "POST", 296 Path: "/b/src_bucket/o/src_obj/copyTo/b/dst_bucket/o/dst_obj", 297 Query: url.Values{ 298 "ifGenerationMatch": {"0"}, 299 }, 300 Code: http.StatusPreconditionFailed, 301 }) 302 So(gs.Publish(ctx, "/dst_bucket/dst_obj", "/src_bucket/src_obj", -1), ShouldBeNil) 303 }) 304 305 Convey("Publish error when checking presence", func() { 306 expect(call{ 307 Method: "POST", 308 Path: "/b/src_bucket/o/src_obj/copyTo/b/dst_bucket/o/dst_obj", 309 Query: url.Values{ 310 "ifGenerationMatch": {"0"}, 311 "ifSourceGenerationMatch": {"1"}, 312 }, 313 Code: http.StatusNotFound, 314 }) 315 expect(call{ 316 Method: "GET", 317 Path: "/b/dst_bucket/o/dst_obj", 318 Code: http.StatusForbidden, 319 }) 320 err := gs.Publish(ctx, "/dst_bucket/dst_obj", "/src_bucket/src_obj", 1) 321 So(StatusCode(err), ShouldEqual, http.StatusForbidden) 322 }) 323 324 Convey("StartUpload success", func() { 325 expect(call{ 326 Method: "POST", 327 Path: "/upload/storage/v1/b/bucket/o", 328 Query: url.Values{ 329 "name": {"a/b/c"}, 330 "uploadType": {"resumable"}, 331 }, 332 Location: "http://upload-session.example.com/a/b/c", 333 }) 334 url, err := gs.StartUpload(ctx, "/bucket/a/b/c") 335 So(err, ShouldBeNil) 336 So(url, ShouldEqual, "http://upload-session.example.com/a/b/c") 337 }) 338 339 Convey("StartUpload error", func() { 340 expect(call{ 341 Method: "POST", 342 Path: "/upload/storage/v1/b/bucket/o", 343 Query: url.Values{ 344 "name": {"a/b/c"}, 345 "uploadType": {"resumable"}, 346 }, 347 Code: http.StatusForbidden, 348 }) 349 url, err := gs.StartUpload(ctx, "/bucket/a/b/c") 350 So(StatusCode(err), ShouldEqual, http.StatusForbidden) 351 So(url, ShouldEqual, "") 352 }) 353 354 Convey("CancelUpload success", func() { 355 expect(call{ 356 Method: "DELETE", 357 Path: "/upload_url", 358 NonJSON: true, 359 Code: 499, 360 }) 361 So(gs.CancelUpload(ctx, srv.URL+"/upload_url"), ShouldBeNil) 362 }) 363 364 Convey("CancelUpload error", func() { 365 expect(call{ 366 Method: "DELETE", 367 Path: "/upload_url", 368 NonJSON: true, 369 Code: 400, 370 }) 371 So(gs.CancelUpload(ctx, srv.URL+"/upload_url"), ShouldNotBeNil) 372 }) 373 374 Convey("Reader works", func() { 375 expect(call{ 376 Method: "GET", 377 Path: "/b/bucket/o/a/b/c", 378 Response: map[string]string{ 379 "generation": "123", 380 "size": "1000", 381 }, 382 }) 383 r, err := gs.Reader(ctx, "/bucket/a/b/c", 0) 384 So(err, ShouldBeNil) 385 So(r, ShouldNotBeNil) 386 387 So(r.Generation(), ShouldEqual, 123) 388 So(r.Size(), ShouldEqual, 1000) 389 390 // Read from the middle. 391 expect(call{ 392 Method: "GET", 393 Path: "/b/bucket/o/a/b/c", 394 Query: url.Values{ 395 "alt": {"media"}, 396 "generation": {"123"}, 397 }, 398 Range: "bytes=100-104", 399 Response: "12345", 400 }) 401 buf := make([]byte, 5) 402 n, err := r.ReadAt(buf, 100) 403 So(err, ShouldBeNil) 404 So(n, ShouldEqual, 5) 405 So(string(buf), ShouldEqual, "12345") 406 407 // Read close to the end. 408 expect(call{ 409 Method: "GET", 410 Path: "/b/bucket/o/a/b/c", 411 Query: url.Values{ 412 "alt": {"media"}, 413 "generation": {"123"}, 414 }, 415 Range: "bytes=998-999", 416 Response: "12", 417 }) 418 buf = make([]byte, 5) 419 n, err = r.ReadAt(buf, 998) 420 So(err, ShouldEqual, io.EOF) 421 So(n, ShouldEqual, 2) 422 So(string(buf), ShouldEqual, "12\x00\x00\x00") 423 424 // Read past the end. 425 n, err = r.ReadAt(buf, 1000) 426 So(err, ShouldEqual, io.EOF) 427 So(n, ShouldEqual, 0) 428 }) 429 430 Convey("Reader with generation", func() { 431 expect(call{ 432 Method: "GET", 433 Path: "/b/bucket/o/a/b/c", 434 Query: url.Values{ 435 "generation": {"123"}, 436 }, 437 Response: map[string]string{ 438 "generation": "123", 439 "size": "1000", 440 }, 441 }) 442 r, err := gs.Reader(ctx, "/bucket/a/b/c", 123) 443 So(err, ShouldBeNil) 444 So(r, ShouldNotBeNil) 445 So(r.Generation(), ShouldEqual, 123) 446 So(r.Size(), ShouldEqual, 1000) 447 }) 448 }) 449 }