functions_test.go 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
  2. // SPDX-License-Identifier: Apache-2.0
  3. package template // import "miniflux.app/v2/internal/template"
  4. import (
  5. "html/template"
  6. "strings"
  7. "testing"
  8. "time"
  9. "miniflux.app/v2/internal/locale"
  10. "miniflux.app/v2/internal/model"
  11. )
  12. func TestDict(t *testing.T) {
  13. d, err := dict("k1", "v1", "k2", "v2")
  14. if err != nil {
  15. t.Fatalf(`The dict should be valid: %v`, err)
  16. }
  17. if value, found := d["k1"]; found {
  18. if value != "v1" {
  19. t.Fatalf(`Unexpected value for k1: got %q`, value)
  20. }
  21. }
  22. if value, found := d["k2"]; found {
  23. if value != "v2" {
  24. t.Fatalf(`Unexpected value for k2: got %q`, value)
  25. }
  26. }
  27. }
  28. func TestDictWithInvalidNumberOfArguments(t *testing.T) {
  29. _, err := dict("k1")
  30. if err == nil {
  31. t.Fatal(`An error should be returned if the number of arguments are not even`)
  32. }
  33. }
  34. func TestDictWithInvalidMap(t *testing.T) {
  35. _, err := dict(1, 2)
  36. if err == nil {
  37. t.Fatal(`An error should be returned if the dict keys are not string`)
  38. }
  39. }
  40. func TestTruncate(t *testing.T) {
  41. scenarios := []struct {
  42. name string
  43. input string
  44. max int
  45. expected string
  46. }{
  47. {
  48. name: "short ascii",
  49. input: "Short text",
  50. max: 25,
  51. expected: "Short text",
  52. },
  53. {
  54. name: "short unicode",
  55. input: "Короткий текст",
  56. max: 25,
  57. expected: "Короткий текст",
  58. },
  59. {
  60. name: "exact ascii length",
  61. input: "Short text",
  62. max: len("Short text"),
  63. expected: "Short text",
  64. },
  65. {
  66. name: "long ascii",
  67. input: "This is a really pretty long English text",
  68. max: 25,
  69. expected: "This is a really pretty l…",
  70. },
  71. {
  72. name: "long unicode",
  73. input: "Это реально очень длинный русский текст",
  74. max: 25,
  75. expected: "Это реально очень длинный…",
  76. },
  77. }
  78. for _, scenario := range scenarios {
  79. t.Run(scenario.name, func(t *testing.T) {
  80. result := truncate(scenario.input, scenario.max)
  81. if result != scenario.expected {
  82. t.Fatalf(`Unexpected output, got %q instead of %q`, result, scenario.expected)
  83. }
  84. })
  85. }
  86. }
  87. func TestTruncateInvalidMax(t *testing.T) {
  88. scenarios := []struct {
  89. name string
  90. max int
  91. }{
  92. {name: "zero", max: 0},
  93. {name: "negative", max: -1},
  94. }
  95. for _, scenario := range scenarios {
  96. t.Run(scenario.name, func(t *testing.T) {
  97. defer func() {
  98. if recover() == nil {
  99. t.Fatal("Expected panic for non-positive max")
  100. }
  101. }()
  102. _ = truncate("Short text", scenario.max)
  103. })
  104. }
  105. }
  106. func TestIsEmail(t *testing.T) {
  107. if !isEmail("user@domain.tld") {
  108. t.Fatal(`This email is valid and should returns true`)
  109. }
  110. if isEmail("invalid") {
  111. t.Fatal(`This email is not valid and should returns false`)
  112. }
  113. }
  114. func TestUntrustedURL(t *testing.T) {
  115. scenarios := map[string]template.URL{
  116. // Pass-through: schemes accepted by the sanitizer allowlist.
  117. "https://example.org/article": "https://example.org/article",
  118. "http://example.org/article": "http://example.org/article",
  119. "mailto:author@example.org": "mailto:author@example.org",
  120. "magnet:?xt=urn:btih:abc": "magnet:?xt=urn:btih:abc",
  121. "feed:https://example.org/": "feed:https://example.org/",
  122. // Rewritten to "#": schemes that enable script execution or
  123. // local-resource access, plus malformed inputs.
  124. "javascript:alert(1)": "#",
  125. "JavaScript:alert(1)": "#",
  126. "data:text/html,<script>alert(1)</script>": "#",
  127. "vbscript:msgbox(1)": "#",
  128. "file:///etc/passwd": "#",
  129. "//evil.example.org/path": "#",
  130. "/relative/path": "#",
  131. "": "#",
  132. }
  133. for input, expected := range scenarios {
  134. t.Run(input, func(t *testing.T) {
  135. if actual := untrustedURL(input); actual != expected {
  136. t.Errorf("untrustedURL(%q) = %q, want %q", input, actual, expected)
  137. }
  138. })
  139. }
  140. }
  141. func TestDuration(t *testing.T) {
  142. now := time.Now()
  143. var dt = []struct {
  144. in time.Time
  145. out string
  146. }{
  147. {time.Time{}, ""},
  148. {now.Add(time.Hour), "1h0m0s"},
  149. {now.Add(time.Minute), "1m0s"},
  150. {now.Add(time.Minute * 40), "40m0s"},
  151. {now.Add(time.Millisecond * 40), "0s"},
  152. {now.Add(time.Millisecond * 80), "0s"},
  153. {now.Add(time.Millisecond * 400), "0s"},
  154. {now.Add(time.Millisecond * 800), "1s"},
  155. {now.Add(time.Millisecond * 4321), "4s"},
  156. {now.Add(time.Millisecond * 8765), "9s"},
  157. {now.Add(time.Microsecond * 12345678), "12s"},
  158. {now.Add(time.Microsecond * 87654321), "1m28s"},
  159. }
  160. for i, tt := range dt {
  161. if out := durationImpl(tt.in, now); out != tt.out {
  162. t.Errorf(`%d. content mismatch for "%v": expected=%q got=%q`, i, tt.in, tt.out, out)
  163. }
  164. }
  165. }
  166. func TestElapsedTime(t *testing.T) {
  167. printer := locale.NewPrinter("en_US")
  168. var dt = []struct {
  169. in time.Time
  170. out string
  171. }{
  172. {time.Time{}, printer.Print("time_elapsed.not_yet")},
  173. {time.Now().Add(time.Hour), printer.Print("time_elapsed.not_yet")},
  174. {time.Now(), printer.Print("time_elapsed.now")},
  175. {time.Now().Add(-time.Minute), printer.Plural("time_elapsed.minutes", 1, 1)},
  176. {time.Now().Add(-time.Minute * 40), printer.Plural("time_elapsed.minutes", 40, 40)},
  177. {time.Now().Add(-time.Hour), printer.Plural("time_elapsed.hours", 1, 1)},
  178. {time.Now().Add(-time.Hour * 3), printer.Plural("time_elapsed.hours", 3, 3)},
  179. {time.Now().Add(-time.Hour * 32), printer.Print("time_elapsed.yesterday")},
  180. {time.Now().Add(-time.Hour * 24 * 3), printer.Plural("time_elapsed.days", 3, 3)},
  181. {time.Now().Add(-time.Hour * 24 * 14), printer.Plural("time_elapsed.days", 14, 14)},
  182. {time.Now().Add(-time.Hour * 24 * 15), printer.Plural("time_elapsed.days", 15, 15)},
  183. {time.Now().Add(-time.Hour * 24 * 21), printer.Plural("time_elapsed.weeks", 3, 3)},
  184. {time.Now().Add(-time.Hour * 24 * 32), printer.Plural("time_elapsed.months", 1, 1)},
  185. {time.Now().Add(-time.Hour * 24 * 60), printer.Plural("time_elapsed.months", 2, 2)},
  186. {time.Now().Add(-time.Hour * 24 * 366), printer.Plural("time_elapsed.years", 1, 1)},
  187. {time.Now().Add(-time.Hour * 24 * 365 * 3), printer.Plural("time_elapsed.years", 3, 3)},
  188. }
  189. for i, tt := range dt {
  190. if out := elapsedTime(printer, "Local", tt.in); out != tt.out {
  191. t.Errorf(`%d. content mismatch for "%v": expected=%q got=%q`, i, tt.in, tt.out, out)
  192. }
  193. }
  194. }
  195. func TestFormatFileSize(t *testing.T) {
  196. scenarios := []struct {
  197. input int64
  198. expected string
  199. }{
  200. {0, "0 B"},
  201. {1, "1 B"},
  202. {500, "500 B"},
  203. {1024, "1.0 KiB"},
  204. {43520, "42.5 KiB"},
  205. {5000 * 1024 * 1024, "4.9 GiB"},
  206. }
  207. for _, scenario := range scenarios {
  208. result := formatFileSize(scenario.input)
  209. if result != scenario.expected {
  210. t.Errorf(`Unexpected result, got %q instead of %q for %d`, result, scenario.expected, scenario.input)
  211. }
  212. }
  213. }
  214. func TestQueryString(t *testing.T) {
  215. params, err := dict("q", "ai", "unread", true, "offset", 20)
  216. if err != nil {
  217. t.Fatalf(`The dict should be valid: %v`, err)
  218. }
  219. got := (&funcMap{}).Map()["queryString"].(func(map[string]any) string)(params)
  220. if got == "" {
  221. t.Fatalf("Expected a query string, got an empty string")
  222. }
  223. if !strings.HasPrefix(got, "?") {
  224. t.Fatalf(`Expected query string to start with "?", got %q`, got)
  225. }
  226. if !strings.Contains(got, "q=ai") {
  227. t.Fatalf(`Expected query string to contain q=ai, got %q`, got)
  228. }
  229. if !strings.Contains(got, "unread=1") {
  230. t.Fatalf(`Expected query string to contain unread=1, got %q`, got)
  231. }
  232. if !strings.Contains(got, "offset=20") {
  233. t.Fatalf(`Expected query string to contain offset=20, got %q`, got)
  234. }
  235. empty, err := dict("q", "", "unread", false, "offset", 0)
  236. if err != nil {
  237. t.Fatalf(`The dict should be valid: %v`, err)
  238. }
  239. got = (&funcMap{}).Map()["queryString"].(func(map[string]any) string)(empty)
  240. if got != "" {
  241. t.Fatalf(`Expected empty query string, got %q`, got)
  242. }
  243. }
  244. func TestCSPExternalFont(t *testing.T) {
  245. want := []string{
  246. `default-src 'none';`,
  247. `img-src * data:;`,
  248. `media-src *;`,
  249. `frame-src *;`,
  250. `style-src 'nonce-1234';`,
  251. `script-src 'nonce-1234'`,
  252. `'strict-dynamic';`,
  253. `font-src test.com;`,
  254. `require-trusted-types-for 'script';`,
  255. `trusted-types html url;`,
  256. `manifest-src 'self';`,
  257. }
  258. got := csp(&model.User{ExternalFontHosts: "test.com"}, "1234")
  259. for _, value := range want {
  260. if !strings.Contains(got, value) {
  261. t.Errorf(`Unexpected result, didn't find %q in %q`, value, got)
  262. }
  263. }
  264. }
  265. func TestCSPNoUser(t *testing.T) {
  266. want := []string{
  267. `default-src 'none';`,
  268. `img-src * data:;`,
  269. `media-src *;`,
  270. `frame-src *;`,
  271. `style-src 'nonce-1234';`,
  272. `script-src 'nonce-1234'`,
  273. `'strict-dynamic';`,
  274. `require-trusted-types-for 'script';`,
  275. `trusted-types html url;`,
  276. `manifest-src 'self';`,
  277. }
  278. got := csp(nil, "1234")
  279. for _, value := range want {
  280. if !strings.Contains(got, value) {
  281. t.Errorf(`Unexpected result, didn't find %q in %q`, value, got)
  282. }
  283. }
  284. }
  285. func TestCSPCustomJSExternalFont(t *testing.T) {
  286. want := []string{
  287. `default-src 'none';`,
  288. `img-src * data:;`,
  289. `media-src *;`,
  290. `frame-src *;`,
  291. `style-src 'nonce-1234';`,
  292. `script-src 'nonce-1234'`,
  293. `'strict-dynamic';`,
  294. `require-trusted-types-for 'script';`,
  295. `trusted-types html url;`,
  296. `manifest-src 'self';`,
  297. }
  298. got := csp(&model.User{ExternalFontHosts: "test.com", CustomJS: "alert(1)"}, "1234")
  299. for _, value := range want {
  300. if !strings.Contains(got, value) {
  301. t.Errorf(`Unexpected result, didn't find %q in %q`, value, got)
  302. }
  303. }
  304. }
  305. func TestCSPExternalFontStylesheet(t *testing.T) {
  306. want := []string{
  307. `default-src 'none';`,
  308. `img-src * data:;`,
  309. `media-src *;`,
  310. `frame-src *;`,
  311. `style-src 'nonce-1234' test.com;`,
  312. `script-src 'nonce-1234'`,
  313. `'strict-dynamic';`,
  314. `require-trusted-types-for 'script';`,
  315. `trusted-types html url;`,
  316. `manifest-src 'self';`,
  317. }
  318. got := csp(&model.User{ExternalFontHosts: "test.com", Stylesheet: "a {color: red;}"}, "1234")
  319. for _, value := range want {
  320. if !strings.Contains(got, value) {
  321. t.Errorf(`Unexpected result, didn't find %q in %q`, value, got)
  322. }
  323. }
  324. }