1
0

controller_test.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  1. package sub
  2. import (
  3. "bytes"
  4. "encoding/base64"
  5. "fmt"
  6. "net/http"
  7. "net/http/httptest"
  8. "os"
  9. "path/filepath"
  10. "strings"
  11. "testing"
  12. "time"
  13. "github.com/gin-gonic/gin"
  14. "github.com/mhsanaei/3x-ui/v3/internal/database"
  15. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  16. "github.com/mhsanaei/3x-ui/v3/internal/web/service"
  17. )
  18. // newTestSUBController builds a controller with just the bits loadSubTemplate
  19. // needs, so the template tests don't require a database.
  20. func newTestSUBController() *SUBController {
  21. return &SUBController{subTemplateCache: map[string]*cachedSubTemplate{}}
  22. }
  23. type subscriptionTestRouterConfig struct {
  24. clashAutoDetect bool
  25. clashUserAgentRegex string
  26. jsonAutoDetect bool
  27. jsonUserAgentRegex string
  28. jsonAlwaysArray bool
  29. }
  30. func newSubscriptionTestRouter(config subscriptionTestRouterConfig) *gin.Engine {
  31. router := gin.New()
  32. options := []SUBControllerOption{
  33. WithSUBJsonEnabled(true),
  34. WithSUBClashEnabled(true),
  35. }
  36. if config.clashAutoDetect {
  37. options = append(options, WithSUBClashAutoDetect(true))
  38. }
  39. if config.clashUserAgentRegex != "" {
  40. options = append(options, WithSUBClashUserAgentRegex(config.clashUserAgentRegex))
  41. }
  42. if config.jsonAutoDetect {
  43. options = append(options, WithSUBJsonAutoDetect(true))
  44. }
  45. if config.jsonUserAgentRegex != "" {
  46. options = append(options, WithSUBJsonUserAgentRegex(config.jsonUserAgentRegex))
  47. }
  48. if config.jsonAlwaysArray {
  49. options = append(options, WithSUBJsonAlwaysArray(true))
  50. }
  51. NewSUBController(router.Group("/"), options...)
  52. return router
  53. }
  54. func TestNewSUBControllerOptions(t *testing.T) {
  55. gin.SetMode(gin.TestMode)
  56. defaults := NewSUBController(gin.New().Group("/"))
  57. if defaults.subPath != "/sub/" || defaults.subJsonPath != "/json/" || defaults.subClashPath != "/clash/" {
  58. t.Fatalf("default paths = %q, %q, %q", defaults.subPath, defaults.subJsonPath, defaults.subClashPath)
  59. }
  60. if !defaults.subEncrypt || defaults.updateInterval != "12" {
  61. t.Fatalf("default encryption/update = %v, %q", defaults.subEncrypt, defaults.updateInterval)
  62. }
  63. if defaults.subService.remarkTemplate != service.DefaultRemarkTemplate {
  64. t.Fatalf("default remark template = %q", defaults.subService.remarkTemplate)
  65. }
  66. if defaults.jsonEnabled || defaults.clashEnabled {
  67. t.Fatalf("format endpoints enabled by default: json=%v clash=%v", defaults.jsonEnabled, defaults.clashEnabled)
  68. }
  69. configured := NewSUBController(
  70. gin.New().Group("/"),
  71. WithSUBPath("/custom/"),
  72. WithSUBJsonEnabled(true),
  73. WithSUBEncryption(false),
  74. WithSUBUpdateInterval("24"),
  75. )
  76. if configured.subPath != "/custom/" || !configured.jsonEnabled || configured.subEncrypt || configured.updateInterval != "24" {
  77. t.Fatalf("configured values were not applied: path=%q json=%v encrypt=%v update=%q",
  78. configured.subPath, configured.jsonEnabled, configured.subEncrypt, configured.updateInterval)
  79. }
  80. }
  81. func TestShouldAutoServeClash(t *testing.T) {
  82. tests := []struct {
  83. name string
  84. autoDetect bool
  85. clashEnabled bool
  86. wantsHTML bool
  87. userAgent string
  88. pattern string
  89. want bool
  90. }{
  91. {name: "clash verge", autoDetect: true, clashEnabled: true, userAgent: "Clash-Verge/v2.4.2", want: true},
  92. {name: "mihomo", autoDetect: true, clashEnabled: true, userAgent: "mihomo/1.19.12", want: true},
  93. {name: "clash case insensitive", autoDetect: true, clashEnabled: true, userAgent: "CLASH-META/1.0", want: true},
  94. {name: "flclash covered by clash", autoDetect: true, clashEnabled: true, userAgent: "FlClash/0.8.91", want: true},
  95. {name: "generic client raw fallback", autoDetect: true, clashEnabled: true, userAgent: "GenericClient/1.10.0"},
  96. {name: "other client raw fallback", autoDetect: true, clashEnabled: true, userAgent: "OtherClient/2.2"},
  97. {name: "unknown raw fallback", autoDetect: true, clashEnabled: true, userAgent: "CustomClient/1.0"},
  98. {name: "empty raw fallback", autoDetect: true, clashEnabled: true},
  99. {name: "browser HTML wins", autoDetect: true, clashEnabled: true, wantsHTML: true, userAgent: "Clash-Verge/v2.4.2"},
  100. {name: "disabled by default", clashEnabled: true, userAgent: "mihomo/1.19.12"},
  101. {name: "clash endpoint disabled", autoDetect: true, userAgent: "mihomo/1.19.12"},
  102. }
  103. for _, tt := range tests {
  104. t.Run(tt.name, func(t *testing.T) {
  105. got := shouldAutoServeClash(tt.autoDetect, tt.clashEnabled, tt.wantsHTML, tt.userAgent, compileUserAgentRegex("Clash/Mihomo", tt.pattern, service.DefaultSubClashUserAgentRegex))
  106. if got != tt.want {
  107. t.Fatalf("shouldAutoServeClash() = %v, want %v", got, tt.want)
  108. }
  109. })
  110. }
  111. }
  112. func TestShouldAutoServeClashUsesConfiguredRegex(t *testing.T) {
  113. configured := compileUserAgentRegex("Clash/Mihomo", `(?i)^custom-client/`, service.DefaultSubClashUserAgentRegex)
  114. if !shouldAutoServeClash(true, true, false, "Custom-Client/1.0", configured) {
  115. t.Fatal("configured User-Agent regex did not match")
  116. }
  117. if shouldAutoServeClash(true, true, false, "Mihomo/1.19", configured) {
  118. t.Fatal("built-in User-Agent matched after a custom regex replaced it")
  119. }
  120. }
  121. func TestShouldAutoServeJson(t *testing.T) {
  122. configured := compileUserAgentRegex("Xray JSON", `(?i)^jsonclient([ /]|$)`, service.DefaultSubJsonUserAgentRegex)
  123. for _, userAgent := range []string{"JsonClient/1.6.32", "jsonclient 1.6.32"} {
  124. if !shouldAutoServeJson(true, true, false, userAgent, configured) {
  125. t.Errorf("configured Xray JSON regex did not match %q", userAgent)
  126. }
  127. }
  128. for _, userAgent := range []string{"GenericClient/1.10.0", "OtherClient/2.2", "ThirdClient/7.0", "CustomClient/1.0"} {
  129. if shouldAutoServeJson(true, true, false, userAgent, configured) {
  130. t.Errorf("configured Xray JSON regex unexpectedly matched %q", userAgent)
  131. }
  132. }
  133. if shouldAutoServeJson(false, true, false, "JsonClient/1.6.32", configured) {
  134. t.Fatal("disabled Xray JSON auto-detection matched")
  135. }
  136. if shouldAutoServeJson(true, false, false, "JsonClient/1.6.32", configured) {
  137. t.Fatal("disabled JSON endpoint matched")
  138. }
  139. if shouldAutoServeJson(true, true, true, "JsonClient/1.6.32", configured) {
  140. t.Fatal("browser HTML request matched Xray JSON")
  141. }
  142. empty := compileUserAgentRegex("Xray JSON", "", service.DefaultSubJsonUserAgentRegex)
  143. if empty != nil {
  144. t.Fatal("empty Xray JSON default should not compile to a matcher")
  145. }
  146. if shouldAutoServeJson(true, true, false, "JsonClient/1.6.32", empty) {
  147. t.Fatal("empty Xray JSON default should not auto-serve")
  148. }
  149. }
  150. func TestShouldAutoServeJsonUsesConfiguredRegex(t *testing.T) {
  151. configured := compileUserAgentRegex("Xray JSON", `(?i)^custom-json/`, service.DefaultSubJsonUserAgentRegex)
  152. if !shouldAutoServeJson(true, true, false, "Custom-JSON/1.0", configured) {
  153. t.Fatal("configured Xray JSON User-Agent regex did not match")
  154. }
  155. if shouldAutoServeJson(true, true, false, "OtherClient/1.10.0", configured) {
  156. t.Fatal("unrelated User-Agent matched after a custom regex was configured")
  157. }
  158. }
  159. func TestCompileUserAgentRegexFallsBackForInvalidPattern(t *testing.T) {
  160. compiled := compileUserAgentRegex("Clash/Mihomo", "[", service.DefaultSubClashUserAgentRegex)
  161. if !compiled.MatchString("Mihomo/1.19") {
  162. t.Fatal("invalid regex did not fall back to the default pattern")
  163. }
  164. }
  165. func TestSanitizeUserAgentForLog(t *testing.T) {
  166. if got := sanitizeUserAgentForLog("client/1.0\r\nforged\tline"); got != "client/1.0 forged line" {
  167. t.Fatalf("sanitizeUserAgentForLog() = %q", got)
  168. }
  169. long := strings.Repeat("界", 513)
  170. if got := sanitizeUserAgentForLog(long); len([]rune(got)) != 512 {
  171. t.Fatalf("sanitized User-Agent length = %d runes, want 512", len([]rune(got)))
  172. }
  173. }
  174. func seedSubMtprotoInbound(t *testing.T, subId, tag string, port int) {
  175. t.Helper()
  176. db := database.GetDB()
  177. secret := "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"
  178. email := tag + "@e"
  179. settings := fmt.Sprintf(`{"clients":[{"email":%q,"subId":%q,"enable":true,"secret":%q}]}`, email, subId, secret)
  180. ib := &model.Inbound{
  181. UserId: 1, Tag: tag, Enable: true, Listen: "203.0.113.5", Port: port,
  182. Protocol: model.MTProto, Remark: tag, Settings: settings, StreamSettings: "{}",
  183. }
  184. if err := db.Create(ib).Error; err != nil {
  185. t.Fatalf("seed mtproto inbound %s: %v", tag, err)
  186. }
  187. client := &model.ClientRecord{Email: email, SubID: subId, Secret: secret, Enable: true}
  188. if err := db.Create(client).Error; err != nil {
  189. t.Fatalf("seed client %s: %v", email, err)
  190. }
  191. if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
  192. t.Fatalf("seed client_inbound %s: %v", email, err)
  193. }
  194. }
  195. func TestAutoDetectFallsBackToRawWhenFormatHasNoContent(t *testing.T) {
  196. seedSubDB(t)
  197. seedSubMtprotoInbound(t, "s1", "tg", 4490)
  198. gin.SetMode(gin.TestMode)
  199. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
  200. req.Header.Set("User-Agent", "Clash-Verge/v2.4.2")
  201. resp := httptest.NewRecorder()
  202. newSubscriptionTestRouter(subscriptionTestRouterConfig{clashAutoDetect: true, jsonAutoDetect: true}).ServeHTTP(resp, req)
  203. if resp.Code != http.StatusOK {
  204. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  205. }
  206. decoded, err := base64.StdEncoding.DecodeString(resp.Body.String())
  207. if err != nil {
  208. t.Fatalf("fallback response is not base64: %v", err)
  209. }
  210. if !strings.Contains(string(decoded), "tg://proxy") {
  211. t.Fatalf("decoded fallback lacks the Telegram proxy link: %s", decoded)
  212. }
  213. }
  214. func TestStandardSubscriptionAutoDetectsFormats(t *testing.T) {
  215. seedSubDB(t)
  216. seedSubInbound(t, "s1", "auto", 4480, 1, `{"network":"tcp","security":"none"}`)
  217. gin.SetMode(gin.TestMode)
  218. t.Run("recognized client receives YAML", func(t *testing.T) {
  219. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
  220. req.Header.Set("User-Agent", "Clash-Verge/v2.4.2")
  221. resp := httptest.NewRecorder()
  222. newSubscriptionTestRouter(subscriptionTestRouterConfig{clashAutoDetect: true}).ServeHTTP(resp, req)
  223. if resp.Code != http.StatusOK {
  224. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  225. }
  226. if got := resp.Header().Get("Content-Type"); got != "application/yaml; charset=utf-8" {
  227. t.Fatalf("Content-Type = %q, want YAML", got)
  228. }
  229. if body := resp.Body.String(); !strings.Contains(body, "proxies:") || !strings.Contains(body, "type: vless") {
  230. t.Fatalf("auto-detected body is not Clash YAML:\n%s", body)
  231. }
  232. })
  233. t.Run("Clash wins when both format regexes match", func(t *testing.T) {
  234. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
  235. req.Header.Set("User-Agent", "Hybrid/1.0")
  236. resp := httptest.NewRecorder()
  237. newSubscriptionTestRouter(subscriptionTestRouterConfig{
  238. clashAutoDetect: true,
  239. clashUserAgentRegex: `(?i)^hybrid/`,
  240. jsonAutoDetect: true,
  241. jsonUserAgentRegex: `(?i)^hybrid/`,
  242. }).ServeHTTP(resp, req)
  243. if resp.Code != http.StatusOK {
  244. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  245. }
  246. if got := resp.Header().Get("Content-Type"); got != "application/yaml; charset=utf-8" {
  247. t.Fatalf("Content-Type = %q, want Clash YAML", got)
  248. }
  249. })
  250. t.Run("disabled setting preserves raw base64", func(t *testing.T) {
  251. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
  252. req.Header.Set("User-Agent", "Clash-Verge/v2.4.2")
  253. resp := httptest.NewRecorder()
  254. newSubscriptionTestRouter(subscriptionTestRouterConfig{}).ServeHTTP(resp, req)
  255. if resp.Code != http.StatusOK {
  256. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  257. }
  258. decoded, err := base64.StdEncoding.DecodeString(resp.Body.String())
  259. if err != nil {
  260. t.Fatalf("raw response is not base64: %v", err)
  261. }
  262. if !strings.Contains(string(decoded), "vless://") {
  263. t.Fatalf("decoded raw response lacks VLESS link: %s", decoded)
  264. }
  265. })
  266. t.Run("configured regex controls detection", func(t *testing.T) {
  267. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
  268. req.Header.Set("User-Agent", "Mihomo/1.19")
  269. resp := httptest.NewRecorder()
  270. newSubscriptionTestRouter(subscriptionTestRouterConfig{
  271. clashAutoDetect: true,
  272. clashUserAgentRegex: `(?i)^custom-client/`,
  273. }).ServeHTTP(resp, req)
  274. if resp.Code != http.StatusOK {
  275. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  276. }
  277. if got := resp.Header().Get("Content-Type"); got == "application/yaml; charset=utf-8" {
  278. t.Fatalf("Content-Type = %q, custom regex should preserve raw response", got)
  279. }
  280. })
  281. t.Run("unrecognized client preserves raw base64", func(t *testing.T) {
  282. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
  283. req.Header.Set("User-Agent", "GenericClient/1.10.0")
  284. resp := httptest.NewRecorder()
  285. newSubscriptionTestRouter(subscriptionTestRouterConfig{
  286. clashAutoDetect: true,
  287. jsonAutoDetect: true,
  288. }).ServeHTTP(resp, req)
  289. if resp.Code != http.StatusOK {
  290. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  291. }
  292. decoded, err := base64.StdEncoding.DecodeString(resp.Body.String())
  293. if err != nil {
  294. t.Fatalf("raw response is not base64: %v", err)
  295. }
  296. if !strings.Contains(string(decoded), "vless://") {
  297. t.Fatalf("decoded raw response lacks VLESS link: %s", decoded)
  298. }
  299. })
  300. t.Run("recognized Xray JSON client receives configuration array", func(t *testing.T) {
  301. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
  302. req.Header.Set("User-Agent", "JsonClient/1.6.32")
  303. resp := httptest.NewRecorder()
  304. newSubscriptionTestRouter(subscriptionTestRouterConfig{
  305. jsonAutoDetect: true,
  306. jsonUserAgentRegex: `(?i)^jsonclient([ /]|$)`,
  307. }).ServeHTTP(resp, req)
  308. if resp.Code != http.StatusOK {
  309. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  310. }
  311. if got := resp.Header().Get("Content-Type"); got != "application/json; charset=utf-8" {
  312. t.Fatalf("Content-Type = %q, want JSON", got)
  313. }
  314. if body := strings.TrimSpace(resp.Body.String()); !strings.HasPrefix(body, "[") || !strings.Contains(body, `"outbounds"`) {
  315. t.Fatalf("auto-detected body is not an Xray JSON configuration array:\n%s", body)
  316. }
  317. })
  318. t.Run("explicit JSON endpoint preserves legacy single object by default", func(t *testing.T) {
  319. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/json/s1", nil)
  320. resp := httptest.NewRecorder()
  321. newSubscriptionTestRouter(subscriptionTestRouterConfig{}).ServeHTTP(resp, req)
  322. if resp.Code != http.StatusOK {
  323. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  324. }
  325. if got := resp.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
  326. t.Fatalf("Content-Type = %q, want legacy text/plain", got)
  327. }
  328. if body := strings.TrimSpace(resp.Body.String()); !strings.HasPrefix(body, "{") {
  329. t.Fatalf("legacy explicit JSON body is not an object: %s", body)
  330. }
  331. })
  332. t.Run("explicit JSON endpoint can follow XTLS array standard", func(t *testing.T) {
  333. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/json/s1", nil)
  334. resp := httptest.NewRecorder()
  335. newSubscriptionTestRouter(subscriptionTestRouterConfig{jsonAlwaysArray: true}).ServeHTTP(resp, req)
  336. if resp.Code != http.StatusOK {
  337. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  338. }
  339. if got := resp.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
  340. t.Fatalf("Content-Type = %q, want legacy text/plain", got)
  341. }
  342. if body := strings.TrimSpace(resp.Body.String()); !strings.HasPrefix(body, "[") {
  343. t.Fatalf("standards-compliant explicit JSON body is not an array: %s", body)
  344. }
  345. })
  346. }
  347. func writeFile(t *testing.T, path, content string) {
  348. t.Helper()
  349. if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
  350. t.Fatalf("write %s: %v", path, err)
  351. }
  352. }
  353. func renderTemplate(t *testing.T, a *SUBController, dir string, data map[string]any) string {
  354. t.Helper()
  355. tmpl, err := a.loadSubTemplate(dir)
  356. if err != nil {
  357. t.Fatalf("loadSubTemplate: unexpected error: %v", err)
  358. }
  359. if tmpl == nil {
  360. t.Fatal("loadSubTemplate: expected a template, got nil")
  361. }
  362. var buf bytes.Buffer
  363. if err := tmpl.Execute(&buf, data); err != nil {
  364. t.Fatalf("execute: %v", err)
  365. }
  366. return buf.String()
  367. }
  368. func TestLoadSubTemplate_RendersIndex(t *testing.T) {
  369. dir := t.TempDir()
  370. writeFile(t, filepath.Join(dir, "index.html"), `<h1>{{ .sId }}</h1>`)
  371. got := renderTemplate(t, newTestSUBController(), dir, map[string]any{"sId": "abc-123"})
  372. if want := `<h1>abc-123</h1>`; got != want {
  373. t.Fatalf("rendered = %q, want %q", got, want)
  374. }
  375. }
  376. func TestLoadSubTemplate_PrefersSubHTML(t *testing.T) {
  377. dir := t.TempDir()
  378. writeFile(t, filepath.Join(dir, "index.html"), `from-index`)
  379. writeFile(t, filepath.Join(dir, "sub.html"), `from-sub`)
  380. got := renderTemplate(t, newTestSUBController(), dir, nil)
  381. if got != "from-sub" {
  382. t.Fatalf("rendered = %q, want %q (sub.html should take precedence)", got, "from-sub")
  383. }
  384. }
  385. func TestLoadSubTemplate_FallbackCases(t *testing.T) {
  386. a := newTestSUBController()
  387. t.Run("missing dir", func(t *testing.T) {
  388. tmpl, err := a.loadSubTemplate(filepath.Join(t.TempDir(), "does-not-exist"))
  389. if tmpl != nil || err != nil {
  390. t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
  391. }
  392. })
  393. t.Run("path is a file not a dir", func(t *testing.T) {
  394. file := filepath.Join(t.TempDir(), "index.html")
  395. writeFile(t, file, `whatever`)
  396. tmpl, err := a.loadSubTemplate(file)
  397. if tmpl != nil || err != nil {
  398. t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
  399. }
  400. })
  401. t.Run("dir without template file", func(t *testing.T) {
  402. tmpl, err := a.loadSubTemplate(t.TempDir())
  403. if tmpl != nil || err != nil {
  404. t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
  405. }
  406. })
  407. }
  408. func TestLoadSubTemplate_MalformedTemplate(t *testing.T) {
  409. dir := t.TempDir()
  410. // Unterminated action — html/template fails to parse this.
  411. writeFile(t, filepath.Join(dir, "index.html"), `<h1>{{ .sId </h1>`)
  412. tmpl, err := newTestSUBController().loadSubTemplate(dir)
  413. if err == nil {
  414. t.Fatal("expected a parse error for a malformed template, got nil")
  415. }
  416. if tmpl != nil {
  417. t.Fatalf("expected nil template on parse error, got %v", tmpl)
  418. }
  419. }
  420. func TestLoadSubTemplate_CacheHitAndInvalidation(t *testing.T) {
  421. a := newTestSUBController()
  422. dir := t.TempDir()
  423. path := filepath.Join(dir, "index.html")
  424. // v1 with a fixed mtime.
  425. writeFile(t, path, `v1`)
  426. t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
  427. if err := os.Chtimes(path, t1, t1); err != nil {
  428. t.Fatalf("chtimes: %v", err)
  429. }
  430. first, err := a.loadSubTemplate(dir)
  431. if err != nil || first == nil {
  432. t.Fatalf("first load: (%v, %v)", first, err)
  433. }
  434. // Same mtime → cache hit returns the identical parsed template.
  435. second, err := a.loadSubTemplate(dir)
  436. if err != nil {
  437. t.Fatalf("second load: %v", err)
  438. }
  439. if second != first {
  440. t.Fatal("expected cache hit to return the same *template.Template pointer")
  441. }
  442. // New content + newer mtime → cache invalidated, fresh content served.
  443. writeFile(t, path, `v2`)
  444. t2 := t1.Add(time.Hour)
  445. if err := os.Chtimes(path, t2, t2); err != nil {
  446. t.Fatalf("chtimes: %v", err)
  447. }
  448. third, err := a.loadSubTemplate(dir)
  449. if err != nil || third == nil {
  450. t.Fatalf("third load: (%v, %v)", third, err)
  451. }
  452. if third == first {
  453. t.Fatal("expected cache invalidation to re-parse the template after mtime change")
  454. }
  455. var buf bytes.Buffer
  456. if err := third.Execute(&buf, nil); err != nil {
  457. t.Fatalf("execute: %v", err)
  458. }
  459. if buf.String() != "v2" {
  460. t.Fatalf("rendered = %q, want %q after edit", buf.String(), "v2")
  461. }
  462. }