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) } }