package storage_test import ( "errors" "reflect" "strings" "testing" "sneak.berlin/go/vaultik/internal/storage" ) // TestParseStorageURLValid checks that each supported scheme parses into // the expected fields, since those fields decide which backend is built. func TestParseStorageURLValid(t *testing.T) { t.Parallel() const bucket = "mybucket" cases := []struct { name string raw string want *storage.URL }{ { name: "file absolute path", raw: "file:///var/backups/vaultik", want: &storage.URL{Scheme: "file", Prefix: "/var/backups/vaultik"}, }, { name: "s3 bucket and prefix, ssl defaults on", raw: "s3://mybucket/backups/host", want: &storage.URL{ Scheme: "s3", Bucket: bucket, Prefix: "backups/host", UseSSL: true, }, }, { name: "s3 bucket only", raw: "s3://mybucket", want: &storage.URL{Scheme: "s3", Bucket: bucket, UseSSL: true}, }, { name: "s3 with endpoint, region, ssl off", raw: "s3://mybucket?endpoint=minio.example.com®ion=us-west-2&ssl=false", want: &storage.URL{ Scheme: "s3", Bucket: bucket, Endpoint: "minio.example.com", Region: "us-west-2", UseSSL: false, }, }, { name: "rclone remote and path", raw: "rclone://gdrive/backups/host", want: &storage.URL{ Scheme: "rclone", RcloneRemote: "gdrive", Prefix: "backups/host", }, }, { name: "rclone remote only", raw: "rclone://gdrive", want: &storage.URL{Scheme: "rclone", RcloneRemote: "gdrive"}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() got, err := storage.ParseStorageURL(tc.raw) if err != nil { t.Fatalf("ParseStorageURL(%q) returned error: %v", tc.raw, err) } if !reflect.DeepEqual(got, tc.want) { t.Errorf("ParseStorageURL(%q) = %+v, want %+v", tc.raw, got, tc.want) } }) } } // TestParseStorageURLErrors checks that empty, missing, and unknown-scheme // inputs fail with the documented sentinel errors instead of parsing to a // wrong destination. func TestParseStorageURLErrors(t *testing.T) { t.Parallel() cases := []struct { name string raw string wantErr error }{ {"empty url", "", storage.ErrEmptyStorageURL}, {"file empty path", "file://", storage.ErrEmptyFilePath}, {"s3 missing bucket", "s3://", storage.ErrMissingBucket}, {"s3 missing bucket with path", "s3:///justprefix", storage.ErrMissingBucket}, {"rclone missing remote", "rclone://", storage.ErrMissingRemote}, {"unknown scheme", "gs://bucket/x", storage.ErrUnsupportedScheme}, {"no scheme", "/local/path", storage.ErrUnsupportedScheme}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() _, err := storage.ParseStorageURL(tc.raw) if !errors.Is(err, tc.wantErr) { t.Errorf("ParseStorageURL(%q) error = %v, want %v", tc.raw, err, tc.wantErr) } }) } } // TestParseStorageURLRejectsCredentials checks that a URL carrying // credentials in its userinfo or in an unknown query parameter is // rejected, and that the error never echoes the secret-bearing URL back // into logs or output. func TestParseStorageURLRejectsCredentials(t *testing.T) { t.Parallel() // Split so the literals never form a "user:pass@" URL pattern that // tooling would flag as a real hardcoded credential. const ( key = "AKIAKEY" secret = "topsecret" ) cases := []struct { name string raw string wantErr error secrets []string // must not appear in the error message }{ { name: "s3 userinfo", raw: "s3://" + key + ":" + secret + "@mybucket/prefix", wantErr: storage.ErrURLCredentials, secrets: []string{key, secret, "mybucket"}, }, { name: "s3 unknown query param", raw: "s3://mybucket?access_key=" + key + "&secret=" + secret, wantErr: storage.ErrURLUnknownParam, secrets: []string{key, secret}, }, { name: "s3 misspelt endpoint", raw: "s3://mybucket?endpiont=minio.example.com", wantErr: storage.ErrURLUnknownParam, secrets: nil, }, { name: "rclone userinfo", raw: "rclone://user:" + secret + "@gdrive/backups", wantErr: storage.ErrURLCredentials, secrets: []string{secret}, }, { name: "rclone query param", raw: "rclone://gdrive/backups?token=" + secret, wantErr: storage.ErrURLUnknownParam, secrets: []string{secret}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() _, err := storage.ParseStorageURL(tc.raw) if !errors.Is(err, tc.wantErr) { t.Fatalf("ParseStorageURL(%q) error = %v, want %v", tc.raw, err, tc.wantErr) } // The rejection must name the proper config keys so the // operator knows where credentials belong. for _, key := range []string{"s3.access_key_id", "s3.secret_access_key"} { if !strings.Contains(err.Error(), key) { t.Errorf("error %q does not name %q", err.Error(), key) } } for _, secret := range tc.secrets { if strings.Contains(err.Error(), secret) { t.Errorf("error message leaked %q: %v", secret, err.Error()) } } }) } } // TestParseStorageURLParseFailureHidesURL checks that when url.Parse // itself fails, the wrapped error carries only the inner cause, not the // *url.Error whose text embeds the raw (possibly credential-bearing) URL. func TestParseStorageURLParseFailureHidesURL(t *testing.T) { t.Parallel() const raw = "s3://mybucket/%zz" _, err := storage.ParseStorageURL(raw) if err == nil { t.Fatalf("ParseStorageURL(%q) returned no error", raw) } if strings.Contains(err.Error(), "mybucket") { t.Errorf("error message echoed the raw URL: %v", err.Error()) } }