server_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  1. package server
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "io"
  7. "io/fs"
  8. "net/http"
  9. "net/http/cookiejar"
  10. "net/http/httptest"
  11. "strconv"
  12. "strings"
  13. "testing"
  14. "testing/fstest"
  15. "time"
  16. "golang.org/x/crypto/bcrypt"
  17. "vocat/internal/auth"
  18. "vocat/internal/store"
  19. )
  20. type testApplication struct {
  21. server *httptest.Server
  22. client *http.Client
  23. }
  24. func newTestApplication(t *testing.T) testApplication {
  25. t.Helper()
  26. database, err := store.Open(context.Background(), ":memory:")
  27. if err != nil {
  28. t.Fatalf("store.Open() error = %v", err)
  29. }
  30. t.Cleanup(func() {
  31. _ = database.Close()
  32. })
  33. authService, err := auth.New(database, auth.Options{
  34. SessionTTL: time.Hour,
  35. BcryptCost: bcrypt.MinCost,
  36. })
  37. if err != nil {
  38. t.Fatal(err)
  39. }
  40. if err := authService.EnsureAdmin(context.Background(), "admin", "correct-password"); err != nil {
  41. t.Fatal(err)
  42. }
  43. assets := fstest.MapFS{
  44. "index.html": &fstest.MapFile{Data: []byte("<html>SPA shell</html>")},
  45. "assets/app.js": &fstest.MapFile{Data: []byte("console.log('ok')")},
  46. }
  47. handler, err := New(Options{
  48. Store: database,
  49. Auth: authService,
  50. Assets: assets,
  51. MaxRequestBodyBytes: 4096,
  52. })
  53. if err != nil {
  54. t.Fatal(err)
  55. }
  56. httpServer := httptest.NewServer(handler)
  57. t.Cleanup(httpServer.Close)
  58. jar, err := cookiejar.New(nil)
  59. if err != nil {
  60. t.Fatal(err)
  61. }
  62. return testApplication{
  63. server: httpServer,
  64. client: &http.Client{Jar: jar},
  65. }
  66. }
  67. func TestHealthAndSPAFallback(t *testing.T) {
  68. app := newTestApplication(t)
  69. response, err := app.client.Get(app.server.URL + "/api/health")
  70. if err != nil {
  71. t.Fatal(err)
  72. }
  73. defer response.Body.Close()
  74. if response.StatusCode != http.StatusOK {
  75. t.Fatalf("health status = %d", response.StatusCode)
  76. }
  77. if response.Header.Get("X-Content-Type-Options") != "nosniff" {
  78. t.Fatal("security headers not present")
  79. }
  80. if response.Header.Get("Access-Control-Allow-Origin") != "" {
  81. t.Fatal("CORS must not be enabled")
  82. }
  83. response, err = app.client.Get(app.server.URL + "/settings/deep/link")
  84. if err != nil {
  85. t.Fatal(err)
  86. }
  87. defer response.Body.Close()
  88. body, _ := io.ReadAll(response.Body)
  89. if !bytes.Contains(body, []byte("SPA shell")) {
  90. t.Fatalf("SPA fallback body = %q", body)
  91. }
  92. response, err = app.client.Get(app.server.URL + "/assets/app.js")
  93. if err != nil {
  94. t.Fatal(err)
  95. }
  96. defer response.Body.Close()
  97. if response.Header.Get("Cache-Control") != "public, max-age=31536000, immutable" {
  98. t.Fatalf("asset Cache-Control = %q", response.Header.Get("Cache-Control"))
  99. }
  100. }
  101. func TestLoginSessionCSRFAndLogout(t *testing.T) {
  102. app := newTestApplication(t)
  103. loginBody := bytes.NewBufferString(`{"username":"admin","password":"correct-password"}`)
  104. response, err := app.client.Post(app.server.URL+"/api/auth/login", "application/json", loginBody)
  105. if err != nil {
  106. t.Fatal(err)
  107. }
  108. var loginResponse struct {
  109. Data struct {
  110. CSRFToken string `json:"csrf_token"`
  111. } `json:"data"`
  112. }
  113. if err := json.NewDecoder(response.Body).Decode(&loginResponse); err != nil {
  114. t.Fatal(err)
  115. }
  116. response.Body.Close()
  117. if response.StatusCode != http.StatusOK || loginResponse.Data.CSRFToken == "" {
  118. t.Fatalf("login status = %d, body = %+v", response.StatusCode, loginResponse)
  119. }
  120. var sessionCookie *http.Cookie
  121. for _, cookie := range response.Cookies() {
  122. if cookie.Name == sessionCookieName {
  123. sessionCookie = cookie
  124. }
  125. }
  126. if sessionCookie == nil || !sessionCookie.HttpOnly || sessionCookie.SameSite != http.SameSiteStrictMode {
  127. t.Fatalf("invalid session cookie: %+v", sessionCookie)
  128. }
  129. response, err = app.client.Get(app.server.URL + "/api/auth/session")
  130. if err != nil {
  131. t.Fatal(err)
  132. }
  133. var sessionResponse struct {
  134. Data struct {
  135. CSRFToken string `json:"csrf_token"`
  136. } `json:"data"`
  137. }
  138. if err := json.NewDecoder(response.Body).Decode(&sessionResponse); err != nil {
  139. t.Fatal(err)
  140. }
  141. response.Body.Close()
  142. if response.StatusCode != http.StatusOK || sessionResponse.Data.CSRFToken == "" {
  143. t.Fatalf("session status = %d, body = %+v", response.StatusCode, sessionResponse)
  144. }
  145. request, err := http.NewRequest(http.MethodPost, app.server.URL+"/api/auth/logout", nil)
  146. if err != nil {
  147. t.Fatal(err)
  148. }
  149. response, err = app.client.Do(request)
  150. if err != nil {
  151. t.Fatal(err)
  152. }
  153. response.Body.Close()
  154. if response.StatusCode != http.StatusForbidden {
  155. t.Fatalf("logout without CSRF status = %d", response.StatusCode)
  156. }
  157. request, err = http.NewRequest(http.MethodPost, app.server.URL+"/api/auth/logout", nil)
  158. if err != nil {
  159. t.Fatal(err)
  160. }
  161. request.Header.Set(csrfHeaderName, sessionResponse.Data.CSRFToken)
  162. response, err = app.client.Do(request)
  163. if err != nil {
  164. t.Fatal(err)
  165. }
  166. response.Body.Close()
  167. if response.StatusCode != http.StatusOK {
  168. t.Fatalf("logout status = %d", response.StatusCode)
  169. }
  170. response, err = app.client.Get(app.server.URL + "/api/auth/session")
  171. if err != nil {
  172. t.Fatal(err)
  173. }
  174. response.Body.Close()
  175. if response.StatusCode != http.StatusUnauthorized {
  176. t.Fatalf("session after logout status = %d", response.StatusCode)
  177. }
  178. }
  179. func TestUnifiedAPIErrors(t *testing.T) {
  180. app := newTestApplication(t)
  181. response, err := app.client.Get(app.server.URL + "/api/not-present")
  182. if err != nil {
  183. t.Fatal(err)
  184. }
  185. if response.StatusCode != http.StatusUnauthorized {
  186. t.Fatalf("status = %d", response.StatusCode)
  187. }
  188. response.Body.Close()
  189. loginBody := bytes.NewBufferString(`{"username":"admin","password":"correct-password"}`)
  190. response, err = app.client.Post(app.server.URL+"/api/auth/login", "application/json", loginBody)
  191. if err != nil {
  192. t.Fatal(err)
  193. }
  194. response.Body.Close()
  195. if response.StatusCode != http.StatusOK {
  196. t.Fatalf("login status = %d", response.StatusCode)
  197. }
  198. response, err = app.client.Get(app.server.URL + "/api/not-present")
  199. if err != nil {
  200. t.Fatal(err)
  201. }
  202. defer response.Body.Close()
  203. if response.StatusCode != http.StatusNotFound {
  204. t.Fatalf("authenticated not-found status = %d", response.StatusCode)
  205. }
  206. var envelope errorEnvelope
  207. if err := json.NewDecoder(response.Body).Decode(&envelope); err != nil {
  208. t.Fatal(err)
  209. }
  210. if envelope.Error.Code != "not_found" {
  211. t.Fatalf("error = %+v", envelope.Error)
  212. }
  213. badLogin := bytes.NewBufferString(`{"username":"admin","password":"wrong","extra":true}`)
  214. response, err = app.client.Post(app.server.URL+"/api/auth/login", "application/json", badLogin)
  215. if err != nil {
  216. t.Fatal(err)
  217. }
  218. defer response.Body.Close()
  219. if response.StatusCode != http.StatusBadRequest {
  220. t.Fatalf("invalid JSON status = %d", response.StatusCode)
  221. }
  222. }
  223. func TestNewRequiresIndex(t *testing.T) {
  224. database, err := store.Open(context.Background(), ":memory:")
  225. if err != nil {
  226. t.Fatal(err)
  227. }
  228. defer database.Close()
  229. authService, err := auth.New(database, auth.Options{
  230. SessionTTL: time.Hour,
  231. BcryptCost: bcrypt.MinCost,
  232. })
  233. if err != nil {
  234. t.Fatal(err)
  235. }
  236. if _, err := New(Options{
  237. Store: database,
  238. Auth: authService,
  239. Assets: fs.FS(fstest.MapFS{}),
  240. }); err == nil {
  241. t.Fatal("New() unexpectedly accepted assets without index.html")
  242. }
  243. }
  244. func TestSecureCookieAttributes(t *testing.T) {
  245. recorder := httptest.NewRecorder()
  246. server := &Server{secureCookies: true}
  247. server.setAuthCookies(
  248. recorder,
  249. "session-token",
  250. "csrf-token",
  251. time.Now().Add(time.Hour),
  252. )
  253. var sessionCookie *http.Cookie
  254. var csrfCookie *http.Cookie
  255. for _, cookie := range recorder.Result().Cookies() {
  256. switch cookie.Name {
  257. case sessionCookieName:
  258. sessionCookie = cookie
  259. case csrfCookieName:
  260. csrfCookie = cookie
  261. }
  262. }
  263. if sessionCookie == nil || !sessionCookie.HttpOnly || !sessionCookie.Secure ||
  264. sessionCookie.SameSite != http.SameSiteStrictMode {
  265. t.Fatalf("invalid session cookie: %+v", sessionCookie)
  266. }
  267. if csrfCookie == nil || csrfCookie.HttpOnly || !csrfCookie.Secure ||
  268. csrfCookie.SameSite != http.SameSiteStrictMode {
  269. t.Fatalf("invalid CSRF cookie: %+v", csrfCookie)
  270. }
  271. }
  272. func TestUIPreferencesDefaultPublicReadAndPersistedWrite(t *testing.T) {
  273. app := newTestApplication(t)
  274. readLanguage := func() (int, string) {
  275. response, err := app.client.Get(app.server.URL + "/api/settings/preferences")
  276. if err != nil {
  277. t.Fatal(err)
  278. }
  279. defer response.Body.Close()
  280. var body struct {
  281. Data struct {
  282. Language string `json:"language"`
  283. } `json:"data"`
  284. }
  285. if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
  286. t.Fatal(err)
  287. }
  288. return response.StatusCode, body.Data.Language
  289. }
  290. status, language := readLanguage()
  291. if status != http.StatusOK || language != "en" {
  292. t.Fatalf("default preferences = %d %q", status, language)
  293. }
  294. putLanguage := func(value string, csrf string) int {
  295. request, err := http.NewRequest(
  296. http.MethodPut,
  297. app.server.URL+"/api/settings/preferences",
  298. strings.NewReader(`{"language":`+strconv.Quote(value)+`}`),
  299. )
  300. if err != nil {
  301. t.Fatal(err)
  302. }
  303. request.Header.Set("Content-Type", "application/json")
  304. if csrf != "" {
  305. request.Header.Set(csrfHeaderName, csrf)
  306. }
  307. response, err := app.client.Do(request)
  308. if err != nil {
  309. t.Fatal(err)
  310. }
  311. defer response.Body.Close()
  312. return response.StatusCode
  313. }
  314. if status := putLanguage("zh", ""); status != http.StatusUnauthorized {
  315. t.Fatalf("unauthenticated write status = %d", status)
  316. }
  317. loginBody := bytes.NewBufferString(`{"username":"admin","password":"correct-password"}`)
  318. response, err := app.client.Post(app.server.URL+"/api/auth/login", "application/json", loginBody)
  319. if err != nil {
  320. t.Fatal(err)
  321. }
  322. var loginResponse struct {
  323. Data struct {
  324. CSRFToken string `json:"csrf_token"`
  325. } `json:"data"`
  326. }
  327. if err := json.NewDecoder(response.Body).Decode(&loginResponse); err != nil {
  328. t.Fatal(err)
  329. }
  330. response.Body.Close()
  331. if response.StatusCode != http.StatusOK || loginResponse.Data.CSRFToken == "" {
  332. t.Fatalf("login status = %d", response.StatusCode)
  333. }
  334. if status := putLanguage("fr", loginResponse.Data.CSRFToken); status != http.StatusBadRequest {
  335. t.Fatalf("invalid language status = %d", status)
  336. }
  337. if status := putLanguage("zh", loginResponse.Data.CSRFToken); status != http.StatusOK {
  338. t.Fatalf("write status = %d", status)
  339. }
  340. if status, language := readLanguage(); status != http.StatusOK || language != "zh" {
  341. t.Fatalf("persisted preferences = %d %q", status, language)
  342. }
  343. }