211 lines
7.1 KiB
Go
211 lines
7.1 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"gardomatic.kleiax.de/internal/auth"
|
|
"gardomatic.kleiax.de/internal/storage"
|
|
"github.com/alexedwards/scs/v2"
|
|
"github.com/julienschmidt/httprouter"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
type sessionTestUserModel struct {
|
|
user storage.User
|
|
}
|
|
|
|
func (m sessionTestUserModel) Insert(user storage.User) (storage.User, error) {
|
|
return user, nil
|
|
}
|
|
|
|
func (m sessionTestUserModel) GetByID(id int) (storage.User, error) {
|
|
if id != m.user.ID {
|
|
return storage.User{}, storage.ErrRecordNotFound
|
|
}
|
|
return m.user, nil
|
|
}
|
|
|
|
func (m sessionTestUserModel) GetByEmail(email string) (storage.User, error) {
|
|
if email != m.user.Email {
|
|
return storage.User{}, storage.ErrRecordNotFound
|
|
}
|
|
return m.user, nil
|
|
}
|
|
|
|
func (m sessionTestUserModel) Update(user storage.User) (storage.User, error) {
|
|
return user, nil
|
|
}
|
|
|
|
func (m sessionTestUserModel) GetForToken(string, string) (storage.User, error) {
|
|
return storage.User{}, storage.ErrRecordNotFound
|
|
}
|
|
|
|
func (m sessionTestUserModel) CreateEmailChange(int, string, time.Duration) (string, error) {
|
|
return "", nil
|
|
}
|
|
func (m sessionTestUserModel) ConfirmEmailChange(string, int) (storage.User, error) {
|
|
return storage.User{}, nil
|
|
}
|
|
func (m sessionTestUserModel) GetAll() ([]storage.User, error) {
|
|
return []storage.User{m.user}, nil
|
|
}
|
|
func (m sessionTestUserModel) UpdateRole(userID int, role storage.ApplicationRole) (storage.User, error) {
|
|
user := m.user
|
|
user.Role = role
|
|
return user, nil
|
|
}
|
|
|
|
func (m sessionTestUserModel) Delete(int) error { return nil }
|
|
|
|
func newSessionTestApplication(t *testing.T) (*application, http.Handler) {
|
|
t.Helper()
|
|
|
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte("correct horse battery staple"), bcrypt.MinCost)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
user := storage.User{
|
|
ID: 42,
|
|
Name: "Alice",
|
|
Email: "alice@example.com",
|
|
Password: *auth.NewPassword(passwordHash),
|
|
Activated: true,
|
|
}
|
|
|
|
sessions := scs.New()
|
|
sessions.Cookie.Name = "gardomatic_session"
|
|
|
|
app := &application{
|
|
config: Config{
|
|
Cors: struct{ TrustedOrigins []string }{
|
|
TrustedOrigins: []string{"http://localhost:8080"},
|
|
},
|
|
},
|
|
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
models: storage.Models{Users: sessionTestUserModel{user: user}},
|
|
sessions: sessions,
|
|
}
|
|
|
|
router := httprouter.New()
|
|
router.HandlerFunc(http.MethodPost, "/v1/session", app.createSessionHandler)
|
|
router.HandlerFunc(http.MethodGet, "/v1/session", app.showSessionHandler)
|
|
router.HandlerFunc(http.MethodDelete, "/v1/session", app.deleteSessionHandler)
|
|
router.HandlerFunc(http.MethodGet, "/v1/account/sessions", app.requireActivatedUser(app.listAccountSessionsHandler))
|
|
router.HandlerFunc(http.MethodDelete, "/v1/account/sessions/:sessionID", app.requireActivatedUser(app.deleteAccountSessionHandler))
|
|
|
|
handler := app.sessions.LoadAndSave(app.enableCORS(app.authenticate(router)))
|
|
return app, handler
|
|
}
|
|
|
|
func TestBrowserSessionLifecycle(t *testing.T) {
|
|
_, handler := newSessionTestApplication(t)
|
|
|
|
loginBody := []byte(`{"email":"alice@example.com","password":"correct horse battery staple"}`)
|
|
loginRequest := httptest.NewRequest(http.MethodPost, "/v1/session", bytes.NewReader(loginBody))
|
|
loginRequest.Header.Set("Content-Type", "application/json")
|
|
loginRequest.Header.Set("Origin", "http://localhost:8080")
|
|
loginResponse := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(loginResponse, loginRequest)
|
|
|
|
if loginResponse.Code != http.StatusCreated {
|
|
t.Fatalf("login status: got %d, want %d; body: %s", loginResponse.Code, http.StatusCreated, loginResponse.Body.String())
|
|
}
|
|
|
|
var sessionCookie *http.Cookie
|
|
for _, cookie := range loginResponse.Result().Cookies() {
|
|
if cookie.Name == "gardomatic_session" {
|
|
sessionCookie = cookie
|
|
break
|
|
}
|
|
}
|
|
if sessionCookie == nil {
|
|
t.Fatal("login response did not contain a session cookie")
|
|
}
|
|
if !sessionCookie.HttpOnly {
|
|
t.Error("session cookie is not HttpOnly")
|
|
}
|
|
|
|
showRequest := httptest.NewRequest(http.MethodGet, "/v1/session", nil)
|
|
showRequest.Header.Set("Origin", "http://localhost:8080")
|
|
showRequest.AddCookie(sessionCookie)
|
|
showResponse := httptest.NewRecorder()
|
|
handler.ServeHTTP(showResponse, showRequest)
|
|
|
|
if showResponse.Code != http.StatusOK {
|
|
t.Fatalf("show session status: got %d, want %d; body: %s", showResponse.Code, http.StatusOK, showResponse.Body.String())
|
|
}
|
|
if got := showResponse.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
|
|
t.Errorf("Access-Control-Allow-Credentials: got %q, want %q", got, "true")
|
|
}
|
|
|
|
listRequest := httptest.NewRequest(http.MethodGet, "/v1/account/sessions", nil)
|
|
listRequest.AddCookie(sessionCookie)
|
|
listResponse := httptest.NewRecorder()
|
|
handler.ServeHTTP(listResponse, listRequest)
|
|
if listResponse.Code != http.StatusOK {
|
|
t.Fatalf("list account sessions status: got %d, want %d; body: %s", listResponse.Code, http.StatusOK, listResponse.Body.String())
|
|
}
|
|
var listed struct {
|
|
Sessions []accountSession `json:"sessions"`
|
|
}
|
|
if err := json.Unmarshal(listResponse.Body.Bytes(), &listed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(listed.Sessions) != 1 || !listed.Sessions[0].Current || listed.Sessions[0].ID == "" {
|
|
t.Fatalf("listed sessions = %+v, want one current session", listed.Sessions)
|
|
}
|
|
|
|
invalidBearerRequest := httptest.NewRequest(http.MethodGet, "/v1/session", nil)
|
|
invalidBearerRequest.Header.Set("Origin", "http://localhost:8080")
|
|
invalidBearerRequest.Header.Set("Authorization", "Bearer invalid")
|
|
invalidBearerRequest.AddCookie(sessionCookie)
|
|
invalidBearerResponse := httptest.NewRecorder()
|
|
handler.ServeHTTP(invalidBearerResponse, invalidBearerRequest)
|
|
|
|
if invalidBearerResponse.Code != http.StatusUnauthorized {
|
|
t.Fatalf("invalid bearer status: got %d, want %d", invalidBearerResponse.Code, http.StatusUnauthorized)
|
|
}
|
|
|
|
logoutRequest := httptest.NewRequest(http.MethodDelete, "/v1/account/sessions/"+listed.Sessions[0].ID, nil)
|
|
logoutRequest.Header.Set("Origin", "http://localhost:8080")
|
|
logoutRequest.AddCookie(sessionCookie)
|
|
logoutResponse := httptest.NewRecorder()
|
|
handler.ServeHTTP(logoutResponse, logoutRequest)
|
|
|
|
if logoutResponse.Code != http.StatusNoContent {
|
|
t.Fatalf("logout status: got %d, want %d", logoutResponse.Code, http.StatusNoContent)
|
|
}
|
|
|
|
showAfterLogoutRequest := httptest.NewRequest(http.MethodGet, "/v1/session", nil)
|
|
showAfterLogoutRequest.Header.Set("Origin", "http://localhost:8080")
|
|
showAfterLogoutResponse := httptest.NewRecorder()
|
|
handler.ServeHTTP(showAfterLogoutResponse, showAfterLogoutRequest)
|
|
|
|
if showAfterLogoutResponse.Code != http.StatusUnauthorized {
|
|
t.Fatalf("show after logout status: got %d, want %d", showAfterLogoutResponse.Code, http.StatusUnauthorized)
|
|
}
|
|
}
|
|
|
|
func TestSessionEndpointRejectsUntrustedBrowserOrigin(t *testing.T) {
|
|
_, handler := newSessionTestApplication(t)
|
|
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/session", bytes.NewReader([]byte(`{}`)))
|
|
request.Header.Set("Origin", "https://attacker.example")
|
|
response := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(response, request)
|
|
|
|
if response.Code != http.StatusForbidden {
|
|
t.Fatalf("status: got %d, want %d", response.Code, http.StatusForbidden)
|
|
}
|
|
}
|