mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 19:33:42 +08:00
398 lines
11 KiB
Go
398 lines
11 KiB
Go
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 TestUnauthenticatedBrowserNavigationRedirectsToLogin(t *testing.T) {
|
|
app := newTestApplication(t)
|
|
client := *app.client
|
|
client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
}
|
|
request, err := http.NewRequest(http.MethodGet, app.server.URL+"/api/devices", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request.Header.Set("Accept", "text/html,application/xhtml+xml")
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode != http.StatusSeeOther {
|
|
t.Fatalf("navigation status = %d", response.StatusCode)
|
|
}
|
|
if location := response.Header.Get("Location"); location != "/login" {
|
|
t.Fatalf("navigation location = %q", location)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|