package server import ( "bytes" "context" "encoding/json" "io" "io/fs" "net/http" "net/http/cookiejar" "net/http/httptest" "strconv" "strings" "testing" "testing/fstest" "time" "golang.org/x/crypto/bcrypt" "vocat/internal/auth" "vocat/internal/store" ) type testApplication struct { server *httptest.Server client *http.Client } func newTestApplication(t *testing.T) testApplication { t.Helper() database, err := store.Open(context.Background(), ":memory:") if err != nil { t.Fatalf("store.Open() error = %v", err) } t.Cleanup(func() { _ = database.Close() }) authService, err := auth.New(database, auth.Options{ SessionTTL: time.Hour, BcryptCost: bcrypt.MinCost, }) if err != nil { t.Fatal(err) } if err := authService.EnsureAdmin(context.Background(), "admin", "correct-password"); err != nil { t.Fatal(err) } assets := fstest.MapFS{ "index.html": &fstest.MapFile{Data: []byte("SPA shell")}, "assets/app.js": &fstest.MapFile{Data: []byte("console.log('ok')")}, } handler, err := New(Options{ Store: database, Auth: authService, Assets: assets, MaxRequestBodyBytes: 4096, }) if err != nil { t.Fatal(err) } httpServer := httptest.NewServer(handler) t.Cleanup(httpServer.Close) jar, err := cookiejar.New(nil) if err != nil { t.Fatal(err) } return testApplication{ server: httpServer, client: &http.Client{Jar: jar}, } } func TestHealthAndSPAFallback(t *testing.T) { app := newTestApplication(t) response, err := app.client.Get(app.server.URL + "/api/health") if err != nil { t.Fatal(err) } defer response.Body.Close() if response.StatusCode != http.StatusOK { t.Fatalf("health status = %d", response.StatusCode) } if response.Header.Get("X-Content-Type-Options") != "nosniff" { t.Fatal("security headers not present") } if response.Header.Get("Access-Control-Allow-Origin") != "" { t.Fatal("CORS must not be enabled") } response, err = app.client.Get(app.server.URL + "/settings/deep/link") if err != nil { t.Fatal(err) } defer response.Body.Close() body, _ := io.ReadAll(response.Body) if !bytes.Contains(body, []byte("SPA shell")) { t.Fatalf("SPA fallback body = %q", body) } response, err = app.client.Get(app.server.URL + "/assets/app.js") if err != nil { t.Fatal(err) } defer response.Body.Close() if response.Header.Get("Cache-Control") != "public, max-age=31536000, immutable" { t.Fatalf("asset Cache-Control = %q", response.Header.Get("Cache-Control")) } } func TestLoginSessionCSRFAndLogout(t *testing.T) { app := newTestApplication(t) loginBody := bytes.NewBufferString(`{"username":"admin","password":"correct-password"}`) response, err := app.client.Post(app.server.URL+"/api/auth/login", "application/json", loginBody) if err != nil { t.Fatal(err) } var loginResponse struct { Data struct { CSRFToken string `json:"csrf_token"` } `json:"data"` } if err := json.NewDecoder(response.Body).Decode(&loginResponse); err != nil { t.Fatal(err) } response.Body.Close() if response.StatusCode != http.StatusOK || loginResponse.Data.CSRFToken == "" { t.Fatalf("login status = %d, body = %+v", response.StatusCode, loginResponse) } var sessionCookie *http.Cookie for _, cookie := range response.Cookies() { if cookie.Name == sessionCookieName { sessionCookie = cookie } } if sessionCookie == nil || !sessionCookie.HttpOnly || sessionCookie.SameSite != http.SameSiteStrictMode { t.Fatalf("invalid session cookie: %+v", sessionCookie) } response, err = app.client.Get(app.server.URL + "/api/auth/session") if err != nil { t.Fatal(err) } var sessionResponse struct { Data struct { CSRFToken string `json:"csrf_token"` } `json:"data"` } if err := json.NewDecoder(response.Body).Decode(&sessionResponse); err != nil { t.Fatal(err) } response.Body.Close() if response.StatusCode != http.StatusOK || sessionResponse.Data.CSRFToken == "" { t.Fatalf("session status = %d, body = %+v", response.StatusCode, sessionResponse) } request, err := http.NewRequest(http.MethodPost, app.server.URL+"/api/auth/logout", nil) if err != nil { t.Fatal(err) } response, err = app.client.Do(request) if err != nil { t.Fatal(err) } response.Body.Close() if response.StatusCode != http.StatusForbidden { t.Fatalf("logout without CSRF status = %d", response.StatusCode) } request, err = http.NewRequest(http.MethodPost, app.server.URL+"/api/auth/logout", nil) if err != nil { t.Fatal(err) } request.Header.Set(csrfHeaderName, sessionResponse.Data.CSRFToken) response, err = app.client.Do(request) if err != nil { t.Fatal(err) } response.Body.Close() if response.StatusCode != http.StatusOK { t.Fatalf("logout status = %d", response.StatusCode) } response, err = app.client.Get(app.server.URL + "/api/auth/session") if err != nil { t.Fatal(err) } response.Body.Close() if response.StatusCode != http.StatusUnauthorized { t.Fatalf("session after logout status = %d", response.StatusCode) } } func TestUnifiedAPIErrors(t *testing.T) { app := newTestApplication(t) response, err := app.client.Get(app.server.URL + "/api/not-present") if err != nil { t.Fatal(err) } if response.StatusCode != http.StatusUnauthorized { t.Fatalf("status = %d", response.StatusCode) } response.Body.Close() loginBody := bytes.NewBufferString(`{"username":"admin","password":"correct-password"}`) response, err = app.client.Post(app.server.URL+"/api/auth/login", "application/json", loginBody) if err != nil { t.Fatal(err) } response.Body.Close() if response.StatusCode != http.StatusOK { t.Fatalf("login status = %d", response.StatusCode) } response, err = app.client.Get(app.server.URL + "/api/not-present") if err != nil { t.Fatal(err) } defer response.Body.Close() if response.StatusCode != http.StatusNotFound { t.Fatalf("authenticated not-found status = %d", response.StatusCode) } var envelope errorEnvelope if err := json.NewDecoder(response.Body).Decode(&envelope); err != nil { t.Fatal(err) } if envelope.Error.Code != "not_found" { t.Fatalf("error = %+v", envelope.Error) } badLogin := bytes.NewBufferString(`{"username":"admin","password":"wrong","extra":true}`) response, err = app.client.Post(app.server.URL+"/api/auth/login", "application/json", badLogin) if err != nil { t.Fatal(err) } defer response.Body.Close() if response.StatusCode != http.StatusBadRequest { t.Fatalf("invalid JSON status = %d", response.StatusCode) } } func TestNewRequiresIndex(t *testing.T) { database, err := store.Open(context.Background(), ":memory:") if err != nil { t.Fatal(err) } defer database.Close() authService, err := auth.New(database, auth.Options{ SessionTTL: time.Hour, BcryptCost: bcrypt.MinCost, }) if err != nil { t.Fatal(err) } if _, err := New(Options{ Store: database, Auth: authService, Assets: fs.FS(fstest.MapFS{}), }); err == nil { t.Fatal("New() unexpectedly accepted assets without index.html") } } func TestSecureCookieAttributes(t *testing.T) { recorder := httptest.NewRecorder() server := &Server{secureCookies: true} server.setAuthCookies( recorder, "session-token", "csrf-token", time.Now().Add(time.Hour), ) var sessionCookie *http.Cookie var csrfCookie *http.Cookie for _, cookie := range recorder.Result().Cookies() { switch cookie.Name { case sessionCookieName: sessionCookie = cookie case csrfCookieName: csrfCookie = cookie } } if sessionCookie == nil || !sessionCookie.HttpOnly || !sessionCookie.Secure || sessionCookie.SameSite != http.SameSiteStrictMode { t.Fatalf("invalid session cookie: %+v", sessionCookie) } if csrfCookie == nil || csrfCookie.HttpOnly || !csrfCookie.Secure || csrfCookie.SameSite != http.SameSiteStrictMode { t.Fatalf("invalid CSRF cookie: %+v", csrfCookie) } } func TestUIPreferencesDefaultPublicReadAndPersistedWrite(t *testing.T) { app := newTestApplication(t) readLanguage := func() (int, string) { response, err := app.client.Get(app.server.URL + "/api/settings/preferences") if err != nil { t.Fatal(err) } defer response.Body.Close() var body struct { Data struct { Language string `json:"language"` } `json:"data"` } if err := json.NewDecoder(response.Body).Decode(&body); err != nil { t.Fatal(err) } return response.StatusCode, body.Data.Language } status, language := readLanguage() if status != http.StatusOK || language != "en" { t.Fatalf("default preferences = %d %q", status, language) } putLanguage := func(value string, csrf string) int { request, err := http.NewRequest( http.MethodPut, app.server.URL+"/api/settings/preferences", strings.NewReader(`{"language":`+strconv.Quote(value)+`}`), ) if err != nil { t.Fatal(err) } request.Header.Set("Content-Type", "application/json") if csrf != "" { request.Header.Set(csrfHeaderName, csrf) } response, err := app.client.Do(request) if err != nil { t.Fatal(err) } defer response.Body.Close() return response.StatusCode } if status := putLanguage("zh", ""); status != http.StatusUnauthorized { t.Fatalf("unauthenticated write status = %d", status) } loginBody := bytes.NewBufferString(`{"username":"admin","password":"correct-password"}`) response, err := app.client.Post(app.server.URL+"/api/auth/login", "application/json", loginBody) if err != nil { t.Fatal(err) } var loginResponse struct { Data struct { CSRFToken string `json:"csrf_token"` } `json:"data"` } if err := json.NewDecoder(response.Body).Decode(&loginResponse); err != nil { t.Fatal(err) } response.Body.Close() if response.StatusCode != http.StatusOK || loginResponse.Data.CSRFToken == "" { t.Fatalf("login status = %d", response.StatusCode) } if status := putLanguage("fr", loginResponse.Data.CSRFToken); status != http.StatusBadRequest { t.Fatalf("invalid language status = %d", status) } if status := putLanguage("zh", loginResponse.Data.CSRFToken); status != http.StatusOK { t.Fatalf("write status = %d", status) } if status, language := readLanguage(); status != http.StatusOK || language != "zh" { t.Fatalf("persisted preferences = %d %q", status, language) } }