| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373 |
- 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("<html>SPA shell</html>")},
- "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)
- }
- }
|