package main import ( "bytes" "context" "errors" "fmt" "image" "image/jpeg" _ "image/png" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "sync/atomic" "testing" "git.monoexp.com/bajdev/rekopub/internal/epubtest" "git.monoexp.com/bajdev/rekopub/internal/pipeline" ) // fakeConverter returns a fixed book and counts how often it was called, so // tests can check that nothing is fetched when arguments are rejected. type fakeConverter struct { title string err error calls int kepub bool // the KEPUB option of the last call } func (f *fakeConverter) convert(ctx context.Context, u *url.URL, opts pipeline.Options) (*pipeline.Book, error) { f.calls++ f.kepub = opts.KEPUB if f.err != nil { return nil, f.err } if opts.BeforeImages != nil { if err := opts.BeforeImages(f.title); err != nil { return nil, err } } return &pipeline.Book{Title: f.title, Content: strings.NewReader("epub:" + f.title)}, nil } type result struct { code int stdout, stderr string } func runCLI(t *testing.T, conv *fakeConverter, args ...string) result { t.Helper() var stdout, stderr bytes.Buffer code := run(context.Background(), args, &stdout, &stderr, conv.convert) return result{code, stdout.String(), stderr.String()} } const testURL = "https://example.com/article" func TestUsageErrors(t *testing.T) { dir := t.TempDir() out := filepath.Join(dir, "a.epub") tests := []struct { name string args []string want string // expected in stderr }{ {"no arguments", nil, "-URL is required"}, {"missing -SAVE", []string{"-URL", testURL}, "-SAVE is required"}, {"missing -URL", []string{"-SAVE", out}, "-URL is required"}, {"lowercase flag", []string{"-url", testURL, "-SAVE", out}, "flag provided but not defined: -url"}, {"extra argument", []string{"-URL", testURL, "-SAVE", out, "extra"}, `unexpected argument "extra"`}, {"ftp URL", []string{"-URL", "ftp://example.com/a", "-SAVE", out}, "must start with http:// or https://"}, {"no scheme", []string{"-URL", "example.com/a", "-SAVE", out}, "must start with http:// or https://"}, {"file URL", []string{"-URL", "file:///etc/passwd", "-SAVE", out}, "must start with http:// or https://"}, {"local path", []string{"-URL", "/etc/passwd", "-SAVE", out}, "must start with http:// or https://"}, {"Windows path", []string{"-URL", `C:\Users\me\notes.html`, "-SAVE", out}, "-URL"}, {"UNC path", []string{"-URL", `\\server\share\page.html`, "-SAVE", out}, "must start with http:// or https://"}, {"no host", []string{"-URL", "https://", "-SAVE", out}, "missing host name"}, {"invalid URL", []string{"-URL", "https://exa mple.com/%zz", "-SAVE", out}, "not a valid URL"}, {"kepub name without -KEPUB", []string{"-URL", testURL, "-SAVE", filepath.Join(dir, "a.kepub.epub")}, "needs -KEPUB"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { conv := &fakeConverter{title: "T"} r := runCLI(t, conv, tt.args...) if r.code != exitUsage { t.Errorf("exit code = %d, want %d", r.code, exitUsage) } if !strings.Contains(r.stderr, tt.want) { t.Errorf("stderr = %q, want it to contain %q", r.stderr, tt.want) } if !strings.Contains(r.stderr, "Usage: rekopub") { t.Errorf("stderr does not show usage: %q", r.stderr) } if conv.calls != 0 { t.Errorf("converter called %d times, want 0", conv.calls) } if r.stdout != "" { t.Errorf("stdout = %q, want empty", r.stdout) } }) } } func TestVersion(t *testing.T) { conv := &fakeConverter{} r := runCLI(t, conv, "-VERSION") if r.code != exitOK || !strings.HasPrefix(r.stdout, "rekopub ") || len(strings.TrimSpace(r.stdout)) <= len("rekopub") { t.Errorf("exit code = %d, stdout = %q", r.code, r.stdout) } if conv.calls != 0 { t.Errorf("-VERSION ran a conversion") } defer func(v string) { version = v }(version) version = "v1.2.3" if r := runCLI(t, conv, "-VERSION"); r.stdout != "rekopub v1.2.3\n" { t.Errorf("stdout = %q, want the version set at build time", r.stdout) } } func TestInterrupted(t *testing.T) { dir := t.TempDir() conv := &fakeConverter{err: fmt.Errorf("%w: %w", pipeline.ErrFetch, context.Canceled)} r := runCLI(t, conv, "-URL", testURL, "-SAVE", dir) if r.code != exitInterrupted || strings.TrimSpace(r.stderr) != "error: interrupted" { t.Errorf("exit code = %d, stderr = %q; want %d and \"error: interrupted\"", r.code, r.stderr, exitInterrupted) } assertEmptyDir(t, dir) } func TestHelp(t *testing.T) { r := runCLI(t, &fakeConverter{}, "-h") if r.code != exitOK { t.Errorf("exit code = %d, want %d", r.code, exitOK) } if !strings.Contains(r.stderr, "Usage: rekopub") { t.Errorf("stderr = %q, want usage", r.stderr) } } func TestSaveToFile(t *testing.T) { path := filepath.Join(t.TempDir(), "a.epub") r := runCLI(t, &fakeConverter{title: "Title"}, "-URL", testURL, "-SAVE", path) if r.code != exitOK { t.Fatalf("exit code = %d, stderr = %q", r.code, r.stderr) } if strings.TrimSpace(r.stdout) != path { t.Errorf("stdout = %q, want %q", r.stdout, path) } assertFile(t, path, "epub:Title") } func TestSaveToFolderCreatesIt(t *testing.T) { dir := filepath.Join(t.TempDir(), "Books", "Articles") r := runCLI(t, &fakeConverter{title: "My Great Article"}, "-URL", testURL, "-SAVE", dir+"/") if r.code != exitOK { t.Fatalf("exit code = %d, stderr = %q", r.code, r.stderr) } want := filepath.Join(dir, "my-great-article.epub") if strings.TrimSpace(r.stdout) != want { t.Errorf("stdout = %q, want %q", r.stdout, want) } assertFile(t, want, "epub:My Great Article") } func TestExistingFileStopsBeforeFetching(t *testing.T) { path := filepath.Join(t.TempDir(), "a.epub") writeFile(t, path, "old") conv := &fakeConverter{title: "Title"} r := runCLI(t, conv, "-URL", testURL, "-SAVE", path) if r.code != exitExists { t.Errorf("exit code = %d, want %d", r.code, exitExists) } if !strings.Contains(r.stderr, "already exists (use -FORCE to overwrite)") { t.Errorf("stderr = %q", r.stderr) } if conv.calls != 0 { t.Errorf("converter called %d times, want 0", conv.calls) } assertFile(t, path, "old") } func TestExistingFileInFolder(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "title.epub") writeFile(t, path, "old") r := runCLI(t, &fakeConverter{title: "Title"}, "-URL", testURL, "-SAVE", dir) if r.code != exitExists { t.Errorf("exit code = %d, want %d; stderr = %q", r.code, exitExists, r.stderr) } assertFile(t, path, "old") } func TestForceOverwrites(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "a.epub") writeFile(t, path, "old") r := runCLI(t, &fakeConverter{title: "Title"}, "-URL", testURL, "-SAVE", path, "-FORCE") if r.code != exitOK { t.Fatalf("exit code = %d, stderr = %q", r.code, r.stderr) } assertFile(t, path, "epub:Title") // -FORCE also applies to folder targets. writeFile(t, filepath.Join(dir, "title.epub"), "old") r = runCLI(t, &fakeConverter{title: "Title"}, "-URL", testURL, "-SAVE", dir, "-FORCE") if r.code != exitOK { t.Fatalf("folder: exit code = %d, stderr = %q", r.code, r.stderr) } assertFile(t, filepath.Join(dir, "title.epub"), "epub:Title") } func TestConversionFailureWritesNothing(t *testing.T) { dir := t.TempDir() conv := &fakeConverter{err: errors.New("boom")} r := runCLI(t, conv, "-URL", testURL, "-SAVE", filepath.Join(dir, "a.epub")) if r.code != exitBuild { t.Errorf("exit code = %d, want %d", r.code, exitBuild) } if !strings.Contains(r.stderr, "error: boom") { t.Errorf("stderr = %q", r.stderr) } assertEmptyDir(t, dir) } func TestSavePathThroughFile(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "Books"), "not a folder") r := runCLI(t, &fakeConverter{title: "Title"}, "-URL", testURL, "-SAVE", filepath.Join(dir, "Books", "a.epub")) if r.code != exitBuild { t.Errorf("exit code = %d, want %d; stderr = %q", r.code, exitBuild, r.stderr) } } func TestKEPUBExtensionNote(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "a.kepub.epub"), "old") // The overwrite check uses the adjusted name, before anything is fetched. conv := &fakeConverter{title: "Title"} r := runCLI(t, conv, "-URL", testURL, "-SAVE", filepath.Join(dir, "a.epub"), "-KEPUB") if !strings.Contains(r.stderr, "note: saving as a.kepub.epub") { t.Errorf("stderr = %q, want a note about the new name", r.stderr) } if r.code != exitExists || conv.calls != 0 { t.Errorf("exit code = %d, converter calls = %d; want %d and 0", r.code, conv.calls, exitExists) } } func TestKEPUBOption(t *testing.T) { dir := t.TempDir() for _, kepub := range []bool{false, true} { conv := &fakeConverter{title: "Title"} args := []string{"-URL", testURL, "-SAVE", dir, "-FORCE"} want := filepath.Join(dir, "title.epub") if kepub { args = append(args, "-KEPUB") want = filepath.Join(dir, "title.kepub.epub") } r := runCLI(t, conv, args...) if r.code != exitOK { t.Fatalf("kepub=%v: exit code = %d, stderr = %q", kepub, r.code, r.stderr) } if conv.kepub != kepub { t.Errorf("kepub=%v: pipeline got KEPUB=%v", kepub, conv.kepub) } if strings.TrimSpace(r.stdout) != want { t.Errorf("kepub=%v: saved to %q, want %q", kepub, r.stdout, want) } } } // assertCover checks the book has a 1404x1872 grayscale cover, listed // first in the spine. With photo, the cover's upper half must show the // photo (mid-grays) rather than the plain cover's black band and white // space. func assertCover(t *testing.T, book *epubtest.Book, photo bool) { t.Helper() opf := book.File(t, ".opf") if !strings.Contains(opf, `href="images/cover.jpg" media-type="image/jpeg" properties="cover-image"`) { t.Fatalf("OPF has no cover image:\n%s", opf) } if strings.Index(opf, ``) > strings.Index(opf, ``) { t.Errorf("cover isn't first in the spine:\n%s", opf) } img, err := jpeg.Decode(strings.NewReader(book.Files["EPUB/images/cover.jpg"])) if err != nil { t.Fatal(err) } gray, ok := img.(*image.Gray) if !ok || gray.Bounds().Dx() != 1404 || gray.Bounds().Dy() != 1872 { t.Fatalf("cover is %T %v, want 1404x1872 grayscale", img, img.Bounds()) } mid := 0 for y := 300; y < 900; y += 10 { for x := 0; x < 1404; x += 10 { if v := gray.GrayAt(x, y).Y; v > 40 && v < 215 { mid++ } } } if hasPhoto := mid > 2000; hasPhoto != photo { t.Errorf("cover photo = %v (%d mid-gray samples), want %v", hasPhoto, mid, photo) } } func writeFile(t *testing.T, path, content string) { t.Helper() if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } } func assertFile(t *testing.T, path, want string) { t.Helper() got, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if string(got) != want { t.Errorf("%s contains %q, want %q", path, got, want) } } func assertEmptyDir(t *testing.T, dir string) { t.Helper() entries, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } if len(entries) != 0 { t.Errorf("%s is not empty: %v", dir, entries) } } // TestEndToEnd runs the real pipeline against a local web server. func TestEndToEnd(t *testing.T) { blog, err := os.ReadFile("../../testdata/pages/blog.html") if err != nil { t.Fatal(err) } docs, err := os.ReadFile("../../testdata/pages/docs.html") if err != nil { t.Fatal(err) } essay, err := os.ReadFile("../../testdata/pages/photo-essay.html") if err != nil { t.Fatal(err) } lightbox, err := os.ReadFile("../../testdata/pages/lightbox.html") if err != nil { t.Fatal(err) } wiki, err := os.ReadFile("../../testdata/pages/wiki.html") if err != nil { t.Fatal(err) } images := map[string]string{ // served path → file in testdata/images "/img/press.jpg": "press.jpg", "/img/diagram.png": "diagram.png", "/img/type.webp": "gradient.webp", // lightbox.html; srcset should select the 1536w file. "/wp-content/uploads/2026/10/upgrade-dialog-1536x864.png": "diagram.png", "/wp-content/uploads/2026/10/settings-640x480.jpg": "press.jpg", "/wp-content/uploads/2026/10/hero.jpg": "press.jpg", // og:image, for the cover // wiki.html; served whatever the query string, as Wikipedia does. "/upload/a/a6/Walking_Robots_logo.png": "diagram.png", "/thumb/9/99/Painted_miniature.jpg/500px-Painted_miniature.jpg": "press.jpg", "/media/record-sheet": "gradient.webp", } var imageRequests atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/img/") || strings.HasPrefix(r.URL.Path, "/wp-content/") || strings.HasPrefix(r.URL.Path, "/upload/") || strings.HasPrefix(r.URL.Path, "/thumb/") || strings.HasPrefix(r.URL.Path, "/media/") { imageRequests.Add(1) name, ok := images[r.URL.Path] if !ok { http.NotFound(w, r) return } http.ServeFile(w, r, filepath.Join("../../testdata/images", name)) return } switch r.URL.Path { case "/wiki/Walking_Robots": w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write(wiki) case "/news/2026/10/kde-turns-30/": w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write(lightbox) case "/essays/print-shop": w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write(essay) case "/posts/eink": w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write(blog) case "/docs/config": w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write(docs) case "/app": w.Header().Set("Content-Type", "text/html") w.Write([]byte(`
`)) default: http.NotFound(w, r) } })) defer srv.Close() realRun := func(args ...string) result { var stdout, stderr bytes.Buffer code := run(context.Background(), args, &stdout, &stderr, pipeline.Run) return result{code, stdout.String(), stderr.String()} } t.Run("article", func(t *testing.T) { dir := t.TempDir() r := realRun("-URL", srv.URL+"/posts/eink", "-SAVE", dir) if r.code != exitOK { t.Fatalf("exit code = %d, stderr = %q", r.code, r.stderr) } path := filepath.Join(dir, "why-e-ink-is-easier-on-the-eyes.epub") if strings.TrimSpace(r.stdout) != path { t.Errorf("stdout = %q, want %q", r.stdout, path) } data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } book := epubtest.Open(t, data) book.AssertWellFormed(t) chapter := book.File(t, "chapter.xhtml") for _, want := range []string{ "

Why E-Ink Is Easier on the Eyes

", "Ada Lovelace · The Reading Desk · 14 March 2025", "Electronic paper does not shine light into your eyes.", `▶ Video: How E Ink works`, } { if !strings.Contains(chapter, want) { t.Errorf("chapter is missing %q", want) } } if strings.Contains(chapter, "Great article, thanks!") { t.Errorf("chapter contains page comments") } assertCover(t, book, false) epubtest.EPUBCheck(t, path) }) t.Run("messy documentation page", func(t *testing.T) { dir := t.TempDir() r := realRun("-URL", srv.URL+"/docs/config", "-SAVE", dir) if r.code != exitOK { t.Fatalf("exit code = %d, stderr = %q", r.code, r.stderr) } path := filepath.Join(dir, "configuring-the-sync-daemon.epub") data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } book := epubtest.Open(t, data) book.AssertWellFormed(t) chapter := book.File(t, "chapter.xhtml") for _, want := range []string{ `
`, `

File location

`, `

Durations

`, "
interval = ",
			"wifi_only",
			``,
			``,
			"recipes — including", // — as a character, not an entity
		} {
			if !strings.Contains(chapter, want) {
				t.Errorf("chapter is missing %q", want)
			}
		}
		for _, unwanted := range []string{"onclick", "style=", "javascript:", "analytics", "File location`,
			`Options 
  1. Durations
`, `Example`, } { if !strings.Contains(nav, want) { t.Errorf("nav is missing %q:\n%s", want, nav) } } if strings.Contains(nav, "Edge cases") { t.Errorf("nav lists an

; only sections and subsections belong in it") } epubtest.EPUBCheck(t, path) }) t.Run("photo essay", func(t *testing.T) { dir := t.TempDir() r := realRun("-URL", srv.URL+"/essays/print-shop", "-SAVE", dir) if r.code != exitOK { t.Fatalf("exit code = %d, stderr = %q", r.code, r.stderr) } if !strings.Contains(r.stderr, "warning: image "+srv.URL+"/img/missing.png skipped: server returned 404 Not Found") { t.Errorf("stderr = %q, want a warning about the missing image", r.stderr) } path := filepath.Join(dir, "a-walk-through-the-print-shop.epub") data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } book := epubtest.Open(t, data) book.AssertWellFormed(t) var stored []string for _, name := range book.Order { if strings.HasPrefix(name, "EPUB/images/") && name != "EPUB/images/cover.jpg" { stored = append(stored, name) } } if len(stored) != 3 { t.Fatalf("book has images %v, want 3 (photo, diagram, WebP picture)", stored) } for _, name := range stored { img, format, err := image.Decode(strings.NewReader(book.Files[name])) if err != nil { t.Fatalf("%s: %v", name, err) } if format != "jpeg" && format != "png" { t.Errorf("%s is %s, want jpeg or png", name, format) } if b := img.Bounds(); b.Dx() > 1404 || b.Dy() > 1872 { t.Errorf("%s is %dx%d, larger than the screen", name, b.Dx(), b.Dy()) } switch img.(type) { case *image.Gray, *image.Paletted: default: t.Errorf("%s decodes as %T, want grayscale", name, img) } } chapter := book.File(t, "chapter.xhtml") for _, want := range []string{ `alt="The old printing press"/>
The 1890s press still runs every Friday.
`, `alt="How ink reaches the paper"/>`, `alt="Drawers of wooden type"/>`, `[Image: small press icon]`, ``, // no invented date } { if !strings.Contains(chapter, want) { t.Errorf("chapter is missing %q", want) } } if strings.Contains(chapter, "tracker.example") || strings.Contains(chapter, `Letterpress Weekly") || !strings.Contains(opf, ""+srv.URL+"/essays/print-shop") { t.Errorf("OPF is missing the publisher or source:\n%s", opf) } epubtest.EPUBCheck(t, path) }) t.Run("images inside lightbox zoom links", func(t *testing.T) { // Regression test: go-trafilatura dropped every image on pages that // wrap images in zoom links, such as Ars Technica articles. dir := t.TempDir() r := realRun("-URL", srv.URL+"/news/2026/10/kde-turns-30/", "-SAVE", dir) if r.code != exitOK { t.Fatalf("exit code = %d, stderr = %q", r.code, r.stderr) } if strings.Contains(r.stderr, "warning") { t.Errorf("unexpected warnings: %q", r.stderr) } path := filepath.Join(dir, "kde-turns-30-with-plasma-6-8.epub") data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } book := epubtest.Open(t, data) book.AssertWellFormed(t) var stored int for _, name := range book.Order { if strings.HasPrefix(name, "EPUB/images/") && name != "EPUB/images/cover.jpg" { stored++ } } if stored != 2 { t.Errorf("book has %d images, want 2", stored) } chapter := book.File(t, "chapter.xhtml") for _, want := range []string{ `
The new remote upgrade dialog. Credit: KDE
`, `alt="System Settings in Plasma 6.8"/>
System Settings has been reorganised.
`, } { if !strings.Contains(chapter, want) { t.Errorf("chapter is missing %q", want) } } if n := strings.Count(chapter, "The new remote upgrade dialog."); n != 1 { t.Errorf("caption appears %d times, want once", n) } assertCover(t, book, true) // the page's og:image, as on Ars Technica epubtest.EPUBCheck(t, path) }) t.Run("image URLs with query strings or no extension", func(t *testing.T) { // Regression test: go-trafilatura dropped images whose URL doesn't end // in a file extension, such as every image on Wikipedia. dir := t.TempDir() r := realRun("-URL", srv.URL+"/wiki/Walking_Robots", "-SAVE", dir) if r.code != exitOK { t.Fatalf("exit code = %d, stderr = %q", r.code, r.stderr) } if strings.Contains(r.stderr, "warning") { t.Errorf("unexpected warnings: %q", r.stderr) } path := filepath.Join(dir, "walking-robots-game.epub") data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } book := epubtest.Open(t, data) book.AssertWellFormed(t) var stored int for _, name := range book.Order { if strings.HasPrefix(name, "EPUB/images/") && name != "EPUB/images/cover.jpg" { stored++ } } if stored != 3 { t.Errorf("book has %d images, want 3 (logo, miniature, record sheet)", stored) } chapter := book.File(t, "chapter.xhtml") for _, want := range []string{ `
The game's original logo
`, `
A painted miniature on a hex map
`, `
A machine's record sheet
`, } { if !strings.Contains(chapter, want) { t.Errorf("chapter is missing %q", want) } } if strings.Contains(chapter, "rekopub.invalid") { t.Errorf("chapter still contains stand-in image URLs:\n%s", chapter) } epubtest.EPUBCheck(t, path) }) t.Run("KEPUB for Kobo", func(t *testing.T) { dir := t.TempDir() r := realRun("-URL", srv.URL+"/essays/print-shop", "-SAVE", filepath.Join(dir, "essay.epub"), "-KEPUB") if r.code != exitOK { t.Fatalf("exit code = %d, stderr = %q", r.code, r.stderr) } if !strings.Contains(r.stderr, "note: saving as essay.kepub.epub") { t.Errorf("stderr = %q, want a note about the .kepub.epub name", r.stderr) } path := filepath.Join(dir, "essay.kepub.epub") if strings.TrimSpace(r.stdout) != path { t.Errorf("stdout = %q, want %q", r.stdout, path) } data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } book := epubtest.Open(t, data) book.AssertWellFormed(t) chapter := book.File(t, "chapter.xhtml") for _, want := range []string{`class="koboSpan"`, "The print shop on Mill Street has been running since 1894.", "
"} { if !strings.Contains(chapter, want) { t.Errorf("chapter is missing %q", want) } } assertCover(t, book, true) epubtest.EPUBCheck(t, path) }) t.Run("existing file stops before images are downloaded", func(t *testing.T) { dir := t.TempDir() existing := filepath.Join(dir, "a-walk-through-the-print-shop.epub") writeFile(t, existing, "old") before := imageRequests.Load() r := realRun("-URL", srv.URL+"/essays/print-shop", "-SAVE", dir) if r.code != exitExists { t.Errorf("exit code = %d, want %d; stderr = %q", r.code, exitExists, r.stderr) } if n := imageRequests.Load() - before; n != 0 { t.Errorf("%d images downloaded, want 0", n) } assertFile(t, existing, "old") }) t.Run("not found", func(t *testing.T) { dir := t.TempDir() r := realRun("-URL", srv.URL+"/missing", "-SAVE", dir) if r.code != exitFetch || !strings.Contains(r.stderr, "returned 404 Not Found") { t.Errorf("exit code = %d, stderr = %q", r.code, r.stderr) } assertEmptyDir(t, dir) }) t.Run("no content", func(t *testing.T) { dir := t.TempDir() r := realRun("-URL", srv.URL+"/app", "-SAVE", dir) if r.code != exitNoContent || !strings.Contains(r.stderr, "no readable article content") { t.Errorf("exit code = %d, stderr = %q", r.code, r.stderr) } assertEmptyDir(t, dir) }) } // TestLocalFilesNeverRead checks that a page can't pull files from the // reader's computer into the book, whichever way it refers to them. func TestLocalFilesNeverRead(t *testing.T) { const marker = "TOP-SECRET-MARKER-7f3a" secrets := t.TempDir() secretText := filepath.Join(secrets, "secret.txt") if err := os.WriteFile(secretText, []byte(marker), 0o644); err != nil { t.Fatal(err) } png, err := os.ReadFile("../../testdata/images/diagram.png") if err != nil { t.Fatal(err) } secretPNG := filepath.Join(secrets, "secret.png") if err := os.WriteFile(secretPNG, png, 0o644); err != nil { t.Fatal(err) } fileURL := func(path string) string { p := filepath.ToSlash(path) if !strings.HasPrefix(p, "/") { p = "/" + p // a Windows path such as C:/Users/... } return "file://" + p } blog, err := os.ReadFile("../../testdata/pages/blog.html") if err != nil { t.Fatal(err) } page := strings.Replace(string(blog), "", ``, 1) page = strings.Replace(page, "

", "

"+ `

one`+ `two`+ `three`+ `four`+ `five`+ `six`+ `notes

`, 1) var requests []string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests = append(requests, r.URL.Path) if r.URL.Path != "/posts/eink" { http.NotFound(w, r) // relative paths resolve to this server return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(page)) })) defer srv.Close() out := filepath.Join(t.TempDir(), "book.epub") var stdout, stderr bytes.Buffer code := run(context.Background(), []string{"-URL", srv.URL + "/posts/eink", "-SAVE", out}, &stdout, &stderr, pipeline.Run) if code != exitOK { t.Fatalf("exit %d: %s", code, stderr.String()) } data, err := os.ReadFile(out) if err != nil { t.Fatal(err) } book := epubtest.Open(t, data) for name, content := range book.Files { if strings.Contains(content, marker) { t.Errorf("%s contains the secret file's content", name) } if strings.HasSuffix(name, ".png") || (strings.HasSuffix(name, ".jpg") && !strings.HasSuffix(name, "cover.jpg")) { t.Errorf("book contains image %s; the only image should be the typographic cover", name) } } if chapter := book.File(t, "chapter.xhtml"); strings.Contains(chapter, "file:") { t.Errorf("chapter still refers to a local file:\n%s", chapter) } t.Logf("server requests: %v\nwarnings:\n%s", requests, stderr.String()) }