builder_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
  2. // SPDX-License-Identifier: Apache-2.0
  3. package response // import "miniflux.app/v2/internal/http/response"
  4. import (
  5. "bytes"
  6. "mime"
  7. "net/http"
  8. "net/http/httptest"
  9. "strings"
  10. "testing"
  11. "time"
  12. )
  13. func TestResponseHasCommonHeaders(t *testing.T) {
  14. r, err := http.NewRequest("GET", "/", nil)
  15. if err != nil {
  16. t.Fatal(err)
  17. }
  18. w := httptest.NewRecorder()
  19. handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  20. NewBuilder(w, r).Write()
  21. })
  22. handler.ServeHTTP(w, r)
  23. resp := w.Result()
  24. headers := map[string]string{
  25. "X-Content-Type-Options": "nosniff",
  26. "X-Frame-Options": "DENY",
  27. }
  28. for header, expected := range headers {
  29. actual := resp.Header.Get(header)
  30. if actual != expected {
  31. t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
  32. }
  33. }
  34. }
  35. func TestBuildResponseWithCustomStatusCode(t *testing.T) {
  36. r, err := http.NewRequest("GET", "/", nil)
  37. if err != nil {
  38. t.Fatal(err)
  39. }
  40. w := httptest.NewRecorder()
  41. handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  42. NewBuilder(w, r).WithStatus(http.StatusNotAcceptable).Write()
  43. })
  44. handler.ServeHTTP(w, r)
  45. resp := w.Result()
  46. expectedStatusCode := http.StatusNotAcceptable
  47. if resp.StatusCode != expectedStatusCode {
  48. t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
  49. }
  50. }
  51. func TestBuildResponseWithCustomHeader(t *testing.T) {
  52. r, err := http.NewRequest("GET", "/", nil)
  53. if err != nil {
  54. t.Fatal(err)
  55. }
  56. w := httptest.NewRecorder()
  57. handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  58. NewBuilder(w, r).WithHeader("X-My-Header", "Value").Write()
  59. })
  60. handler.ServeHTTP(w, r)
  61. resp := w.Result()
  62. expected := "Value"
  63. actual := resp.Header.Get("X-My-Header")
  64. if actual != expected {
  65. t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
  66. }
  67. }
  68. func TestBuildResponseWithAttachment(t *testing.T) {
  69. r, err := http.NewRequest("GET", "/", nil)
  70. if err != nil {
  71. t.Fatal(err)
  72. }
  73. w := httptest.NewRecorder()
  74. handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  75. NewBuilder(w, r).WithAttachment("my_file.pdf").Write()
  76. })
  77. handler.ServeHTTP(w, r)
  78. resp := w.Result()
  79. expected := "attachment; filename=my_file.pdf"
  80. actual := resp.Header.Get("Content-Disposition")
  81. if actual != expected {
  82. t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
  83. }
  84. }
  85. func TestBuildResponseWithAttachmentEscapesFilename(t *testing.T) {
  86. r, err := http.NewRequest("GET", "/", nil)
  87. if err != nil {
  88. t.Fatal(err)
  89. }
  90. w := httptest.NewRecorder()
  91. handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  92. NewBuilder(w, r).WithAttachment(`a";filename="malware.exe`).Write()
  93. })
  94. handler.ServeHTTP(w, r)
  95. resp := w.Result()
  96. actual := resp.Header.Get("Content-Disposition")
  97. mediaType, params, err := mime.ParseMediaType(actual)
  98. if err != nil {
  99. t.Fatalf("Unexpected parse error for %q: %v", actual, err)
  100. }
  101. if mediaType != "attachment" {
  102. t.Fatalf(`Unexpected media type, got %q instead of %q`, mediaType, "attachment")
  103. }
  104. if params["filename"] != `a";filename="malware.exe` {
  105. t.Fatalf(`Unexpected filename, got %q instead of %q`, params["filename"], `a";filename="malware.exe`)
  106. }
  107. }
  108. func TestBuildResponseWithInlineEscapesFilename(t *testing.T) {
  109. r, err := http.NewRequest("GET", "/", nil)
  110. if err != nil {
  111. t.Fatal(err)
  112. }
  113. w := httptest.NewRecorder()
  114. handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  115. NewBuilder(w, r).WithInline(`a";filename="malware.exe`).Write()
  116. })
  117. handler.ServeHTTP(w, r)
  118. resp := w.Result()
  119. actual := resp.Header.Get("Content-Disposition")
  120. mediaType, params, err := mime.ParseMediaType(actual)
  121. if err != nil {
  122. t.Fatalf("Unexpected parse error for %q: %v", actual, err)
  123. }
  124. if mediaType != "inline" {
  125. t.Fatalf(`Unexpected media type, got %q instead of %q`, mediaType, "inline")
  126. }
  127. if params["filename"] != `a";filename="malware.exe` {
  128. t.Fatalf(`Unexpected filename, got %q instead of %q`, params["filename"], `a";filename="malware.exe`)
  129. }
  130. }
  131. func TestFormatContentDisposition(t *testing.T) {
  132. tests := []struct {
  133. name string
  134. dispositionType string
  135. filename string
  136. expected string
  137. }{
  138. {"empty filename returns bare type", "inline", "", "inline"},
  139. {"simple filename", "attachment", "photo.jpg", `attachment; filename=photo.jpg`},
  140. {"filename with double quote", "inline", `a";filename="malware.exe`, `inline; filename="a\";filename=\"malware.exe"`},
  141. {"filename with spaces", "attachment", "my file.txt", `attachment; filename="my file.txt"`},
  142. {"non-ASCII filename", "attachment", "café.png", `attachment; filename*=utf-8''caf%C3%A9.png`},
  143. }
  144. for _, tt := range tests {
  145. t.Run(tt.name, func(t *testing.T) {
  146. actual := formatContentDisposition(tt.dispositionType, tt.filename)
  147. if actual != tt.expected {
  148. t.Fatalf(`formatContentDisposition(%q, %q) = %q, want %q`, tt.dispositionType, tt.filename, actual, tt.expected)
  149. }
  150. })
  151. }
  152. }
  153. func TestBuildResponseWithByteBody(t *testing.T) {
  154. r, err := http.NewRequest("GET", "/", nil)
  155. if err != nil {
  156. t.Fatal(err)
  157. }
  158. w := httptest.NewRecorder()
  159. handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  160. NewBuilder(w, r).WithBodyAsBytes([]byte("body")).Write()
  161. })
  162. handler.ServeHTTP(w, r)
  163. expectedBody := `body`
  164. actualBody := w.Body.String()
  165. if actualBody != expectedBody {
  166. t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
  167. }
  168. }
  169. func TestBuildResponseWithCachingEnabled(t *testing.T) {
  170. r, err := http.NewRequest("GET", "/", nil)
  171. if err != nil {
  172. t.Fatal(err)
  173. }
  174. w := httptest.NewRecorder()
  175. handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  176. NewBuilder(w, r).WithCaching("etag", 1*time.Minute, func(b *Builder) {
  177. b.WithBodyAsString("cached body")
  178. b.Write()
  179. })
  180. })
  181. handler.ServeHTTP(w, r)
  182. resp := w.Result()
  183. expectedStatusCode := http.StatusOK
  184. if resp.StatusCode != expectedStatusCode {
  185. t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
  186. }
  187. expectedBody := `cached body`
  188. actualBody := w.Body.String()
  189. if actualBody != expectedBody {
  190. t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
  191. }
  192. expectedHeader := "public, max-age=60, immutable"
  193. actualHeader := resp.Header.Get("Cache-Control")
  194. if actualHeader != expectedHeader {
  195. t.Fatalf(`Unexpected cache control header, got %q instead of %q`, actualHeader, expectedHeader)
  196. }
  197. if actualETag := resp.Header.Get("ETag"); actualETag != `"etag"` {
  198. t.Fatalf(`Unexpected etag header, got %q instead of %q`, actualETag, `"etag"`)
  199. }
  200. if resp.Header.Get("Expires") == "" {
  201. t.Fatalf(`Expires header should not be empty`)
  202. }
  203. }
  204. func TestBuildResponseWithCachingAndCookie(t *testing.T) {
  205. r, err := http.NewRequest("GET", "/", nil)
  206. if err != nil {
  207. t.Fatal(err)
  208. }
  209. w := httptest.NewRecorder()
  210. handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  211. http.SetCookie(w, &http.Cookie{Name: "session", Value: "secret"})
  212. NewBuilder(w, r).WithCaching("etag", 1*time.Minute, func(b *Builder) {
  213. b.WithBodyAsString("cached body")
  214. b.Write()
  215. })
  216. })
  217. handler.ServeHTTP(w, r)
  218. resp := w.Result()
  219. expectedHeader := "private, no-store"
  220. if actualHeader := resp.Header.Get("Cache-Control"); actualHeader != expectedHeader {
  221. t.Fatalf(`Unexpected cache control header, got %q instead of %q`, actualHeader, expectedHeader)
  222. }
  223. if expires := resp.Header.Get("Expires"); expires != "" {
  224. t.Fatalf(`Expires header should be removed, got %q`, expires)
  225. }
  226. if cookie := resp.Header.Get("Set-Cookie"); cookie == "" {
  227. t.Fatal(`Set-Cookie header should be kept`)
  228. }
  229. }
  230. func TestBuildResponseWithCachingAndIfNoneMatch(t *testing.T) {
  231. tests := []struct {
  232. name string
  233. ifNoneMatch string
  234. expectedStatus int
  235. expectedBody string
  236. }{
  237. {"matching strong etag", `"etag"`, http.StatusNotModified, ""},
  238. {"matching weak etag", `W/"etag"`, http.StatusNotModified, ""},
  239. {"multiple etags with match", `"other", W/"etag"`, http.StatusNotModified, ""},
  240. {"wildcard", `*`, http.StatusNotModified, ""},
  241. {"non-matching etag", `"different"`, http.StatusOK, "cached body"},
  242. }
  243. for _, tt := range tests {
  244. t.Run(tt.name, func(t *testing.T) {
  245. r, err := http.NewRequest("GET", "/", nil)
  246. if err != nil {
  247. t.Fatal(err)
  248. }
  249. r.Header.Set("If-None-Match", tt.ifNoneMatch)
  250. w := httptest.NewRecorder()
  251. handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  252. NewBuilder(w, r).WithCaching("etag", 1*time.Minute, func(b *Builder) {
  253. b.WithBodyAsString("cached body")
  254. b.Write()
  255. })
  256. })
  257. handler.ServeHTTP(w, r)
  258. resp := w.Result()
  259. if resp.StatusCode != tt.expectedStatus {
  260. t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, tt.expectedStatus)
  261. }
  262. if actual := w.Body.String(); actual != tt.expectedBody {
  263. t.Fatalf(`Unexpected body, got %q instead of %q`, actual, tt.expectedBody)
  264. }
  265. if resp.Header.Get("Cache-Control") != "public, max-age=60, immutable" {
  266. t.Fatalf(`Unexpected Cache-Control header: %q`, resp.Header.Get("Cache-Control"))
  267. }
  268. if resp.Header.Get("Expires") == "" {
  269. t.Fatalf(`Expires header should not be empty`)
  270. }
  271. })
  272. }
  273. }
  274. func TestNormalizeETag(t *testing.T) {
  275. tests := []struct {
  276. input string
  277. expected string
  278. }{
  279. {"abc", `"abc"`},
  280. {`"already-quoted"`, `"already-quoted"`},
  281. {`W/"weak"`, `W/"weak"`},
  282. {"", ""},
  283. {" spaced ", `"spaced"`},
  284. }
  285. for _, tt := range tests {
  286. t.Run(tt.input, func(t *testing.T) {
  287. if actual := normalizeETag(tt.input); actual != tt.expected {
  288. t.Fatalf(`normalizeETag(%q) = %q, want %q`, tt.input, actual, tt.expected)
  289. }
  290. })
  291. }
  292. }
  293. func TestIfNoneMatch(t *testing.T) {
  294. tests := []struct {
  295. name string
  296. headerValue string
  297. etag string
  298. expected bool
  299. }{
  300. {"empty header", "", `"etag"`, false},
  301. {"empty etag", `"etag"`, "", false},
  302. {"exact match", `"etag"`, `"etag"`, true},
  303. {"weak vs strong match", `W/"etag"`, `"etag"`, true},
  304. {"wildcard", `*`, `"etag"`, true},
  305. {"no match", `"other"`, `"etag"`, false},
  306. {"match in list", `"a", "etag", "b"`, `"etag"`, true},
  307. {"no match in list", `"a", "b", "c"`, `"etag"`, false},
  308. }
  309. for _, tt := range tests {
  310. t.Run(tt.name, func(t *testing.T) {
  311. if actual := ifNoneMatch(tt.headerValue, tt.etag); actual != tt.expected {
  312. t.Fatalf(`ifNoneMatch(%q, %q) = %v, want %v`, tt.headerValue, tt.etag, actual, tt.expected)
  313. }
  314. })
  315. }
  316. }
  317. func TestBuildResponseWithBrotliCompression(t *testing.T) {
  318. body := strings.Repeat("a", compressionThreshold+1)
  319. r, err := http.NewRequest("GET", "/", nil)
  320. r.Header.Set("Accept-Encoding", "br, gzip, deflate")
  321. if err != nil {
  322. t.Fatal(err)
  323. }
  324. w := httptest.NewRecorder()
  325. handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  326. NewBuilder(w, r).WithBodyAsString(body).Write()
  327. })
  328. handler.ServeHTTP(w, r)
  329. resp := w.Result()
  330. expected := "br"
  331. actual := resp.Header.Get("Content-Encoding")
  332. if actual != expected {
  333. t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
  334. }
  335. }
  336. func TestBuildResponseWithGzipCompression(t *testing.T) {
  337. body := strings.Repeat("a", compressionThreshold+1)
  338. r, err := http.NewRequest("GET", "/", nil)
  339. r.Header.Set("Accept-Encoding", "gzip, deflate")
  340. if err != nil {
  341. t.Fatal(err)
  342. }
  343. w := httptest.NewRecorder()
  344. handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  345. NewBuilder(w, r).WithBodyAsString(body).Write()
  346. })
  347. handler.ServeHTTP(w, r)
  348. resp := w.Result()
  349. expected := "gzip"
  350. actual := resp.Header.Get("Content-Encoding")
  351. if actual != expected {
  352. t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
  353. }
  354. }
  355. func TestBuildResponseWithDeflateCompression(t *testing.T) {
  356. body := strings.Repeat("a", compressionThreshold+1)
  357. r, err := http.NewRequest("GET", "/", nil)
  358. r.Header.Set("Accept-Encoding", "deflate")
  359. if err != nil {
  360. t.Fatal(err)
  361. }
  362. w := httptest.NewRecorder()
  363. handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  364. NewBuilder(w, r).WithBodyAsString(body).Write()
  365. })
  366. handler.ServeHTTP(w, r)
  367. resp := w.Result()
  368. expected := "deflate"
  369. actual := resp.Header.Get("Content-Encoding")
  370. if actual != expected {
  371. t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
  372. }
  373. expectedVary := "Accept-Encoding"
  374. actualVary := resp.Header.Get("Vary")
  375. if actualVary != expectedVary {
  376. t.Fatalf(`Unexpected vary header value, got %q instead of %q`, actualVary, expectedVary)
  377. }
  378. }
  379. func TestBuildResponseWithCompressionDisabled(t *testing.T) {
  380. body := strings.Repeat("a", compressionThreshold+1)
  381. r, err := http.NewRequest("GET", "/", nil)
  382. r.Header.Set("Accept-Encoding", "deflate")
  383. if err != nil {
  384. t.Fatal(err)
  385. }
  386. w := httptest.NewRecorder()
  387. handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  388. NewBuilder(w, r).WithBodyAsString(body).WithoutCompression().Write()
  389. })
  390. handler.ServeHTTP(w, r)
  391. resp := w.Result()
  392. expected := ""
  393. actual := resp.Header.Get("Content-Encoding")
  394. if actual != expected {
  395. t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
  396. }
  397. expectedVary := "Accept-Encoding"
  398. actualVary := resp.Header.Get("Vary")
  399. if actualVary != expectedVary {
  400. t.Fatalf(`Unexpected vary header value, got %q instead of %q`, actualVary, expectedVary)
  401. }
  402. }
  403. func TestBuildResponseWithDeflateCompressionAndSmallPayload(t *testing.T) {
  404. body := strings.Repeat("a", compressionThreshold)
  405. r, err := http.NewRequest("GET", "/", nil)
  406. r.Header.Set("Accept-Encoding", "deflate")
  407. if err != nil {
  408. t.Fatal(err)
  409. }
  410. w := httptest.NewRecorder()
  411. handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  412. NewBuilder(w, r).WithBodyAsString(body).Write()
  413. })
  414. handler.ServeHTTP(w, r)
  415. resp := w.Result()
  416. expected := ""
  417. actual := resp.Header.Get("Content-Encoding")
  418. if actual != expected {
  419. t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
  420. }
  421. expectedVary := "Accept-Encoding"
  422. actualVary := resp.Header.Get("Vary")
  423. if actualVary != expectedVary {
  424. t.Fatalf(`Unexpected vary header value, got %q instead of %q`, actualVary, expectedVary)
  425. }
  426. }
  427. func TestBuildResponseWithoutCompressionHeader(t *testing.T) {
  428. body := strings.Repeat("a", compressionThreshold+1)
  429. r, err := http.NewRequest("GET", "/", nil)
  430. if err != nil {
  431. t.Fatal(err)
  432. }
  433. w := httptest.NewRecorder()
  434. handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  435. NewBuilder(w, r).WithBodyAsString(body).Write()
  436. })
  437. handler.ServeHTTP(w, r)
  438. resp := w.Result()
  439. expected := ""
  440. actual := resp.Header.Get("Content-Encoding")
  441. if actual != expected {
  442. t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
  443. }
  444. expectedVary := "Accept-Encoding"
  445. actualVary := resp.Header.Get("Vary")
  446. if actualVary != expectedVary {
  447. t.Fatalf(`Unexpected vary header value, got %q instead of %q`, actualVary, expectedVary)
  448. }
  449. }
  450. func TestBuildResponseWithReaderBody(t *testing.T) {
  451. r, err := http.NewRequest("GET", "/", nil)
  452. if err != nil {
  453. t.Fatal(err)
  454. }
  455. w := httptest.NewRecorder()
  456. handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  457. NewBuilder(w, r).WithBodyAsReader(bytes.NewBufferString("body")).Write()
  458. })
  459. handler.ServeHTTP(w, r)
  460. if actualBody := w.Body.String(); actualBody != "body" {
  461. t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, "body")
  462. }
  463. }