// Copyright 2014 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package storage import ( "context" "crypto/tls" "encoding/json" "fmt" "io" "io/ioutil" "log" "net" "net/http" "net/http/httptest" "net/url" "os" "reflect" "regexp" "sort" "strings" "testing" "time" "cloud.google.com/go/iam" "cloud.google.com/go/internal/testutil" "cloud.google.com/go/storage/internal/apiv2/storagepb" "github.com/google/go-cmp/cmp" "github.com/googleapis/gax-go/v2" "google.golang.org/api/iterator" "google.golang.org/api/option" raw "google.golang.org/api/storage/v1" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" ) func TestV2HeaderSanitization(t *testing.T) { t.Parallel() var tests = []struct { desc string in []string want []string }{ { desc: "already sanitized headers should not be modified", in: []string{"x-goog-header1:true", "x-goog-header2:0"}, want: []string{"x-goog-header1:true", "x-goog-header2:0"}, }, { desc: "sanitized headers should be sorted", in: []string{"x-goog-header2:0", "x-goog-header1:true"}, want: []string{"x-goog-header1:true", "x-goog-header2:0"}, }, { desc: "non-canonical headers should be removed", in: []string{"x-goog-header1:true", "x-goog-no-value", "non-canonical-header:not-of-use"}, want: []string{"x-goog-header1:true"}, }, { desc: "excluded canonical headers should be removed", in: []string{"x-goog-header1:true", "x-goog-encryption-key:my_key", "x-goog-encryption-key-sha256:my_sha256"}, want: []string{"x-goog-header1:true"}, }, { desc: "dirty headers should be formatted correctly", in: []string{" x-goog-header1 : \textra-spaces ", "X-Goog-Header2:CamelCaseValue"}, want: []string{"x-goog-header1:extra-spaces", "x-goog-header2:CamelCaseValue"}, }, { desc: "duplicate headers should be merged", in: []string{"x-goog-header1:value1", "X-Goog-Header1:value2"}, want: []string{"x-goog-header1:value1,value2"}, }, } for _, test := range tests { got := v2SanitizeHeaders(test.in) if !testutil.Equal(got, test.want) { t.Errorf("%s: got %v, want %v", test.desc, got, test.want) } } } func TestV4HeaderSanitization(t *testing.T) { t.Parallel() var tests = []struct { desc string in []string want []string }{ { desc: "already sanitized headers should not be modified", in: []string{"x-goog-header1:true", "x-goog-header2:0"}, want: []string{"x-goog-header1:true", "x-goog-header2:0"}, }, { desc: "dirty headers should be formatted correctly", in: []string{" x-goog-header1 : \textra-spaces ", "X-Goog-Header2:CamelCaseValue"}, want: []string{"x-goog-header1:extra-spaces", "x-goog-header2:CamelCaseValue"}, }, { desc: "duplicate headers should be merged", in: []string{"x-goog-header1:value1", "X-Goog-Header1:value2"}, want: []string{"x-goog-header1:value1,value2"}, }, { desc: "multiple spaces in value are stripped down to one", in: []string{"foo:bar gaz"}, want: []string{"foo:bar gaz"}, }, { desc: "headers with colons in value are preserved", in: []string{"x-goog-meta-start-time: 2023-02-10T02:00:00Z"}, want: []string{"x-goog-meta-start-time:2023-02-10T02:00:00Z"}, }, { desc: "headers that end in a colon in value are preserved", in: []string{"x-goog-meta-start-time: 2023-02-10T02:"}, want: []string{"x-goog-meta-start-time:2023-02-10T02:"}, }, } for _, test := range tests { got := v4SanitizeHeaders(test.in) sort.Strings(got) sort.Strings(test.want) if !testutil.Equal(got, test.want) { t.Errorf("%s: got %v, want %v", test.desc, got, test.want) } } } func TestSignedURLV2(t *testing.T) { expires, _ := time.Parse(time.RFC3339, "2002-10-02T10:00:00-05:00") tests := []struct { desc string objectName string opts *SignedURLOptions want string }{ { desc: "SignedURLV2 works", objectName: "object-name", opts: &SignedURLOptions{ GoogleAccessID: "xxx@clientid", PrivateKey: dummyKey("rsa"), Method: "GET", MD5: "ICy5YqxZB1uWSwcVLSNLcA==", Expires: expires, ContentType: "application/json", Headers: []string{"x-goog-header1:true", "x-goog-header2:false"}, }, want: "https://storage.googleapis.com/bucket-name/object-name?" + "Expires=1033570800&GoogleAccessId=xxx%40clientid&Signature=" + "RfsHlPtbB2JUYjzCgNr2Mi%2BjggdEuL1V7E6N9o6aaqwVLBDuTv3I0%2B9" + "x94E6rmmr%2FVgnmZigkIUxX%2Blfl7LgKf30uPGLt0mjKGH2p7r9ey1ONJ" + "%2BhVec23FnTRcSgopglvHPuCMWU2oNJE%2F1y8EwWE27baHrG1RhRHbLVF" + "bPpLZ9xTRFK20pluIkfHV00JGljB1imqQHXM%2B2XPWqBngLr%2FwqxLN7i" + "FcUiqR8xQEOHF%2F2e7fbkTHPNq4TazaLZ8X0eZ3eFdJ55A5QmNi8atlN4W" + "5q7Hvs0jcxElG3yqIbx439A995BkspLiAcA%2Fo4%2BxAwEMkGLICdbvakq" + "3eEprNCojw%3D%3D", }, { desc: "With a PEM Private Key", objectName: "object-name", opts: &SignedURLOptions{ GoogleAccessID: "xxx@clientid", PrivateKey: dummyKey("pem"), Method: "GET", MD5: "ICy5YqxZB1uWSwcVLSNLcA==", Expires: expires, ContentType: "application/json", Headers: []string{"x-goog-header1:true", "x-goog-header2:false"}, }, want: "https://storage.googleapis.com/bucket-name/object-name?" + "Expires=1033570800&GoogleAccessId=xxx%40clientid&Signature=" + "TiyKD%2FgGb6Kh0kkb2iF%2FfF%2BnTx7L0J4YiZua8AcTmnidutePEGIU5" + "NULYlrGl6l52gz4zqFb3VFfIRTcPXMdXnnFdMCDhz2QuJBUpsU1Ai9zlyTQ" + "dkb6ShG03xz9%2BEXWAUQO4GBybJw%2FULASuv37xA00SwLdkqj8YdyS5II" + "1lro%3D", }, { desc: "With custom SignBytes", objectName: "object-name", opts: &SignedURLOptions{ GoogleAccessID: "xxx@clientid", SignBytes: func(b []byte) ([]byte, error) { return []byte("signed"), nil }, Method: "GET", MD5: "ICy5YqxZB1uWSwcVLSNLcA==", Expires: expires, ContentType: "application/json", Headers: []string{"x-goog-header1:true", "x-goog-header2:false"}, }, want: "https://storage.googleapis.com/bucket-name/object-name?" + "Expires=1033570800&GoogleAccessId=xxx%40clientid&Signature=" + "c2lnbmVk", // base64('signed') == 'c2lnbmVk' }, { desc: "With unsafe object name", objectName: "object name界", opts: &SignedURLOptions{ GoogleAccessID: "xxx@clientid", PrivateKey: dummyKey("pem"), Method: "GET", MD5: "ICy5YqxZB1uWSwcVLSNLcA==", Expires: expires, ContentType: "application/json", Headers: []string{"x-goog-header1:true", "x-goog-header2:false"}, }, want: "https://storage.googleapis.com/bucket-name/object%20name%E7%95%8C?" + "Expires=1033570800&GoogleAccessId=xxx%40clientid&Signature=bxVH1%2Bl%2" + "BSxpnj3XuqKz6mOFk6M94Y%2B4w85J6FCmJan%2FNhGSpndP6fAw1uLHlOn%2F8xUaY%2F" + "SfZ5GzcQ%2BbxOL1WA37yIwZ7xgLYlO%2ByAi3GuqMUmHZiNCai28emODXQ8RtWHvgv6dE" + "SQ%2F0KpDMIWW7rYCaUa63UkUyeSQsKhrVqkIA%3D", }, } for _, test := range tests { u, err := SignedURL("bucket-name", test.objectName, test.opts) if err != nil { t.Fatalf("[%s] %v", test.desc, err) } if u != test.want { t.Fatalf("[%s] Unexpected signed URL; found %v", test.desc, u) } } } func TestSignedURLV4(t *testing.T) { expires, _ := time.Parse(time.RFC3339, "2002-10-02T10:00:00-05:00") tests := []struct { desc string objectName string now time.Time opts *SignedURLOptions // Note for future implementors: X-Goog-Signature generated by having // the client run through its algorithm with pre-defined input and copy // pasting the output. These tests are not great for testing whether // the right signature is calculated - instead we rely on the backend // and integration tests for that. want string }{ { desc: "SignURLV4 works", objectName: "object-name", now: expires.Add(-24 * time.Hour), opts: &SignedURLOptions{ GoogleAccessID: "xxx@clientid", PrivateKey: dummyKey("rsa"), Method: "POST", Expires: expires, Scheme: SigningSchemeV4, ContentType: "application/json", MD5: "ICy5YqxZB1uWSwcVLSNLcA==", Headers: []string{"x-goog-header1:true", "x-goog-header2:false"}, }, want: "https://storage.googleapis.com/bucket-name/object-name" + "?X-Goog-Algorithm=GOOG4-RSA-SHA256" + "&X-Goog-Credential=xxx%40clientid%2F20021001%2Fauto%2Fstorage%2Fgoog4_request" + "&X-Goog-Date=20021001T100000Z&X-Goog-Expires=86400" + "&X-Goog-Signature=774b11d89663d0562b0909131b8495e70d24e31f3417d3f8fd1438a72b620b256111a7221fecab14a6ebb7dc7eed7984316a794789beb4ecdda67a77407f6de1a68113e8fa2b885e330036a995c08f0f2a7d2c212a3d0a2fd1b392d40305d3fe31ab94c547a7541278f4a956ebb6565ebe4cb27f26e30b334adb7b065adc0d27f9eaa42ee76d75d673fc4523d023d9a636de0b5329f5dffbf80024cf21fdc6236e89aa41976572bfe4807be9a9a01f644ed9f546dcf1e0394665be7610f58c36b3d63379f4d1b64f646f7427f1fc55bb89d7fdd59017d007156c99e26440e828581cddf83faf03e739e5987c062d503f2b73f24049c25edc60ecbbc09f6ce945" + "&X-Goog-SignedHeaders=content-md5%3Bcontent-type%3Bhost%3Bx-goog-header1%3Bx-goog-header2", }, { desc: "With PEM Private Key", objectName: "object-name", now: expires.Add(-24 * time.Hour), opts: &SignedURLOptions{ GoogleAccessID: "xxx@clientid", PrivateKey: dummyKey("pem"), Method: "GET", Expires: expires, Scheme: SigningSchemeV4, }, want: "https://storage.googleapis.com/bucket-name/object-name" + "?X-Goog-Algorithm=GOOG4-RSA-SHA256" + "&X-Goog-Credential=xxx%40clientid%2F20021001%2Fauto%2Fstorage%2Fgoog4_request" + "&X-Goog-Date=20021001T100000Z&X-Goog-Expires=86400" + "&X-Goog-Signature=5592f4b8b2cae14025b619546d69bb463ca8f2caaab538a3cc6b5868c8c64b83a8b04b57d8a82c8696a192f62abddc8d99e0454b3fc33feac5bf87c353f0703aab6cfee60364aaeecec2edd37c1d6e6793d90812b5811b7936a014a3efad5d08477b4fbfaebf04fa61f1ca03f31bcdc46a161868cd2f4e98def6c82634a01454" + "&X-Goog-SignedHeaders=host", }, { desc: "Unsafe object name", objectName: "object name界", now: expires.Add(-24 * time.Hour), opts: &SignedURLOptions{ GoogleAccessID: "xxx@clientid", PrivateKey: dummyKey("pem"), Method: "GET", Expires: expires, Scheme: SigningSchemeV4, }, want: "https://storage.googleapis.com/bucket-name/object%20name%E7%95%8C" + "?X-Goog-Algorithm=GOOG4-RSA-SHA256" + "&X-Goog-Credential=xxx%40clientid%2F20021001%2Fauto%2Fstorage%2Fgoog4_request" + "&X-Goog-Date=20021001T100000Z&X-Goog-Expires=86400" + "&X-Goog-Signature=90fd455fb47725b45c08d65ddf99078184710ad30f09bc2a190c5416ba1596e4c58420e2e48744b03de2d1b85dc8679dcb4c36af6e7a1b2547cd62becaad72aebbbaf7c1686f1aa0fedf8a9b01cef20a8b8630d824a6f8b81bb9eb75f342a7d8a28457a4efd2baac93e37089b84b1506b2af72712187f638e0eafbac650b071a" + "&X-Goog-SignedHeaders=host", }, { desc: "With custom SignBytes", objectName: "object-name", now: expires.Add(-24 * time.Hour), opts: &SignedURLOptions{ GoogleAccessID: "xxx@clientid", SignBytes: func(b []byte) ([]byte, error) { return []byte("signed"), nil }, Method: "GET", Expires: expires, Scheme: SigningSchemeV4, }, want: "https://storage.googleapis.com/bucket-name/object-name" + "?X-Goog-Algorithm=GOOG4-RSA-SHA256" + "&X-Goog-Credential=xxx%40clientid%2F20021001%2Fauto%2Fstorage%2Fgoog4_request" + "&X-Goog-Date=20021001T100000Z&X-Goog-Expires=86400" + "&X-Goog-Signature=7369676e6564" + // hex('signed') = '7369676e6564' "&X-Goog-SignedHeaders=host", }, } oldUTCNow := utcNow defer func() { utcNow = oldUTCNow }() for _, test := range tests { t.Logf("Testcase: '%s'", test.desc) utcNow = func() time.Time { return test.now } got, err := SignedURL("bucket-name", test.objectName, test.opts) if err != nil { t.Fatal(err) } if got != test.want { t.Fatalf("\n\tgot:\t%v\n\twant:\t%v", got, test.want) } } } // TestSignedURL_EmulatorHost tests that SignedURl respects the host set in // STORAGE_EMULATOR_HOST func TestSignedURL_EmulatorHost(t *testing.T) { expires, _ := time.Parse(time.RFC3339, "2002-10-02T10:00:00-05:00") bucketName := "bucket-name" objectName := "obj-name" emulatorHost := os.Getenv("STORAGE_EMULATOR_HOST") defer os.Setenv("STORAGE_EMULATOR_HOST", emulatorHost) tests := []struct { desc string emulatorHost string now time.Time opts *SignedURLOptions // Note for future implementors: X-Goog-Signature generated by having // the client run through its algorithm with pre-defined input and copy // pasting the output. These tests are not great for testing whether // the right signature is calculated - instead we rely on the backend // and integration tests for that. want string }{ { desc: "SignURLV4 creates link to resources in emulator", emulatorHost: "localhost:9000", now: expires.Add(-24 * time.Hour), opts: &SignedURLOptions{ GoogleAccessID: "xxx@clientid", PrivateKey: dummyKey("rsa"), Method: "POST", Expires: expires, Scheme: SigningSchemeV4, Insecure: true, }, want: "http://localhost:9000/" + bucketName + "/" + objectName + "?X-Goog-Algorithm=GOOG4-RSA-SHA256" + "&X-Goog-Credential=xxx%40clientid%2F20021001%2Fauto%2Fstorage%2Fgoog4_request" + "&X-Goog-Date=20021001T100000Z&X-Goog-Expires=86400" + "&X-Goog-Signature=249c53142e57adf594b4f523a8a1f9c15f29b071e9abc0cf6665dbc5f692fc96fac4ab98bbea4c2397384367bc970a2e1771f2c86624475f3273970ecde8ff6df39d647e5c3f3263bf67a743e211c1958a96775edf53ece1f69ed337f0ab7fdc081c6c2b84e57b0922280d27f1da1bff47e77e3822fb1756e4c5cece9d220e6d0824ab9528e97e54f0cb09b352193b0e895344d894de11b3f5f9a2ec7d8fd6d0a4c487afd1896385a3ab9e8c3fcb3862ec0cad6ec10af1b574078eb7c79b558bcd85449a67079a0ee6da97fcbad074f1bf9fdfbdca12945336a8bd0a3b70b4c7708918cb83d10c7c4ff1f8b73275e9d1ba5d3db91069dffdf81eb7badf4e3c80" + "&X-Goog-SignedHeaders=host", }, { desc: "using SigningSchemeV2", emulatorHost: "localhost:9000", now: expires.Add(-24 * time.Hour), opts: &SignedURLOptions{ GoogleAccessID: "xxx@clientid", PrivateKey: dummyKey("rsa"), Method: "POST", Expires: expires, Scheme: SigningSchemeV2, }, want: "https://localhost:9000/" + bucketName + "/" + objectName + "?Expires=1033570800" + "&GoogleAccessId=xxx%40clientid" + "&Signature=oRi3y2tBTmoDto7FezNx4AjC0RXA6fpJjTBa0hINeVroZ%2ByOeRU8MRwJbKg1IkBbV0IjtlPaGwv5YoUH16UYdipBjCXOS%2B1qgRWyzl8AnzvU%2BfwSXSlCk9zPtHHoBkFT7G4cZQOdDTLRrSG%2FmRJ3K09KEHYg%2Fc6R5Dd92inD1tLE2tiFMyHFs5uQHRMsepY4wrWiIQ4u53tPvk%2Fwiq1%2B9yL6x3QGblhdWwjX0BTVBOxexyKTlwczJW0XlWX8wpcTFfzQnJZuujbhanf2g9MGzSmkv3ylyuQdHMJDYp4Bzq%2FmnkNUg0Vp6iEvh9tyVdRNkwXeg3D8qn%2BFSOxcF%2B9vJw%3D%3D", }, { desc: "using VirtualHostedStyle", emulatorHost: "localhost:8000", now: expires.Add(-24 * time.Hour), opts: &SignedURLOptions{ GoogleAccessID: "xxx@clientid", PrivateKey: dummyKey("rsa"), Method: "POST", Expires: expires, Scheme: SigningSchemeV4, Style: VirtualHostedStyle(), Insecure: true, }, want: "http://" + bucketName + ".localhost:8000/" + objectName + "?X-Goog-Algorithm=GOOG4-RSA-SHA256" + "&X-Goog-Credential=xxx%40clientid%2F20021001%2Fauto%2Fstorage%2Fgoog4_request" + "&X-Goog-Date=20021001T100000Z&X-Goog-Expires=86400" + "&X-Goog-Signature=35e0b9d33901a2518956821175f88c2c4eb3f4461b725af74b37c36d23f8bbe927558ac57b0be40d345f20bca55ba0652d38b7a620f8da68d4f733706ad104da468c3a039459acf35f3022e388760cd49893c998c33fe3ccc8c022d7034ab98bdbdcac4b680bb24ae5ed586a42ee9495a873ffc484e297853a8a3892d0d6385c980cb7e3c5c8bdd4939b4c17105f10fe8b5b9744017bf59431ff176c1550ae1c64ddd6628096eb6895c97c5da4d850aca72c14b7f5018c15b34d4b00ec63ff2ccb688ddbef2d32648e247ffd0137498080f320f293eb811a94fb526227324bbbd01335446388797803e67d802f97b52565deba3d2387ecabf4f3094662236017" + "&X-Goog-SignedHeaders=host", }, { desc: "using BucketBoundHostname", emulatorHost: "localhost:8000", now: expires.Add(-24 * time.Hour), opts: &SignedURLOptions{ GoogleAccessID: "xxx@clientid", PrivateKey: dummyKey("rsa"), Method: "POST", Expires: expires, Scheme: SigningSchemeV4, Style: BucketBoundHostname("myhost"), }, want: "https://" + "myhost/" + objectName + "?X-Goog-Algorithm=GOOG4-RSA-SHA256" + "&X-Goog-Credential=xxx%40clientid%2F20021001%2Fauto%2Fstorage%2Fgoog4_request" + "&X-Goog-Date=20021001T100000Z&X-Goog-Expires=86400" + "&X-Goog-Signature=15fe19f6c61bcbdbd6473c32f2bec29caa8a5fa3b2ce32cfb5329a71edaa0d4e5ffe6469f32ed4c23ca2fbed3882fdf1ed107c6a98c2c4995dda6036c64bae51e6cb542c353618f483832aa1f3ef85342ddadd69c13ad4c69fd3f573ea5cf325a58056e3d5a37005217662af63b49fef8688de3c5c7a2f7b43651a030edd0813eb7f7713989a4c29a8add65133ce652895fea9de7dbc6248ee11b4d7c6c1e152df87700100e896e544ba8eeea96584078f56e699665140b750e90550b9b79633f4e7c8409efa807be5670d6e987eeee04a4180be9b9e30bb8557597beaf390a3805cc602c87a3e34800f8bc01449c3dd10ac2f2263e55e55b91e445052548d5e" + "&X-Goog-SignedHeaders=host", }, { desc: "emulator host specifies scheme", emulatorHost: "https://localhost:6000", now: expires.Add(-24 * time.Hour), opts: &SignedURLOptions{ GoogleAccessID: "xxx@clientid", PrivateKey: dummyKey("rsa"), Method: "POST", Expires: expires, Scheme: SigningSchemeV4, Insecure: true, }, want: "http://localhost:6000/" + bucketName + "/" + objectName + "?X-Goog-Algorithm=GOOG4-RSA-SHA256" + "&X-Goog-Credential=xxx%40clientid%2F20021001%2Fauto%2Fstorage%2Fgoog4_request" + "&X-Goog-Date=20021001T100000Z&X-Goog-Expires=86400" + "&X-Goog-Signature=249c53142e57adf594b4f523a8a1f9c15f29b071e9abc0cf6665dbc5f692fc96fac4ab98bbea4c2397384367bc970a2e1771f2c86624475f3273970ecde8ff6df39d647e5c3f3263bf67a743e211c1958a96775edf53ece1f69ed337f0ab7fdc081c6c2b84e57b0922280d27f1da1bff47e77e3822fb1756e4c5cece9d220e6d0824ab9528e97e54f0cb09b352193b0e895344d894de11b3f5f9a2ec7d8fd6d0a4c487afd1896385a3ab9e8c3fcb3862ec0cad6ec10af1b574078eb7c79b558bcd85449a67079a0ee6da97fcbad074f1bf9fdfbdca12945336a8bd0a3b70b4c7708918cb83d10c7c4ff1f8b73275e9d1ba5d3db91069dffdf81eb7badf4e3c80" + "&X-Goog-SignedHeaders=host", }, { desc: "emulator host specifies scheme using SigningSchemeV2", emulatorHost: "https://localhost:8000", now: expires.Add(-24 * time.Hour), opts: &SignedURLOptions{ GoogleAccessID: "xxx@clientid", PrivateKey: dummyKey("rsa"), Method: "POST", Expires: expires, Scheme: SigningSchemeV2, }, want: "https://localhost:8000/" + bucketName + "/" + objectName + "?Expires=1033570800" + "&GoogleAccessId=xxx%40clientid" + "&Signature=oRi3y2tBTmoDto7FezNx4AjC0RXA6fpJjTBa0hINeVroZ%2ByOeRU8MRwJbKg1IkBbV0IjtlPaGwv5YoUH16UYdipBjCXOS%2B1qgRWyzl8AnzvU%2BfwSXSlCk9zPtHHoBkFT7G4cZQOdDTLRrSG%2FmRJ3K09KEHYg%2Fc6R5Dd92inD1tLE2tiFMyHFs5uQHRMsepY4wrWiIQ4u53tPvk%2Fwiq1%2B9yL6x3QGblhdWwjX0BTVBOxexyKTlwczJW0XlWX8wpcTFfzQnJZuujbhanf2g9MGzSmkv3ylyuQdHMJDYp4Bzq%2FmnkNUg0Vp6iEvh9tyVdRNkwXeg3D8qn%2BFSOxcF%2B9vJw%3D%3D", }, } oldUTCNow := utcNow defer func() { utcNow = oldUTCNow }() for _, test := range tests { t.Run(test.desc, func(s *testing.T) { utcNow = func() time.Time { return test.now } os.Setenv("STORAGE_EMULATOR_HOST", test.emulatorHost) got, err := SignedURL(bucketName, objectName, test.opts) if err != nil { s.Fatal(err) } if got != test.want { s.Fatalf("\n\tgot:\t%v\n\twant:\t%v", got, test.want) } }) } } func TestSignedURL_MissingOptions(t *testing.T) { now, _ := time.Parse(time.RFC3339, "2002-10-01T00:00:00-05:00") expires, _ := time.Parse(time.RFC3339, "2002-10-15T00:00:00-05:00") pk := dummyKey("rsa") var tests = []struct { opts *SignedURLOptions errMsg string }{ { &SignedURLOptions{}, "missing required GoogleAccessID", }, { &SignedURLOptions{GoogleAccessID: "access_id"}, "exactly one of PrivateKey or SignedBytes must be set", }, { &SignedURLOptions{ GoogleAccessID: "access_id", SignBytes: func(b []byte) ([]byte, error) { return b, nil }, PrivateKey: pk, }, "exactly one of PrivateKey or SignedBytes must be set", }, { &SignedURLOptions{ GoogleAccessID: "access_id", PrivateKey: pk, }, errMethodNotValid.Error(), }, { &SignedURLOptions{ GoogleAccessID: "access_id", PrivateKey: pk, Method: "getMethod", // wrong method name }, errMethodNotValid.Error(), }, { &SignedURLOptions{ GoogleAccessID: "access_id", PrivateKey: pk, Method: "get", // name will be uppercased }, "missing required expires", }, { &SignedURLOptions{ GoogleAccessID: "access_id", SignBytes: func(b []byte) ([]byte, error) { return b, nil }, }, errMethodNotValid.Error(), }, { &SignedURLOptions{ GoogleAccessID: "access_id", PrivateKey: pk, Method: "PUT", }, "missing required expires", }, { &SignedURLOptions{ GoogleAccessID: "access_id", PrivateKey: pk, Method: "PUT", Expires: expires, MD5: "invalid", }, "invalid MD5 checksum", }, // SigningSchemeV4 tests { &SignedURLOptions{ PrivateKey: pk, Method: "GET", Expires: expires, Scheme: SigningSchemeV4, }, "missing required GoogleAccessID", }, { &SignedURLOptions{ GoogleAccessID: "access_id", Method: "GET", Expires: expires, SignBytes: func(b []byte) ([]byte, error) { return b, nil }, PrivateKey: pk, Scheme: SigningSchemeV4, }, "exactly one of PrivateKey or SignedBytes must be set", }, { &SignedURLOptions{ GoogleAccessID: "access_id", PrivateKey: pk, Expires: expires, Scheme: SigningSchemeV4, }, errMethodNotValid.Error(), }, { &SignedURLOptions{ GoogleAccessID: "access_id", PrivateKey: pk, Method: "PUT", Scheme: SigningSchemeV4, }, "missing required expires", }, { &SignedURLOptions{ GoogleAccessID: "access_id", PrivateKey: pk, Method: "PUT", Expires: now.Add(time.Hour), MD5: "invalid", Scheme: SigningSchemeV4, }, "invalid MD5 checksum", }, { &SignedURLOptions{ GoogleAccessID: "access_id", PrivateKey: pk, Method: "GET", Expires: expires, Scheme: SigningSchemeV4, }, "expires must be within seven days from now", }, { &SignedURLOptions{ GoogleAccessID: "access_id", PrivateKey: pk, Method: "GET", Expires: now.Add(time.Hour), Scheme: SigningSchemeV2, Style: VirtualHostedStyle(), }, "are permitted with SigningSchemeV2", }, } oldUTCNow := utcNow defer func() { utcNow = oldUTCNow }() utcNow = func() time.Time { return now } for _, test := range tests { _, err := SignedURL("bucket", "name", test.opts) if !strings.Contains(err.Error(), test.errMsg) { t.Errorf("expected err: %v, found: %v", test.errMsg, err) } } } func TestPathEncodeV4(t *testing.T) { tests := []struct { input string want string }{ { "path/with/slashes", "path/with/slashes", }, { "path/with/speci@lchar$&", "path/with/speci%40lchar%24%26", }, { "path/with/un_ersc_re/~tilde/sp ace/", "path/with/un_ersc_re/~tilde/sp%20%20ace/", }, } for _, test := range tests { if got := pathEncodeV4(test.input); got != test.want { t.Errorf("pathEncodeV4(%q) = %q, want %q", test.input, got, test.want) } } } func dummyKey(kind string) []byte { slurp, err := ioutil.ReadFile(fmt.Sprintf("./internal/test/dummy_%s", kind)) if err != nil { log.Fatal(err) } return slurp } func TestObjectNames(t *testing.T) { t.Parallel() // Naming requirements: https://cloud.google.com/storage/docs/bucket-naming const maxLegalLength = 1024 type testT struct { name, want string } tests := []testT{ // Embedded characters important in URLs. {"foo % bar", "foo%20%25%20bar"}, {"foo ? bar", "foo%20%3F%20bar"}, {"foo / bar", "foo%20/%20bar"}, {"foo %?/ bar", "foo%20%25%3F/%20bar"}, // Non-Roman scripts {"타코", "%ED%83%80%EC%BD%94"}, {"世界", "%E4%B8%96%E7%95%8C"}, // Longest legal name {strings.Repeat("a", maxLegalLength), strings.Repeat("a", maxLegalLength)}, // Line terminators besides CR and LF: https://en.wikipedia.org/wiki/Newline#Unicode {"foo \u000b bar", "foo%20%0B%20bar"}, {"foo \u000c bar", "foo%20%0C%20bar"}, {"foo \u0085 bar", "foo%20%C2%85%20bar"}, {"foo \u2028 bar", "foo%20%E2%80%A8%20bar"}, {"foo \u2029 bar", "foo%20%E2%80%A9%20bar"}, // Null byte. {"foo \u0000 bar", "foo%20%00%20bar"}, // Non-control characters that are discouraged, but not forbidden, according to the documentation. {"foo # bar", "foo%20%23%20bar"}, {"foo []*? bar", "foo%20%5B%5D%2A%3F%20bar"}, // Angstrom symbol singleton and normalized forms: http://unicode.org/reports/tr15/ {"foo \u212b bar", "foo%20%E2%84%AB%20bar"}, {"foo \u0041\u030a bar", "foo%20A%CC%8A%20bar"}, {"foo \u00c5 bar", "foo%20%C3%85%20bar"}, // Hangul separating jamo: http://www.unicode.org/versions/Unicode7.0.0/ch18.pdf (Table 18-10) {"foo \u3131\u314f bar", "foo%20%E3%84%B1%E3%85%8F%20bar"}, {"foo \u1100\u1161 bar", "foo%20%E1%84%80%E1%85%A1%20bar"}, {"foo \uac00 bar", "foo%20%EA%B0%80%20bar"}, } // C0 control characters not forbidden by the docs. var runes []rune for r := rune(0x01); r <= rune(0x1f); r++ { if r != '\u000a' && r != '\u000d' { runes = append(runes, r) } } tests = append(tests, testT{fmt.Sprintf("foo %s bar", string(runes)), "foo%20%01%02%03%04%05%06%07%08%09%0B%0C%0E%0F%10%11%12%13%14%15%16%17%18%19%1A%1B%1C%1D%1E%1F%20bar"}) // C1 control characters, plus DEL. runes = nil for r := rune(0x7f); r <= rune(0x9f); r++ { runes = append(runes, r) } tests = append(tests, testT{fmt.Sprintf("foo %s bar", string(runes)), "foo%20%7F%C2%80%C2%81%C2%82%C2%83%C2%84%C2%85%C2%86%C2%87%C2%88%C2%89%C2%8A%C2%8B%C2%8C%C2%8D%C2%8E%C2%8F%C2%90%C2%91%C2%92%C2%93%C2%94%C2%95%C2%96%C2%97%C2%98%C2%99%C2%9A%C2%9B%C2%9C%C2%9D%C2%9E%C2%9F%20bar"}) opts := &SignedURLOptions{ GoogleAccessID: "xxx@clientid", PrivateKey: dummyKey("rsa"), Method: "GET", MD5: "ICy5YqxZB1uWSwcVLSNLcA==", Expires: time.Date(2002, time.October, 2, 10, 0, 0, 0, time.UTC), ContentType: "application/json", Headers: []string{"x-goog-header1", "x-goog-header2"}, } for _, test := range tests { g, err := SignedURL("bucket-name", test.name, opts) if err != nil { t.Errorf("SignedURL(%q) err=%v, want nil", test.name, err) } if w := "/bucket-name/" + test.want; !strings.Contains(g, w) { t.Errorf("SignedURL(%q)=%q, want substring %q", test.name, g, w) } } } func TestCondition(t *testing.T) { t.Parallel() gotReq := make(chan *http.Request, 1) hc, close := newTestServer(func(w http.ResponseWriter, r *http.Request) { io.Copy(ioutil.Discard, r.Body) gotReq <- r w.WriteHeader(200) }) defer close() ctx := context.Background() c, err := NewClient(ctx, option.WithHTTPClient(hc)) if err != nil { t.Fatal(err) } obj := c.Bucket("buck").Object("obj") dst := c.Bucket("dstbuck").Object("dst") tests := []struct { fn func() error want string }{ { func() error { _, err := obj.Generation(1234).NewReader(ctx) return err }, "GET /buck/obj?generation=1234", }, { func() error { _, err := obj.If(Conditions{MetagenerationNotMatch: 1234}).Attrs(ctx) return err }, "GET /storage/v1/b/buck/o/obj?alt=json&ifMetagenerationNotMatch=1234&prettyPrint=false&projection=full", }, { func() error { _, err := obj.If(Conditions{MetagenerationMatch: 1234}).Update(ctx, ObjectAttrsToUpdate{}) return err }, "PATCH /storage/v1/b/buck/o/obj?alt=json&ifMetagenerationMatch=1234&prettyPrint=false&projection=full", }, { func() error { return obj.Generation(1234).Delete(ctx) }, "DELETE /storage/v1/b/buck/o/obj?alt=json&generation=1234&prettyPrint=false", }, { func() error { w := obj.If(Conditions{GenerationMatch: 1234}).NewWriter(ctx) w.ContentType = "text/plain" return w.Close() }, "POST /upload/storage/v1/b/buck/o?alt=json&ifGenerationMatch=1234&name=obj&prettyPrint=false&projection=full&uploadType=multipart", }, { func() error { w := obj.If(Conditions{DoesNotExist: true}).NewWriter(ctx) w.ContentType = "text/plain" return w.Close() }, "POST /upload/storage/v1/b/buck/o?alt=json&ifGenerationMatch=0&name=obj&prettyPrint=false&projection=full&uploadType=multipart", }, { func() error { _, err := dst.If(Conditions{MetagenerationMatch: 5678}).CopierFrom(obj.If(Conditions{GenerationMatch: 1234})).Run(ctx) return err }, "POST /storage/v1/b/buck/o/obj/rewriteTo/b/dstbuck/o/dst?alt=json&ifMetagenerationMatch=5678&ifSourceGenerationMatch=1234&prettyPrint=false&projection=full", }, } for i, tt := range tests { if err := tt.fn(); err != nil && err != io.EOF { t.Error(err) continue } select { case r := <-gotReq: got := r.Method + " " + r.RequestURI if got != tt.want { t.Errorf("%d. RequestURI = %q; want %q", i, got, tt.want) } case <-time.After(5 * time.Second): t.Fatalf("%d. timeout", i) } if err != nil { t.Fatal(err) } } readerTests := []struct { fn func() error want string }{ { func() error { _, err := obj.If(Conditions{GenerationMatch: 1234}).NewReader(ctx) return err }, "x-goog-if-generation-match: 1234, x-goog-if-metageneration-match: ", }, { func() error { _, err := obj.If(Conditions{MetagenerationMatch: 5}).NewReader(ctx) return err }, "x-goog-if-generation-match: , x-goog-if-metageneration-match: 5", }, { func() error { _, err := obj.If(Conditions{GenerationMatch: 1234, MetagenerationMatch: 5}).NewReader(ctx) return err }, "x-goog-if-generation-match: 1234, x-goog-if-metageneration-match: 5", }, } for i, tt := range readerTests { if err := tt.fn(); err != nil && err != io.EOF { t.Error(err) continue } select { case r := <-gotReq: generationConds := r.Header.Get("x-goog-if-generation-match") metagenerationConds := r.Header.Get("x-goog-if-metageneration-match") got := fmt.Sprintf( "x-goog-if-generation-match: %s, x-goog-if-metageneration-match: %s", generationConds, metagenerationConds, ) if got != tt.want { t.Errorf("%d. RequestHeaders = %q; want %q", i, got, tt.want) } case <-time.After(5 * time.Second): t.Fatalf("%d. timeout", i) } if err != nil { t.Fatal(err) } } // Test an error, too: err = obj.Generation(1234).NewWriter(ctx).Close() if err == nil || !strings.Contains(err.Error(), "storage: generation not supported") { t.Errorf("want error about unsupported generation; got %v", err) } } func TestConditionErrors(t *testing.T) { t.Parallel() for _, conds := range []Conditions{ {GenerationMatch: 0}, {DoesNotExist: false}, // same as above, actually {GenerationMatch: 1, GenerationNotMatch: 2}, {GenerationNotMatch: 2, DoesNotExist: true}, {MetagenerationMatch: 1, MetagenerationNotMatch: 2}, } { if err := conds.validate(""); err == nil { t.Errorf("%+v: got nil, want error", conds) } } } func expectedAttempts(value int) *int { return &value } // Test that ObjectHandle.Retryer correctly configures the retry configuration // in the ObjectHandle. func TestObjectRetryer(t *testing.T) { testCases := []struct { name string call func(o *ObjectHandle) *ObjectHandle want *retryConfig }{ { name: "all defaults", call: func(o *ObjectHandle) *ObjectHandle { return o.Retryer() }, want: &retryConfig{}, }, { name: "set all options", call: func(o *ObjectHandle) *ObjectHandle { return o.Retryer( WithBackoff(gax.Backoff{ Initial: 2 * time.Second, Max: 30 * time.Second, Multiplier: 3, }), WithMaxAttempts(5), WithPolicy(RetryAlways), WithErrorFunc(func(err error) bool { return false })) }, want: &retryConfig{ backoff: &gax.Backoff{ Initial: 2 * time.Second, Max: 30 * time.Second, Multiplier: 3, }, maxAttempts: expectedAttempts(5), policy: RetryAlways, shouldRetry: func(err error) bool { return false }, }, }, { name: "set some backoff options", call: func(o *ObjectHandle) *ObjectHandle { return o.Retryer( WithBackoff(gax.Backoff{ Multiplier: 3, })) }, want: &retryConfig{ backoff: &gax.Backoff{ Multiplier: 3, }}, }, { name: "set policy only", call: func(o *ObjectHandle) *ObjectHandle { return o.Retryer(WithPolicy(RetryNever)) }, want: &retryConfig{ policy: RetryNever, }, }, { name: "set max retry attempts only", call: func(o *ObjectHandle) *ObjectHandle { return o.Retryer(WithMaxAttempts(11)) }, want: &retryConfig{ maxAttempts: expectedAttempts(11), }, }, { name: "set ErrorFunc only", call: func(o *ObjectHandle) *ObjectHandle { return o.Retryer( WithErrorFunc(func(err error) bool { return false })) }, want: &retryConfig{ shouldRetry: func(err error) bool { return false }, }, }, } for _, tc := range testCases { t.Run(tc.name, func(s *testing.T) { o := tc.call(&ObjectHandle{}) if diff := cmp.Diff( o.retry, tc.want, cmp.AllowUnexported(retryConfig{}, gax.Backoff{}), // ErrorFunc cannot be compared directly, but we check if both are // either nil or non-nil. cmp.Comparer(func(a, b func(err error) bool) bool { return (a == nil && b == nil) || (a != nil && b != nil) }), ); diff != "" { s.Fatalf("retry not configured correctly: %v", diff) } }) } } // Test that Client.SetRetry correctly configures the retry configuration // on the Client. func TestClientSetRetry(t *testing.T) { testCases := []struct { name string clientOptions []RetryOption want *retryConfig }{ { name: "all defaults", clientOptions: []RetryOption{}, want: &retryConfig{}, }, { name: "set all options", clientOptions: []RetryOption{ WithBackoff(gax.Backoff{ Initial: 2 * time.Second, Max: 30 * time.Second, Multiplier: 3, }), WithMaxAttempts(5), WithPolicy(RetryAlways), WithErrorFunc(func(err error) bool { return false }), }, want: &retryConfig{ backoff: &gax.Backoff{ Initial: 2 * time.Second, Max: 30 * time.Second, Multiplier: 3, }, maxAttempts: expectedAttempts(5), policy: RetryAlways, shouldRetry: func(err error) bool { return false }, }, }, { name: "set some backoff options", clientOptions: []RetryOption{ WithBackoff(gax.Backoff{ Multiplier: 3, }), }, want: &retryConfig{ backoff: &gax.Backoff{ Multiplier: 3, }}, }, { name: "set policy only", clientOptions: []RetryOption{ WithPolicy(RetryNever), }, want: &retryConfig{ policy: RetryNever, }, }, { name: "set max retry attempts only", clientOptions: []RetryOption{ WithMaxAttempts(7), }, want: &retryConfig{ maxAttempts: expectedAttempts(7), }, }, { name: "set ErrorFunc only", clientOptions: []RetryOption{ WithErrorFunc(func(err error) bool { return false }), }, want: &retryConfig{ shouldRetry: func(err error) bool { return false }, }, }, } for _, tc := range testCases { t.Run(tc.name, func(s *testing.T) { c, err := NewClient(context.Background(), option.WithoutAuthentication()) if err != nil { t.Fatalf("NewClient: %v", err) } defer c.Close() c.SetRetry(tc.clientOptions...) if diff := cmp.Diff( c.retry, tc.want, cmp.AllowUnexported(retryConfig{}, gax.Backoff{}), // ErrorFunc cannot be compared directly, but we check if both are // either nil or non-nil. cmp.Comparer(func(a, b func(err error) bool) bool { return (a == nil && b == nil) || (a != nil && b != nil) }), ); diff != "" { s.Fatalf("retry not configured correctly: %v", diff) } }) } } // Test the interactions between Client, ObjectHandle and BucketHandle Retryers, // and that they correctly configure the retry configuration for objects, ACLs, and HmacKeys func TestRetryer(t *testing.T) { testCases := []struct { name string clientOptions []RetryOption bucketOptions []RetryOption objectOptions []RetryOption want *retryConfig }{ { name: "no retries", want: nil, }, { name: "object retryer configures retry", objectOptions: []RetryOption{ WithPolicy(RetryAlways), WithMaxAttempts(5), WithErrorFunc(ShouldRetry), }, want: &retryConfig{ shouldRetry: ShouldRetry, maxAttempts: expectedAttempts(5), policy: RetryAlways, }, }, { name: "bucket retryer configures retry", bucketOptions: []RetryOption{ WithBackoff(gax.Backoff{ Initial: time.Minute, Max: time.Hour, Multiplier: 6, }), WithPolicy(RetryAlways), WithMaxAttempts(11), WithErrorFunc(ShouldRetry), }, want: &retryConfig{ backoff: &gax.Backoff{ Initial: time.Minute, Max: time.Hour, Multiplier: 6, }, shouldRetry: ShouldRetry, maxAttempts: expectedAttempts(11), policy: RetryAlways, }, }, { name: "client retryer configures retry", clientOptions: []RetryOption{ WithBackoff(gax.Backoff{ Initial: time.Minute, Max: time.Hour, Multiplier: 6, }), WithPolicy(RetryAlways), WithMaxAttempts(7), WithErrorFunc(ShouldRetry), }, want: &retryConfig{ backoff: &gax.Backoff{ Initial: time.Minute, Max: time.Hour, Multiplier: 6, }, shouldRetry: ShouldRetry, maxAttempts: expectedAttempts(7), policy: RetryAlways, }, }, { name: "object retryer overrides bucket retryer", bucketOptions: []RetryOption{ WithPolicy(RetryAlways), }, objectOptions: []RetryOption{ WithPolicy(RetryNever), WithMaxAttempts(5), WithErrorFunc(ShouldRetry), }, want: &retryConfig{ policy: RetryNever, maxAttempts: expectedAttempts(5), shouldRetry: ShouldRetry, }, }, { name: "object retryer overrides client retryer", clientOptions: []RetryOption{ WithPolicy(RetryAlways), }, objectOptions: []RetryOption{ WithPolicy(RetryNever), WithMaxAttempts(11), WithErrorFunc(ShouldRetry), }, want: &retryConfig{ policy: RetryNever, maxAttempts: expectedAttempts(11), shouldRetry: ShouldRetry, }, }, { name: "bucket retryer overrides client retryer", clientOptions: []RetryOption{ WithBackoff(gax.Backoff{ Initial: time.Minute, Max: time.Hour, Multiplier: 6, }), WithPolicy(RetryAlways), }, bucketOptions: []RetryOption{ WithBackoff(gax.Backoff{ Initial: time.Nanosecond, Max: time.Microsecond, }), WithErrorFunc(ShouldRetry), WithMaxAttempts(5), }, want: &retryConfig{ policy: RetryAlways, maxAttempts: expectedAttempts(5), shouldRetry: ShouldRetry, backoff: &gax.Backoff{ Initial: time.Nanosecond, Max: time.Microsecond, }, }, }, { name: "object retryer overrides bucket retryer backoff options", bucketOptions: []RetryOption{ WithBackoff(gax.Backoff{ Initial: time.Minute, Max: time.Hour, Multiplier: 6, }), }, objectOptions: []RetryOption{ WithBackoff(gax.Backoff{ Initial: time.Nanosecond, Max: time.Microsecond, }), }, want: &retryConfig{ backoff: &gax.Backoff{ Initial: time.Nanosecond, Max: time.Microsecond, }, }, }, { name: "object retryer does not override bucket retryer if option is not set", bucketOptions: []RetryOption{ WithPolicy(RetryNever), WithErrorFunc(ShouldRetry), WithMaxAttempts(5), }, objectOptions: []RetryOption{ WithBackoff(gax.Backoff{ Initial: time.Nanosecond, Max: time.Second, }), }, want: &retryConfig{ policy: RetryNever, maxAttempts: expectedAttempts(5), shouldRetry: ShouldRetry, backoff: &gax.Backoff{ Initial: time.Nanosecond, Max: time.Second, }, }, }, { name: "object's backoff completely overwrites bucket's backoff", bucketOptions: []RetryOption{ WithBackoff(gax.Backoff{ Initial: time.Hour, }), }, objectOptions: []RetryOption{ WithBackoff(gax.Backoff{ Multiplier: 4, }), }, want: &retryConfig{ backoff: &gax.Backoff{ Multiplier: 4, }, }, }, } for _, tc := range testCases { t.Run(tc.name, func(s *testing.T) { ctx := context.Background() c, err := NewClient(ctx, option.WithoutAuthentication()) if err != nil { t.Fatalf("NewClient: %v", err) } defer c.Close() if len(tc.clientOptions) > 0 { c.SetRetry(tc.clientOptions...) } b := c.Bucket("buck") if len(tc.bucketOptions) > 0 { b = b.Retryer(tc.bucketOptions...) } o := b.Object("obj") if len(tc.objectOptions) > 0 { o = o.Retryer(tc.objectOptions...) } configHandleCases := []struct { r *retryConfig name string want *retryConfig }{ { name: "object.retry", r: o.retry, want: tc.want, }, { name: "object.ACL()", r: o.ACL().retry, want: tc.want, }, { name: "bucket.ACL()", r: b.ACL().retry, want: b.retry, }, { name: "bucket.DefaultObjectACL()", r: b.DefaultObjectACL().retry, want: b.retry, }, { name: "client.HMACKeyHandle()", r: c.HMACKeyHandle("pID", "accessID").retry, want: c.retry, }, } for _, ac := range configHandleCases { s.Run(ac.name, func(ss *testing.T) { if diff := cmp.Diff( ac.want, ac.r, cmp.AllowUnexported(retryConfig{}, gax.Backoff{}), // ErrorFunc cannot be compared directly, but we check if both are // either nil or non-nil. cmp.Comparer(func(a, b func(err error) bool) bool { return (a == nil && b == nil) || (a != nil && b != nil) }), ); diff != "" { ss.Fatalf("retry not configured correctly: %v", diff) } }) } }) } } // Test object compose. func TestObjectCompose(t *testing.T) { t.Parallel() gotURL := make(chan string, 1) gotBody := make(chan []byte, 1) hc, close := newTestServer(func(w http.ResponseWriter, r *http.Request) { body, _ := ioutil.ReadAll(r.Body) gotURL <- r.URL.String() gotBody <- body w.Write([]byte("{}")) }) defer close() ctx := context.Background() c, err := NewClient(ctx, option.WithHTTPClient(hc)) if err != nil { t.Fatal(err) } testCases := []struct { desc string dst *ObjectHandle srcs []*ObjectHandle attrs *ObjectAttrs sendCRC32C bool wantReq raw.ComposeRequest wantURL string wantErr bool }{ { desc: "basic case", dst: c.Bucket("foo").Object("bar"), srcs: []*ObjectHandle{ c.Bucket("foo").Object("baz"), c.Bucket("foo").Object("quux"), }, wantURL: "/storage/v1/b/foo/o/bar/compose?alt=json&prettyPrint=false", wantReq: raw.ComposeRequest{ Destination: &raw.Object{Bucket: "foo"}, SourceObjects: []*raw.ComposeRequestSourceObjects{ {Name: "baz"}, {Name: "quux"}, }, }, }, { desc: "with object attrs", dst: c.Bucket("foo").Object("bar"), srcs: []*ObjectHandle{ c.Bucket("foo").Object("baz"), c.Bucket("foo").Object("quux"), }, attrs: &ObjectAttrs{ Name: "not-bar", ContentType: "application/json", }, wantURL: "/storage/v1/b/foo/o/bar/compose?alt=json&prettyPrint=false", wantReq: raw.ComposeRequest{ Destination: &raw.Object{ Bucket: "foo", Name: "not-bar", ContentType: "application/json", }, SourceObjects: []*raw.ComposeRequestSourceObjects{ {Name: "baz"}, {Name: "quux"}, }, }, }, { desc: "with conditions", dst: c.Bucket("foo").Object("bar").If(Conditions{ GenerationMatch: 12, MetagenerationMatch: 34, }), srcs: []*ObjectHandle{ c.Bucket("foo").Object("baz").Generation(56), c.Bucket("foo").Object("quux").If(Conditions{GenerationMatch: 78}), }, wantURL: "/storage/v1/b/foo/o/bar/compose?alt=json&ifGenerationMatch=12&ifMetagenerationMatch=34&prettyPrint=false", wantReq: raw.ComposeRequest{ Destination: &raw.Object{Bucket: "foo"}, SourceObjects: []*raw.ComposeRequestSourceObjects{ { Name: "baz", Generation: 56, }, { Name: "quux", ObjectPreconditions: &raw.ComposeRequestSourceObjectsObjectPreconditions{ IfGenerationMatch: 78, }, }, }, }, }, { desc: "with crc32c", dst: c.Bucket("foo").Object("bar"), srcs: []*ObjectHandle{ c.Bucket("foo").Object("baz"), c.Bucket("foo").Object("quux"), }, attrs: &ObjectAttrs{ CRC32C: 42, }, sendCRC32C: true, wantURL: "/storage/v1/b/foo/o/bar/compose?alt=json&prettyPrint=false", wantReq: raw.ComposeRequest{ Destination: &raw.Object{Bucket: "foo", Crc32c: "AAAAKg=="}, SourceObjects: []*raw.ComposeRequestSourceObjects{ {Name: "baz"}, {Name: "quux"}, }, }, }, { desc: "no sources", dst: c.Bucket("foo").Object("bar"), wantErr: true, }, { desc: "destination, no bucket", dst: c.Bucket("").Object("bar"), srcs: []*ObjectHandle{ c.Bucket("foo").Object("baz"), }, wantErr: true, }, { desc: "destination, no object", dst: c.Bucket("foo").Object(""), srcs: []*ObjectHandle{ c.Bucket("foo").Object("baz"), }, wantErr: true, }, { desc: "source, different bucket", dst: c.Bucket("foo").Object("bar"), srcs: []*ObjectHandle{ c.Bucket("otherbucket").Object("baz"), }, wantErr: true, }, { desc: "source, no object", dst: c.Bucket("foo").Object("bar"), srcs: []*ObjectHandle{ c.Bucket("foo").Object(""), }, wantErr: true, }, { desc: "destination, bad condition", dst: c.Bucket("foo").Object("bar").Generation(12), srcs: []*ObjectHandle{ c.Bucket("foo").Object("baz"), }, wantErr: true, }, { desc: "source, bad condition", dst: c.Bucket("foo").Object("bar"), srcs: []*ObjectHandle{ c.Bucket("foo").Object("baz").If(Conditions{MetagenerationMatch: 12}), }, wantErr: true, }, } for _, tt := range testCases { composer := tt.dst.ComposerFrom(tt.srcs...) if tt.attrs != nil { composer.ObjectAttrs = *tt.attrs } composer.SendCRC32C = tt.sendCRC32C _, err := composer.Run(ctx) if gotErr := err != nil; gotErr != tt.wantErr { t.Errorf("%s: got error %v; want err %t", tt.desc, err, tt.wantErr) continue } if tt.wantErr { continue } u, body := <-gotURL, <-gotBody if u != tt.wantURL { t.Errorf("%s: request URL\ngot %q\nwant %q", tt.desc, u, tt.wantURL) } var req raw.ComposeRequest if err := json.Unmarshal(body, &req); err != nil { t.Errorf("%s: json.Unmarshal %v (body %s)", tt.desc, err, body) } if !testutil.Equal(req, tt.wantReq) { // Print to JSON. wantReq, _ := json.Marshal(tt.wantReq) t.Errorf("%s: request body\ngot %s\nwant %s", tt.desc, body, wantReq) } } } // Test that ObjectIterator's Next and NextPage methods correctly terminate // if there is nothing to iterate over. func TestEmptyObjectIterator(t *testing.T) { t.Parallel() hClient, close := newTestServer(func(w http.ResponseWriter, r *http.Request) { io.Copy(ioutil.Discard, r.Body) fmt.Fprintf(w, "{}") }) defer close() ctx := context.Background() client, err := NewClient(ctx, option.WithHTTPClient(hClient)) if err != nil { t.Fatal(err) } it := client.Bucket("b").Objects(ctx, nil) _, err = it.Next() if err != iterator.Done { t.Errorf("got %v, want Done", err) } } // Test that BucketIterator's Next method correctly terminates if there is // nothing to iterate over. func TestEmptyBucketIterator(t *testing.T) { t.Parallel() hClient, close := newTestServer(func(w http.ResponseWriter, r *http.Request) { io.Copy(ioutil.Discard, r.Body) fmt.Fprintf(w, "{}") }) defer close() ctx := context.Background() client, err := NewClient(ctx, option.WithHTTPClient(hClient)) if err != nil { t.Fatal(err) } it := client.Buckets(ctx, "project") _, err = it.Next() if err != iterator.Done { t.Errorf("got %v, want Done", err) } } func TestCodecUint32(t *testing.T) { t.Parallel() for _, u := range []uint32{0, 1, 256, 0xFFFFFFFF} { s := encodeUint32(u) d, err := decodeUint32(s) if err != nil { t.Fatal(err) } if d != u { t.Errorf("got %d, want input %d", d, u) } } } func TestUserProject(t *testing.T) { // Verify that the userProject query param is sent. t.Parallel() ctx := context.Background() gotURL := make(chan *url.URL, 1) hClient, close := newTestServer(func(w http.ResponseWriter, r *http.Request) { io.Copy(ioutil.Discard, r.Body) gotURL <- r.URL if strings.Contains(r.URL.String(), "/rewriteTo/") { res := &raw.RewriteResponse{Done: true} bytes, err := res.MarshalJSON() if err != nil { t.Fatal(err) } w.Write(bytes) } else { fmt.Fprintf(w, "{}") } }) defer close() client, err := NewClient(ctx, option.WithHTTPClient(hClient)) if err != nil { t.Fatal(err) } re := regexp.MustCompile(`\buserProject=p\b`) b := client.Bucket("b").UserProject("p") o := b.Object("o") check := func(msg string, f func()) { f() select { case u := <-gotURL: if !re.MatchString(u.RawQuery) { t.Errorf("%s: query string %q does not contain userProject", msg, u.RawQuery) } case <-time.After(2 * time.Second): t.Errorf("%s: timed out", msg) } } check("buckets.delete", func() { b.Delete(ctx) }) check("buckets.get", func() { b.Attrs(ctx) }) check("buckets.patch", func() { b.Update(ctx, BucketAttrsToUpdate{}) }) check("storage.objects.compose", func() { o.ComposerFrom(b.Object("x")).Run(ctx) }) check("storage.objects.delete", func() { o.Delete(ctx) }) check("storage.objects.get", func() { o.Attrs(ctx) }) check("storage.objects.insert", func() { o.NewWriter(ctx).Close() }) check("storage.objects.list", func() { b.Objects(ctx, nil).Next() }) check("storage.objects.patch", func() { o.Update(ctx, ObjectAttrsToUpdate{}) }) check("storage.objects.rewrite", func() { o.CopierFrom(b.Object("x")).Run(ctx) }) check("storage.objectAccessControls.list", func() { o.ACL().List(ctx) }) check("storage.objectAccessControls.update", func() { o.ACL().Set(ctx, "", "") }) check("storage.objectAccessControls.delete", func() { o.ACL().Delete(ctx, "") }) check("storage.bucketAccessControls.list", func() { b.ACL().List(ctx) }) check("storage.bucketAccessControls.update", func() { b.ACL().Set(ctx, "", "") }) check("storage.bucketAccessControls.delete", func() { b.ACL().Delete(ctx, "") }) check("storage.defaultObjectAccessControls.list", func() { b.DefaultObjectACL().List(ctx) }) check("storage.defaultObjectAccessControls.update", func() { b.DefaultObjectACL().Set(ctx, "", "") }) check("storage.defaultObjectAccessControls.delete", func() { b.DefaultObjectACL().Delete(ctx, "") }) check("buckets.getIamPolicy", func() { b.IAM().Policy(ctx) }) check("buckets.setIamPolicy", func() { p := &iam.Policy{} p.Add("m", iam.Owner) b.IAM().SetPolicy(ctx, p) }) check("buckets.testIamPermissions", func() { b.IAM().TestPermissions(ctx, nil) }) check("storage.notifications.insert", func() { b.AddNotification(ctx, &Notification{TopicProjectID: "p", TopicID: "t"}) }) check("storage.notifications.delete", func() { b.DeleteNotification(ctx, "n") }) check("storage.notifications.list", func() { b.Notifications(ctx) }) } func newTestServer(handler func(w http.ResponseWriter, r *http.Request)) (*http.Client, func()) { ts := httptest.NewTLSServer(http.HandlerFunc(handler)) tlsConf := &tls.Config{InsecureSkipVerify: true} tr := &http.Transport{ TLSClientConfig: tlsConf, DialTLS: func(netw, addr string) (net.Conn, error) { return tls.Dial("tcp", ts.Listener.Addr().String(), tlsConf) }, } return &http.Client{Transport: tr}, func() { tr.CloseIdleConnections() ts.Close() } } func TestRawObjectToObjectAttrs(t *testing.T) { t.Parallel() tests := []struct { in *raw.Object want *ObjectAttrs }{ {in: nil, want: nil}, { in: &raw.Object{ Bucket: "Test", ContentLanguage: "en-us", ContentType: "video/mpeg", CustomTime: "2020-08-25T19:33:36Z", EventBasedHold: false, Etag: "Zkyw9ACJZUvcYmlFaKGChzhmtnE/dt1zHSfweiWpwzdGsqXwuJZqiD0", Generation: 7, Md5Hash: "MTQ2ODNjYmE0NDRkYmNjNmRiMjk3NjQ1ZTY4M2Y1YzE=", Name: "foo.mp4", RetentionExpirationTime: "2019-03-31T19:33:36Z", Size: 1 << 20, TimeCreated: "2019-03-31T19:32:10Z", TimeDeleted: "2019-03-31T19:33:39Z", TemporaryHold: true, ComponentCount: 2, }, want: &ObjectAttrs{ Bucket: "Test", Created: time.Date(2019, 3, 31, 19, 32, 10, 0, time.UTC), ContentLanguage: "en-us", ContentType: "video/mpeg", CustomTime: time.Date(2020, 8, 25, 19, 33, 36, 0, time.UTC), Deleted: time.Date(2019, 3, 31, 19, 33, 39, 0, time.UTC), EventBasedHold: false, Etag: "Zkyw9ACJZUvcYmlFaKGChzhmtnE/dt1zHSfweiWpwzdGsqXwuJZqiD0", Generation: 7, MD5: []byte("14683cba444dbcc6db297645e683f5c1"), Name: "foo.mp4", RetentionExpirationTime: time.Date(2019, 3, 31, 19, 33, 36, 0, time.UTC), Size: 1 << 20, TemporaryHold: true, ComponentCount: 2, }, }, } for i, tt := range tests { got := newObject(tt.in) if diff := testutil.Diff(got, tt.want); diff != "" { t.Errorf("#%d: newObject mismatches:\ngot=-, want=+:\n%s", i, diff) } } } func TestObjectAttrsToRawObject(t *testing.T) { t.Parallel() bucketName := "the-bucket" in := &ObjectAttrs{ Bucket: "Test", Created: time.Date(2019, 3, 31, 19, 32, 10, 0, time.UTC), ContentLanguage: "en-us", ContentType: "video/mpeg", Deleted: time.Date(2019, 3, 31, 19, 33, 39, 0, time.UTC), EventBasedHold: false, Etag: "Zkyw9ACJZUvcYmlFaKGChzhmtnE/dt1zHSfweiWpwzdGsqXwuJZqiD0", Generation: 7, MD5: []byte("14683cba444dbcc6db297645e683f5c1"), Name: "foo.mp4", RetentionExpirationTime: time.Date(2019, 3, 31, 19, 33, 36, 0, time.UTC), Size: 1 << 20, TemporaryHold: true, } want := &raw.Object{ Bucket: bucketName, ContentLanguage: "en-us", ContentType: "video/mpeg", EventBasedHold: false, Name: "foo.mp4", RetentionExpirationTime: "2019-03-31T19:33:36Z", TemporaryHold: true, } got := in.toRawObject(bucketName) if !testutil.Equal(got, want) { if diff := testutil.Diff(got, want); diff != "" { t.Errorf("toRawObject mismatches:\ngot=-, want=+:\n%s", diff) } } } func TestProtoObjectToObjectAttrs(t *testing.T) { t.Parallel() now := time.Now() tests := []struct { in *storagepb.Object want *ObjectAttrs }{ {in: nil, want: nil}, { in: &storagepb.Object{ Bucket: "Test", ContentLanguage: "en-us", ContentType: "video/mpeg", CustomTime: timestamppb.New(now), EventBasedHold: proto.Bool(false), Generation: 7, Checksums: &storagepb.ObjectChecksums{Md5Hash: []byte("14683cba444dbcc6db297645e683f5c1")}, Name: "foo.mp4", RetentionExpireTime: timestamppb.New(now), Size: 1 << 20, CreateTime: timestamppb.New(now), DeleteTime: timestamppb.New(now), TemporaryHold: true, ComponentCount: 2, }, want: &ObjectAttrs{ Bucket: "Test", Created: now, ContentLanguage: "en-us", ContentType: "video/mpeg", CustomTime: now, Deleted: now, EventBasedHold: false, Generation: 7, MD5: []byte("14683cba444dbcc6db297645e683f5c1"), Name: "foo.mp4", RetentionExpirationTime: now, Size: 1 << 20, TemporaryHold: true, ComponentCount: 2, }, }, } for i, tt := range tests { got := newObjectFromProto(tt.in) if diff := testutil.Diff(got, tt.want); diff != "" { t.Errorf("#%d: newObject mismatches:\ngot=-, want=+:\n%s", i, diff) } } } func TestObjectAttrsToProtoObject(t *testing.T) { t.Parallel() now := time.Now() b := "bucket" want := &storagepb.Object{ Bucket: "projects/_/buckets/" + b, ContentLanguage: "en-us", ContentType: "video/mpeg", CustomTime: timestamppb.New(now), EventBasedHold: proto.Bool(false), Generation: 7, Name: "foo.mp4", RetentionExpireTime: timestamppb.New(now), Size: 1 << 20, CreateTime: timestamppb.New(now), DeleteTime: timestamppb.New(now), TemporaryHold: true, } in := &ObjectAttrs{ Created: now, ContentLanguage: "en-us", ContentType: "video/mpeg", CustomTime: now, Deleted: now, EventBasedHold: false, Generation: 7, Name: "foo.mp4", RetentionExpirationTime: now, Size: 1 << 20, TemporaryHold: true, } got := in.toProtoObject(b) if diff := testutil.Diff(got, want); diff != "" { t.Errorf("toProtoObject mismatches:\ngot=-, want=+:\n%s", diff) } } func TestApplyCondsProto(t *testing.T) { for _, tst := range []struct { name string in, want proto.Message err error gen int64 conds *Conditions }{ { name: "generation", gen: 123, in: &storagepb.ReadObjectRequest{}, want: &storagepb.ReadObjectRequest{Generation: 123}, }, { name: "invalid_no_generation", gen: 123, in: &storagepb.WriteObjectRequest{}, err: fmt.Errorf("generation not supported"), }, { name: "if_match", gen: -1, in: &storagepb.ReadObjectRequest{}, want: &storagepb.ReadObjectRequest{IfGenerationMatch: proto.Int64(123), IfMetagenerationMatch: proto.Int64(123)}, conds: &Conditions{GenerationMatch: 123, MetagenerationMatch: 123}, }, { name: "if_dne", gen: -1, in: &storagepb.ReadObjectRequest{}, want: &storagepb.ReadObjectRequest{IfGenerationMatch: proto.Int64(0)}, conds: &Conditions{DoesNotExist: true}, }, { name: "if_not_match", gen: -1, in: &storagepb.ReadObjectRequest{}, want: &storagepb.ReadObjectRequest{IfGenerationNotMatch: proto.Int64(123), IfMetagenerationNotMatch: proto.Int64(123)}, conds: &Conditions{GenerationNotMatch: 123, MetagenerationNotMatch: 123}, }, { name: "invalid_multiple_conditions", gen: -1, in: &storagepb.ReadObjectRequest{}, conds: &Conditions{MetagenerationMatch: 123, MetagenerationNotMatch: 123}, err: fmt.Errorf("multiple conditions"), }, } { if err := applyCondsProto(tst.name, tst.gen, tst.conds, tst.in); tst.err == nil && err != nil { t.Errorf("%s: error got %v, want nil", tst.name, err) } else if tst.err != nil && (err == nil || !strings.Contains(err.Error(), tst.err.Error())) { t.Errorf("%s: error got %v, want %v", tst.name, err, tst.err) } else if diff := cmp.Diff(tst.in, tst.want, cmp.Comparer(proto.Equal)); tst.err == nil && diff != "" { t.Errorf("%s: got(-),want(+):\n%s", tst.name, diff) } } } func TestAttrToFieldMapCoverage(t *testing.T) { t.Parallel() oa := reflect.TypeOf((*ObjectAttrs)(nil)).Elem() oaFields := make(map[string]bool) for i := 0; i < oa.NumField(); i++ { fieldName := oa.Field(i).Name oaFields[fieldName] = true } // Check that all fields of attrToFieldMap exist in ObjectAttrs. for k := range attrToFieldMap { if _, ok := oaFields[k]; !ok { t.Errorf("%v is not an ObjectAttrs field", k) } } // Check that all fields of ObjectAttrs exist in attrToFieldMap, with // known exceptions which aren't sent over the wire but are settable by // the user. for k := range oaFields { if _, ok := attrToFieldMap[k]; !ok { if k != "Prefix" && k != "PredefinedACL" { t.Errorf("ObjectAttrs.%v is not in attrToFieldMap", k) } } } } func TestEmulatorWithCredentialsFile(t *testing.T) { t.Setenv("STORAGE_EMULATOR_HOST", "localhost:1234") client, err := NewClient(context.Background(), option.WithCredentialsFile("/path/to/key.json")) if err != nil { t.Fatalf("failed creating a client with credentials file when running agains an emulator: %v", err) return } client.Close() } // Create a client using a combination of custom endpoint and // STORAGE_EMULATOR_HOST env variable and verify that raw.BasePath (used // for writes) and xmlHost and scheme (used for reads) are all set correctly. func TestWithEndpoint(t *testing.T) { originalStorageEmulatorHost := os.Getenv("STORAGE_EMULATOR_HOST") testCases := []struct { desc string CustomEndpoint string StorageEmulatorHost string WantRawBasePath string WantXMLHost string WantScheme string }{ { desc: "No endpoint or emulator host specified", CustomEndpoint: "", StorageEmulatorHost: "", WantRawBasePath: "https://storage.googleapis.com/storage/v1/", WantXMLHost: "storage.googleapis.com", WantScheme: "https", }, { desc: "With specified https endpoint, no specified emulator host", CustomEndpoint: "https://fake.gcs.com:8080/storage/v1", StorageEmulatorHost: "", WantRawBasePath: "https://fake.gcs.com:8080/storage/v1", WantXMLHost: "fake.gcs.com:8080", WantScheme: "https", }, { desc: "With specified http endpoint, no specified emulator host", CustomEndpoint: "http://fake.gcs.com:8080/storage/v1", StorageEmulatorHost: "", WantRawBasePath: "http://fake.gcs.com:8080/storage/v1", WantXMLHost: "fake.gcs.com:8080", WantScheme: "http", }, { desc: "Emulator host specified, no specified endpoint", CustomEndpoint: "", StorageEmulatorHost: "http://emu.com", WantRawBasePath: "http://emu.com/storage/v1/", WantXMLHost: "emu.com", WantScheme: "http", }, { desc: "Emulator host specified without scheme", CustomEndpoint: "", StorageEmulatorHost: "emu.com", WantRawBasePath: "http://emu.com/storage/v1/", WantXMLHost: "emu.com", WantScheme: "http", }, { desc: "Emulator host specified as host:port", CustomEndpoint: "", StorageEmulatorHost: "localhost:9000", WantRawBasePath: "http://localhost:9000/storage/v1/", WantXMLHost: "localhost:9000", WantScheme: "http", }, { desc: "Endpoint overrides emulator host when both are specified - https", CustomEndpoint: "https://fake.gcs.com:8080/storage/v1", StorageEmulatorHost: "http://emu.com", WantRawBasePath: "https://fake.gcs.com:8080/storage/v1", WantXMLHost: "fake.gcs.com:8080", WantScheme: "https", }, { desc: "Endpoint overrides emulator host when both are specified - http", CustomEndpoint: "http://fake.gcs.com:8080/storage/v1", StorageEmulatorHost: "https://emu.com", WantRawBasePath: "http://fake.gcs.com:8080/storage/v1", WantXMLHost: "fake.gcs.com:8080", WantScheme: "http", }, { desc: "Endpoint overrides emulator host when host is specified as scheme://host:port", CustomEndpoint: "http://localhost:8080/storage/v1", StorageEmulatorHost: "https://localhost:9000", WantRawBasePath: "http://localhost:8080/storage/v1", WantXMLHost: "localhost:8080", WantScheme: "http", }, { desc: "Endpoint overrides emulator host when host is specified as host:port", CustomEndpoint: "http://localhost:8080/storage/v1", StorageEmulatorHost: "localhost:9000", WantRawBasePath: "http://localhost:8080/storage/v1", WantXMLHost: "localhost:8080", WantScheme: "http", }, } ctx := context.Background() for _, tc := range testCases { os.Setenv("STORAGE_EMULATOR_HOST", tc.StorageEmulatorHost) c, err := NewClient(ctx, option.WithEndpoint(tc.CustomEndpoint), option.WithoutAuthentication()) if err != nil { t.Fatalf("error creating client: %v", err) } if c.raw.BasePath != tc.WantRawBasePath { t.Errorf("%s: raw.BasePath not set correctly\n\tgot %v, want %v", tc.desc, c.raw.BasePath, tc.WantRawBasePath) } if c.xmlHost != tc.WantXMLHost { t.Errorf("%s: xmlHost not set correctly\n\tgot %v, want %v", tc.desc, c.xmlHost, tc.WantXMLHost) } if c.scheme != tc.WantScheme { t.Errorf("%s: scheme not set correctly\n\tgot %v, want %v", tc.desc, c.scheme, tc.WantScheme) } } os.Setenv("STORAGE_EMULATOR_HOST", originalStorageEmulatorHost) } // Create a client using a combination of custom endpoint and STORAGE_EMULATOR_HOST // env variable and verify that the client hits the correct endpoint for several // different operations performe in sequence. // Verifies also that raw.BasePath, xmlHost and scheme are not changed // after running the operations. func TestOperationsWithEndpoint(t *testing.T) { originalStorageEmulatorHost := os.Getenv("STORAGE_EMULATOR_HOST") defer os.Setenv("STORAGE_EMULATOR_HOST", originalStorageEmulatorHost) gotURL := make(chan string, 1) gotHost := make(chan string, 1) gotMethod := make(chan string, 1) timedOut := make(chan bool, 1) hClient, closeServer := newTestServer(func(w http.ResponseWriter, r *http.Request) { done := make(chan bool, 1) io.Copy(ioutil.Discard, r.Body) fmt.Fprintf(w, "{}") go func() { gotHost <- r.Host gotURL <- r.RequestURI gotMethod <- r.Method done <- true }() select { case <-timedOut: case <-done: } }) defer closeServer() testCases := []struct { desc string CustomEndpoint string StorageEmulatorHost string wantScheme string wantHost string }{ { desc: "No endpoint or emulator host specified", CustomEndpoint: "", StorageEmulatorHost: "", wantScheme: "https", wantHost: "storage.googleapis.com", }, { desc: "emulator host specified", CustomEndpoint: "", StorageEmulatorHost: "https://" + "addr", wantScheme: "https", wantHost: "addr", }, { desc: "endpoint specified", CustomEndpoint: "https://" + "end" + "/storage/v1/", StorageEmulatorHost: "", wantScheme: "https", wantHost: "end", }, { desc: "both emulator and endpoint specified", CustomEndpoint: "https://" + "end" + "/storage/v1/", StorageEmulatorHost: "http://host", wantScheme: "https", wantHost: "end", }, } for _, tc := range testCases { ctx := context.Background() t.Run(tc.desc, func(t *testing.T) { timeout := time.After(time.Second) done := make(chan bool, 1) go func() { os.Setenv("STORAGE_EMULATOR_HOST", tc.StorageEmulatorHost) c, err := NewClient(ctx, option.WithHTTPClient(hClient), option.WithEndpoint(tc.CustomEndpoint)) if err != nil { t.Errorf("error creating client: %v", err) return } originalRawBasePath := c.raw.BasePath originalXMLHost := c.xmlHost originalScheme := c.scheme operations := []struct { desc string runOp func() error wantURL string wantMethod string }{ { desc: "Create a bucket", runOp: func() error { return c.Bucket("test-bucket").Create(ctx, "pid", nil) }, wantURL: "/storage/v1/b?alt=json&prettyPrint=false&project=pid", wantMethod: "POST", }, { desc: "Upload an object", runOp: func() error { w := c.Bucket("test-bucket").Object("file").NewWriter(ctx) _, err = io.Copy(w, strings.NewReader("copyng into bucket")) if err != nil { return err } return w.Close() }, wantURL: "/upload/storage/v1/b/test-bucket/o?alt=json&name=file&prettyPrint=false&projection=full&uploadType=multipart", wantMethod: "POST", }, { desc: "Download an object", runOp: func() error { rc, err := c.Bucket("test-bucket").Object("file").NewReader(ctx) if err != nil { return err } _, err = io.Copy(ioutil.Discard, rc) if err != nil { return err } return rc.Close() }, wantURL: "/test-bucket/file", wantMethod: "GET", }, { desc: "Delete bucket", runOp: func() error { return c.Bucket("test-bucket").Delete(ctx) }, wantURL: "/storage/v1/b/test-bucket?alt=json&prettyPrint=false", wantMethod: "DELETE", }, } // Check that the calls made to the server are as expected // given the operations performed for _, op := range operations { if err := op.runOp(); err != nil { t.Errorf("%s: %v", op.desc, err) } u, method := <-gotURL, <-gotMethod if u != op.wantURL { t.Errorf("%s: unexpected request URL\ngot %q\nwant %q", op.desc, u, op.wantURL) } if method != op.wantMethod { t.Errorf("%s: unexpected request method\ngot %q\nwant %q", op.desc, method, op.wantMethod) } if got := <-gotHost; got != tc.wantHost { t.Errorf("%s: unexpected request host\ngot %q\nwant %q", op.desc, got, tc.wantHost) } } // Check that the client fields have not changed if c.raw.BasePath != originalRawBasePath { t.Errorf("raw.BasePath changed\n\tgot:\t\t%v\n\toriginal:\t%v", c.raw.BasePath, originalRawBasePath) } if c.xmlHost != originalXMLHost { t.Errorf("xmlHost changed\n\tgot:\t\t%v\n\toriginal:\t%v", c.xmlHost, originalXMLHost) } if c.scheme != originalScheme { t.Errorf("scheme changed\n\tgot:\t\t%v\n\toriginal:\t%v", c.scheme, originalScheme) } done <- true }() select { case <-timeout: t.Errorf("test timeout") timedOut <- true case <-done: } }) } } func TestSignedURLOptionsClone(t *testing.T) { t.Parallel() opts := &SignedURLOptions{ GoogleAccessID: "accessID", PrivateKey: []byte{}, SignBytes: func(b []byte) ([]byte, error) { return b, nil }, Method: "GET", Expires: time.Now(), ContentType: "text/plain", Headers: []string{}, QueryParameters: map[string][]string{}, MD5: "some-checksum", Style: VirtualHostedStyle(), Insecure: true, Scheme: SigningSchemeV2, Hostname: "localhost:8000", } // Check that all fields are set to a non-zero value, so we can check that // clone accurately clones all fields and catch newly added fields not cloned reflectOpts := reflect.ValueOf(*opts) for i := 0; i < reflectOpts.NumField(); i++ { zero, err := isZeroValue(reflectOpts.Field(i)) if err != nil { t.Errorf("IsZero: %v", err) } if zero { t.Errorf("SignedURLOptions field %d not set", i) } } // Check that fields are properly cloned optsClone := opts.clone() // We need a special comparer for functions signBytesComp := func(a func([]byte) ([]byte, error), b func([]byte) ([]byte, error)) bool { return reflect.ValueOf(a) == reflect.ValueOf(b) } if diff := cmp.Diff(opts, optsClone, cmp.Comparer(signBytesComp), cmp.AllowUnexported(SignedURLOptions{})); diff != "" { t.Errorf("clone does not match (original: -, cloned: +):\n%s", diff) } } func TestParseProjectNumber(t *testing.T) { for _, tst := range []struct { input string want uint64 }{ {"projects/123", 123}, {"projects/123/foos/456", 123}, {"projects/abc-123/foos/456", 0}, {"projects/abc-123", 0}, {"projects/abc", 0}, {"projects/abc/foos", 0}, } { if got := parseProjectNumber(tst.input); got != tst.want { t.Errorf("For %q: got %v, expected %v", tst.input, got, tst.want) } } } func TestObjectValidate(t *testing.T) { for _, c := range []struct { name string bucket string object string wantSuccess bool }{ { name: "valid object", bucket: "my-bucket", object: "my-object", wantSuccess: true, }, { name: "empty bucket name", bucket: "", object: "my-object", wantSuccess: false, }, { name: "empty object name", bucket: "my-bucket", object: "", wantSuccess: false, }, { name: "invalid utf-8", bucket: "my-bucket", object: "\xc3\x28", wantSuccess: false, }, { name: "object name .", bucket: "my-bucket", object: ".", wantSuccess: false, }, } { t.Run(c.name, func(r *testing.T) { b := &BucketHandle{name: c.bucket} err := b.Object(c.object).validate() if c.wantSuccess && err != nil { r.Errorf("want success, got error %v", err) } if !c.wantSuccess && err == nil { r.Errorf("want error, got nil") } }) } } // isZeroValue reports whether v is the zero value for its type // It errors if the argument is unknown func isZeroValue(v reflect.Value) (bool, error) { switch v.Kind() { case reflect.Bool: return !v.Bool(), nil case reflect.Int, reflect.Int64: return v.Int() == 0, nil case reflect.Uint, reflect.Uint64: return v.Uint() == 0, nil case reflect.Array: for i := 0; i < v.Len(); i++ { zero, err := isZeroValue(v.Index(i)) if !zero || err != nil { return false, err } } return true, nil case reflect.Func, reflect.Interface, reflect.Map, reflect.Slice, reflect.Ptr: return v.IsNil(), nil case reflect.String: return v.Len() == 0, nil case reflect.Struct: for i := 0; i < v.NumField(); i++ { zero, err := isZeroValue(v.Field(i)) if !zero || err != nil { return false, err } } return true, nil default: return false, fmt.Errorf("unable to check kind %s", v.Kind()) } }