diff --git a/registry/remote/example_test.go b/registry/remote/example_test.go index 5d251189..35502a94 100644 --- a/registry/remote/example_test.go +++ b/registry/remote/example_test.go @@ -194,6 +194,7 @@ func TestMain(m *testing.M) { w.Header().Set("Content-Type", ocispec.MediaTypeImageManifest) w.Header().Set("Docker-Content-Digest", exampleManifestDigest) w.Header().Set("Content-Length", strconv.Itoa(len([]byte(exampleManifest)))) + w.Header().Set("Warning", `299 - "This image is deprecated and will be removed soon."`) if m == "GET" { w.Write([]byte(exampleManifest)) } @@ -730,6 +731,41 @@ func Example_pullByDigest() { // {"schemaVersion":2,"config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:569224ae188c06e97b9fcadaeb2358fb0fb7c4eb105d49aee2620b2719abea43","size":22},"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar","digest":"sha256:ef79e47691ad1bc702d7a256da6323ec369a8fc3159b4f1798a47136f3b38c10","size":21}]} } +func Example_handleWarning() { + repo, err := remote.NewRepository(fmt.Sprintf("%s/%s", host, exampleRepositoryName)) + if err != nil { + panic(err) + } + // 1. specify HandleWarning + repo.HandleWarning = func(warning remote.Warning) { + fmt.Printf("Warning from %s: %s\n", repo.Reference.Repository, warning.Text) + } + + ctx := context.Background() + exampleDigest := "sha256:b53dc03a49f383ba230d8ac2b78a9c4aec132e4a9f36cc96524df98163202cc7" + // 2. resolve the descriptor + descriptor, err := repo.Resolve(ctx, exampleDigest) + if err != nil { + panic(err) + } + fmt.Println(descriptor.Digest) + fmt.Println(descriptor.Size) + + // 3. fetch the content byte[] from the repository + pulledBlob, err := content.FetchAll(ctx, repo, descriptor) + if err != nil { + panic(err) + } + fmt.Println(string(pulledBlob)) + + // Output: + // Warning from example: This image is deprecated and will be removed soon. + // sha256:b53dc03a49f383ba230d8ac2b78a9c4aec132e4a9f36cc96524df98163202cc7 + // 337 + // Warning from example: This image is deprecated and will be removed soon. + // {"schemaVersion":2,"config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:569224ae188c06e97b9fcadaeb2358fb0fb7c4eb105d49aee2620b2719abea43","size":22},"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar","digest":"sha256:ef79e47691ad1bc702d7a256da6323ec369a8fc3159b4f1798a47136f3b38c10","size":21}]} +} + // Example_pushAndTag gives example snippet of pushing an OCI image with a tag. func Example_pushAndTag() { repo, err := remote.NewRepository(fmt.Sprintf("%s/%s", host, exampleRepositoryName)) diff --git a/registry/remote/registry.go b/registry/remote/registry.go index bd6a3b0d..8ae538d9 100644 --- a/registry/remote/registry.go +++ b/registry/remote/registry.go @@ -73,6 +73,21 @@ func (r *Registry) client() Client { return r.Client } +// do sends an HTTP request and returns an HTTP response using the HTTP client +// returned by r.client(). +func (r *Registry) do(req *http.Request) (*http.Response, error) { + if r.HandleWarning == nil { + return r.client().Do(req) + } + + resp, err := r.client().Do(req) + if err != nil { + return nil, err + } + handleWarningHeaders(resp.Header.Values(headerWarning), r.HandleWarning) + return resp, nil +} + // Ping checks whether or not the registry implement Docker Registry API V2 or // OCI Distribution Specification. // Ping can be used to check authentication when an auth client is configured. @@ -87,7 +102,7 @@ func (r *Registry) Ping(ctx context.Context) error { return err } - resp, err := r.client().Do(req) + resp, err := r.do(req) if err != nil { return err } @@ -142,7 +157,7 @@ func (r *Registry) repositories(ctx context.Context, last string, fn func(repos } req.URL.RawQuery = q.Encode() } - resp, err := r.client().Do(req) + resp, err := r.do(req) if err != nil { return "", err } diff --git a/registry/remote/registry_test.go b/registry/remote/registry_test.go index 994e8db3..8f91c4e1 100644 --- a/registry/remote/registry_test.go +++ b/registry/remote/registry_test.go @@ -16,9 +16,11 @@ limitations under the License. package remote import ( + "bytes" "context" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "net/url" @@ -266,6 +268,118 @@ func TestRegistry_Repositories_WithLastParam(t *testing.T) { } } +func TestRegistry_do(t *testing.T) { + data := []byte(`hello world!`) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/test" { + t.Errorf("unexpected access: %s %s", r.Method, r.URL) + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Add("Warning", `299 - "Test 1: Good warning."`) + w.Header().Add("Warning", `199 - "Test 2: Warning with a non-299 code."`) + w.Header().Add("Warning", `299 - "Test 3: Good warning."`) + w.Header().Add("Warning", `299 myregistry.example.com "Test 4: Warning with a non-unknown agent"`) + w.Header().Add("Warning", `299 - "Test 5: Warning with a date." "Sat, 25 Aug 2012 23:34:45 GMT"`) + w.Header().Add("wArnIng", `299 - "Test 6: Good warning."`) + w.Write(data) + })) + defer ts.Close() + uri, err := url.Parse(ts.URL) + if err != nil { + t.Fatalf("invalid test http server: %v", err) + } + testURL := ts.URL + "/test" + + // test do() without HandleWarning + reg, err := NewRegistry(uri.Host) + if err != nil { + t.Fatal("NewRegistry() error =", err) + } + req, err := http.NewRequest(http.MethodGet, testURL, nil) + if err != nil { + t.Fatal("failed to create test request:", err) + } + resp, err := reg.do(req) + if err != nil { + t.Fatal("Registry.do() error =", err) + } + if resp.StatusCode != http.StatusOK { + t.Errorf("Registry.do() status code = %v, want %v", resp.StatusCode, http.StatusOK) + } + if got := len(resp.Header["Warning"]); got != 6 { + t.Errorf("Registry.do() warning header len = %v, want %v", got, 6) + } + got, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal("io.ReadAll() error =", err) + } + resp.Body.Close() + if !bytes.Equal(got, data) { + t.Errorf("Registry.do() = %v, want %v", got, data) + } + + // test do() with HandleWarning + reg, err = NewRegistry(uri.Host) + if err != nil { + t.Fatal("NewRegistry() error =", err) + } + var gotWarnings []Warning + reg.HandleWarning = func(warning Warning) { + gotWarnings = append(gotWarnings, warning) + } + + req, err = http.NewRequest(http.MethodGet, testURL, nil) + if err != nil { + t.Fatal("failed to create test request:", err) + } + resp, err = reg.do(req) + if err != nil { + t.Fatal("Registry.do() error =", err) + } + if resp.StatusCode != http.StatusOK { + t.Errorf("Registry.do() status code = %v, want %v", resp.StatusCode, http.StatusOK) + } + if got := len(resp.Header["Warning"]); got != 6 { + t.Errorf("Registry.do() warning header len = %v, want %v", got, 6) + } + got, err = io.ReadAll(resp.Body) + if err != nil { + t.Errorf("Registry.do() = %v, want %v", got, data) + } + resp.Body.Close() + if !bytes.Equal(got, data) { + t.Errorf("Registry.do() = %v, want %v", got, data) + } + + wantWarnings := []Warning{ + { + WarningValue: WarningValue{ + Code: 299, + Agent: "-", + Text: "Test 1: Good warning.", + }, + }, + { + WarningValue: WarningValue{ + Code: 299, + Agent: "-", + Text: "Test 3: Good warning.", + }, + }, + { + WarningValue: WarningValue{ + Code: 299, + Agent: "-", + Text: "Test 6: Good warning.", + }, + }, + } + if !reflect.DeepEqual(gotWarnings, wantWarnings) { + t.Errorf("Registry.do() = %v, want %v", gotWarnings, wantWarnings) + } +} + // indexOf returns the index of an element within a slice func indexOf(element string, data []string) int { for ind, val := range data { diff --git a/registry/remote/repository.go b/registry/remote/repository.go index 794711ee..0f8c6acd 100644 --- a/registry/remote/repository.go +++ b/registry/remote/repository.go @@ -139,6 +139,14 @@ type Repository struct { // - https://github.com/opencontainers/distribution-spec/blob/v1.1.0-rc3/spec.md#deleting-manifests SkipReferrersGC bool + // HandleWarning handles the warning returned by the remote server. + // Callers SHOULD deduplicate warnings from multiple associated responses. + // + // References: + // - https://github.com/opencontainers/distribution-spec/blob/v1.1.0-rc3/spec.md#warnings + // - https://www.rfc-editor.org/rfc/rfc7234#section-5.5 + HandleWarning func(warning Warning) + // NOTE: Must keep fields in sync with newRepositoryWithOptions function. // referrersState represents that if the repository supports Referrers API. @@ -234,6 +242,21 @@ func (r *Repository) client() Client { return r.Client } +// do sends an HTTP request and returns an HTTP response using the HTTP client +// returned by r.client(). +func (r *Repository) do(req *http.Request) (*http.Response, error) { + if r.HandleWarning == nil { + return r.client().Do(req) + } + + resp, err := r.client().Do(req) + if err != nil { + return nil, err + } + handleWarningHeaders(resp.Header.Values(headerWarning), r.HandleWarning) + return resp, nil +} + // blobStore detects the blob store for the given descriptor. func (r *Repository) blobStore(desc ocispec.Descriptor) registry.BlobStore { if isManifest(r.ManifestMediaTypes, desc) { @@ -391,7 +414,7 @@ func (r *Repository) tags(ctx context.Context, last string, fn func(tags []strin } req.URL.RawQuery = q.Encode() } - resp, err := r.client().Do(req) + resp, err := r.do(req) if err != nil { return "", err } @@ -508,7 +531,7 @@ func (r *Repository) referrersPageByAPI(ctx context.Context, artifactType string req.URL.RawQuery = q.Encode() } - resp, err := r.client().Do(req) + resp, err := r.do(req) if err != nil { return "", err } @@ -619,7 +642,7 @@ func (r *Repository) pingReferrers(ctx context.Context) (bool, error) { if err != nil { return false, err } - resp, err := r.client().Do(req) + resp, err := r.do(req) if err != nil { return false, err } @@ -657,7 +680,7 @@ func (r *Repository) delete(ctx context.Context, target ocispec.Descriptor, isMa return err } - resp, err := r.client().Do(req) + resp, err := r.do(req) if err != nil { return err } @@ -689,7 +712,7 @@ func (s *blobStore) Fetch(ctx context.Context, target ocispec.Descriptor) (rc io return nil, err } - resp, err := s.repo.client().Do(req) + resp, err := s.repo.do(req) if err != nil { return nil, err } @@ -736,7 +759,7 @@ func (s *blobStore) Mount(ctx context.Context, desc ocispec.Descriptor, fromRepo if err != nil { return err } - resp, err := s.repo.client().Do(req) + resp, err := s.repo.do(req) if err != nil { return err } @@ -809,7 +832,7 @@ func (s *blobStore) Push(ctx context.Context, expected ocispec.Descriptor, conte return err } - resp, err := s.repo.client().Do(req) + resp, err := s.repo.do(req) if err != nil { return err } @@ -864,7 +887,7 @@ func (s *blobStore) completePushAfterInitialPost(ctx context.Context, req *http. if auth := resp.Request.Header.Get("Authorization"); auth != "" { req.Header.Set("Authorization", auth) } - resp, err = s.repo.client().Do(req) + resp, err = s.repo.do(req) if err != nil { return err } @@ -910,7 +933,7 @@ func (s *blobStore) Resolve(ctx context.Context, reference string) (ocispec.Desc return ocispec.Descriptor{}, err } - resp, err := s.repo.client().Do(req) + resp, err := s.repo.do(req) if err != nil { return ocispec.Descriptor{}, err } @@ -945,7 +968,7 @@ func (s *blobStore) FetchReference(ctx context.Context, reference string) (desc return ocispec.Descriptor{}, nil, err } - resp, err := s.repo.client().Do(req) + resp, err := s.repo.do(req) if err != nil { return ocispec.Descriptor{}, nil, err } @@ -1021,7 +1044,7 @@ func (s *manifestStore) Fetch(ctx context.Context, target ocispec.Descriptor) (r } req.Header.Set("Accept", target.MediaType) - resp, err := s.repo.client().Do(req) + resp, err := s.repo.do(req) if err != nil { return nil, err } @@ -1147,7 +1170,7 @@ func (s *manifestStore) Resolve(ctx context.Context, reference string) (ocispec. } req.Header.Set("Accept", manifestAcceptHeader(s.repo.ManifestMediaTypes)) - resp, err := s.repo.client().Do(req) + resp, err := s.repo.do(req) if err != nil { return ocispec.Descriptor{}, err } @@ -1179,7 +1202,7 @@ func (s *manifestStore) FetchReference(ctx context.Context, reference string) (d } req.Header.Set("Accept", manifestAcceptHeader(s.repo.ManifestMediaTypes)) - resp, err := s.repo.client().Do(req) + resp, err := s.repo.do(req) if err != nil { return ocispec.Descriptor{}, nil, err } @@ -1277,7 +1300,7 @@ func (s *manifestStore) push(ctx context.Context, expected ocispec.Descriptor, c return err } } - resp, err := client.Do(req) + resp, err := s.repo.do(req) if err != nil { return err } diff --git a/registry/remote/repository_test.go b/registry/remote/repository_test.go index bf0a2ad6..fdd5e9ce 100644 --- a/registry/remote/repository_test.go +++ b/registry/remote/repository_test.go @@ -6832,3 +6832,115 @@ func TestRepository_pingReferrers_Concurrent(t *testing.T) { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateSupported) } } + +func TestRepository_do(t *testing.T) { + data := []byte(`hello world!`) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/test" { + t.Errorf("unexpected access: %s %s", r.Method, r.URL) + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Add("Warning", `299 - "Test 1: Good warning."`) + w.Header().Add("Warning", `199 - "Test 2: Warning with a non-299 code."`) + w.Header().Add("Warning", `299 - "Test 3: Good warning."`) + w.Header().Add("Warning", `299 myregistry.example.com "Test 4: Warning with a non-unknown agent"`) + w.Header().Add("Warning", `299 - "Test 5: Warning with a date." "Sat, 25 Aug 2012 23:34:45 GMT"`) + w.Header().Add("wArnIng", `299 - "Test 6: Good warning."`) + w.Write(data) + })) + defer ts.Close() + uri, err := url.Parse(ts.URL) + if err != nil { + t.Fatalf("invalid test http server: %v", err) + } + testURL := ts.URL + "/test" + + // test do() without HandleWarning + repo, err := NewRepository(uri.Host + "/test") + if err != nil { + t.Fatal("NewRepository() error =", err) + } + req, err := http.NewRequest(http.MethodGet, testURL, nil) + if err != nil { + t.Fatal("failed to create test request:", err) + } + resp, err := repo.do(req) + if err != nil { + t.Fatal("Repository.do() error =", err) + } + if resp.StatusCode != http.StatusOK { + t.Errorf("Repository.do() status code = %v, want %v", resp.StatusCode, http.StatusOK) + } + if got := len(resp.Header["Warning"]); got != 6 { + t.Errorf("Repository.do() warning header len = %v, want %v", got, 6) + } + got, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal("io.ReadAll() error =", err) + } + resp.Body.Close() + if !bytes.Equal(got, data) { + t.Errorf("Repository.do() = %v, want %v", got, data) + } + + // test do() with HandleWarning + repo, err = NewRepository(uri.Host + "/test") + if err != nil { + t.Fatal("NewRepository() error =", err) + } + var gotWarnings []Warning + repo.HandleWarning = func(warning Warning) { + gotWarnings = append(gotWarnings, warning) + } + + req, err = http.NewRequest(http.MethodGet, testURL, nil) + if err != nil { + t.Fatal("failed to create test request:", err) + } + resp, err = repo.do(req) + if err != nil { + t.Fatal("Repository.do() error =", err) + } + if resp.StatusCode != http.StatusOK { + t.Errorf("Repository.do() status code = %v, want %v", resp.StatusCode, http.StatusOK) + } + if got := len(resp.Header["Warning"]); got != 6 { + t.Errorf("Repository.do() warning header len = %v, want %v", got, 6) + } + got, err = io.ReadAll(resp.Body) + if err != nil { + t.Errorf("Repository.do() = %v, want %v", got, data) + } + resp.Body.Close() + if !bytes.Equal(got, data) { + t.Errorf("Repository.do() = %v, want %v", got, data) + } + + wantWarnings := []Warning{ + { + WarningValue: WarningValue{ + Code: 299, + Agent: "-", + Text: "Test 1: Good warning.", + }, + }, + { + WarningValue: WarningValue{ + Code: 299, + Agent: "-", + Text: "Test 3: Good warning.", + }, + }, + { + WarningValue: WarningValue{ + Code: 299, + Agent: "-", + Text: "Test 6: Good warning.", + }, + }, + } + if !reflect.DeepEqual(gotWarnings, wantWarnings) { + t.Errorf("Repository.do() = %v, want %v", gotWarnings, wantWarnings) + } +} diff --git a/registry/remote/warning.go b/registry/remote/warning.go new file mode 100644 index 00000000..ff8f9c02 --- /dev/null +++ b/registry/remote/warning.go @@ -0,0 +1,100 @@ +/* +Copyright The ORAS Authors. +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 remote + +import ( + "errors" + "fmt" + "strconv" + "strings" +) + +const ( + // headerWarning is the "Warning" header. + // Reference: https://www.rfc-editor.org/rfc/rfc7234#section-5.5 + headerWarning = "Warning" + + // warnCode299 is the 299 warn-code. + // Reference: https://www.rfc-editor.org/rfc/rfc7234#section-5.5 + warnCode299 = 299 + + // warnAgentUnknown represents an unknown warn-agent. + // Reference: https://www.rfc-editor.org/rfc/rfc7234#section-5.5 + warnAgentUnknown = "-" +) + +// errUnexpectedWarningFormat is returned by parseWarningHeader when +// an unexpected warning format is encountered. +var errUnexpectedWarningFormat = errors.New("unexpected warning format") + +// WarningValue represents the value of the Warning header. +// +// References: +// - https://github.com/opencontainers/distribution-spec/blob/v1.1.0-rc3/spec.md#warnings +// - https://www.rfc-editor.org/rfc/rfc7234#section-5.5 +type WarningValue struct { + // Code is the warn-code. + Code int + // Agent is the warn-agent. + Agent string + // Text is the warn-text. + Text string +} + +// Warning contains the value of the warning header and may contain +// other information related to the warning. +// +// References: +// - https://github.com/opencontainers/distribution-spec/blob/v1.1.0-rc3/spec.md#warnings +// - https://www.rfc-editor.org/rfc/rfc7234#section-5.5 +type Warning struct { + // WarningValue is the value of the warning header. + WarningValue +} + +// parseWarningHeader parses the warning header into WarningValue. +func parseWarningHeader(header string) (WarningValue, error) { + if len(header) < 9 || !strings.HasPrefix(header, `299 - "`) || !strings.HasSuffix(header, `"`) { + // minimum header value: `299 - "x"` + return WarningValue{}, fmt.Errorf("%s: %w", header, errUnexpectedWarningFormat) + } + + // validate text only as code and agent are fixed + quotedText := header[6:] // behind `299 - `, quoted by " + text, err := strconv.Unquote(quotedText) + if err != nil { + return WarningValue{}, fmt.Errorf("%s: unexpected text: %w: %v", header, errUnexpectedWarningFormat, err) + } + + return WarningValue{ + Code: warnCode299, + Agent: warnAgentUnknown, + Text: text, + }, nil +} + +// handleWarningHeaders parses the warning headers and handles the parsed +// warnings using handleWarning. +func handleWarningHeaders(headers []string, handleWarning func(Warning)) { + for _, h := range headers { + if value, err := parseWarningHeader(h); err == nil { + // ignore warnings in unexpected formats + handleWarning(Warning{ + WarningValue: value, + }) + } + } +} diff --git a/registry/remote/warning_test.go b/registry/remote/warning_test.go new file mode 100644 index 00000000..d8c22b66 --- /dev/null +++ b/registry/remote/warning_test.go @@ -0,0 +1,158 @@ +/* +Copyright The ORAS Authors. +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 remote + +import ( + "errors" + "reflect" + "testing" +) + +func Test_parseWarningHeader(t *testing.T) { + tests := []struct { + name string + header string + want WarningValue + wantErr error + }{ + { + name: "Valid warning", + header: `299 - "This is a warning."`, + want: WarningValue{ + Code: 299, + Agent: "-", + Text: "This is a warning.", + }, + }, + { + name: "Valid meaningless warning", + header: `299 - " "`, + want: WarningValue{ + Code: 299, + Agent: "-", + Text: " ", + }, + }, + { + name: "Multiple spaces in warning", + header: `299 - "This is a warning."`, + want: WarningValue{}, + wantErr: errUnexpectedWarningFormat, + }, + { + name: "Leading space in warning", + header: ` 299 - "This is a warning."`, + want: WarningValue{}, + wantErr: errUnexpectedWarningFormat, + }, + { + name: "Trailing space in warning", + header: `299 - "This is a warning." `, + want: WarningValue{}, + wantErr: errUnexpectedWarningFormat, + }, + { + name: "Warning with a non-299 code", + header: `199 - "This is a warning."`, + want: WarningValue{}, + wantErr: errUnexpectedWarningFormat, + }, + { + name: "Warning with a non-unknown agent", + header: `299 localhost:5000 "This is a warning."`, + want: WarningValue{}, + wantErr: errUnexpectedWarningFormat, + }, + { + name: "Warning with a date", + header: `299 - "This is a warning." "Sat, 25 Aug 2012 23:34:45 GMT"`, + want: WarningValue{}, + wantErr: errUnexpectedWarningFormat, + }, + { + name: "Invalid format", + header: `299 - "This is a warning." something strange`, + want: WarningValue{}, + wantErr: errUnexpectedWarningFormat, + }, + { + name: "Not a warning", + header: `foo bar baz`, + want: WarningValue{}, + wantErr: errUnexpectedWarningFormat, + }, + { + name: "No code", + header: `- "This is a warning."`, + want: WarningValue{}, + wantErr: errUnexpectedWarningFormat, + }, + { + name: "No agent", + header: `299 "This is a warning."`, + want: WarningValue{}, + wantErr: errUnexpectedWarningFormat, + }, + { + name: "No text", + header: `299 -`, + want: WarningValue{}, + wantErr: errUnexpectedWarningFormat, + }, + { + name: "Empty text", + header: `299 - ""`, + want: WarningValue{}, + wantErr: errUnexpectedWarningFormat, + }, + { + name: "Unquoted text", + header: `299 - This is a warning.`, + want: WarningValue{}, + wantErr: errUnexpectedWarningFormat, + }, + { + name: "Single-quoted text", + header: `299 - 'This is a warning.'`, + want: WarningValue{}, + wantErr: errUnexpectedWarningFormat, + }, + { + name: "Back-quoted text", + header: "299 - `This is a warning.`", + want: WarningValue{}, + wantErr: errUnexpectedWarningFormat, + }, + { + name: "Invalid quotes", + header: `299 - 'This is a warning."`, + want: WarningValue{}, + wantErr: errUnexpectedWarningFormat, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseWarningHeader(tt.header) + if !errors.Is(err, tt.wantErr) { + t.Errorf("parseWarningHeader() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("parseWarningHeader() = %v, want %v", got, tt.want) + } + }) + } +}