All checks were successful
check / check (push) Successful in 1m37s
Refactor Dockerfile to use a separate lint stage with a pinned golangci-lint v2.11.3 Docker image instead of installing golangci-lint via curl in the builder stage. This follows the pattern used by sneak/pixa. Changes: - Dockerfile: separate lint stage using golangci/golangci-lint:v2.11.3 (Debian-based, pinned by sha256) with COPY --from=lint dependency - Bump Go from 1.24 to 1.26.1 (golang:1.26.1-bookworm, pinned) - Bump golangci-lint from v1.64.8 to v2.11.3 - Migrate .golangci.yml from v1 to v2 format (same linters, format only) - All Docker images pinned by sha256 digest - Fix all lint issues from the v2 linter upgrade: - Add package comments to all packages - Add doc comments to all exported types, functions, and methods - Fix unchecked errors (errcheck) - Fix unused parameters (revive) - Fix gosec warnings (MaxBytesReader for form parsing) - Fix staticcheck suggestions (fmt.Fprintf instead of WriteString) - Rename DeliveryTask to Task to avoid stutter (delivery.Task) - Rename shadowed builtin 'max' parameter - Update README.md version requirements
508 lines
10 KiB
Go
508 lines
10 KiB
Go
package session_test
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/gorilla/sessions"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/webhooker/internal/config"
|
|
"sneak.berlin/go/webhooker/internal/session"
|
|
)
|
|
|
|
const testKeySize = 32
|
|
|
|
// testSession creates a Session with a real cookie store for
|
|
// testing.
|
|
func testSession(t *testing.T) *session.Session {
|
|
t.Helper()
|
|
|
|
key := make([]byte, testKeySize)
|
|
|
|
for i := range key {
|
|
key[i] = byte(i + 42)
|
|
}
|
|
|
|
store := sessions.NewCookieStore(key)
|
|
store.Options = &sessions.Options{
|
|
Path: "/",
|
|
MaxAge: 86400 * 7,
|
|
HttpOnly: true,
|
|
Secure: false,
|
|
SameSite: http.SameSiteLaxMode,
|
|
}
|
|
|
|
cfg := &config.Config{
|
|
Environment: config.EnvironmentDev,
|
|
}
|
|
|
|
log := slog.New(slog.NewTextHandler(
|
|
os.Stderr,
|
|
&slog.HandlerOptions{Level: slog.LevelDebug},
|
|
))
|
|
|
|
return session.NewForTest(store, cfg, log, key)
|
|
}
|
|
|
|
// --- Get and Save Tests ---
|
|
|
|
func TestGet_NewSession(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
sess, err := s.Get(req)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
assert.True(
|
|
t, sess.IsNew,
|
|
"session should be new when no cookie is present",
|
|
)
|
|
}
|
|
|
|
func TestGet_ExistingSession(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
// Create and save a session
|
|
req1 := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
w1 := httptest.NewRecorder()
|
|
|
|
sess1, err := s.Get(req1)
|
|
require.NoError(t, err)
|
|
|
|
sess1.Values["test_key"] = "test_value"
|
|
require.NoError(t, s.Save(req1, w1, sess1))
|
|
|
|
// Extract cookies
|
|
cookies := w1.Result().Cookies()
|
|
require.NotEmpty(t, cookies)
|
|
|
|
// Make a new request with the session cookie
|
|
req2 := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
for _, c := range cookies {
|
|
req2.AddCookie(c)
|
|
}
|
|
|
|
sess2, err := s.Get(req2)
|
|
require.NoError(t, err)
|
|
assert.False(
|
|
t, sess2.IsNew,
|
|
"session should not be new when cookie is present",
|
|
)
|
|
assert.Equal(t, "test_value", sess2.Values["test_key"])
|
|
}
|
|
|
|
func TestSave_SetsCookie(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
sess, err := s.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
sess.Values["key"] = "value"
|
|
|
|
err = s.Save(req, w, sess)
|
|
require.NoError(t, err)
|
|
|
|
cookies := w.Result().Cookies()
|
|
require.NotEmpty(t, cookies, "Save should set a cookie")
|
|
|
|
// Verify the cookie has the expected name
|
|
var found bool
|
|
|
|
for _, c := range cookies {
|
|
if c.Name == session.SessionName {
|
|
found = true
|
|
|
|
assert.True(
|
|
t, c.HttpOnly,
|
|
"session cookie should be HTTP-only",
|
|
)
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
assert.True(
|
|
t, found,
|
|
"should find a cookie named %s", session.SessionName,
|
|
)
|
|
}
|
|
|
|
// --- SetUser and User Retrieval Tests ---
|
|
|
|
func TestSetUser_SetsAllFields(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
sess, err := s.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
s.SetUser(sess, "user-abc-123", "alice")
|
|
|
|
assert.Equal(
|
|
t, "user-abc-123", sess.Values[session.UserIDKey],
|
|
)
|
|
assert.Equal(
|
|
t, "alice", sess.Values[session.UsernameKey],
|
|
)
|
|
assert.Equal(
|
|
t, true, sess.Values[session.AuthenticatedKey],
|
|
)
|
|
}
|
|
|
|
func TestGetUserID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
sess, err := s.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
// Before setting user
|
|
userID, ok := s.GetUserID(sess)
|
|
assert.False(
|
|
t, ok, "should return false when no user ID is set",
|
|
)
|
|
assert.Empty(t, userID)
|
|
|
|
// After setting user
|
|
s.SetUser(sess, "user-xyz", "bob")
|
|
|
|
userID, ok = s.GetUserID(sess)
|
|
assert.True(t, ok)
|
|
assert.Equal(t, "user-xyz", userID)
|
|
}
|
|
|
|
func TestGetUsername(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
sess, err := s.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
// Before setting user
|
|
username, ok := s.GetUsername(sess)
|
|
assert.False(
|
|
t, ok, "should return false when no username is set",
|
|
)
|
|
assert.Empty(t, username)
|
|
|
|
// After setting user
|
|
s.SetUser(sess, "user-xyz", "bob")
|
|
|
|
username, ok = s.GetUsername(sess)
|
|
assert.True(t, ok)
|
|
assert.Equal(t, "bob", username)
|
|
}
|
|
|
|
// --- IsAuthenticated Tests ---
|
|
|
|
func TestIsAuthenticated_NoSession(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
sess, err := s.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
assert.False(
|
|
t, s.IsAuthenticated(sess),
|
|
"new session should not be authenticated",
|
|
)
|
|
}
|
|
|
|
func TestIsAuthenticated_AfterSetUser(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
sess, err := s.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
s.SetUser(sess, "user-123", "alice")
|
|
assert.True(t, s.IsAuthenticated(sess))
|
|
}
|
|
|
|
func TestIsAuthenticated_AfterClearUser(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
sess, err := s.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
s.SetUser(sess, "user-123", "alice")
|
|
require.True(t, s.IsAuthenticated(sess))
|
|
|
|
s.ClearUser(sess)
|
|
|
|
assert.False(
|
|
t, s.IsAuthenticated(sess),
|
|
"should not be authenticated after ClearUser",
|
|
)
|
|
}
|
|
|
|
func TestIsAuthenticated_WrongType(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
sess, err := s.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
// Set authenticated to a non-bool value
|
|
sess.Values[session.AuthenticatedKey] = "yes"
|
|
|
|
assert.False(
|
|
t, s.IsAuthenticated(sess),
|
|
"should return false for non-bool authenticated value",
|
|
)
|
|
}
|
|
|
|
// --- ClearUser Tests ---
|
|
|
|
func TestClearUser_RemovesAllKeys(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
sess, err := s.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
s.SetUser(sess, "user-123", "alice")
|
|
s.ClearUser(sess)
|
|
|
|
_, hasUserID := sess.Values[session.UserIDKey]
|
|
assert.False(t, hasUserID, "UserIDKey should be removed")
|
|
|
|
_, hasUsername := sess.Values[session.UsernameKey]
|
|
assert.False(t, hasUsername, "UsernameKey should be removed")
|
|
|
|
_, hasAuth := sess.Values[session.AuthenticatedKey]
|
|
assert.False(
|
|
t, hasAuth, "AuthenticatedKey should be removed",
|
|
)
|
|
}
|
|
|
|
// --- Destroy Tests ---
|
|
|
|
func TestDestroy_InvalidatesSession(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
sess, err := s.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
s.SetUser(sess, "user-123", "alice")
|
|
|
|
s.Destroy(sess)
|
|
|
|
// After Destroy: MaxAge should be -1 (delete cookie) and
|
|
// user data cleared
|
|
assert.Equal(
|
|
t, -1, sess.Options.MaxAge,
|
|
"Destroy should set MaxAge to -1",
|
|
)
|
|
assert.False(
|
|
t, s.IsAuthenticated(sess),
|
|
"should not be authenticated after Destroy",
|
|
)
|
|
|
|
_, hasUserID := sess.Values[session.UserIDKey]
|
|
assert.False(t, hasUserID, "Destroy should clear user ID")
|
|
}
|
|
|
|
// --- Session Persistence Round-Trip ---
|
|
|
|
func TestSessionPersistence_RoundTrip(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
// Step 1: Create session, set user, save
|
|
req1 := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
w1 := httptest.NewRecorder()
|
|
|
|
sess1, err := s.Get(req1)
|
|
require.NoError(t, err)
|
|
s.SetUser(sess1, "user-round-trip", "charlie")
|
|
require.NoError(t, s.Save(req1, w1, sess1))
|
|
|
|
cookies := w1.Result().Cookies()
|
|
require.NotEmpty(t, cookies)
|
|
|
|
// Step 2: New request with cookies -- session data should
|
|
// persist
|
|
req2 := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet, "/profile", nil,
|
|
)
|
|
|
|
for _, c := range cookies {
|
|
req2.AddCookie(c)
|
|
}
|
|
|
|
sess2, err := s.Get(req2)
|
|
require.NoError(t, err)
|
|
|
|
assert.True(
|
|
t, s.IsAuthenticated(sess2),
|
|
"session should be authenticated after round-trip",
|
|
)
|
|
|
|
userID, ok := s.GetUserID(sess2)
|
|
assert.True(t, ok)
|
|
assert.Equal(t, "user-round-trip", userID)
|
|
|
|
username, ok := s.GetUsername(sess2)
|
|
assert.True(t, ok)
|
|
assert.Equal(t, "charlie", username)
|
|
}
|
|
|
|
// --- Constants Tests ---
|
|
|
|
func TestSessionConstants(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assert.Equal(t, "webhooker_session", session.SessionName)
|
|
assert.Equal(t, "user_id", session.UserIDKey)
|
|
assert.Equal(t, "username", session.UsernameKey)
|
|
assert.Equal(t, "authenticated", session.AuthenticatedKey)
|
|
}
|
|
|
|
// --- Edge Cases ---
|
|
|
|
func TestSetUser_OverwritesPreviousUser(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
sess, err := s.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
s.SetUser(sess, "user-1", "alice")
|
|
assert.True(t, s.IsAuthenticated(sess))
|
|
|
|
// Overwrite with a different user
|
|
s.SetUser(sess, "user-2", "bob")
|
|
|
|
userID, ok := s.GetUserID(sess)
|
|
assert.True(t, ok)
|
|
assert.Equal(t, "user-2", userID)
|
|
|
|
username, ok := s.GetUsername(sess)
|
|
assert.True(t, ok)
|
|
assert.Equal(t, "bob", username)
|
|
}
|
|
|
|
func TestDestroy_ThenSave_DeletesCookie(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
// Create a session
|
|
req1 := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
w1 := httptest.NewRecorder()
|
|
|
|
sess, err := s.Get(req1)
|
|
require.NoError(t, err)
|
|
s.SetUser(sess, "user-123", "alice")
|
|
require.NoError(t, s.Save(req1, w1, sess))
|
|
|
|
cookies := w1.Result().Cookies()
|
|
require.NotEmpty(t, cookies)
|
|
|
|
// Destroy and save
|
|
req2 := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet, "/logout", nil,
|
|
)
|
|
|
|
for _, c := range cookies {
|
|
req2.AddCookie(c)
|
|
}
|
|
|
|
w2 := httptest.NewRecorder()
|
|
|
|
sess2, err := s.Get(req2)
|
|
require.NoError(t, err)
|
|
s.Destroy(sess2)
|
|
require.NoError(t, s.Save(req2, w2, sess2))
|
|
|
|
// The cookie should have MaxAge = -1 (browser should delete)
|
|
responseCookies := w2.Result().Cookies()
|
|
|
|
var sessionCookie *http.Cookie
|
|
|
|
for _, c := range responseCookies {
|
|
if c.Name == session.SessionName {
|
|
sessionCookie = c
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
require.NotNil(
|
|
t, sessionCookie,
|
|
"should have a session cookie in response",
|
|
)
|
|
assert.Negative(
|
|
t, sessionCookie.MaxAge,
|
|
"destroyed session cookie should have negative MaxAge",
|
|
)
|
|
}
|