diff --git a/changes/20250925093723.feature b/changes/20250925093723.feature new file mode 100644 index 0000000000..25b23b5322 --- /dev/null +++ b/changes/20250925093723.feature @@ -0,0 +1 @@ +:sparkles: [headers] support for [TUS](https://tus.io/protocols/resumable-upload) headers diff --git a/changes/20250925122057.feature b/changes/20250925122057.feature new file mode 100644 index 0000000000..7db49ba616 --- /dev/null +++ b/changes/20250925122057.feature @@ -0,0 +1 @@ +:sparkles: `[collection]` Add a `MapWithError` function diff --git a/utils/collection/search.go b/utils/collection/search.go index f6b5156412..003e1b9524 100644 --- a/utils/collection/search.go +++ b/utils/collection/search.go @@ -87,6 +87,7 @@ func Filter[S ~[]E, E any](s S, f FilterFunc[E]) (result S) { } type MapFunc[T1, T2 any] func(T1) T2 +type MapWithErrorFunc[T1, T2 any] func(T1) (T2, error) func IdentityMapFunc[T any]() MapFunc[T, T] { return func(i T) T { @@ -105,6 +106,22 @@ func Map[T1 any, T2 any](s []T1, f MapFunc[T1, T2]) (result []T2) { return result } +// MapWithError creates a new slice and populates it with the results of calling the provided function on every element in input slice. If an error happens, the mapping stops and the error returned. +func MapWithError[T1 any, T2 any](s []T1, f MapWithErrorFunc[T1, T2]) (result []T2, err error) { + result = make([]T2, len(s)) + + for i := range s { + var subErr error + result[i], subErr = f(s[i]) + if subErr != nil { + err = subErr + return + } + } + + return +} + // Reject is the opposite of Filter and returns the elements of collection for which the filtering function f returns false. // This is functionally equivalent to slices.DeleteFunc but it returns a new slice. func Reject[S ~[]E, E any](s S, f FilterFunc[E]) S { diff --git a/utils/collection/search_test.go b/utils/collection/search_test.go index e8aaa31f6c..f9b9b92240 100644 --- a/utils/collection/search_test.go +++ b/utils/collection/search_test.go @@ -11,6 +11,9 @@ import ( "github.com/go-faker/faker/v4" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ARM-software/golang-utils/utils/safecast" ) func TestFind(t *testing.T) { @@ -140,10 +143,17 @@ func TestMap(t *testing.T) { return fmt.Sprintf("Hello world %v", i) }) assert.ElementsMatch(t, []string{"Hello world 1", "Hello world 2"}, mapped) - mapped = Map([]int64{1, 2, 3, 4}, func(x int64) string { - return strconv.FormatInt(x, 10) + num := []int{1, 2, 3, 4} + numStr := []string{"1", "2", "3", "4"} + mapped = Map(num, func(x int) string { + return strconv.FormatInt(safecast.ToInt64(x), 10) }) - assert.ElementsMatch(t, []string{"1", "2", "3", "4"}, mapped) + assert.ElementsMatch(t, numStr, mapped) + m, err := MapWithError[string, int](numStr, strconv.Atoi) + require.NoError(t, err) + assert.ElementsMatch(t, num, m) + _, err = MapWithError[string, int](append(numStr, faker.Word(), "5"), strconv.Atoi) + require.Error(t, err) } func TestReduce(t *testing.T) { diff --git a/utils/hashing/hash.go b/utils/hashing/hash.go index b3672506cf..0674af7f45 100644 --- a/utils/hashing/hash.go +++ b/utils/hashing/hash.go @@ -11,7 +11,6 @@ import ( "crypto/sha1" //nolint:gosec "crypto/sha256" "encoding/hex" - "fmt" "hash" "io" "math" @@ -76,6 +75,42 @@ func newHashingAlgorithm(htype string, algorithm hash.Hash) (IHash, error) { }, nil } +// DetermineHashingAlgorithmCanonicalReference determines the hashing algorithm reference from a string. +func DetermineHashingAlgorithmCanonicalReference(name string) (ref string, err error) { + n := strings.TrimSpace(strings.ReplaceAll(name, "-", "")) + if reflection.IsEmpty(n) { + err = commonerrors.UndefinedVariable("algorithm name") + return + } + switch { + case strings.EqualFold(HashMd5, n): + ref = HashMd5 + case strings.EqualFold(HashSha1, n): + + ref = HashSha1 + case strings.EqualFold(HashSha256, n): + ref = HashSha256 + case strings.EqualFold(HashMurmur, n): + ref = HashMurmur + case strings.EqualFold(HashXXHash, n): + ref = HashXXHash + case strings.EqualFold(HashBlake2256, n): + ref = HashBlake2256 + default: + err = commonerrors.New(commonerrors.ErrNotFound, "could not find the corresponding hashing algorithm") + } + return +} + +// DetermineHashingAlgorithm returns a hashing algorithm based on a string reference. Similar to NewHashingAlgorithm but more flexible. +func DetermineHashingAlgorithm(algorithm string) (IHash, error) { + htype, err := DetermineHashingAlgorithmCanonicalReference(algorithm) + if err != nil { + return nil, err + } + return NewHashingAlgorithm(htype) +} + func NewHashingAlgorithm(htype string) (IHash, error) { var hash hash.Hash var err error @@ -94,11 +129,11 @@ func NewHashingAlgorithm(htype string) (IHash, error) { hash, err = blake2b.New256(nil) } if err != nil { - return nil, fmt.Errorf("%w: failed loading the hashing algorithm: %v", commonerrors.ErrUnexpected, err.Error()) + return nil, commonerrors.WrapError(commonerrors.ErrUnexpected, err, "failed loading the hashing algorithm") } if hash == nil { - return nil, fmt.Errorf("%w: could not find the corresponding hashing algorithm", commonerrors.ErrNotFound) + return nil, commonerrors.New(commonerrors.ErrNotFound, "could not find the corresponding hashing algorithm") } return newHashingAlgorithm(htype, hash) } diff --git a/utils/http/header_client.go b/utils/http/header_client.go index 9172e2aa06..b7f9debf5a 100644 --- a/utils/http/header_client.go +++ b/utils/http/header_client.go @@ -7,6 +7,8 @@ import ( "slices" "strings" + headers2 "github.com/go-http-utils/headers" + "github.com/ARM-software/golang-utils/utils/commonerrors" "github.com/ARM-software/golang-utils/utils/http/headers" ) @@ -76,13 +78,13 @@ func (c *ClientWithHeaders) Post(url, contentType string, rawBody interface{}) ( if err != nil { return nil, err } - req.Header.Set(headers.HeaderContentType, contentType) // make sure to overrwrite any in the headers + req.Header.Set(headers2.ContentType, contentType) // make sure to overwrite any in the headers return c.client.Do(req) } func (c *ClientWithHeaders) PostForm(url string, data url.Values) (*http.Response, error) { rawBody := strings.NewReader(data.Encode()) - return c.Post(url, headers.HeaderXWWWFormURLEncoded, rawBody) + return c.Post(url, headers.MIMEXWWWFormURLEncoded, rawBody) } func (c *ClientWithHeaders) StandardClient() *http.Client { diff --git a/utils/http/headers/headers.go b/utils/http/headers/headers.go index d0e57ee46d..2a18f14120 100644 --- a/utils/http/headers/headers.go +++ b/utils/http/headers/headers.go @@ -38,8 +38,16 @@ const ( HeaderTusExtension = "Tus-Extension" HeaderTusMaxSize = "Tus-Max-Size" HeaderXHTTPMethodOverride = "X-HTTP-Method-Override" - HeaderXWWWFormURLEncoded = "application/x-www-form-urlencoded" - HeaderContentType = "Content-Type" + // TUS extensions Headers + HeaderUploadMetadata = "Upload-Metadata" // See https://tus.io/protocols/resumable-upload#upload-metadata + HeaderUploadDeferLength = "Upload-Defer-Length" // See https://tus.io/protocols/resumable-upload#upload-defer-length + HeaderUploadExpires = "Upload-Expires" // See https://tus.io/protocols/resumable-upload#upload-expires + HeaderChecksumAlgorithm = "Tus-Checksum-Algorithm" // See https://tus.io/protocols/resumable-upload#tus-checksum-algorithm + HeaderChecksum = "Upload-Checksum" // See https://tus.io/protocols/resumable-upload#upload-checksum + HeaderUploadConcat = "Upload-Concat" // See https://tus.io/protocols/resumable-upload#upload-concat + + MIMEXWWWFormURLEncoded = "application/x-www-form-urlencoded" + MIMETusUpload = "application/offset+octet-stream" ) var ( @@ -62,6 +70,12 @@ var ( HeaderTusExtension, HeaderTusMaxSize, HeaderXHTTPMethodOverride, + HeaderUploadMetadata, + HeaderUploadDeferLength, + HeaderUploadExpires, + HeaderChecksumAlgorithm, + HeaderChecksum, + HeaderUploadConcat, headers.Accept, headers.AcceptCharset, headers.AcceptEncoding, @@ -198,12 +212,12 @@ func ParseAuthorizationHeader(r *http.Request) (string, string, error) { // and makes sure it has 2 parts func ParseAuthorisationValue(authHeader string) (scheme string, token string, err error) { if reflection.IsEmpty(authHeader) { - err = commonerrors.New(commonerrors.ErrUndefined, "authorization header is not set") + err = commonerrors.Newf(commonerrors.ErrUndefined, "`%v` header is not set", headers.Authorization) return } parts := strings.Fields(authHeader) if len(parts) != 2 { - err = commonerrors.New(commonerrors.ErrInvalid, "`Authorization` header contains incorrect number of parts") + err = commonerrors.Newf(commonerrors.ErrInvalid, "`%v` header contains incorrect number of parts", headers.Authorization) return } scheme = parts[0] @@ -342,11 +356,11 @@ func GenerateAuthorizationHeaderValue(scheme string, token string) (value string // AddToUserAgent adds some information to the `User Agent`. func AddToUserAgent(r *http.Request, elements ...string) (err error) { if r == nil { - err = fmt.Errorf("%w: missing request", commonerrors.ErrUndefined) + err = commonerrors.UndefinedVariable("request") return } if reflection.IsEmpty(elements) { - err = fmt.Errorf("%w: empty elements to add", commonerrors.ErrUndefined) + err = commonerrors.New(commonerrors.ErrUndefined, "empty elements to add") return } r.Header.Set(headers.UserAgent, useragent.AddValuesToUserAgent(FetchUserAgent(r), elements...)) diff --git a/utils/http/headers/tus/tus.go b/utils/http/headers/tus/tus.go new file mode 100644 index 0000000000..d48cd5866b --- /dev/null +++ b/utils/http/headers/tus/tus.go @@ -0,0 +1,123 @@ +package tus + +import ( + "context" + "net/url" + "regexp" + "strings" + + "github.com/ARM-software/golang-utils/utils/collection" + "github.com/ARM-software/golang-utils/utils/commonerrors" + "github.com/ARM-software/golang-utils/utils/encoding/base64" + "github.com/ARM-software/golang-utils/utils/field" + "github.com/ARM-software/golang-utils/utils/hashing" + "github.com/ARM-software/golang-utils/utils/reflection" +) + +const KeyTUSMetadata = "filename" + +// ParseTUSHash parses the checksum header value and tries to determine the different elements it contains. +// See https://tus.io/protocols/resumable-upload#upload-checksum +func ParseTUSHash(checksum string) (hashAlgo, hash string, err error) { + if reflection.IsEmpty(checksum) { + err = commonerrors.UndefinedVariable("checksum") + return + } + match := regexp.MustCompile(`^\s*([a-zA-Z0-9-_]+)\s+([-A-Za-z0-9+/]*={0,3})$`).FindStringSubmatch(checksum) + if match == nil || len(match) != 3 { + err = commonerrors.Newf(commonerrors.ErrInvalid, "invalid checksum format") + return + } + + hashAlgo, err = hashing.DetermineHashingAlgorithmCanonicalReference(match[1]) + if err != nil { + err = commonerrors.WrapError(commonerrors.ErrUnsupported, err, "hashing algorithm is not supported") + return + } + + h := strings.TrimSpace(match[2]) + if !base64.IsEncoded(h) { + err = commonerrors.Newf(commonerrors.ErrInvalid, "checksum is not base64 encoded") + return + } + hash, err = base64.DecodeString(context.Background(), h) + if err != nil { + err = commonerrors.WrapError(commonerrors.ErrMarshalling, err, "failed decoding checksum") + } + return +} + +// ParseTUSConcatHeader parses the `Concat` header value https://tus.io/protocols/resumable-upload#upload-concat +func ParseTUSConcatHeader(concat string) (isPartial bool, partials []*url.URL, err error) { + header := strings.TrimSpace(concat) + if reflection.IsEmpty(header) { + err = commonerrors.UndefinedVariable("concat header") + return + } + if strings.EqualFold(header, "partial") { + isPartial = true + return + } + h := strings.TrimPrefix(header, "final;") + if header == h { + err = commonerrors.New(commonerrors.ErrInvalid, "invalid header value") + return + } + partials, err = collection.MapWithError[string, *url.URL](collection.ParseListWithCleanup(h, " "), url.Parse) + if err != nil { + err = commonerrors.WrapError(commonerrors.ErrInvalid, commonerrors.New(commonerrors.ErrMarshalling, "invalid partial url value"), "invalid header value") + } + if len(partials) == 0 { + err = commonerrors.New(commonerrors.ErrInvalid, "no partial url found") + } + return +} + +// ParseTUSMetadataHeader parses the `metadata` header value https://tus.io/protocols/resumable-upload#upload-metadata +func ParseTUSMetadataHeader(header string) (filename *string, elements map[string]any, err error) { + h := strings.TrimSpace(header) + if reflection.IsEmpty(h) { + err = commonerrors.UndefinedVariable("metadata header") + return + } + e := collection.ParseCommaSeparatedList(h) + if len(e) == 0 { + err = commonerrors.UndefinedVariable("metadata header") + return + } + elements = make(map[string]any, len(e)/2) + for i := range e { + subElem := collection.ParseListWithCleanup(e[i], " ") + switch len(subElem) { + case 1: + elements[subElem[0]] = true + case 2: + key := subElem[0] + value := subElem[1] + _, has := elements[key] + if has { + err = commonerrors.WrapError(commonerrors.ErrInvalid, commonerrors.Newf(commonerrors.ErrInvalid, "duplicated key [%v]", key), "invalid metadata element") + return + } + if !base64.IsEncoded(value) { + err = commonerrors.WrapError(commonerrors.ErrInvalid, commonerrors.New(commonerrors.ErrMarshalling, "value is not base64 encoded"), "invalid metadata element") + return + } + v, subErr := base64.DecodeString(context.Background(), value) + if subErr != nil { + err = commonerrors.WrapError(commonerrors.ErrInvalid, commonerrors.New(commonerrors.ErrMarshalling, "value is not base64 encoded"), "invalid metadata element") + return + } + elements[key] = v + if strings.EqualFold(key, KeyTUSMetadata) { + filename = field.ToOptionalString(v) + } + + default: + err = commonerrors.New(commonerrors.ErrInvalid, "invalid metadata header element") + return + } + } + + return +} diff --git a/utils/http/headers/tus/tus_test.go b/utils/http/headers/tus/tus_test.go new file mode 100644 index 0000000000..7384077772 --- /dev/null +++ b/utils/http/headers/tus/tus_test.go @@ -0,0 +1,369 @@ +package tus + +import ( + "encoding/base64" + "fmt" + "net/url" + "strings" + "testing" + + "github.com/go-faker/faker/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ARM-software/golang-utils/utils/collection" + "github.com/ARM-software/golang-utils/utils/commonerrors" + "github.com/ARM-software/golang-utils/utils/commonerrors/errortest" + "github.com/ARM-software/golang-utils/utils/field" + "github.com/ARM-software/golang-utils/utils/hashing" +) + +func TestParseTUSHash(t *testing.T) { + sha1, err := hashing.DetermineHashingAlgorithm("sha1") + require.NoError(t, err) + + tests := []struct { + header string + expectedAlgo string + expectedChecksum string + expectedError error + }{ + { + header: "sha1 MmFhZTZjMzVjOTRmY2ZiNDE1ZGJlOTVmNDA4YjljZTkxZWU4NDZlZA==", + expectedAlgo: hashing.HashSha1, + expectedChecksum: hashing.CalculateStringHash(sha1, "hello world"), + }, + { + header: "sha1 dGhpcyBpcyBhIHRlc3QgdmFsdWUgb2J2aW91c2x5==", + expectedAlgo: hashing.HashSha1, + expectedChecksum: "this is a test value obviously", + }, + { + header: "sha256 dGhpcyBpcyBhIHRlc3QgdmFsdWUgb2J2aW91c2x5=", + expectedAlgo: hashing.HashSha256, + expectedChecksum: "this is a test value obviously", + }, + { + header: "sha1", + expectedAlgo: "", + expectedChecksum: "", + expectedError: commonerrors.ErrInvalid, + }, + { + header: " Lve95gjOVATpfV8EL5X4nxwjKHE=", + expectedAlgo: "", + expectedChecksum: "", + expectedError: commonerrors.ErrInvalid, + }, + { + header: "", + expectedAlgo: "", + expectedChecksum: "", + expectedError: commonerrors.ErrUndefined, + }, + { + header: "sha1 dGhpcyBpcyBhIHRlc3QgdmFsdWUgb2J2aW91c2x5", + expectedAlgo: hashing.HashSha1, + expectedChecksum: "this is a test value obviously", + }, + { + header: "sha1 not_base64!!!", + expectedAlgo: "", + expectedChecksum: "", + expectedError: commonerrors.ErrInvalid, + }, + { + header: "SHA256 dGhpcyBpcyBhIHRlc3QgdmFsdWUgb2J2aW91c2x5=", + expectedAlgo: hashing.HashSha256, + expectedChecksum: "this is a test value obviously", + }, + { + header: "sha1-md5 Lve95gjOVATpfV8EL5X4nxwjKHE=", + expectedAlgo: "sha1-md5", + expectedChecksum: "Lve95gjOVATpfV8EL5X4nxwjKHE=", + expectedError: commonerrors.ErrUnsupported, + }, + { + header: "sha1 Lve95g jOVATpfV8EL5X4nxwjKHE=", + expectedAlgo: "", + expectedChecksum: "", + expectedError: commonerrors.ErrInvalid, + }, + } + + for i := range tests { + test := tests[i] + t.Run(test.header, func(t *testing.T) { + algo, hash, err := ParseTUSHash(test.header) + + if test.expectedError != nil { + errortest.AssertError(t, err, test.expectedError) + } else { + require.NoError(t, err) + assert.Equal(t, test.expectedAlgo, algo) + assert.Equal(t, test.expectedChecksum, hash) + } + }) + } +} + +func TestParseTUSConcatHeader(t *testing.T) { + url1 := strings.ToLower(faker.URL()) + url2 := strings.ToLower(faker.URL()) + + tests := []struct { + input string + isPartial bool + expectedPartialURL []string + expectedError error + }{ + { + input: "partial", + isPartial: true, + expectedPartialURL: nil, + }, + { + input: "final; https://example.com/uploads/1 /files/2", + isPartial: false, + expectedPartialURL: []string{ + "https://example.com/uploads/1", + "/files/2", + }, + }, + { + input: "final; /a /b", + isPartial: false, + expectedPartialURL: []string{ + "/a", + "/b", + }, + }, + { + input: " final; /x /y ", + isPartial: false, + expectedPartialURL: []string{ + "/x", + "/y", + }, + }, + { + input: fmt.Sprintf(" final; %v %v ", url1, url2), + isPartial: false, + expectedPartialURL: []string{ + url1, + url2, + }, + }, + { + input: "final; /u?id=1#frag /v?w=2", + isPartial: false, + expectedPartialURL: []string{ + "/u?id=1#frag", + "/v?w=2", + }, + }, + { + input: "final;", + expectedError: commonerrors.ErrInvalid, + }, + { + input: "final", + expectedError: commonerrors.ErrInvalid, + }, + { + input: "", + expectedError: commonerrors.ErrUndefined, + }, + { + input: "partial; /a /b", + expectedError: commonerrors.ErrInvalid, + }, + { + input: fmt.Sprintf("%v; /a /b", faker.Word()), + expectedError: commonerrors.ErrInvalid, + }, + { + input: "final; /good /bad %url", + expectedError: commonerrors.ErrInvalid, + }, + { + input: "final; /a\t/b", + expectedError: commonerrors.ErrInvalid, + }, + { + input: "final; http://%zz", + expectedError: commonerrors.ErrInvalid, + }, + { + input: "final; /a ", + isPartial: false, + expectedPartialURL: []string{ + "/a", + }, + }, + } + + for i := range tests { + test := tests[i] + t.Run(test.input, func(t *testing.T) { + isPartial, partials, err := ParseTUSConcatHeader(test.input) + + if test.expectedError != nil { + errortest.AssertError(t, err, test.expectedError) + } else { + require.NoError(t, err) + assert.Equal(t, test.isPartial, isPartial) + p := collection.Map[*url.URL, string](partials, func(u *url.URL) string { + require.NotNil(t, u) + return strings.ToLower(u.String()) + }) + assert.ElementsMatch(t, test.expectedPartialURL, p) + } + }) + } + +} + +func toBase64Encoded(s string) string { return base64.URLEncoding.EncodeToString([]byte(s)) } + +func TestParseTUSMetadataHeader(t *testing.T) { + + key := faker.Word() + value := faker.Paragraph() + utf8Name := "résumé 2025.txt" + valuea := faker.Paragraph() + valueb := faker.Paragraph() + + tests := []struct { + input string + expectedFilename *string + expectedElements map[string]any + expectedError error + }{ + { + input: " ", + expectedError: commonerrors.ErrUndefined, + }, + { + input: ` filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==,is_confidential`, + expectedFilename: field.ToOptionalString("world_domination_plan.pdf"), + expectedElements: map[string]any{ + "filename": "world_domination_plan.pdf", + "is_confidential": true, + }, + }, + { + input: fmt.Sprintf("%v %v", key, toBase64Encoded(value)), + expectedElements: map[string]any{ + key: value, + }, + }, + { + input: fmt.Sprintf("foo,bar,filename %v", toBase64Encoded("x")), + expectedFilename: field.ToOptionalString("x"), + expectedElements: map[string]any{ + "foo": true, // empty value + "bar": true, // empty value + "filename": "x", + }, + }, + { + input: fmt.Sprintf("filename %v , meta %v, empty", toBase64Encoded("x"), toBase64Encoded("y")), + expectedFilename: field.ToOptionalString("x"), + expectedElements: map[string]any{ + "filename": "x", + "meta": "y", + "empty": true, + }, + }, + { + input: "note " + toBase64Encoded("A/B+C=D=="), + expectedElements: map[string]any{ + "note": "A/B+C=D==", + }, + }, + { + input: "filename " + toBase64Encoded(utf8Name), + expectedFilename: field.ToOptionalString(utf8Name), + expectedElements: map[string]any{ + "filename": utf8Name, + }, + }, + { + input: fmt.Sprintf(" %v ", toBase64Encoded("x")), + expectedElements: map[string]any{ + toBase64Encoded("x"): true, + }, + }, + { + input: "file name " + toBase64Encoded("x"), + expectedError: commonerrors.ErrInvalid, + }, + { + input: "a " + toBase64Encoded("1") + ",a " + toBase64Encoded("2"), + expectedError: commonerrors.ErrInvalid, + }, + { + input: "filename not-base64@@", + expectedError: commonerrors.ErrInvalid, + }, + { + input: fmt.Sprintf("a %v,,b %v", toBase64Encoded(valuea), toBase64Encoded(valueb)), + expectedElements: map[string]any{ + "a": valuea, + "b": valueb, + }, + }, + { + input: fmt.Sprintf("a %v,b %v,", toBase64Encoded(valuea), toBase64Encoded(valueb)), + expectedElements: map[string]any{ + "a": valuea, + "b": valueb, + }, + }, + { + input: " , filename " + toBase64Encoded("x"), + expectedFilename: field.ToOptionalString("x"), + expectedElements: map[string]any{ + "filename": "x", + }, + }, + { + input: func() string { + parts := []string{ + "id " + toBase64Encoded("123"), + "owner " + toBase64Encoded("alice"), + "tag " + toBase64Encoded("blue"), + "desc " + toBase64Encoded("hello world"), + "filename " + toBase64Encoded("big.bin"), + } + return strings.Join(parts, ", ") + }(), + expectedFilename: field.ToOptionalString("big.bin"), + expectedElements: map[string]any{ + "id": "123", + "owner": "alice", + "tag": "blue", + "desc": "hello world", + "filename": "big.bin", + }, + }, + } + + for i := range tests { + test := tests[i] + t.Run(test.input, func(t *testing.T) { + filename, elems, err := ParseTUSMetadataHeader(test.input) + + if test.expectedError != nil { + errortest.AssertError(t, err, test.expectedError) + } else { + require.NoError(t, err) + assert.Equal(t, field.OptionalString(test.expectedFilename, ""), field.OptionalString(filename, "")) + actualElements := collection.ConvertMapToPairSlice(elems, "=") + expectedElements := collection.ConvertMapToPairSlice(test.expectedElements, "=") + assert.ElementsMatch(t, expectedElements, actualElements) + } + }) + } +}