builder_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542
  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, 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 TestBuildResponseWithCachingAndIfNoneMatch(t *testing.T) {
  205. tests := []struct {
  206. name string
  207. ifNoneMatch string
  208. expectedStatus int
  209. expectedBody string
  210. }{
  211. {"matching strong etag", `"etag"`, http.StatusNotModified, ""},
  212. {"matching weak etag", `W/"etag"`, http.StatusNotModified, ""},
  213. {"multiple etags with match", `"other", W/"etag"`, http.StatusNotModified, ""},
  214. {"wildcard", `*`, http.StatusNotModified, ""},
  215. {"non-matching etag", `"different"`, http.StatusOK, "cached body"},
  216. }
  217. for _, tt := range tests {
  218. t.Run(tt.name, func(t *testing.T) {
  219. r, err := http.NewRequest("GET", "/", nil)
  220. if err != nil {
  221. t.Fatal(err)
  222. }
  223. r.Header.Set("If-None-Match", tt.ifNoneMatch)
  224. w := httptest.NewRecorder()
  225. handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  226. NewBuilder(w, r).WithCaching("etag", 1*time.Minute, func(b *Builder) {
  227. b.WithBodyAsString("cached body")
  228. b.Write()
  229. })
  230. })
  231. handler.ServeHTTP(w, r)
  232. resp := w.Result()
  233. if resp.StatusCode != tt.expectedStatus {
  234. t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, tt.expectedStatus)
  235. }
  236. if actual := w.Body.String(); actual != tt.expectedBody {
  237. t.Fatalf(`Unexpected body, got %q instead of %q`, actual, tt.expectedBody)
  238. }
  239. if resp.Header.Get("Cache-Control") != "public, immutable" {
  240. t.Fatalf(`Unexpected Cache-Control header: %q`, resp.Header.Get("Cache-Control"))
  241. }
  242. if resp.Header.Get("Expires") == "" {
  243. t.Fatalf(`Expires header should not be empty`)
  244. }
  245. })
  246. }
  247. }
  248. func TestNormalizeETag(t *testing.T) {
  249. tests := []struct {
  250. input string
  251. expected string
  252. }{
  253. {"abc", `"abc"`},
  254. {`"already-quoted"`, `"already-quoted"`},
  255. {`W/"weak"`, `W/"weak"`},
  256. {"", ""},
  257. {" spaced ", `"spaced"`},
  258. }
  259. for _, tt := range tests {
  260. t.Run(tt.input, func(t *testing.T) {
  261. if actual := normalizeETag(tt.input); actual != tt.expected {
  262. t.Fatalf(`normalizeETag(%q) = %q, want %q`, tt.input, actual, tt.expected)
  263. }
  264. })
  265. }
  266. }
  267. func TestIfNoneMatch(t *testing.T) {
  268. tests := []struct {
  269. name string
  270. headerValue string
  271. etag string
  272. expected bool
  273. }{
  274. {"empty header", "", `"etag"`, false},
  275. {"empty etag", `"etag"`, "", false},
  276. {"exact match", `"etag"`, `"etag"`, true},
  277. {"weak vs strong match", `W/"etag"`, `"etag"`, true},
  278. {"wildcard", `*`, `"etag"`, true},
  279. {"no match", `"other"`, `"etag"`, false},
  280. {"match in list", `"a", "etag", "b"`, `"etag"`, true},
  281. {"no match in list", `"a", "b", "c"`, `"etag"`, false},
  282. }
  283. for _, tt := range tests {
  284. t.Run(tt.name, func(t *testing.T) {
  285. if actual := ifNoneMatch(tt.headerValue, tt.etag); actual != tt.expected {
  286. t.Fatalf(`ifNoneMatch(%q, %q) = %v, want %v`, tt.headerValue, tt.etag, actual, tt.expected)
  287. }
  288. })
  289. }
  290. }
  291. func TestBuildResponseWithBrotliCompression(t *testing.T) {
  292. body := strings.Repeat("a", compressionThreshold+1)
  293. r, err := http.NewRequest("GET", "/", nil)
  294. r.Header.Set("Accept-Encoding", "gzip, deflate, br")
  295. if err != nil {
  296. t.Fatal(err)
  297. }
  298. w := httptest.NewRecorder()
  299. handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  300. NewBuilder(w, r).WithBodyAsString(body).Write()
  301. })
  302. handler.ServeHTTP(w, r)
  303. resp := w.Result()
  304. expected := "br"
  305. actual := resp.Header.Get("Content-Encoding")
  306. if actual != expected {
  307. t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
  308. }
  309. }
  310. func TestBuildResponseWithGzipCompression(t *testing.T) {
  311. body := strings.Repeat("a", compressionThreshold+1)
  312. r, err := http.NewRequest("GET", "/", nil)
  313. r.Header.Set("Accept-Encoding", "gzip, deflate")
  314. if err != nil {
  315. t.Fatal(err)
  316. }
  317. w := httptest.NewRecorder()
  318. handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  319. NewBuilder(w, r).WithBodyAsString(body).Write()
  320. })
  321. handler.ServeHTTP(w, r)
  322. resp := w.Result()
  323. expected := "gzip"
  324. actual := resp.Header.Get("Content-Encoding")
  325. if actual != expected {
  326. t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
  327. }
  328. }
  329. func TestBuildResponseWithDeflateCompression(t *testing.T) {
  330. body := strings.Repeat("a", compressionThreshold+1)
  331. r, err := http.NewRequest("GET", "/", nil)
  332. r.Header.Set("Accept-Encoding", "deflate")
  333. if err != nil {
  334. t.Fatal(err)
  335. }
  336. w := httptest.NewRecorder()
  337. handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  338. NewBuilder(w, r).WithBodyAsString(body).Write()
  339. })
  340. handler.ServeHTTP(w, r)
  341. resp := w.Result()
  342. expected := "deflate"
  343. actual := resp.Header.Get("Content-Encoding")
  344. if actual != expected {
  345. t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
  346. }
  347. expectedVary := "Accept-Encoding"
  348. actualVary := resp.Header.Get("Vary")
  349. if actualVary != expectedVary {
  350. t.Fatalf(`Unexpected vary header value, got %q instead of %q`, actualVary, expectedVary)
  351. }
  352. }
  353. func TestBuildResponseWithCompressionDisabled(t *testing.T) {
  354. body := strings.Repeat("a", compressionThreshold+1)
  355. r, err := http.NewRequest("GET", "/", nil)
  356. r.Header.Set("Accept-Encoding", "deflate")
  357. if err != nil {
  358. t.Fatal(err)
  359. }
  360. w := httptest.NewRecorder()
  361. handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  362. NewBuilder(w, r).WithBodyAsString(body).WithoutCompression().Write()
  363. })
  364. handler.ServeHTTP(w, r)
  365. resp := w.Result()
  366. expected := ""
  367. actual := resp.Header.Get("Content-Encoding")
  368. if actual != expected {
  369. t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
  370. }
  371. expectedVary := ""
  372. actualVary := resp.Header.Get("Vary")
  373. if actualVary != expectedVary {
  374. t.Fatalf(`Unexpected vary header value, got %q instead of %q`, actualVary, expectedVary)
  375. }
  376. }
  377. func TestBuildResponseWithDeflateCompressionAndSmallPayload(t *testing.T) {
  378. body := strings.Repeat("a", compressionThreshold)
  379. r, err := http.NewRequest("GET", "/", nil)
  380. r.Header.Set("Accept-Encoding", "deflate")
  381. if err != nil {
  382. t.Fatal(err)
  383. }
  384. w := httptest.NewRecorder()
  385. handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  386. NewBuilder(w, r).WithBodyAsString(body).Write()
  387. })
  388. handler.ServeHTTP(w, r)
  389. resp := w.Result()
  390. expected := ""
  391. actual := resp.Header.Get("Content-Encoding")
  392. if actual != expected {
  393. t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
  394. }
  395. expectedVary := ""
  396. actualVary := resp.Header.Get("Vary")
  397. if actualVary != expectedVary {
  398. t.Fatalf(`Unexpected vary header value, got %q instead of %q`, actualVary, expectedVary)
  399. }
  400. }
  401. func TestBuildResponseWithoutCompressionHeader(t *testing.T) {
  402. body := strings.Repeat("a", compressionThreshold+1)
  403. r, err := http.NewRequest("GET", "/", nil)
  404. if err != nil {
  405. t.Fatal(err)
  406. }
  407. w := httptest.NewRecorder()
  408. handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  409. NewBuilder(w, r).WithBodyAsString(body).Write()
  410. })
  411. handler.ServeHTTP(w, r)
  412. resp := w.Result()
  413. expected := ""
  414. actual := resp.Header.Get("Content-Encoding")
  415. if actual != expected {
  416. t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
  417. }
  418. expectedVary := "Accept-Encoding"
  419. actualVary := resp.Header.Get("Vary")
  420. if actualVary != expectedVary {
  421. t.Fatalf(`Unexpected vary header value, got %q instead of %q`, actualVary, expectedVary)
  422. }
  423. }
  424. func TestBuildResponseWithReaderBody(t *testing.T) {
  425. r, err := http.NewRequest("GET", "/", nil)
  426. if err != nil {
  427. t.Fatal(err)
  428. }
  429. w := httptest.NewRecorder()
  430. handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  431. NewBuilder(w, r).WithBodyAsReader(bytes.NewBufferString("body")).Write()
  432. })
  433. handler.ServeHTTP(w, r)
  434. if actualBody := w.Body.String(); actualBody != "body" {
  435. t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, "body")
  436. }
  437. }