embed_test.go 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911
  1. //go:build embed
  2. package web
  3. import (
  4. "bytes"
  5. "context"
  6. "io/fs"
  7. "net/http"
  8. "net/http/httptest"
  9. "os"
  10. "path/filepath"
  11. "strings"
  12. "testing"
  13. "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  14. "github.com/gin-gonic/gin"
  15. "github.com/stretchr/testify/assert"
  16. "github.com/stretchr/testify/require"
  17. )
  18. func init() {
  19. gin.SetMode(gin.TestMode)
  20. }
  21. func TestInjectSiteTitle(t *testing.T) {
  22. t.Run("replaces_title_with_site_name", func(t *testing.T) {
  23. html := []byte(`<html><head><title>Sub2API - AI API Gateway</title></head><body></body></html>`)
  24. settingsJSON := []byte(`{"site_name":"MyCustomSite"}`)
  25. result := injectSiteTitle(html, settingsJSON)
  26. assert.Contains(t, string(result), "<title>MyCustomSite - AI API Gateway</title>")
  27. assert.NotContains(t, string(result), "Sub2API")
  28. })
  29. t.Run("returns_unchanged_when_site_name_empty", func(t *testing.T) {
  30. html := []byte(`<html><head><title>Sub2API - AI API Gateway</title></head><body></body></html>`)
  31. settingsJSON := []byte(`{"site_name":""}`)
  32. result := injectSiteTitle(html, settingsJSON)
  33. assert.Equal(t, string(html), string(result))
  34. })
  35. t.Run("returns_unchanged_when_site_name_missing", func(t *testing.T) {
  36. html := []byte(`<html><head><title>Sub2API - AI API Gateway</title></head><body></body></html>`)
  37. settingsJSON := []byte(`{"other_field":"value"}`)
  38. result := injectSiteTitle(html, settingsJSON)
  39. assert.Equal(t, string(html), string(result))
  40. })
  41. t.Run("returns_unchanged_when_invalid_json", func(t *testing.T) {
  42. html := []byte(`<html><head><title>Sub2API - AI API Gateway</title></head><body></body></html>`)
  43. settingsJSON := []byte(`{invalid json}`)
  44. result := injectSiteTitle(html, settingsJSON)
  45. assert.Equal(t, string(html), string(result))
  46. })
  47. t.Run("returns_unchanged_when_no_title_tag", func(t *testing.T) {
  48. html := []byte(`<html><head></head><body></body></html>`)
  49. settingsJSON := []byte(`{"site_name":"MyCustomSite"}`)
  50. result := injectSiteTitle(html, settingsJSON)
  51. assert.Equal(t, string(html), string(result))
  52. })
  53. t.Run("returns_unchanged_when_title_has_attributes", func(t *testing.T) {
  54. // The function looks for "<title>" literally, so attributes are not supported
  55. // This is acceptable since index.html uses plain <title> without attributes
  56. html := []byte(`<html><head><title lang="en">Sub2API</title></head><body></body></html>`)
  57. settingsJSON := []byte(`{"site_name":"NewSite"}`)
  58. result := injectSiteTitle(html, settingsJSON)
  59. // Should return unchanged since <title> with attributes is not matched
  60. assert.Equal(t, string(html), string(result))
  61. })
  62. t.Run("escapes_html_in_site_name", func(t *testing.T) {
  63. html := []byte(`<html><head><title>Sub2API - AI API Gateway</title></head><body></body></html>`)
  64. settingsJSON := []byte(`{"site_name":"</title><script>alert(1)</script><title>"}`)
  65. result := injectSiteTitle(html, settingsJSON)
  66. assert.NotContains(t, string(result), "<script>")
  67. assert.Contains(t, string(result), "&lt;/title&gt;&lt;script&gt;alert(1)&lt;/script&gt;&lt;title&gt;")
  68. })
  69. t.Run("escapes_ampersand_in_site_name", func(t *testing.T) {
  70. html := []byte(`<html><head><title>Sub2API</title></head><body></body></html>`)
  71. settingsJSON := []byte(`{"site_name":"A&B"}`)
  72. result := injectSiteTitle(html, settingsJSON)
  73. assert.Contains(t, string(result), "<title>A&amp;B - AI API Gateway</title>")
  74. })
  75. t.Run("preserves_rest_of_html", func(t *testing.T) {
  76. html := []byte(`<html><head><meta charset="UTF-8"><title>Sub2API</title><script src="app.js"></script></head><body><div id="app"></div></body></html>`)
  77. settingsJSON := []byte(`{"site_name":"TestSite"}`)
  78. result := injectSiteTitle(html, settingsJSON)
  79. assert.Contains(t, string(result), `<meta charset="UTF-8">`)
  80. assert.Contains(t, string(result), `<script src="app.js"></script>`)
  81. assert.Contains(t, string(result), `<div id="app"></div>`)
  82. assert.Contains(t, string(result), "<title>TestSite - AI API Gateway</title>")
  83. })
  84. }
  85. func TestInjectSiteFavicon(t *testing.T) {
  86. t.Run("replaces_favicon_with_site_logo", func(t *testing.T) {
  87. html := []byte(`<html><head><link rel="icon" type="image/png" href="/logo.png" /></head></html>`)
  88. settingsJSON := []byte(`{"site_logo":"https://example.com/custom-logo.png"}`)
  89. result := injectSiteFavicon(html, settingsJSON)
  90. assert.Contains(t, string(result), `<link rel="icon" href="https://example.com/custom-logo.png" />`)
  91. assert.NotContains(t, string(result), `/logo.png`)
  92. })
  93. t.Run("supports_relative_and_data_image_urls", func(t *testing.T) {
  94. html := []byte(`<link rel="icon" href="/logo.png" />`)
  95. assert.Contains(t, string(injectSiteFavicon(html, []byte(`{"site_logo":"/uploads/logo.svg"}`))), `/uploads/logo.svg`)
  96. assert.Contains(t, string(injectSiteFavicon(html, []byte(`{"site_logo":"data:image/png;base64,abc"}`))), `data:image/png;base64,abc`)
  97. })
  98. t.Run("rejects_unsafe_logo_urls", func(t *testing.T) {
  99. html := []byte(`<link rel="icon" href="/logo.png" />`)
  100. result := injectSiteFavicon(html, []byte(`{"site_logo":"javascript:alert(1)"}`))
  101. assert.Equal(t, string(html), string(result))
  102. })
  103. t.Run("escapes_logo_url_for_html", func(t *testing.T) {
  104. html := []byte(`<link rel="icon" href="/logo.png" />`)
  105. result := injectSiteFavicon(html, []byte(`{"site_logo":"https://example.com/logo.png?a=1&b=2"}`))
  106. assert.Contains(t, string(result), `a=1&amp;b=2`)
  107. })
  108. }
  109. func TestReplaceNoncePlaceholder(t *testing.T) {
  110. t.Run("replaces_single_placeholder", func(t *testing.T) {
  111. html := []byte(`<script nonce="__CSP_NONCE_VALUE__">console.log('test');</script>`)
  112. nonce := "abc123xyz"
  113. result := replaceNoncePlaceholder(html, nonce)
  114. expected := `<script nonce="abc123xyz">console.log('test');</script>`
  115. assert.Equal(t, expected, string(result))
  116. })
  117. t.Run("replaces_multiple_placeholders", func(t *testing.T) {
  118. html := []byte(`<script nonce="__CSP_NONCE_VALUE__">a</script><script nonce="__CSP_NONCE_VALUE__">b</script>`)
  119. nonce := "nonce123"
  120. result := replaceNoncePlaceholder(html, nonce)
  121. assert.Equal(t, 2, strings.Count(string(result), `nonce="nonce123"`))
  122. assert.NotContains(t, string(result), NonceHTMLPlaceholder)
  123. })
  124. t.Run("handles_empty_nonce", func(t *testing.T) {
  125. html := []byte(`<script nonce="__CSP_NONCE_VALUE__">test</script>`)
  126. nonce := ""
  127. result := replaceNoncePlaceholder(html, nonce)
  128. assert.Equal(t, `<script nonce="">test</script>`, string(result))
  129. })
  130. t.Run("no_placeholder_returns_unchanged", func(t *testing.T) {
  131. html := []byte(`<script>console.log('test');</script>`)
  132. nonce := "abc123"
  133. result := replaceNoncePlaceholder(html, nonce)
  134. assert.Equal(t, string(html), string(result))
  135. })
  136. t.Run("handles_empty_html", func(t *testing.T) {
  137. html := []byte(``)
  138. nonce := "abc123"
  139. result := replaceNoncePlaceholder(html, nonce)
  140. assert.Empty(t, result)
  141. })
  142. }
  143. func TestNonceHTMLPlaceholder(t *testing.T) {
  144. t.Run("constant_value", func(t *testing.T) {
  145. assert.Equal(t, "__CSP_NONCE_VALUE__", NonceHTMLPlaceholder)
  146. })
  147. }
  148. // mockSettingsProvider implements PublicSettingsProvider for testing
  149. type mockSettingsProvider struct {
  150. settings any
  151. err error
  152. called int
  153. }
  154. func (m *mockSettingsProvider) GetPublicSettingsForInjection(ctx context.Context) (any, error) {
  155. m.called++
  156. return m.settings, m.err
  157. }
  158. func TestFrontendServer_InjectSettings(t *testing.T) {
  159. t.Run("injects_settings_with_nonce_placeholder", func(t *testing.T) {
  160. provider := &mockSettingsProvider{
  161. settings: map[string]string{"key": "value"},
  162. }
  163. server, err := NewFrontendServer(provider)
  164. require.NoError(t, err)
  165. settingsJSON := []byte(`{"test":"data"}`)
  166. result := server.injectSettings(settingsJSON)
  167. // Should contain the script with nonce placeholder
  168. assert.Contains(t, string(result), `<script nonce="__CSP_NONCE_VALUE__">`)
  169. assert.Contains(t, string(result), `window.__APP_CONFIG__={"test":"data"};`)
  170. assert.Contains(t, string(result), `</script></head>`)
  171. })
  172. t.Run("injects_before_head_close", func(t *testing.T) {
  173. provider := &mockSettingsProvider{
  174. settings: map[string]string{"key": "value"},
  175. }
  176. server, err := NewFrontendServer(provider)
  177. require.NoError(t, err)
  178. settingsJSON := []byte(`{}`)
  179. result := server.injectSettings(settingsJSON)
  180. // Script should be injected before </head>
  181. headCloseIndex := bytes.Index(result, []byte("</head>"))
  182. scriptIndex := bytes.Index(result, []byte(`<script nonce="`))
  183. assert.True(t, scriptIndex < headCloseIndex, "script should be before </head>")
  184. })
  185. t.Run("handles_complex_settings", func(t *testing.T) {
  186. provider := &mockSettingsProvider{
  187. settings: map[string]any{
  188. "nested": map[string]any{
  189. "array": []int{1, 2, 3},
  190. },
  191. },
  192. }
  193. server, err := NewFrontendServer(provider)
  194. require.NoError(t, err)
  195. settingsJSON := []byte(`{"nested":{"array":[1,2,3]},"special":"<>&"}`)
  196. result := server.injectSettings(settingsJSON)
  197. assert.Contains(t, string(result), `window.__APP_CONFIG__={"nested":{"array":[1,2,3]},"special":"<>&"};`)
  198. })
  199. }
  200. func TestFrontendServer_ServeIndexHTML(t *testing.T) {
  201. t.Run("serves_html_with_nonce", func(t *testing.T) {
  202. provider := &mockSettingsProvider{
  203. settings: map[string]string{"test": "value"},
  204. }
  205. server, err := NewFrontendServer(provider)
  206. require.NoError(t, err)
  207. // Create a gin context with nonce
  208. w := httptest.NewRecorder()
  209. c, _ := gin.CreateTestContext(w)
  210. c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
  211. // Set nonce in context (simulating SecurityHeaders middleware)
  212. testNonce := "test-nonce-12345"
  213. c.Set(middleware.CSPNonceKey, testNonce)
  214. server.serveIndexHTML(c)
  215. assert.Equal(t, http.StatusOK, w.Code)
  216. assert.Contains(t, w.Header().Get("Content-Type"), "text/html")
  217. body := w.Body.String()
  218. // Nonce placeholder should be replaced
  219. assert.NotContains(t, body, NonceHTMLPlaceholder)
  220. assert.Contains(t, body, `nonce="`+testNonce+`"`)
  221. })
  222. t.Run("caches_html_content", func(t *testing.T) {
  223. provider := &mockSettingsProvider{
  224. settings: map[string]string{"test": "value"},
  225. }
  226. server, err := NewFrontendServer(provider)
  227. require.NoError(t, err)
  228. // First request
  229. w1 := httptest.NewRecorder()
  230. c1, _ := gin.CreateTestContext(w1)
  231. c1.Request = httptest.NewRequest(http.MethodGet, "/", nil)
  232. c1.Set(middleware.CSPNonceKey, "nonce1")
  233. server.serveIndexHTML(c1)
  234. assert.Equal(t, 1, provider.called)
  235. // Second request - should use cache
  236. w2 := httptest.NewRecorder()
  237. c2, _ := gin.CreateTestContext(w2)
  238. c2.Request = httptest.NewRequest(http.MethodGet, "/", nil)
  239. c2.Set(middleware.CSPNonceKey, "nonce2")
  240. server.serveIndexHTML(c2)
  241. // Settings provider should not be called again
  242. assert.Equal(t, 1, provider.called)
  243. // But nonce should be different
  244. assert.Contains(t, w2.Body.String(), `nonce="nonce2"`)
  245. })
  246. t.Run("sets_etag_header", func(t *testing.T) {
  247. provider := &mockSettingsProvider{
  248. settings: map[string]string{"test": "value"},
  249. }
  250. server, err := NewFrontendServer(provider)
  251. require.NoError(t, err)
  252. w := httptest.NewRecorder()
  253. c, _ := gin.CreateTestContext(w)
  254. c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
  255. c.Set(middleware.CSPNonceKey, "nonce123")
  256. server.serveIndexHTML(c)
  257. etag := w.Header().Get("ETag")
  258. assert.NotEmpty(t, etag)
  259. assert.True(t, strings.HasPrefix(etag, `"`))
  260. assert.True(t, strings.HasSuffix(etag, `"`))
  261. })
  262. t.Run("returns_304_for_matching_etag", func(t *testing.T) {
  263. provider := &mockSettingsProvider{
  264. settings: map[string]string{"test": "value"},
  265. }
  266. server, err := NewFrontendServer(provider)
  267. require.NoError(t, err)
  268. // Use a real router for proper 304 handling
  269. router := gin.New()
  270. router.Use(func(c *gin.Context) {
  271. c.Set(middleware.CSPNonceKey, "test-nonce")
  272. c.Next()
  273. })
  274. router.Use(server.Middleware())
  275. // First request to populate cache and get ETag
  276. w1 := httptest.NewRecorder()
  277. req1 := httptest.NewRequest(http.MethodGet, "/", nil)
  278. router.ServeHTTP(w1, req1)
  279. etag := w1.Header().Get("ETag")
  280. require.NotEmpty(t, etag)
  281. // Second request with If-None-Match
  282. w2 := httptest.NewRecorder()
  283. req2 := httptest.NewRequest(http.MethodGet, "/", nil)
  284. req2.Header.Set("If-None-Match", etag)
  285. router.ServeHTTP(w2, req2)
  286. assert.Equal(t, http.StatusNotModified, w2.Code)
  287. assert.Empty(t, w2.Body.String())
  288. })
  289. t.Run("sets_cache_control_header", func(t *testing.T) {
  290. provider := &mockSettingsProvider{
  291. settings: map[string]string{"test": "value"},
  292. }
  293. server, err := NewFrontendServer(provider)
  294. require.NoError(t, err)
  295. w := httptest.NewRecorder()
  296. c, _ := gin.CreateTestContext(w)
  297. c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
  298. c.Set(middleware.CSPNonceKey, "nonce123")
  299. server.serveIndexHTML(c)
  300. assert.Equal(t, "no-cache", w.Header().Get("Cache-Control"))
  301. })
  302. t.Run("fallback_on_settings_error", func(t *testing.T) {
  303. provider := &mockSettingsProvider{
  304. err: context.DeadlineExceeded,
  305. }
  306. server, err := NewFrontendServer(provider)
  307. require.NoError(t, err)
  308. // Invalidate cache to force settings fetch
  309. server.InvalidateCache()
  310. w := httptest.NewRecorder()
  311. c, _ := gin.CreateTestContext(w)
  312. c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
  313. c.Set(middleware.CSPNonceKey, "nonce123")
  314. server.serveIndexHTML(c)
  315. // Should still return 200 with base HTML
  316. assert.Equal(t, http.StatusOK, w.Code)
  317. assert.Contains(t, w.Header().Get("Content-Type"), "text/html")
  318. })
  319. }
  320. func TestFrontendServer_InvalidateCache(t *testing.T) {
  321. t.Run("invalidates_cache", func(t *testing.T) {
  322. provider := &mockSettingsProvider{
  323. settings: map[string]string{"test": "value"},
  324. }
  325. server, err := NewFrontendServer(provider)
  326. require.NoError(t, err)
  327. // First request to populate cache
  328. w1 := httptest.NewRecorder()
  329. c1, _ := gin.CreateTestContext(w1)
  330. c1.Request = httptest.NewRequest(http.MethodGet, "/", nil)
  331. c1.Set(middleware.CSPNonceKey, "nonce1")
  332. server.serveIndexHTML(c1)
  333. assert.Equal(t, 1, provider.called)
  334. // Invalidate cache
  335. server.InvalidateCache()
  336. // Update settings
  337. provider.settings = map[string]string{"test": "new_value"}
  338. // Second request should fetch new settings
  339. w2 := httptest.NewRecorder()
  340. c2, _ := gin.CreateTestContext(w2)
  341. c2.Request = httptest.NewRequest(http.MethodGet, "/", nil)
  342. c2.Set(middleware.CSPNonceKey, "nonce2")
  343. server.serveIndexHTML(c2)
  344. assert.Equal(t, 2, provider.called)
  345. })
  346. t.Run("handles_nil_server", func(t *testing.T) {
  347. var server *FrontendServer
  348. // Should not panic
  349. assert.NotPanics(t, func() {
  350. server.InvalidateCache()
  351. })
  352. })
  353. t.Run("handles_nil_cache", func(t *testing.T) {
  354. server := &FrontendServer{}
  355. // Should not panic
  356. assert.NotPanics(t, func() {
  357. server.InvalidateCache()
  358. })
  359. })
  360. }
  361. func TestOverrideFilesNeverReceiveImmutableCacheHeaders(t *testing.T) {
  362. t.Parallel()
  363. overrideDir := t.TempDir()
  364. cleanPath := "assets/index-AbCd1234.js"
  365. filePath := filepath.Join(overrideDir, cleanPath)
  366. require.NoError(t, os.MkdirAll(filepath.Dir(filePath), 0o755))
  367. require.NoError(t, os.WriteFile(filePath, []byte("override"), 0o644))
  368. t.Run("frontend_server_override", func(t *testing.T) {
  369. t.Parallel()
  370. w := httptest.NewRecorder()
  371. c, _ := gin.CreateTestContext(w)
  372. c.Request = httptest.NewRequest(http.MethodGet, "/"+cleanPath, nil)
  373. server := &FrontendServer{overrideDir: overrideDir}
  374. assert.True(t, server.tryServeOverride(c, cleanPath))
  375. assert.Empty(t, w.Header().Get("Cache-Control"))
  376. })
  377. t.Run("legacy_override", func(t *testing.T) {
  378. t.Parallel()
  379. w := httptest.NewRecorder()
  380. c, _ := gin.CreateTestContext(w)
  381. c.Request = httptest.NewRequest(http.MethodGet, "/"+cleanPath, nil)
  382. assert.True(t, tryServeOverrideFile(c, overrideDir, cleanPath))
  383. assert.Empty(t, w.Header().Get("Cache-Control"))
  384. })
  385. }
  386. func TestFrontendServer_Middleware(t *testing.T) {
  387. t.Run("skips_api_routes", func(t *testing.T) {
  388. provider := &mockSettingsProvider{
  389. settings: map[string]string{"test": "value"},
  390. }
  391. server, err := NewFrontendServer(provider)
  392. require.NoError(t, err)
  393. apiPaths := []string{
  394. "/api/v1/users",
  395. "/models",
  396. "/v1/models",
  397. "/v1beta/chat",
  398. "/backend-api/codex/responses",
  399. "/backend-api/codex/responses/compact",
  400. "/antigravity/test",
  401. "/setup/init",
  402. "/health",
  403. "/responses",
  404. "/responses/compact",
  405. }
  406. for _, path := range apiPaths {
  407. t.Run(path, func(t *testing.T) {
  408. router := gin.New()
  409. router.Use(server.Middleware())
  410. nextCalled := false
  411. router.GET(path, func(c *gin.Context) {
  412. nextCalled = true
  413. c.String(http.StatusOK, "ok")
  414. })
  415. w := httptest.NewRecorder()
  416. req := httptest.NewRequest(http.MethodGet, path, nil)
  417. router.ServeHTTP(w, req)
  418. assert.True(t, nextCalled, "next handler should be called for API route")
  419. })
  420. }
  421. })
  422. t.Run("skips_responses_compact_post_routes", func(t *testing.T) {
  423. provider := &mockSettingsProvider{
  424. settings: map[string]string{"test": "value"},
  425. }
  426. server, err := NewFrontendServer(provider)
  427. require.NoError(t, err)
  428. router := gin.New()
  429. router.Use(server.Middleware())
  430. nextCalled := false
  431. router.POST("/responses/compact", func(c *gin.Context) {
  432. nextCalled = true
  433. c.String(http.StatusOK, `{"ok":true}`)
  434. })
  435. w := httptest.NewRecorder()
  436. req := httptest.NewRequest(http.MethodPost, "/responses/compact", strings.NewReader(`{"model":"gpt-5"}`))
  437. req.Header.Set("Content-Type", "application/json")
  438. router.ServeHTTP(w, req)
  439. assert.True(t, nextCalled, "next handler should be called for compact API route")
  440. assert.Equal(t, http.StatusOK, w.Code)
  441. assert.JSONEq(t, `{"ok":true}`, w.Body.String())
  442. })
  443. t.Run("skips_alpha_search_post_route", func(t *testing.T) {
  444. provider := &mockSettingsProvider{
  445. settings: map[string]string{"test": "value"},
  446. }
  447. server, err := NewFrontendServer(provider)
  448. require.NoError(t, err)
  449. router := gin.New()
  450. router.Use(server.Middleware())
  451. nextCalled := false
  452. router.POST("/alpha/search", func(c *gin.Context) {
  453. nextCalled = true
  454. c.JSON(http.StatusOK, gin.H{"ok": true})
  455. })
  456. w := httptest.NewRecorder()
  457. req := httptest.NewRequest(http.MethodPost, "/alpha/search", strings.NewReader(`{"model":"gpt-5.6-sol"}`))
  458. req.Header.Set("Content-Type", "application/json")
  459. router.ServeHTTP(w, req)
  460. assert.True(t, nextCalled, "next handler should be called for alpha search API route")
  461. assert.Equal(t, http.StatusOK, w.Code)
  462. assert.JSONEq(t, `{"ok":true}`, w.Body.String())
  463. })
  464. t.Run("serves_index_for_spa_routes", func(t *testing.T) {
  465. provider := &mockSettingsProvider{
  466. settings: map[string]string{"test": "value"},
  467. }
  468. server, err := NewFrontendServer(provider)
  469. require.NoError(t, err)
  470. router := gin.New()
  471. router.Use(func(c *gin.Context) {
  472. c.Set(middleware.CSPNonceKey, "test-nonce")
  473. c.Next()
  474. })
  475. router.Use(server.Middleware())
  476. spaPaths := []string{
  477. "/",
  478. "/dashboard",
  479. "/users/123",
  480. "/settings/profile",
  481. }
  482. for _, path := range spaPaths {
  483. t.Run(path, func(t *testing.T) {
  484. w := httptest.NewRecorder()
  485. req := httptest.NewRequest(http.MethodGet, path, nil)
  486. router.ServeHTTP(w, req)
  487. assert.Equal(t, http.StatusOK, w.Code)
  488. assert.Contains(t, w.Header().Get("Content-Type"), "text/html")
  489. })
  490. }
  491. })
  492. t.Run("serves_static_files", func(t *testing.T) {
  493. provider := &mockSettingsProvider{
  494. settings: map[string]string{"test": "value"},
  495. }
  496. server, err := NewFrontendServer(provider)
  497. require.NoError(t, err)
  498. router := gin.New()
  499. router.Use(server.Middleware())
  500. // Request for existing static file
  501. w := httptest.NewRecorder()
  502. req := httptest.NewRequest(http.MethodGet, "/logo.png", nil)
  503. router.ServeHTTP(w, req)
  504. assert.Equal(t, http.StatusOK, w.Code)
  505. assert.Contains(t, w.Header().Get("Content-Type"), "image/png")
  506. assert.Empty(t, w.Header().Get("Cache-Control"))
  507. entries, err := fs.ReadDir(server.distFS, "assets")
  508. require.NoError(t, err)
  509. fingerprintedPath := ""
  510. for _, entry := range entries {
  511. candidate := "assets/" + entry.Name()
  512. if !entry.IsDir() && isFingerprintedEmbeddedAssetPath(candidate) {
  513. fingerprintedPath = candidate
  514. break
  515. }
  516. }
  517. require.NotEmpty(t, fingerprintedPath)
  518. assetWriter := httptest.NewRecorder()
  519. assetRequest := httptest.NewRequest(http.MethodGet, "/"+fingerprintedPath, nil)
  520. router.ServeHTTP(assetWriter, assetRequest)
  521. assert.Equal(t, http.StatusOK, assetWriter.Code)
  522. assert.Equal(t, staticAssetsCacheControl, assetWriter.Header().Get("Cache-Control"))
  523. })
  524. }
  525. func TestEmbeddedFrontendBypassesBareVideoAPIRoutes(t *testing.T) {
  526. for _, path := range []string{
  527. "/videos/generations",
  528. "/videos/edits",
  529. "/videos/extensions",
  530. "/videos/request-123",
  531. } {
  532. require.True(t, shouldBypassEmbeddedFrontend(path), "path=%s", path)
  533. }
  534. }
  535. func TestNewFrontendServer(t *testing.T) {
  536. t.Run("creates_server_successfully", func(t *testing.T) {
  537. provider := &mockSettingsProvider{
  538. settings: map[string]string{"test": "value"},
  539. }
  540. server, err := NewFrontendServer(provider)
  541. require.NoError(t, err)
  542. assert.NotNil(t, server)
  543. assert.NotNil(t, server.distFS)
  544. assert.NotNil(t, server.fileServer)
  545. assert.NotNil(t, server.baseHTML)
  546. assert.NotNil(t, server.cache)
  547. assert.Equal(t, provider, server.settings)
  548. })
  549. t.Run("reads_base_html", func(t *testing.T) {
  550. provider := &mockSettingsProvider{
  551. settings: map[string]string{"test": "value"},
  552. }
  553. server, err := NewFrontendServer(provider)
  554. require.NoError(t, err)
  555. assert.NotEmpty(t, server.baseHTML)
  556. assert.Contains(t, string(server.baseHTML), "<!doctype html>")
  557. })
  558. }
  559. func TestHasEmbeddedFrontend(t *testing.T) {
  560. t.Run("returns_true_when_frontend_embedded", func(t *testing.T) {
  561. result := HasEmbeddedFrontend()
  562. assert.True(t, result)
  563. })
  564. }
  565. // Tests for legacy ServeEmbeddedFrontend function
  566. func TestServeEmbeddedFrontend(t *testing.T) {
  567. t.Run("serves_static_files", func(t *testing.T) {
  568. middleware := ServeEmbeddedFrontend()
  569. router := gin.New()
  570. router.Use(middleware)
  571. w := httptest.NewRecorder()
  572. req := httptest.NewRequest(http.MethodGet, "/logo.png", nil)
  573. router.ServeHTTP(w, req)
  574. assert.Equal(t, http.StatusOK, w.Code)
  575. assert.Contains(t, w.Header().Get("Content-Type"), "image/png")
  576. })
  577. t.Run("serves_index_html_for_root", func(t *testing.T) {
  578. middleware := ServeEmbeddedFrontend()
  579. router := gin.New()
  580. router.Use(middleware)
  581. w := httptest.NewRecorder()
  582. req := httptest.NewRequest(http.MethodGet, "/", nil)
  583. router.ServeHTTP(w, req)
  584. assert.Equal(t, http.StatusOK, w.Code)
  585. assert.Contains(t, w.Header().Get("Content-Type"), "text/html")
  586. assert.Contains(t, w.Body.String(), "<!doctype html>")
  587. })
  588. t.Run("serves_index_html_for_spa_routes", func(t *testing.T) {
  589. middleware := ServeEmbeddedFrontend()
  590. router := gin.New()
  591. router.Use(middleware)
  592. spaPaths := []string{"/dashboard", "/users/123", "/settings"}
  593. for _, path := range spaPaths {
  594. t.Run(path, func(t *testing.T) {
  595. w := httptest.NewRecorder()
  596. req := httptest.NewRequest(http.MethodGet, path, nil)
  597. router.ServeHTTP(w, req)
  598. assert.Equal(t, http.StatusOK, w.Code)
  599. assert.Contains(t, w.Header().Get("Content-Type"), "text/html")
  600. })
  601. }
  602. })
  603. t.Run("skips_api_routes", func(t *testing.T) {
  604. middleware := ServeEmbeddedFrontend()
  605. apiPaths := []string{
  606. "/api/users",
  607. "/models",
  608. "/v1/models",
  609. "/v1beta/chat",
  610. "/backend-api/codex/responses",
  611. "/backend-api/codex/responses/compact",
  612. "/antigravity/test",
  613. "/setup/init",
  614. "/health",
  615. "/responses",
  616. "/responses/compact",
  617. }
  618. for _, path := range apiPaths {
  619. t.Run(path, func(t *testing.T) {
  620. nextCalled := false
  621. router := gin.New()
  622. router.Use(middleware)
  623. router.GET(path, func(c *gin.Context) {
  624. nextCalled = true
  625. c.String(http.StatusOK, "ok")
  626. })
  627. w := httptest.NewRecorder()
  628. req := httptest.NewRequest(http.MethodGet, path, nil)
  629. router.ServeHTTP(w, req)
  630. assert.True(t, nextCalled, "next handler should be called for API route")
  631. })
  632. }
  633. })
  634. }
  635. // Tests for HTMLCache
  636. func TestHTMLCache(t *testing.T) {
  637. t.Run("new_cache_returns_nil", func(t *testing.T) {
  638. cache := NewHTMLCache()
  639. assert.Nil(t, cache.Get())
  640. })
  641. t.Run("set_and_get", func(t *testing.T) {
  642. cache := NewHTMLCache()
  643. cache.SetBaseHTML([]byte("<html></html>"))
  644. html := []byte("<html><body>test</body></html>")
  645. settings := []byte(`{"key":"value"}`)
  646. cache.Set(html, settings)
  647. result := cache.Get()
  648. require.NotNil(t, result)
  649. assert.Equal(t, html, result.Content)
  650. assert.NotEmpty(t, result.ETag)
  651. })
  652. t.Run("invalidate_clears_cache", func(t *testing.T) {
  653. cache := NewHTMLCache()
  654. cache.SetBaseHTML([]byte("<html></html>"))
  655. html := []byte("<html><body>test</body></html>")
  656. settings := []byte(`{"key":"value"}`)
  657. cache.Set(html, settings)
  658. require.NotNil(t, cache.Get())
  659. cache.Invalidate()
  660. assert.Nil(t, cache.Get())
  661. })
  662. t.Run("etag_changes_with_settings", func(t *testing.T) {
  663. cache := NewHTMLCache()
  664. cache.SetBaseHTML([]byte("<html></html>"))
  665. html := []byte("<html><body>test</body></html>")
  666. cache.Set(html, []byte(`{"v":1}`))
  667. etag1 := cache.Get().ETag
  668. cache.Invalidate()
  669. cache.Set(html, []byte(`{"v":2}`))
  670. etag2 := cache.Get().ETag
  671. assert.NotEqual(t, etag1, etag2)
  672. })
  673. t.Run("etag_format", func(t *testing.T) {
  674. cache := NewHTMLCache()
  675. cache.SetBaseHTML([]byte("<html></html>"))
  676. cache.Set([]byte("<html></html>"), []byte(`{}`))
  677. result := cache.Get()
  678. // ETag should be quoted
  679. assert.True(t, strings.HasPrefix(result.ETag, `"`))
  680. assert.True(t, strings.HasSuffix(result.ETag, `"`))
  681. // Should contain dash separator
  682. assert.Contains(t, result.ETag[1:len(result.ETag)-1], "-")
  683. })
  684. }
  685. // Benchmark tests
  686. func BenchmarkReplaceNoncePlaceholder(b *testing.B) {
  687. html := []byte(`<!DOCTYPE html><html><head><script nonce="__CSP_NONCE_VALUE__">window.__APP_CONFIG__={"test":"data"};</script></head><body></body></html>`)
  688. nonce := "abcdefghijklmnop123456=="
  689. b.ResetTimer()
  690. for i := 0; i < b.N; i++ {
  691. replaceNoncePlaceholder(html, nonce)
  692. }
  693. }
  694. func BenchmarkFrontendServerServeIndexHTML(b *testing.B) {
  695. provider := &mockSettingsProvider{
  696. settings: map[string]string{"test": "value"},
  697. }
  698. server, _ := NewFrontendServer(provider)
  699. b.ResetTimer()
  700. for i := 0; i < b.N; i++ {
  701. w := httptest.NewRecorder()
  702. c, _ := gin.CreateTestContext(w)
  703. c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
  704. c.Set(middleware.CSPNonceKey, "test-nonce")
  705. server.serveIndexHTML(c)
  706. }
  707. }