From 38f6d65c0907ff5ce4003c76dbdddd248565383c Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 15 Oct 2026 19:37:35 +0200 Subject: [PATCH] test(19.3-05): build tags, S3 tag guard, DiskExtension and public_url - manifest_tags_test.go: build.tags load, trim, dedupe and validation errors naming build.tags; SaveManifest keeps tags and plugin order; goBuildArgs puts one -tags before -o - s3_test.go: s3:// refused without summercms_s3 naming the tag, opens with it (both build modes, any scheme case) - diskext_test.go: RESEARCH probe rows and table consistency - url_test.go: public_url base joining, File.URL and thumbnails, malformed values refused naming the key - store_test.go: Postgres regression table through Store - gate: attach names in both tag modes, Store regression in docker --- internal/build/manifest_tags_test.go | 189 ++++++++++++++++++++++++++ modules/lagoon/attach/diskext_test.go | 111 +++++++++++++++ modules/lagoon/attach/s3_test.go | 68 +++++++++ modules/lagoon/attach/store_test.go | 81 +++++++++++ modules/lagoon/attach/url_test.go | 104 ++++++++++++++ scripts/check-phase19.3.sh | 20 +++ 6 files changed, 573 insertions(+) create mode 100644 internal/build/manifest_tags_test.go create mode 100644 modules/lagoon/attach/diskext_test.go create mode 100644 modules/lagoon/attach/s3_test.go diff --git a/internal/build/manifest_tags_test.go b/internal/build/manifest_tags_test.go new file mode 100644 index 0000000..840f4e6 --- /dev/null +++ b/internal/build/manifest_tags_test.go @@ -0,0 +1,189 @@ +package build + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +// Unit tests (plan 05 of phase 19.3) for summer.yaml build.tags (D-12). + +const tagsManifestHead = "module: git.golem15.com/acme/acme\n" + + "binary: acme\n" + + "plugins:\n" + + " - id: acme.blog\n" + + " module: git.golem15.com/acme/acme/plugins/blog\n" + +func writeManifest(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), manifestName) + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestManifestBuildTags(t *testing.T) { + for _, tc := range []struct { + name string + body string + want []string + }{ + {"no build block", tagsManifestHead, nil}, + {"empty build block", tagsManifestHead + "build: {}\n", nil}, + {"null tags", tagsManifestHead + "build:\n tags:\n", nil}, + {"empty list", tagsManifestHead + "build:\n tags: []\n", nil}, + {"one tag", tagsManifestHead + "build:\n tags: [summercms_s3]\n", []string{"summercms_s3"}}, + {"trimmed and deduplicated in order", tagsManifestHead + "build:\n tags:\n - \" netgo \"\n - summercms_s3\n - netgo\n - \"summercms_s3\\t\"\n - osusergo\n", []string{"netgo", "summercms_s3", "osusergo"}}, + {"digits and underscores", tagsManifestHead + "build:\n tags: [go1_x, \"123\"]\n", []string{"go1_x", "123"}}, + } { + t.Run(tc.name, func(t *testing.T) { + m, err := LoadManifest(writeManifest(t, tc.body)) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(m.Tags, tc.want) { + t.Fatalf("Tags = %q, want %q", m.Tags, tc.want) + } + if len(m.Plugins) != 1 || m.Plugins[0].ID != "acme.blog" { + t.Fatalf("plugins = %+v", m.Plugins) + } + }) + } + for _, tag := range []string{"summercms_s3", "a", "0", "x_1_y"} { + if err := ValidateBuildTag(tag); err != nil { + t.Errorf("ValidateBuildTag(%q) = %v", tag, err) + } + } +} + +func TestManifestBuildTagsInvalid(t *testing.T) { + for _, tc := range []struct { + name string + yaml string + want string + }{ + {"dash", "build:\n tags: [summercms-s3]\n", "build.tags[0]"}, + {"space", "build:\n tags: [netgo, \"summercms s3\"]\n", "build.tags[1]"}, + {"upper case", "build:\n tags: [SummerCMS_S3]\n", "build.tags[0]"}, + {"flag injection", "build:\n tags: [\"x -ldflags=-X\"]\n", "build.tags[0]"}, + {"comma", "build:\n tags: [\"a,b\"]\n", "build.tags[0]"}, + {"empty string", "build:\n tags: [\" \"]\n", "build.tags[0]"}, + {"non-string entry", "build:\n tags: [42]\n", "build.tags[0]"}, + {"non-list tags", "build:\n tags: summercms_s3\n", "build.tags"}, + {"mapping tags", "build:\n tags:\n s3: true\n", "build.tags"}, + {"non-mapping build", "build: [summercms_s3]\n", "build.tags"}, + {"scalar build", "build: summercms_s3\n", "build.tags"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := LoadManifest(writeManifest(t, tagsManifestHead+tc.yaml)) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want one naming %s", err, tc.want) + } + }) + } + for _, bad := range []string{"", "A", "a-b", "a b", "a.b", "-tags", "a,b", "รค"} { + if err := ValidateBuildTag(bad); err == nil { + t.Errorf("ValidateBuildTag(%q) accepted", bad) + } + } +} + +func TestSaveManifestKeepsBuildTags(t *testing.T) { + path := writeManifest(t, tagsManifestHead+ + " - id: acme.shop\n"+ + " module: git.golem15.com/acme/acme/plugins/shop\n"+ + "build:\n tags:\n - summercms_s3\n - netgo\n") + m, err := LoadManifest(path) + if err != nil { + t.Fatal(err) + } + // What plugin:add does: append a plugin and save. + m.Plugins = append(m.Plugins, Plugin{ID: "acme.faq", Module: "git.golem15.com/acme/acme/plugins/faq"}) + if err := SaveManifest(path, m); err != nil { + t.Fatal(err) + } + again, err := LoadManifest(path) + if err != nil { + t.Fatal(err) + } + var ids []string + for _, p := range again.Plugins { + ids = append(ids, p.ID) + } + if !slices.Equal(ids, []string{"acme.blog", "acme.shop", "acme.faq"}) { + t.Fatalf("plugin order = %q", ids) + } + if !slices.Equal(again.Tags, []string{"summercms_s3", "netgo"}) { + t.Fatalf("Tags = %q after the round trip", again.Tags) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(string(raw), "build:\n tags:\n - summercms_s3\n - netgo\n") { + t.Fatalf("build block is not written after the plugins:\n%s", raw) + } + + // No tags: no build block at all. + m.Tags = nil + if err := SaveManifest(path, m); err != nil { + t.Fatal(err) + } + raw, _ = os.ReadFile(path) + if strings.Contains(string(raw), "build:") { + t.Fatalf("empty tags wrote a build block:\n%s", raw) + } + m.Tags = []string{} + if err := SaveManifest(path, m); err != nil { + t.Fatal(err) + } + raw, _ = os.ReadFile(path) + if strings.Contains(string(raw), "build:") { + t.Fatalf("an empty tag list wrote a build block:\n%s", raw) + } + + // SaveManifest refuses an invalid tag and leaves the file alone. + before, _ := os.ReadFile(path) + m.Tags = []string{"ok", "not-ok"} + if err := SaveManifest(path, m); err == nil || !strings.Contains(err.Error(), "build.tags[1]") { + t.Fatalf("SaveManifest with an invalid tag: %v", err) + } + after, _ := os.ReadFile(path) + if string(after) != string(before) { + t.Fatal("SaveManifest wrote a file with an invalid tag") + } +} + +func TestGoBuildArgs(t *testing.T) { + if got := goBuildArgs("bin/acme", nil); !slices.Equal(got, []string{"build", "-o", "bin/acme", "."}) { + t.Fatalf("no tags = %q", got) + } + if got := goBuildArgs("bin/acme", []string{}); slices.ContainsFunc(got, func(a string) bool { return strings.HasPrefix(a, "-tags") }) { + t.Fatalf("empty tags = %q", got) + } + got := goBuildArgs("bin/acme", []string{"a", "b"}) + if !slices.Equal(got, []string{"build", "-tags=a,b", "-o", "bin/acme", "."}) { + t.Fatalf("tags = %q", got) + } + n := 0 + for _, a := range got { + if strings.HasPrefix(a, "-tags") { + n++ + } + } + if n != 1 { + t.Fatalf("%d -tags arguments", n) + } + + // The tags of a loaded manifest reach the arguments unchanged. + m, err := LoadManifest(writeManifest(t, tagsManifestHead+"build:\n tags: [summercms_s3, netgo]\n")) + if err != nil { + t.Fatal(err) + } + if got := goBuildArgs("bin/"+m.Binary, m.Tags); !slices.Equal(got, []string{"build", "-tags=summercms_s3,netgo", "-o", "bin/acme", "."}) { + t.Fatalf("manifest tags = %q", got) + } +} diff --git a/modules/lagoon/attach/diskext_test.go b/modules/lagoon/attach/diskext_test.go new file mode 100644 index 0000000..82c3c25 --- /dev/null +++ b/modules/lagoon/attach/diskext_test.go @@ -0,0 +1,111 @@ +package attach + +import ( + "net/http" + "slices" + "testing" +) + +// TestDiskExtensionTable (plan 05 of phase 19.3, D-15) pins DiskExtension on +// the RESEARCH probe rows and on the consistency of its tables: a client +// extension that belongs to the sniffed type is kept, any other becomes the +// type's canonical one, and a script-capable extension under a generic type +// becomes txt (text/*) or bin. +func TestDiskExtensionTable(t *testing.T) { + for _, tc := range []struct { + contentType, clientExt, want string + }{ + // RESEARCH probe rows. + {"image/png", "html", "png"}, + {"image/jpeg", "jpeg", "jpeg"}, + {"image/jpeg", "jpg", "jpg"}, + {"image/jpeg", "png", "jpg"}, + {"video/webm", "mkv", "mkv"}, + {"video/webm", "webm", "webm"}, + {"application/zip", "docx", "docx"}, + {"application/zip", "xlsx", "xlsx"}, + {"text/plain; charset=utf-8", "ics", "ics"}, + {"text/plain; charset=utf-8", "html", "txt"}, + {"text/xml; charset=utf-8", "svg", "txt"}, + {"application/octet-stream", "js", "bin"}, + {"application/pdf", "txt", "pdf"}, + // HTML bytes under their own name never stay html. + {"text/html; charset=utf-8", "html", "txt"}, + {"text/html; charset=utf-8", "txt", "txt"}, + // Normalisation: case, a leading dot, parameters and spaces. + {"IMAGE/PNG", ".PNG", "png"}, + {" image/jpeg ; q=1", "JPE", "jpe"}, + {"application/octet-stream", ".Docx", "docx"}, + // Extensions outside [a-z0-9]{1,10} fall back like a script-capable one. + {"application/octet-stream", "", "bin"}, + {"text/plain", "", "txt"}, + {"application/octet-stream", "tar.gz", "bin"}, + {"text/plain", "averyverylongext", "txt"}, + {"application/octet-stream", "ex e", "bin"}, + // Other specific types. + {"image/gif", "png", "gif"}, + {"image/webp", "webp", "webp"}, + {"application/ogg", "opus", "opus"}, + {"audio/mpeg", "wav", "mp3"}, + {"font/woff2", "woff2", "woff2"}, + {"application/x-gzip", "tgz", "tgz"}, + } { + if got := DiskExtension(tc.contentType, tc.clientExt); got != tc.want { + t.Errorf("DiskExtension(%q, %q) = %q, want %q", tc.contentType, tc.clientExt, got, tc.want) + } + } + + // Every specific type keeps each of its own extensions, maps a + // script-capable one to its canonical extension, and lists no + // script-capable extension itself. + for ct, exts := range sniffedExtensions { + if len(exts) == 0 { + t.Fatalf("%s has no extensions", ct) + } + for _, ext := range exts { + if !extPattern.MatchString(ext) { + t.Errorf("%s: extension %q does not match the disk name pattern", ct, ext) + } + if slices.Contains(scriptCapableExtensions, ext) { + t.Errorf("%s: script-capable extension %q is kept", ct, ext) + } + if got := DiskExtension(ct, ext); got != ext { + t.Errorf("DiskExtension(%q, %q) = %q", ct, ext, got) + } + } + for _, bad := range scriptCapableExtensions { + if got := DiskExtension(ct, bad); got != exts[0] { + t.Errorf("DiskExtension(%q, %q) = %q, want %q", ct, bad, got, exts[0]) + } + } + } + + // Under every generic type a script-capable extension is rewritten. + for _, ct := range []string{"text/plain; charset=utf-8", "text/html; charset=utf-8", "text/xml; charset=utf-8", "text/css"} { + for _, bad := range scriptCapableExtensions { + if got := DiskExtension(ct, bad); got != "txt" { + t.Errorf("DiskExtension(%q, %q) = %q, want txt", ct, bad, got) + } + } + } + for _, ct := range []string{"application/octet-stream", "application/zip", "application/x-msdownload", ""} { + for _, bad := range scriptCapableExtensions { + if got := DiskExtension(ct, bad); got != "bin" { + t.Errorf("DiskExtension(%q, %q) = %q, want bin", ct, bad, got) + } + } + } + + // The table agrees with what the standard library actually sniffs. + for body, want := range map[string]string{ + "\x89PNG\r\n\x1a\n": "png", + "%PDF-1.7\n": "pdf", + "x": "txt", + "": "txt", + } { + ct := http.DetectContentType([]byte(body)) + if got := DiskExtension(ct, "html"); got != want { + t.Errorf("body %q sniffed %q: DiskExtension = %q, want %q", body, ct, got, want) + } + } +} diff --git a/modules/lagoon/attach/s3_test.go b/modules/lagoon/attach/s3_test.go new file mode 100644 index 0000000..a12df3d --- /dev/null +++ b/modules/lagoon/attach/s3_test.go @@ -0,0 +1,68 @@ +package attach + +import ( + "strings" + "testing" +) + +// TestOpenBucketS3 (plan 05 of phase 19.3, D-13) has no build constraint: it +// runs in both modes and branches on s3Enabled. Without the summercms_s3 tag +// an s3:// bucket_url (any case of the scheme) fails boot naming the tag and +// where to set it; with the tag the bucket opens on dummy AWS credentials +// (s3blob connects lazily, so nothing leaves the host) and closes. +func TestOpenBucketS3(t *testing.T) { + t.Setenv("AWS_ACCESS_KEY_ID", "unit-access-key") + t.Setenv("AWS_SECRET_ACCESS_KEY", "unit-secret-key") + t.Setenv("AWS_REGION", "eu-central-1") + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") + t.Cleanup(func() { + setPublicPathPrefix(defaultPublicPathPrefix) + setPublicURLBase("") + }) + if S3BuildTag != "summercms_s3" { + t.Fatalf("S3BuildTag = %q", S3BuildTag) + } + + for _, raw := range []string{ + "s3://unit-bucket?region=eu-central-1", + "S3://unit-bucket?region=eu-central-1", + "s3://unit-bucket?region=auto&endpoint=https://storage.example.com&use_path_style=true", + } { + t.Run(raw, func(t *testing.T) { + cfg := openTestConfig(t, "uploads:\n bucket_url: \""+raw+"\"\n") + bucket, err := OpenBucket(t.Context(), cfg) + if !s3Enabled { + if err == nil { + _ = bucket.Close() + t.Fatal("s3:// opened without the summercms_s3 tag") + } + for _, want := range []string{"storage.uploads.bucket_url", S3BuildTag, "build.tags", "summer.yaml", "-tags " + S3BuildTag, "GOFLAGS=-tags=" + S3BuildTag} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error %q does not name %q", err, want) + } + } + if strings.Contains(err.Error(), "unit-bucket") || strings.Contains(err.Error(), "unit-secret") { + t.Fatalf("error %q repeats the bucket URL", err) + } + return + } + if err != nil { + t.Fatalf("OpenBucket with the tag: %v", err) + } + if err := bucket.Close(); err != nil { + t.Fatalf("close: %v", err) + } + }) + } + + // Other schemes never reach the tag check. + for _, raw := range []string{"mem://", "file:///tmp/x", "fileblob:///tmp/x", "s3x://b", "gs://bucket", "s3:no-slashes"} { + if err := checkS3Driver(raw); err != nil { + t.Fatalf("checkS3Driver(%q) = %v", raw, err) + } + } + err := checkS3Driver("s3://b") + if s3Enabled != (err == nil) { + t.Fatalf("checkS3Driver(s3://b) = %v with s3Enabled %t", err, s3Enabled) + } +} diff --git a/modules/lagoon/attach/store_test.go b/modules/lagoon/attach/store_test.go index a907e73..8b61295 100644 --- a/modules/lagoon/attach/store_test.go +++ b/modules/lagoon/attach/store_test.go @@ -6,6 +6,7 @@ import ( "context" "errors" "image" + "image/jpeg" "image/png" "io" "regexp" @@ -343,3 +344,83 @@ func TestStoreDiskExtensionSmoke(t *testing.T) { t.Fatalf("HTML body named note.txt: disk name %q, file_name %q", note.DiskName, note.FileName) } } + +// TestStoreDiskExtension (plan 05 of phase 19.3, D-15) runs the stored-XSS +// regression table through Store on Postgres: the disk name and the URL end +// in the extension DiskExtension picks for the sniffed bytes, file_name keeps +// the client's name and content_type the sniffed type. +func TestStoreDiskExtension(t *testing.T) { + if testing.Short() { + t.Skip("requires testcontainers postgres") + } + ctx := t.Context() + gdb := attachGorm(t) + if err := lagoon.Migrate(gdb, nil); err != nil { + t.Fatal(err) + } + bucket := memblob.OpenBucket(nil) + t.Cleanup(func() { _ = bucket.Close() }) + lim := attach.Limits{Extensions: []string{"html", "png", "jpg", "jpeg", "docx", "txt", "ics", "svg", "js", "pdf", "mkv"}} + + var jpegBody bytes.Buffer + if err := jpeg.Encode(&jpegBody, image.NewRGBA(image.Rect(0, 0, 4, 4)), nil); err != nil { + t.Fatal(err) + } + var docxBody bytes.Buffer + zw := zip.NewWriter(&docxBody) + w, err := zw.Create("word/document.xml") + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte("")); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + webm := []byte("\x1a\x45\xdf\xa3\x9f\x42\x86\x81\x01\x42\xf7\x81\x01\x42\xf2\x81\x04\x42\xf3\x81\x08\x42\x82\x84webm\x42\x87\x81\x04\x42\x85\x81\x02") + + for _, tc := range []struct { + name, fileName string + body []byte + ext, ct string + }{ + {"png named html", "x.html", smokePNG(t), "png", "image/png"}, + {"jpeg named png", "photo.png", jpegBody.Bytes(), "jpg", "image/jpeg"}, + {"jpeg named jpeg", "photo.jpeg", jpegBody.Bytes(), "jpeg", "image/jpeg"}, + {"docx under zip", "report.docx", docxBody.Bytes(), "docx", "application/zip"}, + {"ics under text", "event.ics", []byte("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n"), "ics", "text/plain"}, + {"html named txt", "note.txt", []byte(""), "txt", "text/html"}, + {"html named html", "page.html", []byte(""), "txt", "text/html"}, + {"svg under xml", "logo.svg", []byte(``), "txt", "text/xml"}, + {"js under text", "app.js", []byte("console.log(document.cookie)\n"), "txt", "text/plain"}, + // A generic sniff keeps the content_type rule (from the extension). + {"js under octet-stream", "app.js", []byte{0x00, 0x01, 0x02, 'a', 'l', 'e', 'r', 't'}, "bin", "text/javascript"}, + {"pdf named txt", "doc.txt", []byte("%PDF-1.4\n%\xe2\xe3\xcf\xd3\n1 0 obj\n<<>>\nendobj\n"), "pdf", "application/pdf"}, + {"mkv under webm", "clip.mkv", webm, "mkv", "video/webm"}, + } { + t.Run(tc.name, func(t *testing.T) { + f, err := attach.Store(ctx, gdb, bucket, attach.Upload{FileName: tc.fileName, Body: bytes.NewReader(tc.body), Public: true}, lim) + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(f.DiskName, "."+tc.ext) || !strings.HasSuffix(f.URL(), "/"+f.DiskName) || !strings.HasSuffix(f.URL(), "."+tc.ext) { + t.Fatalf("disk name %q URL %q, want .%s", f.DiskName, f.URL(), tc.ext) + } + if f.FileName != tc.fileName || !strings.HasPrefix(f.ContentType, tc.ct) { + t.Fatalf("file_name %q content_type %q, want %q %q", f.FileName, f.ContentType, tc.fileName, tc.ct) + } + var stored attach.File + if err := gdb.First(&stored, f.ID).Error; err != nil { + t.Fatal(err) + } + if stored.DiskName != f.DiskName || stored.FileName != tc.fileName { + t.Fatalf("stored row %+v", stored) + } + got, err := bucket.ReadAll(ctx, attach.BlobKey(f.DiskName)) + if err != nil || !bytes.Equal(got, tc.body) { + t.Fatalf("blob under %s: %v", attach.BlobKey(f.DiskName), err) + } + }) + } +} diff --git a/modules/lagoon/attach/url_test.go b/modules/lagoon/attach/url_test.go index 95ac814..45d44ea 100644 --- a/modules/lagoon/attach/url_test.go +++ b/modules/lagoon/attach/url_test.go @@ -362,3 +362,107 @@ func TestBucketAndURLEdges(t *testing.T) { } } } + +// TestPublicURLBase (plan 05 of phase 19.3, D-12) builds direct URLs from +// storage.uploads.public_url: trailing slashes on the base and leading +// slashes on the key collapse to one, File.URL and thumbnails use the base, +// and clearing it restores the proxy prefix. +func TestPublicURLBase(t *testing.T) { + t.Cleanup(func() { + setPublicPathPrefix(defaultPublicPathPrefix) + setPublicURLBase("") + }) + key := BlobKey("651a2b3c4d5e61234567.png") + for _, tc := range []struct{ base, key, want string }{ + {"https://cdn.example.com", key, "https://cdn.example.com/" + key}, + {"https://cdn.example.com/", key, "https://cdn.example.com/" + key}, + {"https://cdn.example.com/uploads//", "/" + key, "https://cdn.example.com/uploads/" + key}, + {"http://bucket.s3.example.com/a/b", "//" + key, "http://bucket.s3.example.com/a/b/" + key}, + } { + setPublicURLBase(tc.base) + if got := PublicURL(tc.key); got != tc.want { + t.Errorf("base %q key %q: PublicURL = %q, want %q", tc.base, tc.key, got, tc.want) + } + } + + bucket := memblob.OpenBucket(nil) + t.Cleanup(func() { _ = bucket.Close() }) + setPublicURLBase("https://cdn.example.com/uploads/") + if PublicURLBase() != "https://cdn.example.com/uploads/" { + t.Fatalf("PublicURLBase = %q", PublicURLBase()) + } + f := &File{ID: 31, DiskName: "651a2b3c4d5e61234567.png"} + if got, want := f.URL(), "https://cdn.example.com/uploads/651/a2b/3c4/651a2b3c4d5e61234567.png"; got != want { + t.Fatalf("File.URL = %q, want %q", got, want) + } + if err := bucket.WriteAll(t.Context(), BlobKey(f.DiskName), testJPEG(t), nil); err != nil { + t.Fatal(err) + } + thumb, err := f.Thumb(t.Context(), bucket, 40, 30, "crop") + if err != nil { + t.Fatal(err) + } + if want := "https://cdn.example.com/uploads/651/a2b/3c4/thumb_31_40_30_0_0_crop.png"; thumb != want { + t.Fatalf("thumb = %q, want %q", thumb, want) + } + + setPublicURLBase("") + if got := f.URL(); got != "/storage/uploads/651/a2b/3c4/651a2b3c4d5e61234567.png" { + t.Fatalf("without a base File.URL = %q", got) + } +} + +// TestPublicURLValidation refuses every malformed storage.uploads.public_url +// at OpenBucket, naming the key and never echoing credentials; an unset or +// blank value keeps the proxy prefix. +func TestPublicURLValidation(t *testing.T) { + t.Cleanup(func() { + setPublicPathPrefix(defaultPublicPathPrefix) + setPublicURLBase("") + }) + for _, bad := range []string{ + "ftp://cdn.example.com/uploads", + "file:///srv/uploads", + "https://user:secret@cdn.example.com", + "https://token@cdn.example.com", + "https://cdn.example.com/uploads?v=1", + "https://cdn.example.com/uploads?", + "https://cdn.example.com/uploads#top", + "/storage/uploads", + "cdn.example.com/uploads", + "https:///uploads", + "https://cdn.example.com:bad/", + } { + setPublicURLBase("https://stale.example.com") + cfg := openTestConfig(t, "uploads:\n bucket_url: \"mem://\"\n public_url: \""+bad+"\"\n") + bucket, err := OpenBucket(t.Context(), cfg) + if err == nil { + _ = bucket.Close() + t.Fatalf("public_url %q accepted", bad) + } + if !strings.Contains(err.Error(), "storage.uploads.public_url") || strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "token@") { + t.Fatalf("public_url %q: error %q", bad, err) + } + if _, perr := parsePublicURL(bad); perr == nil { + t.Fatalf("parsePublicURL(%q) accepted", bad) + } + } + for _, unset := range []string{"", " "} { + setPublicURLBase("https://stale.example.com") + cfg := openTestConfig(t, "uploads:\n bucket_url: \"mem://\"\n public_url: \""+unset+"\"\n") + bucket, err := OpenBucket(t.Context(), cfg) + if err != nil { + t.Fatalf("public_url %q: %v", unset, err) + } + _ = bucket.Close() + if PublicURLBase() != "" || PublicURL("a/b.png") != "/storage/uploads/a/b.png" { + t.Fatalf("public_url %q: base %q URL %q", unset, PublicURLBase(), PublicURL("a/b.png")) + } + } + for _, ok := range []string{"https://cdn.example.com", "HTTP://cdn.example.com:8080/u/", " https://cdn.example.com/uploads "} { + u, err := parsePublicURL(ok) + if err != nil || u == nil { + t.Fatalf("parsePublicURL(%q) = %v, %v", ok, u, err) + } + } +} diff --git a/scripts/check-phase19.3.sh b/scripts/check-phase19.3.sh index 09ed45e..d6da811 100755 --- a/scripts/check-phase19.3.sh +++ b/scripts/check-phase19.3.sh @@ -77,9 +77,25 @@ SURF_TESTS=( TestAPIDocsTitleEscaped TestBuildRouterStoreFallback TestBuildRouterUsesPublishedStore TestBuildRouterNeverDials ) +BUILD_TESTS=( + TestManifestBuildTags + TestManifestBuildTagsInvalid + TestSaveManifestKeepsBuildTags + TestGoBuildArgs +) +# The attach names run twice: untagged and with -tags summercms_s3. +ATTACH_TESTS=( + TestOpenBucketS3 + TestDiskExtensionTable + TestPublicURLBase + TestPublicURLValidation +) # docker stage: testcontainers suites. DOCKER_CABANA_TESTS=(TestReorderHookRunsInTx TestReorderHookErrorRollsBack TestReorderWithoutHookUnchanged) +DOCKER_ATTACH_TESTS=( + TestStoreDiskExtension +) usage() { cat >&2 <<'EOF' @@ -344,6 +360,9 @@ run_go() { go_short "$ROOT" go_named "$ROOT" ./modules/bouncer -short "${BOUNCER_TESTS[@]}" go_named "$ROOT" ./modules/surf -short "${SURF_TESTS[@]}" + go_named "$ROOT" ./internal/build -short "${BUILD_TESTS[@]}" + go_named "$ROOT" ./modules/lagoon/attach -short "${ATTACH_TESTS[@]}" + go_named "$ROOT" ./modules/lagoon/attach "-short -tags summercms_s3" "${ATTACH_TESTS[@]}" echo "phase19.3 go passed" } @@ -357,6 +376,7 @@ run_docker() { return 1 fi go_named "$ROOT" ./modules/cabana "-timeout 20m" "${DOCKER_CABANA_TESTS[@]}" + go_named "$ROOT" ./modules/lagoon/attach "-timeout 20m" "${DOCKER_ATTACH_TESTS[@]}" echo "phase19.3 docker passed" }