server_test.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542
  1. package web
  2. import (
  3. "context"
  4. "encoding/json"
  5. "net/http"
  6. "net/http/httptest"
  7. "net/url"
  8. "os"
  9. "path"
  10. "path/filepath"
  11. "runtime"
  12. "slices"
  13. "strings"
  14. "sync"
  15. "testing"
  16. "time"
  17. )
  18. // fakeCLI stands in for the rekopub command. It writes a book named after
  19. // the page's last path segment into the -SAVE folder, as the command does.
  20. type fakeCLI struct {
  21. mu sync.Mutex
  22. calls [][]string
  23. fail runResult // returned instead of a book when Code != 0
  24. block chan struct{} // if set, runs wait for it to close
  25. }
  26. func (f *fakeCLI) run(ctx context.Context, args []string) runResult {
  27. f.mu.Lock()
  28. f.calls = append(f.calls, args)
  29. f.mu.Unlock()
  30. if f.block != nil {
  31. select {
  32. case <-f.block:
  33. case <-ctx.Done():
  34. return runResult{Code: -1}
  35. }
  36. }
  37. if f.fail.Code != 0 {
  38. return f.fail
  39. }
  40. var page, dir string
  41. kepub := false
  42. for _, a := range args {
  43. switch {
  44. case strings.HasPrefix(a, "-URL="):
  45. u, _ := url.Parse(strings.TrimPrefix(a, "-URL="))
  46. page = filepath.Base(u.Path)
  47. case strings.HasPrefix(a, "-SAVE="):
  48. dir = strings.TrimPrefix(a, "-SAVE=")
  49. case a == "-KEPUB":
  50. kepub = true
  51. }
  52. }
  53. path := filepath.Join(dir, page+".epub")
  54. if kepub {
  55. path = filepath.Join(dir, page+".kepub.epub")
  56. }
  57. if err := os.WriteFile(path, []byte("book:"+page), 0o640); err != nil {
  58. return runResult{Code: 4, Err: err.Error()}
  59. }
  60. abs, _ := filepath.Abs(path) // the command prints absolute paths
  61. return runResult{Path: abs}
  62. }
  63. func (f *fakeCLI) count() int {
  64. f.mu.Lock()
  65. defer f.mu.Unlock()
  66. return len(f.calls)
  67. }
  68. type testServer struct {
  69. *Server
  70. cli *fakeCLI
  71. now time.Time
  72. http http.Handler
  73. }
  74. func newTestServer(t *testing.T, cfg Config) *testServer {
  75. t.Helper()
  76. cfg.CLI = "rekopub"
  77. // Relative, as on the server (DATA_DIR=data).
  78. t.Chdir(t.TempDir())
  79. cfg.Dir = filepath.Join("data", "books")
  80. s, err := New(cfg)
  81. if err != nil {
  82. t.Fatal(err)
  83. }
  84. ts := &testServer{Server: s, cli: &fakeCLI{}, now: time.Now()}
  85. s.run = ts.cli.run
  86. s.now = func() time.Time { return ts.now }
  87. s.isLocal = func(_ context.Context, host string) bool { return host == "localhost" || host == "10.0.0.1" }
  88. ts.http = s.Handler()
  89. return ts
  90. }
  91. type response struct {
  92. code int
  93. body string
  94. rep reply
  95. hdr http.Header
  96. }
  97. func (ts *testServer) do(t *testing.T, req *http.Request) response {
  98. t.Helper()
  99. w := httptest.NewRecorder()
  100. ts.http.ServeHTTP(w, req)
  101. res := response{code: w.Code, body: w.Body.String(), hdr: w.Header()}
  102. if strings.HasPrefix(w.Header().Get("Content-Type"), "application/json") {
  103. if err := json.Unmarshal(w.Body.Bytes(), &res.rep); err != nil {
  104. t.Fatalf("bad JSON %q: %v", res.body, err)
  105. }
  106. }
  107. return res
  108. }
  109. // post submits the form as the page's script does, from visitor ip.
  110. func (ts *testServer) post(t *testing.T, ip, page, format string) response {
  111. t.Helper()
  112. form := url.Values{"url": {page}}
  113. if format != "" {
  114. form.Set("format", format)
  115. }
  116. req := httptest.NewRequest("POST", "/api/convert", strings.NewReader(form.Encode()))
  117. req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
  118. req.Header.Set("Accept", "application/json")
  119. req.Header.Set("X-Real-IP", ip)
  120. return ts.do(t, req)
  121. }
  122. func (ts *testServer) get(t *testing.T, ip, path string) response {
  123. t.Helper()
  124. req := httptest.NewRequest("GET", path, nil)
  125. req.Header.Set("X-Real-IP", ip)
  126. return ts.do(t, req)
  127. }
  128. func TestConvertEPUB(t *testing.T) {
  129. ts := newTestServer(t, Config{})
  130. res := ts.post(t, "203.0.113.5", "https://example.com/posts/hello#top", "")
  131. if res.code != http.StatusOK || len(res.rep.Files) != 1 {
  132. t.Fatalf("code = %d, body = %s", res.code, res.body)
  133. }
  134. if res.rep.KeepHours != 24 {
  135. t.Errorf("keepHours = %d, want 24", res.rep.KeepHours)
  136. }
  137. f := res.rep.Files[0]
  138. if f.Format != "EPUB" || f.Name != "hello.epub" || f.Size != int64(len("book:hello")) {
  139. t.Errorf("file = %+v", f)
  140. }
  141. args := ts.cli.calls[0]
  142. if len(args) != 2 || args[0] != "-URL=https://example.com/posts/hello" ||
  143. !strings.HasPrefix(args[1], "-SAVE="+ts.cfg.Dir+string(filepath.Separator)) || !strings.HasSuffix(args[1], string(filepath.Separator)) {
  144. t.Errorf("command args = %q", args)
  145. }
  146. dl := ts.get(t, "198.51.100.7", f.URL)
  147. if dl.code != http.StatusOK || dl.body != "book:hello" {
  148. t.Fatalf("download: code = %d, body = %q", dl.code, dl.body)
  149. }
  150. if got := dl.hdr.Get("Content-Disposition"); got != `attachment; filename="hello.epub"` {
  151. t.Errorf("Content-Disposition = %q", got)
  152. }
  153. if got := dl.hdr.Get("Content-Type"); got != "application/epub+zip" {
  154. t.Errorf("Content-Type = %q", got)
  155. }
  156. if got := dl.hdr.Get("X-Content-Type-Options"); got != "nosniff" {
  157. t.Errorf("X-Content-Type-Options = %q", got)
  158. }
  159. }
  160. func TestConvertBoth(t *testing.T) {
  161. ts := newTestServer(t, Config{})
  162. res := ts.post(t, "203.0.113.5", "https://example.com/a", "both")
  163. if res.code != http.StatusOK || len(res.rep.Files) != 2 {
  164. t.Fatalf("code = %d, body = %s", res.code, res.body)
  165. }
  166. if res.rep.Files[0].Name != "a.epub" || res.rep.Files[1].Name != "a.kepub.epub" || res.rep.Files[1].Format != "KEPUB" {
  167. t.Errorf("files = %+v", res.rep.Files)
  168. }
  169. if path.Dir(res.rep.Files[0].URL) != path.Dir(res.rep.Files[1].URL) {
  170. t.Errorf("books of one conversion are in different folders: %+v", res.rep.Files)
  171. }
  172. if !slices.Contains(ts.cli.calls[1], "-KEPUB") {
  173. t.Errorf("second run args = %q, want -KEPUB", ts.cli.calls[1])
  174. }
  175. // Both books count as one conversion.
  176. ts.Server.mu.Lock()
  177. n := len(ts.all)
  178. ts.Server.mu.Unlock()
  179. if n != 1 {
  180. t.Errorf("counted %d conversions, want 1", n)
  181. }
  182. }
  183. func TestConvertKEPUB(t *testing.T) {
  184. ts := newTestServer(t, Config{})
  185. res := ts.post(t, "203.0.113.5", "https://example.com/a", "kepub")
  186. if res.code != http.StatusOK || len(res.rep.Files) != 1 || res.rep.Files[0].Name != "a.kepub.epub" {
  187. t.Fatalf("code = %d, body = %s", res.code, res.body)
  188. }
  189. }
  190. func TestBadRequests(t *testing.T) {
  191. tests := []struct {
  192. name, page, format, want string
  193. }{
  194. {"empty", "", "", "Enter the address"},
  195. {"no scheme", "example.com/a", "", "starting with http"},
  196. {"ftp", "ftp://example.com/a", "", "starting with http"},
  197. {"javascript", "javascript:alert(1)", "", "starting with http"},
  198. {"file", "file:///etc/passwd", "", "starting with http"},
  199. {"no host", "https:///a", "", "starting with http"},
  200. {"credentials", "https://user:pw@example.com/a", "", "user name or password"},
  201. {"port", "https://example.com:3000/a", "", "standard web ports"},
  202. {"control", "https://example.com/a\nb", "", "valid web address"},
  203. {"too long", "https://example.com/" + strings.Repeat("a", maxURLLen), "", "too long"},
  204. {"localhost", "http://localhost/a", "", "local or private"},
  205. {"private", "http://10.0.0.1/a", "", "local or private"},
  206. {"format", "https://example.com/a", "pdf", "Choose EPUB"},
  207. }
  208. ts := newTestServer(t, Config{Limit: 3})
  209. for _, tt := range tests {
  210. t.Run(tt.name, func(t *testing.T) {
  211. res := ts.post(t, "203.0.113.5", tt.page, tt.format)
  212. if res.code != http.StatusBadRequest || !strings.Contains(res.rep.Error, tt.want) {
  213. t.Errorf("code = %d, error = %q; want 400 and %q", res.code, res.rep.Error, tt.want)
  214. }
  215. })
  216. }
  217. if n := ts.cli.count(); n != 0 {
  218. t.Errorf("command ran %d times, want 0", n)
  219. }
  220. // Rejected requests don't use up conversions.
  221. if res := ts.get(t, "203.0.113.5", "/api/status"); res.rep.Paused {
  222. t.Errorf("paused after rejected requests: %+v", res.rep)
  223. }
  224. }
  225. func TestOversizedForm(t *testing.T) {
  226. ts := newTestServer(t, Config{})
  227. res := ts.post(t, "203.0.113.5", "https://example.com/"+strings.Repeat("a", maxBodyLen), "")
  228. if res.code != http.StatusBadRequest {
  229. t.Errorf("code = %d, want 400", res.code)
  230. }
  231. }
  232. func TestLimitForEveryone(t *testing.T) {
  233. ts := newTestServer(t, Config{Limit: 3, PerVisitor: 10, Window: 15 * time.Minute})
  234. for i, ip := range []string{"203.0.113.1", "203.0.113.2", "203.0.113.3"} {
  235. if res := ts.post(t, ip, "https://example.com/a", ""); res.code != http.StatusOK {
  236. t.Fatalf("conversion %d: code = %d, body = %s", i+1, res.code, res.body)
  237. }
  238. ts.now = ts.now.Add(time.Minute)
  239. }
  240. res := ts.post(t, "203.0.113.4", "https://example.com/a", "")
  241. want := "Conversions are temporarily paused. Please try again in 12 minutes."
  242. if res.code != http.StatusTooManyRequests || !res.rep.Paused || res.rep.Error != want {
  243. t.Errorf("code = %d, reply = %+v; want 429 and %q", res.code, res.rep, want)
  244. }
  245. if st := ts.get(t, "203.0.113.9", "/api/status"); !st.rep.Paused || st.rep.Error != want {
  246. t.Errorf("status = %+v, want paused", st.rep)
  247. }
  248. if n := ts.cli.count(); n != 3 {
  249. t.Errorf("command ran %d times, want 3", n)
  250. }
  251. // The first conversion stops counting 15 minutes after it started.
  252. ts.now = ts.now.Add(12 * time.Minute)
  253. if st := ts.get(t, "203.0.113.9", "/api/status"); st.rep.Paused {
  254. t.Errorf("still paused after the window: %+v", st.rep)
  255. }
  256. if res := ts.post(t, "203.0.113.4", "https://example.com/a", ""); res.code != http.StatusOK {
  257. t.Errorf("after the window: code = %d, body = %s", res.code, res.body)
  258. }
  259. }
  260. func TestLimitPerVisitor(t *testing.T) {
  261. ts := newTestServer(t, Config{Limit: 50, PerVisitor: 2})
  262. for range 2 {
  263. if res := ts.post(t, "2001:db8:1:2::1", "https://example.com/a", ""); res.code != http.StatusOK {
  264. t.Fatalf("code = %d, body = %s", res.code, res.body)
  265. }
  266. }
  267. // Another address in the same IPv6 /64 is the same visitor.
  268. res := ts.post(t, "2001:db8:1:2::ffff", "https://example.com/a", "")
  269. if res.code != http.StatusTooManyRequests || !strings.Contains(res.rep.Error, "You've made 2 conversions in the last 15 minutes") {
  270. t.Errorf("code = %d, reply = %+v", res.code, res.rep)
  271. }
  272. if st := ts.get(t, "2001:db8:1:2::1", "/api/status"); !st.rep.Paused {
  273. t.Errorf("status for the visitor = %+v, want paused", st.rep)
  274. }
  275. // Everyone else can still convert.
  276. if st := ts.get(t, "203.0.113.1", "/api/status"); st.rep.Paused {
  277. t.Errorf("status for others = %+v, want not paused", st.rep)
  278. }
  279. if res := ts.post(t, "2001:db8:1:3::1", "https://example.com/a", ""); res.code != http.StatusOK {
  280. t.Errorf("other visitor: code = %d, body = %s", res.code, res.body)
  281. }
  282. }
  283. func TestStorageLimit(t *testing.T) {
  284. ts := newTestServer(t, Config{MaxStorage: 10})
  285. if res := ts.post(t, "203.0.113.1", "https://example.com/abcdefgh", ""); res.code != http.StatusOK {
  286. t.Fatalf("code = %d, body = %s", res.code, res.body)
  287. }
  288. if res := ts.post(t, "203.0.113.2", "https://example.com/a", ""); res.code != http.StatusTooManyRequests || !res.rep.Paused {
  289. t.Errorf("over storage: code = %d, reply = %+v", res.code, res.rep)
  290. }
  291. // Once the book expires, conversions resume.
  292. ts.now = ts.now.Add(25 * time.Hour)
  293. ageAll(t, ts.cfg.Dir, 25*time.Hour)
  294. ts.Clean()
  295. if res := ts.post(t, "203.0.113.2", "https://example.com/a", ""); res.code != http.StatusOK {
  296. t.Errorf("after clean: code = %d, body = %s", res.code, res.body)
  297. }
  298. }
  299. func TestBusy(t *testing.T) {
  300. ts := newTestServer(t, Config{MaxRunning: 1, MaxWaiting: 1, Wait: 50 * time.Millisecond})
  301. ts.cli.block = make(chan struct{})
  302. first := make(chan response)
  303. go func() { first <- ts.post(t, "203.0.113.1", "https://example.com/a", "") }()
  304. for ts.cli.count() == 0 {
  305. time.Sleep(time.Millisecond)
  306. }
  307. res := ts.post(t, "203.0.113.2", "https://example.com/b", "")
  308. if res.code != http.StatusServiceUnavailable || !strings.Contains(res.rep.Error, "busy") {
  309. t.Errorf("code = %d, reply = %+v; want 503 busy", res.code, res.rep)
  310. }
  311. close(ts.cli.block)
  312. if res := <-first; res.code != http.StatusOK {
  313. t.Errorf("first: code = %d, body = %s", res.code, res.body)
  314. }
  315. // The conversion that never ran was taken back.
  316. ts.Server.mu.Lock()
  317. n := len(*ts.visitor["203.0.113.2"])
  318. ts.Server.mu.Unlock()
  319. if n != 0 {
  320. t.Errorf("busy visitor has %d conversions counted, want 0", n)
  321. }
  322. }
  323. func TestFailedConversion(t *testing.T) {
  324. ts := newTestServer(t, Config{})
  325. ts.cli.fail = runResult{Code: exitNoContent, Err: "no readable content found"}
  326. res := ts.post(t, "203.0.113.1", "https://example.com/a", "both")
  327. if res.code != http.StatusUnprocessableEntity || !strings.Contains(res.rep.Error, "No readable article") {
  328. t.Errorf("code = %d, reply = %+v", res.code, res.rep)
  329. }
  330. if n := ts.cli.count(); n != 1 {
  331. t.Errorf("command ran %d times, want 1 (no KEPUB after the EPUB failed)", n)
  332. }
  333. if entries, _ := os.ReadDir(ts.cfg.Dir); len(entries) != 0 {
  334. t.Errorf("folder left behind: %v", entries)
  335. }
  336. }
  337. func TestUnexpectedOutputPath(t *testing.T) {
  338. ts := newTestServer(t, Config{})
  339. ts.Server.run = func(context.Context, []string) runResult { return runResult{Path: "/etc/passwd"} }
  340. res := ts.post(t, "203.0.113.1", "https://example.com/a", "")
  341. if res.code != http.StatusUnprocessableEntity || res.rep.Files != nil {
  342. t.Errorf("code = %d, reply = %+v", res.code, res.rep)
  343. }
  344. }
  345. func TestExplain(t *testing.T) {
  346. ctx := context.Background()
  347. tests := []struct {
  348. res runResult
  349. want string
  350. }{
  351. {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"},
  352. {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"},
  353. {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"},
  354. {runResult{Code: 3}, "No readable article"},
  355. {runResult{Code: 4, Err: "saving: /opt/apps/x: permission denied"}, "Something went wrong"},
  356. {runResult{Code: -1}, "Something went wrong"},
  357. }
  358. for _, tt := range tests {
  359. if got := explain(ctx, tt.res); !strings.Contains(got, tt.want) {
  360. t.Errorf("explain(%+v) = %q, want %q", tt.res, got, tt.want)
  361. }
  362. }
  363. expired, cancel := context.WithDeadline(ctx, time.Now().Add(-time.Second))
  364. defer cancel()
  365. if got := explain(expired, runResult{Code: -1}); !strings.Contains(got, "too long") {
  366. t.Errorf("timeout: %q", got)
  367. }
  368. }
  369. func TestBookLinks(t *testing.T) {
  370. ts := newTestServer(t, Config{})
  371. res := ts.post(t, "203.0.113.1", "https://example.com/a", "")
  372. link := res.rep.Files[0].URL
  373. if err := os.WriteFile(filepath.Join(filepath.Dir(ts.cfg.Dir), "secret.epub"), []byte("TOP-SECRET-CONTENT"), 0o600); err != nil {
  374. t.Fatal(err)
  375. }
  376. for _, p := range []string{
  377. "/books/0123456789abcdef0123456789abcdef/a.epub", // unknown id
  378. strings.TrimSuffix(link, "a.epub") + "b.epub", // unknown name
  379. strings.TrimSuffix(link, ".epub") + ".html", // not a book name
  380. "/books/../secret.epub",
  381. "/books/%2e%2e/secret.epub",
  382. "/books/x/..%2fsecret.epub",
  383. } {
  384. if res := ts.get(t, "203.0.113.1", p); res.code == http.StatusOK || strings.Contains(res.body, "TOP-SECRET") {
  385. t.Errorf("GET %s: code = %d, body = %q", p, res.code, res.body)
  386. }
  387. }
  388. ts.now = ts.now.Add(25 * time.Hour)
  389. if res := ts.get(t, "203.0.113.1", link); res.code != http.StatusNotFound || !strings.Contains(res.body, "<h1>Link expired</h1>") {
  390. t.Errorf("expired link: code = %d, body = %q", res.code, res.body)
  391. }
  392. }
  393. func TestClean(t *testing.T) {
  394. ts := newTestServer(t, Config{Keep: 2 * time.Hour})
  395. old := ts.post(t, "203.0.113.1", "https://example.com/old", "").rep.Files[0]
  396. ageAll(t, ts.cfg.Dir, 3*time.Hour)
  397. fresh := ts.post(t, "203.0.113.2", "https://example.com/fresh", "").rep.Files[0]
  398. stray := filepath.Join(ts.cfg.Dir, "not-a-conversion")
  399. if err := os.Mkdir(stray, 0o750); err != nil {
  400. t.Fatal(err)
  401. }
  402. ts.Clean()
  403. if _, err := os.Stat(filepath.Join(ts.cfg.Dir, strings.Split(old.URL, "/")[2])); !os.IsNotExist(err) {
  404. t.Errorf("old conversion kept: %v", err)
  405. }
  406. if res := ts.get(t, "203.0.113.1", fresh.URL); res.code != http.StatusOK {
  407. t.Errorf("fresh book: code = %d", res.code)
  408. }
  409. if _, err := os.Stat(stray); err != nil {
  410. t.Errorf("unrelated folder removed: %v", err)
  411. }
  412. if got := ts.stored.Load(); got != fresh.Size {
  413. t.Errorf("stored = %d, want %d", got, fresh.Size)
  414. }
  415. if res := ts.post(t, "203.0.113.1", "https://example.com/x", ""); res.rep.KeepHours != 2 {
  416. t.Errorf("keepHours = %d, want 2", res.rep.KeepHours)
  417. }
  418. }
  419. func TestHTMLReply(t *testing.T) {
  420. ts := newTestServer(t, Config{})
  421. form := url.Values{"url": {"example.com"}}
  422. req := httptest.NewRequest("POST", "/api/convert", strings.NewReader(form.Encode()))
  423. req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
  424. res := ts.do(t, req)
  425. if res.code != http.StatusBadRequest || !strings.Contains(res.hdr.Get("Content-Type"), "text/html") {
  426. t.Fatalf("code = %d, Content-Type = %q", res.code, res.hdr.Get("Content-Type"))
  427. }
  428. ts.cli.fail = runResult{Code: 1, Err: `couldn't fetch the page: <script>alert(1)</script>`}
  429. form = url.Values{"url": {"https://example.com/a"}}
  430. req = httptest.NewRequest("POST", "/api/convert", strings.NewReader(form.Encode()))
  431. req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
  432. res = ts.do(t, req)
  433. if strings.Contains(res.body, "<script>") || !strings.Contains(res.body, "&lt;script&gt;") {
  434. t.Errorf("error not escaped:\n%s", res.body)
  435. }
  436. ts.cli.fail = runResult{}
  437. req = httptest.NewRequest("POST", "/api/convert", strings.NewReader(form.Encode()))
  438. req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
  439. res = ts.do(t, req)
  440. if res.code != http.StatusOK || !strings.Contains(res.body, "Links work for 24 hours") || !strings.Contains(res.body, `href="/books/`) {
  441. t.Errorf("code = %d, body:\n%s", res.code, res.body)
  442. }
  443. }
  444. func TestCrossOriginRefused(t *testing.T) {
  445. ts := newTestServer(t, Config{})
  446. form := url.Values{"url": {"https://example.com/a"}}
  447. req := httptest.NewRequest("POST", "/api/convert", strings.NewReader(form.Encode()))
  448. req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
  449. req.Header.Set("Sec-Fetch-Site", "cross-site")
  450. if res := ts.do(t, req); res.code != http.StatusForbidden {
  451. t.Errorf("code = %d, want 403", res.code)
  452. }
  453. if n := ts.cli.count(); n != 0 {
  454. t.Errorf("command ran %d times, want 0", n)
  455. }
  456. }
  457. func TestVisitorKey(t *testing.T) {
  458. tests := []struct{ realIP, remote, want string }{
  459. {"203.0.113.5", "127.0.0.1:5000", "203.0.113.5"},
  460. {"", "203.0.113.6:5000", "203.0.113.6"},
  461. {"::ffff:203.0.113.7", "127.0.0.1:5000", "203.0.113.7"},
  462. {"2001:db8:1:2:3:4:5:6", "127.0.0.1:5000", "2001:db8:1:2::/64"},
  463. }
  464. for _, tt := range tests {
  465. r := httptest.NewRequest("GET", "/", nil)
  466. r.RemoteAddr = tt.remote
  467. if tt.realIP != "" {
  468. r.Header.Set("X-Real-IP", tt.realIP)
  469. }
  470. if got := visitorKey(r); got != tt.want {
  471. t.Errorf("visitorKey(%q, %q) = %q, want %q", tt.realIP, tt.remote, got, tt.want)
  472. }
  473. }
  474. }
  475. // TestExecRunner runs a stand-in script through the real runner.
  476. func TestExecRunner(t *testing.T) {
  477. if runtime.GOOS == "windows" {
  478. t.Skip("uses a shell script")
  479. }
  480. t.Setenv("REKOPUB_TEST_SECRET", "x")
  481. dir := t.TempDir()
  482. script := filepath.Join(dir, "rekopub")
  483. body := "#!/bin/sh\n" +
  484. `[ -z "$REKOPUB_TEST_SECRET$HOME" ] || { echo "error: environment not empty" >&2; exit 4; }` + "\n" +
  485. `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" +
  486. `echo "$2" | cut -c7-` + "\n"
  487. if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
  488. t.Fatal(err)
  489. }
  490. run := execRunner(script)
  491. if res := run(context.Background(), []string{"-URL=https://example.com/ok", "-SAVE=/x/a.epub"}); res.Code != 0 || res.Path != "/x/a.epub" {
  492. t.Errorf("ok run = %+v", res)
  493. }
  494. res := run(context.Background(), []string{"-URL=https://example.com/fail", "-SAVE=/x/"})
  495. if res.Code != 1 || res.Err != "couldn't fetch the page: example.com returned 404 Not Found" {
  496. t.Errorf("failed run = %+v", res)
  497. }
  498. }
  499. // ageAll makes every file and folder under dir look d older.
  500. func ageAll(t *testing.T, dir string, d time.Duration) {
  501. t.Helper()
  502. filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
  503. if err != nil {
  504. return err
  505. }
  506. old := info.ModTime().Add(-d)
  507. return os.Chtimes(p, old, old)
  508. })
  509. }