controller_test.go 21 KB

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