Check / check (pull_request) Skipped
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
437 lines
10 KiB
Go
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)")
|
|
})
|
|
}
|