Initial commit
CI / test (push) Canceled after 0s

This commit is contained in:
2026-09-12 22:22:17 +02:00
commit 904d14b64c
314 changed files with 31884 additions and 0 deletions
+210
View File
@@ -0,0 +1,210 @@
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)
}
}