package main import ( "bytes" "encoding/base64" "encoding/json" "io" "net/http" "net/url" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestSevenDaySession(t *testing.T) { setup := setupTestServer(t) // Ensure test user exists (uses helper with auto-cleanup) userID := getTestUserID(t, setup.DB) _ = userID // Use userID to avoid linter warning t.Run("Login returns 7-day session", func(t *testing.T) { loginReq := map[string]string{ "login": "testuser@tests.bookhoard.internal", "password": "Test@Pass123!", } body, _ := json.Marshal(loginReq) req, err := http.NewRequest("POST", setup.Server.URL+"/api/auth/login", bytes.NewReader(body)) require.NoError(t, err) req.Header.Set("Content-Type", "application/json") client := &http.Client{} resp, err := client.Do(req) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusOK, resp.StatusCode) var loginResult map[string]interface{} err = json.NewDecoder(resp.Body).Decode(&loginResult) require.NoError(t, err) // Verify ExpiresIn is 7 days in seconds (604800) expiresIn, ok := loginResult["expires_in"].(float64) require.True(t, ok, "Should have expires_in") assert.Equal(t, float64(604800), expiresIn, "ExpiresIn should be 7 days (604800 seconds)") // Verify token cookie is set with Max-Age of 7 days cookies := resp.Cookies() var tokenCookie *http.Cookie for _, c := range cookies { if c.Name == "token" { tokenCookie = c break } } require.NotNil(t, tokenCookie, "Token cookie should be set") assert.Equal(t, 604800, tokenCookie.MaxAge, "Cookie Max-Age should be 7 days (604800 seconds)") // Verify access_token exists accessToken, ok := loginResult["access_token"].(string) require.True(t, ok, "Should have access_token") require.NotEmpty(t, accessToken, "Access token should not be empty") }) t.Run("Refresh token returns 7-day access token", func(t *testing.T) { // First login to get refresh token loginReq := map[string]string{ "login": "testuser@tests.bookhoard.internal", "password": "Test@Pass123!", } loginBody, _ := json.Marshal(loginReq) loginHTTP, err := http.NewRequest("POST", setup.Server.URL+"/api/auth/login", bytes.NewReader(loginBody)) require.NoError(t, err) loginHTTP.Header.Set("Content-Type", "application/json") loginResp, err := http.DefaultClient.Do(loginHTTP) require.NoError(t, err) defer loginResp.Body.Close() require.Equal(t, http.StatusOK, loginResp.StatusCode) var loginResult map[string]interface{} json.NewDecoder(loginResp.Body).Decode(&loginResult) refreshToken, ok := loginResult["refresh_token"].(string) require.True(t, ok, "Should have refresh_token") // Now use the refresh token refreshReq := map[string]interface{}{ "refresh_token": refreshToken, } refreshBody, _ := json.Marshal(refreshReq) refreshHTTP, err := http.NewRequest("POST", setup.Server.URL+"/api/auth/refresh", bytes.NewReader(refreshBody)) require.NoError(t, err) refreshHTTP.Header.Set("Content-Type", "application/json") refreshResp, err := http.DefaultClient.Do(refreshHTTP) require.NoError(t, err) defer refreshResp.Body.Close() assert.Equal(t, http.StatusOK, refreshResp.StatusCode) var refreshResult map[string]interface{} json.NewDecoder(refreshResp.Body).Decode(&refreshResult) // Verify ExpiresIn is 7 days expiresIn, ok := refreshResult["expires_in"].(float64) require.True(t, ok, "Should have expires_in in refresh response") assert.Equal(t, float64(604800), expiresIn, "Refreshed token ExpiresIn should be 7 days (604800 seconds)") // Verify access_token exists accessToken, ok := refreshResult["access_token"].(string) require.True(t, ok, "Should have access_token in refresh response") require.NotEmpty(t, accessToken, "Access token should not be empty") }) t.Run("JWT token has 7-day expiration claim", func(t *testing.T) { loginReq := map[string]string{ "login": "testuser@tests.bookhoard.internal", "password": "Test@Pass123!", } body, _ := json.Marshal(loginReq) req, err := http.NewRequest("POST", setup.Server.URL+"/api/auth/login", bytes.NewReader(body)) require.NoError(t, err) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() var authResponse struct { Token string `json:"access_token"` } err = json.NewDecoder(resp.Body).Decode(&authResponse) require.NoError(t, err) // Decode JWT (simple split, in real code use proper JWT library) parts := strings.Split(authResponse.Token, ".") require.Len(t, parts, 3, "JWT should have 3 parts") // Decode payload (base64url encoded) payload := parts[1] decoded, err := base64.RawURLEncoding.DecodeString(payload) require.NoError(t, err, "Should be able to decode JWT payload") var claims struct { Exp int64 `json:"exp"` Iat int64 `json:"iat"` } err = json.Unmarshal(decoded, &claims) require.NoError(t, err) // Verify expiration is approximately 7 days from now now := time.Now().Unix() expectedExp := now + (7 * 24 * 60 * 60) timeDiff := claims.Exp - expectedExp assert.True(t, timeDiff > -10 && timeDiff < 10, "JWT exp claim should be approximately 7 days from now, got diff: %d", timeDiff) }) } func Test401ErrorHandler(t *testing.T) { setup := setupTestServer(t) t.Run("HTML request redirects to login", func(t *testing.T) { req, err := http.NewRequest("GET", setup.Server.URL+"/dashboard", nil) require.NoError(t, err) req.Header.Set("Accept", "text/html") client := &http.Client{ CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, } resp, err := client.Do(req) require.NoError(t, err) // Should redirect to login with session=expired assert.Equal(t, http.StatusFound, resp.StatusCode) location := resp.Header.Get("Location") assert.Contains(t, location, "/login?session=expired", "Should redirect to login with session expired query param") }) t.Run("API request returns JSON error", func(t *testing.T) { req, err := http.NewRequest("GET", setup.Server.URL+"/api/libraries/visible", nil) require.NoError(t, err) req.Header.Set("Accept", "application/json") resp, err := http.DefaultClient.Do(req) require.NoError(t, err) assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) var errorResponse struct { Error string `json:"error"` Message string `json:"message"` } err = json.NewDecoder(resp.Body).Decode(&errorResponse) require.NoError(t, err) assert.Equal(t, "session_expired", errorResponse.Error) assert.Contains(t, errorResponse.Message, "session has expired") }) } func TestNoClientSideCookies(t *testing.T) { setup := setupTestServer(t) t.Run("Register does not set document.cookie", func(t *testing.T) { formData := url.Values{} formData.Set("email", "nocookie@example.com") formData.Set("username", "nocookie") formData.Set("password", "TestPassword123!") formData.Set("first_name", "No") formData.Set("last_name", "Cookie") req, err := http.NewRequest("POST", setup.Server.URL+"/api/auth/register", strings.NewReader(formData.Encode())) require.NoError(t, err) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("HX-Request", "true") resp, err := http.DefaultClient.Do(req) require.NoError(t, err) resp.Body.Close() // Accept both 201 (new user) and 409 (already exists from previous run) require.True(t, resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusConflict, "Registration should return 201 or 409") // Only check document.cookie if we got a successful response if resp.StatusCode == http.StatusCreated { // Re-read body since we already closed it formData2 := url.Values{} formData2.Set("email", "nocookie3@example.com") formData2.Set("username", "nocookie3") formData2.Set("password", "TestPassword123!") formData2.Set("first_name", "No") formData2.Set("last_name", "Cookie3") req2, err := http.NewRequest("POST", setup.Server.URL+"/api/auth/register", strings.NewReader(formData2.Encode())) require.NoError(t, err) req2.Header.Set("Content-Type", "application/x-www-form-urlencoded") req2.Header.Set("HX-Request", "true") resp2, err := http.DefaultClient.Do(req2) require.NoError(t, err) defer resp2.Body.Close() body, err := io.ReadAll(resp2.Body) require.NoError(t, err) bodyStr := string(body) // Verify document.cookie is NOT set in the response assert.NotContains(t, bodyStr, "document.cookie", "Response should not contain document.cookie") } }) t.Run("Login does not set document.cookie", func(t *testing.T) { // Use the standard test user loginData := url.Values{} loginData.Set("login", "testuser@tests.bookhoard.internal") loginData.Set("password", "Test@Pass123!") loginReq, err := http.NewRequest("POST", setup.Server.URL+"/api/auth/login", strings.NewReader(loginData.Encode())) require.NoError(t, err) loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") loginReq.Header.Set("HX-Request", "true") loginResp, err := http.DefaultClient.Do(loginReq) require.NoError(t, err) defer loginResp.Body.Close() assert.Equal(t, http.StatusOK, loginResp.StatusCode) body, err := io.ReadAll(loginResp.Body) require.NoError(t, err) bodyStr := string(body) // Verify document.cookie is NOT set in the response assert.NotContains(t, bodyStr, "document.cookie", "Login response should not contain document.cookie") }) }