Files
upaas/internal/service/auth/auth_test.go
T
sneak 55795ddfc4
Check / check (pull_request) Skipped
Hash passwords with 1 MiB in tests so make test fits in 4 GiB (closes #261)
On a build machine with 4 GiB, docker build . failed: the auth test
binary was killed for lack of memory. upaasd hashes passwords with
argon2id at 64 MiB per hash; the auth and handlers tests run many such
hashes at once, and the race detector multiplies what each one takes,
so make test peaked at 2.7 GiB.

The memory per hash is now the auth service's ArgonMemory field. New
sets it to the same 64 MiB, which upaasd keeps; the test helpers lower
it to 1 MiB. A new test checks that New sets 64 MiB and hashes and
verifies a password with it. GOMAXPROCS=4 make test now peaks at about
1.4 GiB.

Model: opus-5-5
2026-10-03 00:37:04 +00:00

437 lines
10 KiB
Go

package auth_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/service/auth"
)
func setupTestService(t *testing.T) (*auth.Service, func()) {
t.Helper()
// Create temp directory
tmpDir := t.TempDir()
// Set up globals
globals.SetAppname("upaas-test")
globals.SetVersion("test")
globalsInst, err := globals.New(fx.Lifecycle(nil))
require.NoError(t, err)
loggerInst, err := logger.New(
fx.Lifecycle(nil),
logger.Params{Globals: globalsInst},
)
require.NoError(t, err)
// Create test config
cfg := &config.Config{
Port: 8080,
DataDir: tmpDir,
SessionSecret: "test-secret-key-at-least-32-chars",
}
// Create database
dbInst, err := database.New(fx.Lifecycle(nil), database.Params{
Logger: loggerInst,
Config: cfg,
})
require.NoError(t, err)
// Connect database manually for tests
dbPath := filepath.Join(tmpDir, "upaas.db")
cfg.DataDir = tmpDir
_ = dbPath // database will create this
// Create service
svc, err := auth.New(fx.Lifecycle(nil), auth.ServiceParams{
Logger: loggerInst,
Config: cfg,
Database: dbInst,
})
require.NoError(t, err)
// 1 MiB per hash instead of 64 MiB; see Service.ArgonMemory. The tests
// that use setupAuthService keep 64 MiB.
svc.ArgonMemory = 1024
// t.TempDir() automatically cleans up after test
cleanup := func() {}
return svc, cleanup
}
func setupAuthService(t *testing.T, debug bool) *auth.Service {
t.Helper()
tmpDir := t.TempDir()
globals.SetAppname("upaas-test")
globals.SetVersion("test")
globalsInst, err := globals.New(fx.Lifecycle(nil))
require.NoError(t, err)
loggerInst, err := logger.New(
fx.Lifecycle(nil),
logger.Params{Globals: globalsInst},
)
require.NoError(t, err)
cfg := &config.Config{
Port: 8080,
DataDir: tmpDir,
SessionSecret: "test-secret-key-at-least-32-chars",
Debug: debug,
}
dbInst, err := database.New(fx.Lifecycle(nil), database.Params{
Logger: loggerInst,
Config: cfg,
})
require.NoError(t, err)
svc, err := auth.New(fx.Lifecycle(nil), auth.ServiceParams{
Logger: loggerInst,
Config: cfg,
Database: dbInst,
})
require.NoError(t, err)
return svc
}
func getSessionCookie(t *testing.T, svc *auth.Service) *http.Cookie {
t.Helper()
_, err := svc.CreateUser(context.Background(), "admin", "password123")
require.NoError(t, err)
user, err := svc.Authenticate(context.Background(), "admin", "password123")
require.NoError(t, err)
recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
err = svc.CreateSession(recorder, request, user)
require.NoError(t, err)
for _, c := range recorder.Result().Cookies() {
if c.Name == "upaas_session" {
return c
}
}
return nil
}
func TestSessionCookieSecureFlag(testingT *testing.T) {
testingT.Parallel()
testingT.Run("secure flag is true when debug is false", func(t *testing.T) {
t.Parallel()
svc := setupAuthService(t, false)
cookie := getSessionCookie(t, svc)
require.NotNil(t, cookie, "session cookie should exist")
assert.True(
t,
cookie.Secure,
"session cookie should have Secure flag in production mode",
)
})
}
func TestHashPassword(testingT *testing.T) {
testingT.Parallel()
testingT.Run("hashes password successfully", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
hash, err := svc.HashPassword("testpassword")
require.NoError(t, err)
assert.NotEmpty(t, hash)
assert.NotEqual(t, "testpassword", hash)
assert.Contains(t, hash, "$") // salt$hash format
})
testingT.Run("produces different hashes for same password", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
hash1, err := svc.HashPassword("testpassword")
require.NoError(t, err)
hash2, err := svc.HashPassword("testpassword")
require.NoError(t, err)
assert.NotEqual(t, hash1, hash2) // Different salts
})
}
func TestVerifyPassword(testingT *testing.T) {
testingT.Parallel()
testingT.Run("verifies correct password", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
hash, err := svc.HashPassword("correctpassword")
require.NoError(t, err)
valid := svc.VerifyPassword(hash, "correctpassword")
assert.True(t, valid)
})
testingT.Run("rejects incorrect password", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
hash, err := svc.HashPassword("correctpassword")
require.NoError(t, err)
valid := svc.VerifyPassword(hash, "wrongpassword")
assert.False(t, valid)
})
testingT.Run("rejects empty password", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
hash, err := svc.HashPassword("correctpassword")
require.NoError(t, err)
valid := svc.VerifyPassword(hash, "")
assert.False(t, valid)
})
testingT.Run("rejects invalid hash format", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
valid := svc.VerifyPassword("invalid-hash", "password")
assert.False(t, valid)
})
}
// TestHashPasswordWithUpaasdMemory hashes and verifies a password with the
// memory New sets, which upaasd uses. setupTestService lowers it.
func TestHashPasswordWithUpaasdMemory(t *testing.T) {
t.Parallel()
svc := setupAuthService(t, false)
require.Equal(t, uint32(64*1024), svc.ArgonMemory)
hash, err := svc.HashPassword("correctpassword")
require.NoError(t, err)
assert.True(t, svc.VerifyPassword(hash, "correctpassword"))
assert.False(t, svc.VerifyPassword(hash, "wrongpassword"))
}
func TestIsSetupRequired(testingT *testing.T) {
testingT.Parallel()
testingT.Run("returns true when no users exist", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
required, err := svc.IsSetupRequired(context.Background())
require.NoError(t, err)
assert.True(t, required)
})
}
func TestCreateUser(testingT *testing.T) {
testingT.Parallel()
testingT.Run("creates user successfully", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
user, err := svc.CreateUser(context.Background(), "admin", "password123")
require.NoError(t, err)
require.NotNil(t, user)
assert.Equal(t, "admin", user.Username)
assert.NotEmpty(t, user.PasswordHash)
assert.NotZero(t, user.ID)
})
testingT.Run("rejects duplicate user", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
_, err := svc.CreateUser(context.Background(), "admin", "password123")
require.NoError(t, err)
_, err = svc.CreateUser(context.Background(), "admin2", "password456")
assert.ErrorIs(t, err, auth.ErrUserExists)
})
}
func TestCreateUserRaceCondition(testingT *testing.T) {
testingT.Parallel()
testingT.Run("concurrent setup requests create only one user", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
const goroutines = 10
results := make(chan error, goroutines)
start := make(chan struct{})
for i := range goroutines {
go func(idx int) {
<-start // Wait for all goroutines to be ready
_, err := svc.CreateUser(
context.Background(),
fmt.Sprintf("admin%d", idx),
"password123456",
)
results <- err
}(i)
}
// Release all goroutines simultaneously
close(start)
var successes, failures int
for range goroutines {
err := <-results
if err == nil {
successes++
} else {
require.ErrorIs(t, err, auth.ErrUserExists)
failures++
}
}
assert.Equal(t, 1, successes, "exactly one goroutine should succeed")
assert.Equal(
t,
goroutines-1,
failures,
"all other goroutines should fail with ErrUserExists",
)
})
}
func TestAuthenticate(testingT *testing.T) {
testingT.Parallel()
testingT.Run("authenticates valid credentials", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
_, err := svc.CreateUser(context.Background(), "admin", "password123")
require.NoError(t, err)
user, err := svc.Authenticate(context.Background(), "admin", "password123")
require.NoError(t, err)
require.NotNil(t, user)
assert.Equal(t, "admin", user.Username)
})
testingT.Run("rejects invalid password", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
_, err := svc.CreateUser(context.Background(), "admin", "password123")
require.NoError(t, err)
_, err = svc.Authenticate(context.Background(), "admin", "wrongpassword")
assert.ErrorIs(t, err, auth.ErrInvalidCredentials)
})
testingT.Run("rejects unknown user", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
_, err := svc.Authenticate(context.Background(), "nonexistent", "password")
assert.ErrorIs(t, err, auth.ErrInvalidCredentials)
})
}
func TestDestroySessionMaxAge(testingT *testing.T) {
testingT.Parallel()
testingT.Run("sets MaxAge to exactly -1", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil,
)
err := svc.DestroySession(recorder, request)
require.NoError(t, err)
// Check the Set-Cookie header to verify MaxAge is -1 (immediate expiry).
// With MaxAge = -1, the cookie should have Max-Age=0 in the HTTP header
// (per http.Cookie semantics: negative MaxAge means delete now).
cookies := recorder.Result().Cookies()
require.NotEmpty(t, cookies, "expected a Set-Cookie header")
found := false
for _, c := range cookies {
if c.MaxAge < 0 {
found = true
break
}
}
assert.True(t, found, "expected a cookie with negative MaxAge (deletion)")
})
}