test(12-05): bring the Phase 12 framework packages to full unit coverage
- lagoon: Go-typed request values (typed slices and maps, sized integers, floats, file pointers, typed path lookups), regex delimiters, mimes sniffing (jpg/jpeg, SVG, PHP names, unreadable content), UploadedFileFromHeader, the rule builders, custom catalog lines with :input/:index/:position, and exists: against Postgres (text compare, inferred and NULL columns, arrays, unsafe identifiers, missing tables) - lagoon/attach: thumbnails in every mode from PNG, GIF and JPEG originals, bucket URL normalisation, OpenBucket/Publish refusals, URL helpers and static prefix stripping - tide: part validation and encoding edges, symlinks out of the fixture directory, Content-Type handling, every response mask and the coverage report helpers Coverage: lagoon 84.4%, lagoon/attach 87.7%, tide 81.4%, beachcomber 84.3%, beachcomber/typesense 95.9%.
This commit is contained in:
@@ -294,3 +294,123 @@ func TestNormalizePublicationAlbumDates(t *testing.T) {
|
||||
t.Fatal("a Z album date must show as a diff")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultipartPartEdges covers part validation, file reading and encoding
|
||||
// edges: file attributes without a file, empty and value-only parts,
|
||||
// default file names and content types, unicode and quoted names, a part
|
||||
// that contains the boundary, a missing or tampered file, a file outside the
|
||||
// fixture directory and the Content-Type handling of prepareRequest.
|
||||
func TestMultipartPartEdges(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
png := []byte("\x89PNG\r\n\x1a\nfixture")
|
||||
if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "files", "okładka.png"), png, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(png)
|
||||
pin := hex.EncodeToString(sum[:])
|
||||
|
||||
for name, req := range map[string]Request{
|
||||
"file attributes without a file": {Parts: []Part{{Name: "a", Filename: "x.png"}}},
|
||||
"content type without a file": {Parts: []Part{{Name: "a", ContentType: "image/png"}}},
|
||||
"sha without a file": {Parts: []Part{{Name: "a", SHA256: pin}}},
|
||||
"blank name": {Parts: []Part{{Name: " ", Value: "x"}}},
|
||||
"absolute file": {Parts: []Part{{Name: "a", File: "/etc/passwd", SHA256: pin}}},
|
||||
"short sha": {Parts: []Part{{Name: "a", File: "files/okładka.png", SHA256: "abc"}}},
|
||||
} {
|
||||
if err := validateParts(req); err == nil {
|
||||
t.Errorf("%s: validateParts accepted it", name)
|
||||
}
|
||||
}
|
||||
if err := validateParts(Request{}); err != nil {
|
||||
t.Fatalf("no parts: %v", err)
|
||||
}
|
||||
|
||||
parts := []Part{
|
||||
{Name: "empty", Value: ""},
|
||||
{Name: `tit"le`, Value: "Łódź"},
|
||||
{Name: "file", File: "files/okładka.png", SHA256: strings.ToUpper(pin)},
|
||||
}
|
||||
body, ct, err := encodeParts(dir, parts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ct != "multipart/form-data; boundary="+MultipartBoundary {
|
||||
t.Fatalf("content type %q", ct)
|
||||
}
|
||||
text := string(body)
|
||||
for _, want := range []string{
|
||||
`Content-Disposition: form-data; name="empty"`,
|
||||
`Content-Disposition: form-data; name="tit\"le"` + "\r\n\r\nŁódź",
|
||||
`Content-Disposition: form-data; name="file"; filename="okładka.png"`,
|
||||
"Content-Type: application/octet-stream",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("encoded body lacks %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
again, _, err := encodeParts(dir, parts)
|
||||
if err != nil || !bytes.Equal(body, again) {
|
||||
t.Fatal("encoding is not deterministic")
|
||||
}
|
||||
if _, _, err := encodeParts(dir, []Part{{Name: "x", Value: "--" + MultipartBoundary}}); err == nil {
|
||||
t.Error("a value holding the boundary was encoded")
|
||||
}
|
||||
for name, p := range map[string]Part{
|
||||
"missing": {Name: "f", File: "files/nope.png", SHA256: pin},
|
||||
"tampered": {Name: "f", File: "files/okładka.png", SHA256: strings.Repeat("0", 64)},
|
||||
"escaping": {Name: "f", File: "../okładka.png", SHA256: pin},
|
||||
} {
|
||||
if _, err := readPartFile(dir, p); err == nil {
|
||||
t.Errorf("%s part file was read", name)
|
||||
}
|
||||
}
|
||||
outside := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(outside, "x.png"), png, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(outside, "x.png"), filepath.Join(dir, "files", "link.png")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readPartFile(dir, Part{Name: "f", File: "files/link.png", SHA256: pin}); err == nil {
|
||||
t.Error("a symlink leaving the fixture directory was read")
|
||||
}
|
||||
if raw, err := readPartFile("", Part{Name: "f", File: filepath.Join(dir, "files", "okładka.png")[len(dir)+1:], SHA256: pin}); err == nil && raw != nil {
|
||||
t.Error("a part file resolved without its fixture directory")
|
||||
}
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
headers map[string]string
|
||||
want string
|
||||
}{
|
||||
"none recorded": {nil, "multipart/form-data; boundary=" + MultipartBoundary},
|
||||
"stale boundary": {map[string]string{"content-type": "multipart/form-data; boundary=old"}, "multipart/form-data; boundary=" + MultipartBoundary},
|
||||
"unparsable": {map[string]string{"Content-Type": ";;;"}, "multipart/form-data; boundary=" + MultipartBoundary},
|
||||
"kept non-multipart": {map[string]string{"Content-Type": "text/plain"}, "text/plain"},
|
||||
} {
|
||||
out, err := prepareRequest(Request{Method: "POST", Headers: tc.headers, Parts: parts[:1]}, dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := ""
|
||||
for k, v := range out.Headers {
|
||||
if strings.EqualFold(k, "Content-Type") {
|
||||
got = v
|
||||
}
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("%s: Content-Type %q, want %q", name, got, tc.want)
|
||||
}
|
||||
}
|
||||
if out, err := prepareRequest(Request{Body: "a=1"}, dir); err != nil || out.Body != "a=1" {
|
||||
t.Fatalf("no parts: %+v %v", out, err)
|
||||
}
|
||||
if _, err := prepareRequest(Request{Body: "a=1", Parts: parts[:1]}, dir); err == nil {
|
||||
t.Fatal("body and parts were prepared")
|
||||
}
|
||||
if _, err := prepareRequest(Request{Parts: []Part{{Name: "f", File: "files/nope.png", SHA256: pin}}}, dir); err == nil {
|
||||
t.Fatal("a missing part file was prepared")
|
||||
}
|
||||
}
|
||||
|
||||
95
modules/tide/normalize_upload_test.go
Normal file
95
modules/tide/normalize_upload_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package tide
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func normalizeOne(t *testing.T, body string, step Step, prefix string) (string, []Diff) {
|
||||
t.Helper()
|
||||
out, diffs := normalizeJSON([]byte(body), step, maskOptions{uploadPrefix: prefix})
|
||||
return strings.NewReplacer(`\u003c`, "<", `\u003e`, ">").Replace(string(out)), diffs
|
||||
}
|
||||
|
||||
// TestNormalizeMaskEdges covers the response normalizer's masks: upload
|
||||
// URLs under the default and a custom prefix, look-alikes under another
|
||||
// prefix, keys holding other types, ids and dates of the wrong shape,
|
||||
// disabled paths and pass-through of bodies that are not JSON.
|
||||
func TestNormalizeMaskEdges(t *testing.T) {
|
||||
orig := DefaultUploadPrefix + "/6ab/f82/1c3/6abf821c3d4e5f6a7b8c9d.png"
|
||||
thumb := DefaultUploadPrefix + "/6ab/f82/1c3/thumb_57_200_200_0_0_crop.png"
|
||||
cases := []struct {
|
||||
name, body, prefix string
|
||||
step Step
|
||||
want string
|
||||
diffs int
|
||||
}{
|
||||
{"original and thumb", `{"url":"` + orig + `","thumb_url":"` + thumb + `"}`, "", Step{},
|
||||
`{"thumb_url":"` + DefaultUploadPrefix + `/<partition>/thumb_<id>_200_200_0_0_crop.png","url":"` + DefaultUploadPrefix + `/<partition>/<disk_name>.png"}`, 0},
|
||||
{"custom prefix with a slash", `{"url":"/files/6ab/f82/1c3/6abf821c3d4e5f6a7b8c9d.jpg"}`, "/files/", Step{},
|
||||
`{"url":"/files/<partition>/<disk_name>.jpg"}`, 0},
|
||||
{"upload under another prefix", `{"url":"/elsewhere/6ab/f82/1c3/6abf821c3d4e5f6a7b8c9d.png"}`, "", Step{}, "", 1},
|
||||
{"plain url kept", `{"url":"https://example.com/a.png"}`, "", Step{}, `{"url":"https://example.com/a.png"}`, 0},
|
||||
{"non-string url kept", `{"url":5,"thumb_url":null}`, "", Step{}, `{"thumb_url":null,"url":5}`, 0},
|
||||
{"slug never masked", `{"slug":"abc_id","id":7}`, "", Step{}, `{"id":"<id>","slug":"abc_id"}`, 0},
|
||||
{"collection key", `{"collection_key":"e53f1bd22f01bbe8268079f016301ad5","client_id":null}`, "", Step{}, `{"client_id":null,"collection_key":"<id>"}`, 0},
|
||||
{"collection key of the wrong type", `{"collection_key":5}`, "", Step{}, "", 1},
|
||||
{"issued at", `{"token_issued_at":1700000000}`, "", Step{}, `{"token_issued_at":"<id>"}`, 0},
|
||||
{"wrong date shape", `{"created_at":"2020-01-01 10:00:00"}`, "", Step{}, "", 1},
|
||||
{"date of the wrong type", `{"updated_at":5}`, "", Step{}, "", 1},
|
||||
{"null date and id", `{"deleted_at":null,"genre_id":null}`, "", Step{}, `{"deleted_at":null,"genre_id":null}`, 0},
|
||||
{"decimal id", `{"id":1.5}`, "", Step{}, "", 1},
|
||||
{"string id", `{"album_id":"7"}`, "", Step{}, "", 1},
|
||||
{"id list", `{"collection_ids":[3,4]}`, "", Step{}, `{"collection_ids":["<id>","<id>"]}`, 0},
|
||||
{"checkpoint", `{"checkpoint":"2020-01-01T10:00:00+00:00"}`, "", Step{}, `{"checkpoint":"<datetime>"}`, 0},
|
||||
{"disabled by key", `{"created_at":"not a date"}`, "", Step{Normalize: []NormalizeRule{{Path: "created_at", Disable: true}}}, `{"created_at":"not a date"}`, 0},
|
||||
{"disabled by path", `{"errors":{"album_id":["The album id field is required."]}}`, "", Step{Normalize: []NormalizeRule{{Path: "$.errors.album_id[0]", Disable: true}}}, `{"errors":{"album_id":["The album id field is required."]}}`, 0},
|
||||
{"disabled path without $", `{"a":{"user_id":"x"}}`, "", Step{Normalize: []NormalizeRule{{Path: "a.user_id", Disable: true}, {Path: "a.user_id"}}}, `{"a":{"user_id":"x"}}`, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, diffs := normalizeOne(t, tc.body, tc.step, tc.prefix)
|
||||
if len(diffs) != tc.diffs {
|
||||
t.Fatalf("diffs %+v, want %d", diffs, tc.diffs)
|
||||
}
|
||||
if tc.want != "" && got != tc.want {
|
||||
t.Fatalf("got %s\nwant %s", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, raw := range []string{"", " ", "not json", "<html>"} {
|
||||
if got, diffs := normalizeOne(t, raw, Step{}, ""); got != raw || diffs != nil {
|
||||
t.Errorf("non-JSON %q became %q %v", raw, got, diffs)
|
||||
}
|
||||
}
|
||||
if lastPathKey("$.a.b[3]") != "b" || lastPathKey("$.x") != "x" || !strings.HasPrefix(maskOptions{}.prefix(), "/storage") {
|
||||
t.Error("path key helpers")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCoverageReportHelpers covers the coverage table and batch helpers the
|
||||
// parity CLI prints.
|
||||
func TestCoverageReportHelpers(t *testing.T) {
|
||||
c := Coverage{Rows: []CoverageRow{{ID: "GET_x", Status: "ported", Outcome: "pass"}}}
|
||||
if h := CoverageHeaders(); strings.Join(h, ",") != "route,status,outcome" {
|
||||
t.Fatalf("headers %v", h)
|
||||
}
|
||||
if rows := c.CoverageRows(); len(rows) != 1 || strings.Join(rows[0], ",") != "GET_x,ported,pass" {
|
||||
t.Fatalf("rows %v", rows)
|
||||
}
|
||||
if c.ResumeLine() != "manifest recording complete" {
|
||||
t.Fatal(c.ResumeLine())
|
||||
}
|
||||
c.ResumeRemaining = 3
|
||||
if c.ResumeLine() != "resume remaining 3" {
|
||||
t.Fatal(c.ResumeLine())
|
||||
}
|
||||
for in, want := range map[string]int{"": 0, " ": 0, "7": 7} {
|
||||
if n, err := ParseNextBatch(in); err != nil || n != want {
|
||||
t.Errorf("ParseNextBatch(%q) = %d %v", in, n, err)
|
||||
}
|
||||
}
|
||||
if _, err := ParseNextBatch("x"); err == nil {
|
||||
t.Error("a non-number batch")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user