| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542 |
- package web
- import (
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "net/url"
- "os"
- "path"
- "path/filepath"
- "runtime"
- "slices"
- "strings"
- "sync"
- "testing"
- "time"
- )
- // fakeCLI stands in for the rekopub command. It writes a book named after
- // the page's last path segment into the -SAVE folder, as the command does.
- type fakeCLI struct {
- mu sync.Mutex
- calls [][]string
- fail runResult // returned instead of a book when Code != 0
- block chan struct{} // if set, runs wait for it to close
- }
- func (f *fakeCLI) run(ctx context.Context, args []string) runResult {
- f.mu.Lock()
- f.calls = append(f.calls, args)
- f.mu.Unlock()
- if f.block != nil {
- select {
- case <-f.block:
- case <-ctx.Done():
- return runResult{Code: -1}
- }
- }
- if f.fail.Code != 0 {
- return f.fail
- }
- var page, dir string
- kepub := false
- for _, a := range args {
- switch {
- case strings.HasPrefix(a, "-URL="):
- u, _ := url.Parse(strings.TrimPrefix(a, "-URL="))
- page = filepath.Base(u.Path)
- case strings.HasPrefix(a, "-SAVE="):
- dir = strings.TrimPrefix(a, "-SAVE=")
- case a == "-KEPUB":
- kepub = true
- }
- }
- path := filepath.Join(dir, page+".epub")
- if kepub {
- path = filepath.Join(dir, page+".kepub.epub")
- }
- if err := os.WriteFile(path, []byte("book:"+page), 0o640); err != nil {
- return runResult{Code: 4, Err: err.Error()}
- }
- abs, _ := filepath.Abs(path) // the command prints absolute paths
- return runResult{Path: abs}
- }
- func (f *fakeCLI) count() int {
- f.mu.Lock()
- defer f.mu.Unlock()
- return len(f.calls)
- }
- type testServer struct {
- *Server
- cli *fakeCLI
- now time.Time
- http http.Handler
- }
- func newTestServer(t *testing.T, cfg Config) *testServer {
- t.Helper()
- cfg.CLI = "rekopub"
- // Relative, as on the server (DATA_DIR=data).
- t.Chdir(t.TempDir())
- cfg.Dir = filepath.Join("data", "books")
- s, err := New(cfg)
- if err != nil {
- t.Fatal(err)
- }
- ts := &testServer{Server: s, cli: &fakeCLI{}, now: time.Now()}
- s.run = ts.cli.run
- s.now = func() time.Time { return ts.now }
- s.isLocal = func(_ context.Context, host string) bool { return host == "localhost" || host == "10.0.0.1" }
- ts.http = s.Handler()
- return ts
- }
- type response struct {
- code int
- body string
- rep reply
- hdr http.Header
- }
- func (ts *testServer) do(t *testing.T, req *http.Request) response {
- t.Helper()
- w := httptest.NewRecorder()
- ts.http.ServeHTTP(w, req)
- res := response{code: w.Code, body: w.Body.String(), hdr: w.Header()}
- if strings.HasPrefix(w.Header().Get("Content-Type"), "application/json") {
- if err := json.Unmarshal(w.Body.Bytes(), &res.rep); err != nil {
- t.Fatalf("bad JSON %q: %v", res.body, err)
- }
- }
- return res
- }
- // post submits the form as the page's script does, from visitor ip.
- func (ts *testServer) post(t *testing.T, ip, page, format string) response {
- t.Helper()
- form := url.Values{"url": {page}}
- if format != "" {
- form.Set("format", format)
- }
- req := httptest.NewRequest("POST", "/api/convert", strings.NewReader(form.Encode()))
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- req.Header.Set("Accept", "application/json")
- req.Header.Set("X-Real-IP", ip)
- return ts.do(t, req)
- }
- func (ts *testServer) get(t *testing.T, ip, path string) response {
- t.Helper()
- req := httptest.NewRequest("GET", path, nil)
- req.Header.Set("X-Real-IP", ip)
- return ts.do(t, req)
- }
- func TestConvertEPUB(t *testing.T) {
- ts := newTestServer(t, Config{})
- res := ts.post(t, "203.0.113.5", "https://example.com/posts/hello#top", "")
- if res.code != http.StatusOK || len(res.rep.Files) != 1 {
- t.Fatalf("code = %d, body = %s", res.code, res.body)
- }
- if res.rep.KeepHours != 24 {
- t.Errorf("keepHours = %d, want 24", res.rep.KeepHours)
- }
- f := res.rep.Files[0]
- if f.Format != "EPUB" || f.Name != "hello.epub" || f.Size != int64(len("book:hello")) {
- t.Errorf("file = %+v", f)
- }
- args := ts.cli.calls[0]
- if len(args) != 2 || args[0] != "-URL=https://example.com/posts/hello" ||
- !strings.HasPrefix(args[1], "-SAVE="+ts.cfg.Dir+string(filepath.Separator)) || !strings.HasSuffix(args[1], string(filepath.Separator)) {
- t.Errorf("command args = %q", args)
- }
- dl := ts.get(t, "198.51.100.7", f.URL)
- if dl.code != http.StatusOK || dl.body != "book:hello" {
- t.Fatalf("download: code = %d, body = %q", dl.code, dl.body)
- }
- if got := dl.hdr.Get("Content-Disposition"); got != `attachment; filename="hello.epub"` {
- t.Errorf("Content-Disposition = %q", got)
- }
- if got := dl.hdr.Get("Content-Type"); got != "application/epub+zip" {
- t.Errorf("Content-Type = %q", got)
- }
- if got := dl.hdr.Get("X-Content-Type-Options"); got != "nosniff" {
- t.Errorf("X-Content-Type-Options = %q", got)
- }
- }
- func TestConvertBoth(t *testing.T) {
- ts := newTestServer(t, Config{})
- res := ts.post(t, "203.0.113.5", "https://example.com/a", "both")
- if res.code != http.StatusOK || len(res.rep.Files) != 2 {
- t.Fatalf("code = %d, body = %s", res.code, res.body)
- }
- if res.rep.Files[0].Name != "a.epub" || res.rep.Files[1].Name != "a.kepub.epub" || res.rep.Files[1].Format != "KEPUB" {
- t.Errorf("files = %+v", res.rep.Files)
- }
- if path.Dir(res.rep.Files[0].URL) != path.Dir(res.rep.Files[1].URL) {
- t.Errorf("books of one conversion are in different folders: %+v", res.rep.Files)
- }
- if !slices.Contains(ts.cli.calls[1], "-KEPUB") {
- t.Errorf("second run args = %q, want -KEPUB", ts.cli.calls[1])
- }
- // Both books count as one conversion.
- ts.Server.mu.Lock()
- n := len(ts.all)
- ts.Server.mu.Unlock()
- if n != 1 {
- t.Errorf("counted %d conversions, want 1", n)
- }
- }
- func TestConvertKEPUB(t *testing.T) {
- ts := newTestServer(t, Config{})
- res := ts.post(t, "203.0.113.5", "https://example.com/a", "kepub")
- if res.code != http.StatusOK || len(res.rep.Files) != 1 || res.rep.Files[0].Name != "a.kepub.epub" {
- t.Fatalf("code = %d, body = %s", res.code, res.body)
- }
- }
- func TestBadRequests(t *testing.T) {
- tests := []struct {
- name, page, format, want string
- }{
- {"empty", "", "", "Enter the address"},
- {"no scheme", "example.com/a", "", "starting with http"},
- {"ftp", "ftp://example.com/a", "", "starting with http"},
- {"javascript", "javascript:alert(1)", "", "starting with http"},
- {"file", "file:///etc/passwd", "", "starting with http"},
- {"no host", "https:///a", "", "starting with http"},
- {"credentials", "https://user:pw@example.com/a", "", "user name or password"},
- {"port", "https://example.com:3000/a", "", "standard web ports"},
- {"control", "https://example.com/a\nb", "", "valid web address"},
- {"too long", "https://example.com/" + strings.Repeat("a", maxURLLen), "", "too long"},
- {"localhost", "http://localhost/a", "", "local or private"},
- {"private", "http://10.0.0.1/a", "", "local or private"},
- {"format", "https://example.com/a", "pdf", "Choose EPUB"},
- }
- ts := newTestServer(t, Config{Limit: 3})
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- res := ts.post(t, "203.0.113.5", tt.page, tt.format)
- if res.code != http.StatusBadRequest || !strings.Contains(res.rep.Error, tt.want) {
- t.Errorf("code = %d, error = %q; want 400 and %q", res.code, res.rep.Error, tt.want)
- }
- })
- }
- if n := ts.cli.count(); n != 0 {
- t.Errorf("command ran %d times, want 0", n)
- }
- // Rejected requests don't use up conversions.
- if res := ts.get(t, "203.0.113.5", "/api/status"); res.rep.Paused {
- t.Errorf("paused after rejected requests: %+v", res.rep)
- }
- }
- func TestOversizedForm(t *testing.T) {
- ts := newTestServer(t, Config{})
- res := ts.post(t, "203.0.113.5", "https://example.com/"+strings.Repeat("a", maxBodyLen), "")
- if res.code != http.StatusBadRequest {
- t.Errorf("code = %d, want 400", res.code)
- }
- }
- func TestLimitForEveryone(t *testing.T) {
- ts := newTestServer(t, Config{Limit: 3, PerVisitor: 10, Window: 15 * time.Minute})
- for i, ip := range []string{"203.0.113.1", "203.0.113.2", "203.0.113.3"} {
- if res := ts.post(t, ip, "https://example.com/a", ""); res.code != http.StatusOK {
- t.Fatalf("conversion %d: code = %d, body = %s", i+1, res.code, res.body)
- }
- ts.now = ts.now.Add(time.Minute)
- }
- res := ts.post(t, "203.0.113.4", "https://example.com/a", "")
- want := "Conversions are temporarily paused. Please try again in 12 minutes."
- if res.code != http.StatusTooManyRequests || !res.rep.Paused || res.rep.Error != want {
- t.Errorf("code = %d, reply = %+v; want 429 and %q", res.code, res.rep, want)
- }
- if st := ts.get(t, "203.0.113.9", "/api/status"); !st.rep.Paused || st.rep.Error != want {
- t.Errorf("status = %+v, want paused", st.rep)
- }
- if n := ts.cli.count(); n != 3 {
- t.Errorf("command ran %d times, want 3", n)
- }
- // The first conversion stops counting 15 minutes after it started.
- ts.now = ts.now.Add(12 * time.Minute)
- if st := ts.get(t, "203.0.113.9", "/api/status"); st.rep.Paused {
- t.Errorf("still paused after the window: %+v", st.rep)
- }
- if res := ts.post(t, "203.0.113.4", "https://example.com/a", ""); res.code != http.StatusOK {
- t.Errorf("after the window: code = %d, body = %s", res.code, res.body)
- }
- }
- func TestLimitPerVisitor(t *testing.T) {
- ts := newTestServer(t, Config{Limit: 50, PerVisitor: 2})
- for range 2 {
- if res := ts.post(t, "2001:db8:1:2::1", "https://example.com/a", ""); res.code != http.StatusOK {
- t.Fatalf("code = %d, body = %s", res.code, res.body)
- }
- }
- // Another address in the same IPv6 /64 is the same visitor.
- res := ts.post(t, "2001:db8:1:2::ffff", "https://example.com/a", "")
- if res.code != http.StatusTooManyRequests || !strings.Contains(res.rep.Error, "You've made 2 conversions in the last 15 minutes") {
- t.Errorf("code = %d, reply = %+v", res.code, res.rep)
- }
- if st := ts.get(t, "2001:db8:1:2::1", "/api/status"); !st.rep.Paused {
- t.Errorf("status for the visitor = %+v, want paused", st.rep)
- }
- // Everyone else can still convert.
- if st := ts.get(t, "203.0.113.1", "/api/status"); st.rep.Paused {
- t.Errorf("status for others = %+v, want not paused", st.rep)
- }
- if res := ts.post(t, "2001:db8:1:3::1", "https://example.com/a", ""); res.code != http.StatusOK {
- t.Errorf("other visitor: code = %d, body = %s", res.code, res.body)
- }
- }
- func TestStorageLimit(t *testing.T) {
- ts := newTestServer(t, Config{MaxStorage: 10})
- if res := ts.post(t, "203.0.113.1", "https://example.com/abcdefgh", ""); res.code != http.StatusOK {
- t.Fatalf("code = %d, body = %s", res.code, res.body)
- }
- if res := ts.post(t, "203.0.113.2", "https://example.com/a", ""); res.code != http.StatusTooManyRequests || !res.rep.Paused {
- t.Errorf("over storage: code = %d, reply = %+v", res.code, res.rep)
- }
- // Once the book expires, conversions resume.
- ts.now = ts.now.Add(25 * time.Hour)
- ageAll(t, ts.cfg.Dir, 25*time.Hour)
- ts.Clean()
- if res := ts.post(t, "203.0.113.2", "https://example.com/a", ""); res.code != http.StatusOK {
- t.Errorf("after clean: code = %d, body = %s", res.code, res.body)
- }
- }
- func TestBusy(t *testing.T) {
- ts := newTestServer(t, Config{MaxRunning: 1, MaxWaiting: 1, Wait: 50 * time.Millisecond})
- ts.cli.block = make(chan struct{})
- first := make(chan response)
- go func() { first <- ts.post(t, "203.0.113.1", "https://example.com/a", "") }()
- for ts.cli.count() == 0 {
- time.Sleep(time.Millisecond)
- }
- res := ts.post(t, "203.0.113.2", "https://example.com/b", "")
- if res.code != http.StatusServiceUnavailable || !strings.Contains(res.rep.Error, "busy") {
- t.Errorf("code = %d, reply = %+v; want 503 busy", res.code, res.rep)
- }
- close(ts.cli.block)
- if res := <-first; res.code != http.StatusOK {
- t.Errorf("first: code = %d, body = %s", res.code, res.body)
- }
- // The conversion that never ran was taken back.
- ts.Server.mu.Lock()
- n := len(*ts.visitor["203.0.113.2"])
- ts.Server.mu.Unlock()
- if n != 0 {
- t.Errorf("busy visitor has %d conversions counted, want 0", n)
- }
- }
- func TestFailedConversion(t *testing.T) {
- ts := newTestServer(t, Config{})
- ts.cli.fail = runResult{Code: exitNoContent, Err: "no readable content found"}
- res := ts.post(t, "203.0.113.1", "https://example.com/a", "both")
- if res.code != http.StatusUnprocessableEntity || !strings.Contains(res.rep.Error, "No readable article") {
- t.Errorf("code = %d, reply = %+v", res.code, res.rep)
- }
- if n := ts.cli.count(); n != 1 {
- t.Errorf("command ran %d times, want 1 (no KEPUB after the EPUB failed)", n)
- }
- if entries, _ := os.ReadDir(ts.cfg.Dir); len(entries) != 0 {
- t.Errorf("folder left behind: %v", entries)
- }
- }
- func TestUnexpectedOutputPath(t *testing.T) {
- ts := newTestServer(t, Config{})
- ts.Server.run = func(context.Context, []string) runResult { return runResult{Path: "/etc/passwd"} }
- res := ts.post(t, "203.0.113.1", "https://example.com/a", "")
- if res.code != http.StatusUnprocessableEntity || res.rep.Files != nil {
- t.Errorf("code = %d, reply = %+v", res.code, res.rep)
- }
- }
- func TestExplain(t *testing.T) {
- ctx := context.Background()
- tests := []struct {
- res runResult
- want string
- }{
- {runResult{Code: 1, Err: "couldn't fetch the page: example.com returned 404 Not Found"}, "Couldn't download the page: example.com returned 404 Not Found"},
- {runResult{Code: 1, Err: "couldn't fetch the page: example.com is a local or private address (10.0.0.1); …"}, "local or private network address"},
- {runResult{Code: 2, Err: "-URL 10.0.0.1 is on this computer or a private network; add -LOCAL to convert it"}, "local or private network address"},
- {runResult{Code: 3}, "No readable article"},
- {runResult{Code: 4, Err: "saving: /opt/apps/x: permission denied"}, "Something went wrong"},
- {runResult{Code: -1}, "Something went wrong"},
- }
- for _, tt := range tests {
- if got := explain(ctx, tt.res); !strings.Contains(got, tt.want) {
- t.Errorf("explain(%+v) = %q, want %q", tt.res, got, tt.want)
- }
- }
- expired, cancel := context.WithDeadline(ctx, time.Now().Add(-time.Second))
- defer cancel()
- if got := explain(expired, runResult{Code: -1}); !strings.Contains(got, "too long") {
- t.Errorf("timeout: %q", got)
- }
- }
- func TestBookLinks(t *testing.T) {
- ts := newTestServer(t, Config{})
- res := ts.post(t, "203.0.113.1", "https://example.com/a", "")
- link := res.rep.Files[0].URL
- if err := os.WriteFile(filepath.Join(filepath.Dir(ts.cfg.Dir), "secret.epub"), []byte("TOP-SECRET-CONTENT"), 0o600); err != nil {
- t.Fatal(err)
- }
- for _, p := range []string{
- "/books/0123456789abcdef0123456789abcdef/a.epub", // unknown id
- strings.TrimSuffix(link, "a.epub") + "b.epub", // unknown name
- strings.TrimSuffix(link, ".epub") + ".html", // not a book name
- "/books/../secret.epub",
- "/books/%2e%2e/secret.epub",
- "/books/x/..%2fsecret.epub",
- } {
- if res := ts.get(t, "203.0.113.1", p); res.code == http.StatusOK || strings.Contains(res.body, "TOP-SECRET") {
- t.Errorf("GET %s: code = %d, body = %q", p, res.code, res.body)
- }
- }
- ts.now = ts.now.Add(25 * time.Hour)
- if res := ts.get(t, "203.0.113.1", link); res.code != http.StatusNotFound || !strings.Contains(res.body, "<h1>Link expired</h1>") {
- t.Errorf("expired link: code = %d, body = %q", res.code, res.body)
- }
- }
- func TestClean(t *testing.T) {
- ts := newTestServer(t, Config{Keep: 2 * time.Hour})
- old := ts.post(t, "203.0.113.1", "https://example.com/old", "").rep.Files[0]
- ageAll(t, ts.cfg.Dir, 3*time.Hour)
- fresh := ts.post(t, "203.0.113.2", "https://example.com/fresh", "").rep.Files[0]
- stray := filepath.Join(ts.cfg.Dir, "not-a-conversion")
- if err := os.Mkdir(stray, 0o750); err != nil {
- t.Fatal(err)
- }
- ts.Clean()
- if _, err := os.Stat(filepath.Join(ts.cfg.Dir, strings.Split(old.URL, "/")[2])); !os.IsNotExist(err) {
- t.Errorf("old conversion kept: %v", err)
- }
- if res := ts.get(t, "203.0.113.1", fresh.URL); res.code != http.StatusOK {
- t.Errorf("fresh book: code = %d", res.code)
- }
- if _, err := os.Stat(stray); err != nil {
- t.Errorf("unrelated folder removed: %v", err)
- }
- if got := ts.stored.Load(); got != fresh.Size {
- t.Errorf("stored = %d, want %d", got, fresh.Size)
- }
- if res := ts.post(t, "203.0.113.1", "https://example.com/x", ""); res.rep.KeepHours != 2 {
- t.Errorf("keepHours = %d, want 2", res.rep.KeepHours)
- }
- }
- func TestHTMLReply(t *testing.T) {
- ts := newTestServer(t, Config{})
- form := url.Values{"url": {"example.com"}}
- req := httptest.NewRequest("POST", "/api/convert", strings.NewReader(form.Encode()))
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- res := ts.do(t, req)
- if res.code != http.StatusBadRequest || !strings.Contains(res.hdr.Get("Content-Type"), "text/html") {
- t.Fatalf("code = %d, Content-Type = %q", res.code, res.hdr.Get("Content-Type"))
- }
- ts.cli.fail = runResult{Code: 1, Err: `couldn't fetch the page: <script>alert(1)</script>`}
- form = url.Values{"url": {"https://example.com/a"}}
- req = httptest.NewRequest("POST", "/api/convert", strings.NewReader(form.Encode()))
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- res = ts.do(t, req)
- if strings.Contains(res.body, "<script>") || !strings.Contains(res.body, "<script>") {
- t.Errorf("error not escaped:\n%s", res.body)
- }
- ts.cli.fail = runResult{}
- req = httptest.NewRequest("POST", "/api/convert", strings.NewReader(form.Encode()))
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- res = ts.do(t, req)
- if res.code != http.StatusOK || !strings.Contains(res.body, "Links work for 24 hours") || !strings.Contains(res.body, `href="/books/`) {
- t.Errorf("code = %d, body:\n%s", res.code, res.body)
- }
- }
- func TestCrossOriginRefused(t *testing.T) {
- ts := newTestServer(t, Config{})
- form := url.Values{"url": {"https://example.com/a"}}
- req := httptest.NewRequest("POST", "/api/convert", strings.NewReader(form.Encode()))
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- req.Header.Set("Sec-Fetch-Site", "cross-site")
- if res := ts.do(t, req); res.code != http.StatusForbidden {
- t.Errorf("code = %d, want 403", res.code)
- }
- if n := ts.cli.count(); n != 0 {
- t.Errorf("command ran %d times, want 0", n)
- }
- }
- func TestVisitorKey(t *testing.T) {
- tests := []struct{ realIP, remote, want string }{
- {"203.0.113.5", "127.0.0.1:5000", "203.0.113.5"},
- {"", "203.0.113.6:5000", "203.0.113.6"},
- {"::ffff:203.0.113.7", "127.0.0.1:5000", "203.0.113.7"},
- {"2001:db8:1:2:3:4:5:6", "127.0.0.1:5000", "2001:db8:1:2::/64"},
- }
- for _, tt := range tests {
- r := httptest.NewRequest("GET", "/", nil)
- r.RemoteAddr = tt.remote
- if tt.realIP != "" {
- r.Header.Set("X-Real-IP", tt.realIP)
- }
- if got := visitorKey(r); got != tt.want {
- t.Errorf("visitorKey(%q, %q) = %q, want %q", tt.realIP, tt.remote, got, tt.want)
- }
- }
- }
- // TestExecRunner runs a stand-in script through the real runner.
- func TestExecRunner(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("uses a shell script")
- }
- t.Setenv("REKOPUB_TEST_SECRET", "x")
- dir := t.TempDir()
- script := filepath.Join(dir, "rekopub")
- body := "#!/bin/sh\n" +
- `[ -z "$REKOPUB_TEST_SECRET$HOME" ] || { echo "error: environment not empty" >&2; exit 4; }` + "\n" +
- `case "$1" in *fail*) echo "warning: one image" >&2; echo "error: couldn't fetch the page: example.com returned 404 Not Found" >&2; exit 1;; esac` + "\n" +
- `echo "$2" | cut -c7-` + "\n"
- if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
- t.Fatal(err)
- }
- run := execRunner(script)
- if res := run(context.Background(), []string{"-URL=https://example.com/ok", "-SAVE=/x/a.epub"}); res.Code != 0 || res.Path != "/x/a.epub" {
- t.Errorf("ok run = %+v", res)
- }
- res := run(context.Background(), []string{"-URL=https://example.com/fail", "-SAVE=/x/"})
- if res.Code != 1 || res.Err != "couldn't fetch the page: example.com returned 404 Not Found" {
- t.Errorf("failed run = %+v", res)
- }
- }
- // ageAll makes every file and folder under dir look d older.
- func ageAll(t *testing.T, dir string, d time.Duration) {
- t.Helper()
- filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
- if err != nil {
- return err
- }
- old := info.ModTime().Add(-d)
- return os.Chtimes(p, old, old)
- })
- }
|