archivmail-export nutzte noch die alte storage.New(path string)-Signatur statt storage.Config (fehlender Keyfile/Compress hätte Rohbytes statt Klartext-EML exportiert). Toten Self-Assignment-Code in storage.go entfernt. Testdateien (storage, audit, api, userstore, auth) an aktuelle Signaturen angeglichen; auth-Tests liefen bisher gegen einen SQLite-Pfad statt Postgres- DSN und wurden auf das TEST_DATABASE_URL-Schema-Isolationsmuster der übrigen Pakete umgestellt. api_test.go las den Login-Token noch aus dem JSON-Body statt aus dem httpOnly-Cookie (Auth-Contract-Drift). TestParseMissingDate an tatsächliches Verhalten angepasst: der Parser lässt das Datum bewusst als Zero-Value, der time.Now()-Fallback sitzt in der Storage-Schicht — damit bleibt nachvollziehbar ob ein Datum aus der Mail stammt oder vom Archiv gesetzt wurde (GoBD). Verifiziert auf 192.168.1.132: go build/vet/test ./... komplett grün, kein Skip (Postgres + Manticore erreichbar). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019j28kGcaJAhBnrYX34hGdt
206 lines
5.2 KiB
Go
206 lines
5.2 KiB
Go
package auth_test
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"archivmail/internal/auth"
|
|
"archivmail/internal/userstore"
|
|
)
|
|
|
|
// newTestAuth builds an auth.Manager on top of a schema-isolated PostgreSQL
|
|
// userstore. userstore.New expects a PostgreSQL DSN (pgxpool) — the former
|
|
// SQLite file path ("users.db") made pgx fail with "cannot parse ...".
|
|
func newTestAuth(t *testing.T) (*auth.Manager, *userstore.Store) {
|
|
t.Helper()
|
|
dsn := os.Getenv("TEST_DATABASE_URL")
|
|
if dsn == "" {
|
|
t.Skip("TEST_DATABASE_URL not set — skipping (needs PostgreSQL)")
|
|
}
|
|
|
|
schema := "authtest_" + strings.ToLower(strings.ReplaceAll(t.Name(), "/", "_"))
|
|
if len(schema) > 63 {
|
|
schema = schema[:63]
|
|
}
|
|
|
|
ctx := context.Background()
|
|
conn, err := pgx.Connect(ctx, dsn)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
if _, err := conn.Exec(ctx, "CREATE SCHEMA IF NOT EXISTS "+schema); err != nil {
|
|
conn.Close(ctx)
|
|
t.Fatalf("create schema: %v", err)
|
|
}
|
|
conn.Close(ctx)
|
|
|
|
sep := "?"
|
|
if strings.Contains(dsn, "?") {
|
|
sep = "&"
|
|
}
|
|
schemaDSN := dsn + sep + "search_path=" + schema
|
|
|
|
store, err := userstore.New(schemaDSN)
|
|
if err != nil {
|
|
t.Fatalf("userstore.New: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
store.Close()
|
|
conn2, _ := pgx.Connect(context.Background(), dsn)
|
|
if conn2 != nil {
|
|
conn2.Exec(context.Background(), "DROP SCHEMA "+schema+" CASCADE")
|
|
conn2.Close(context.Background())
|
|
}
|
|
})
|
|
|
|
// Seed a test user
|
|
store.Create(userstore.CreateUserRequest{
|
|
Username: "testadmin",
|
|
Email: "admin@example.com",
|
|
Password: "adminpass",
|
|
Role: userstore.RoleAdmin,
|
|
})
|
|
store.Create(userstore.CreateUserRequest{
|
|
Username: "regularuser",
|
|
Email: "user@example.com",
|
|
Password: "userpass",
|
|
Role: userstore.RoleUser,
|
|
})
|
|
|
|
mgr := auth.New(store, nil, "test-jwt-secret-32chars-long-enough", "0000000000000000000000000000000000000000000000000000000000000000")
|
|
return mgr, store
|
|
}
|
|
|
|
func TestLoginSuccess(t *testing.T) {
|
|
mgr, _ := newTestAuth(t)
|
|
|
|
token, user, totpRequired, err := mgr.Login("testadmin", "adminpass")
|
|
if err != nil {
|
|
t.Fatalf("Login: %v", err)
|
|
}
|
|
if totpRequired {
|
|
t.Error("expected totpRequired=false for user without TOTP")
|
|
}
|
|
if token == "" {
|
|
t.Error("expected non-empty token")
|
|
}
|
|
if user.Username != "testadmin" {
|
|
t.Errorf("Username = %q", user.Username)
|
|
}
|
|
if user.Role != userstore.RoleAdmin {
|
|
t.Errorf("Role = %q", user.Role)
|
|
}
|
|
}
|
|
|
|
func TestLoginWrongPassword(t *testing.T) {
|
|
mgr, _ := newTestAuth(t)
|
|
|
|
if _, _, _, err := mgr.Login("testadmin", "wrongpass"); err == nil {
|
|
t.Error("expected error for wrong password")
|
|
}
|
|
}
|
|
|
|
func TestLoginUnknownUser(t *testing.T) {
|
|
mgr, _ := newTestAuth(t)
|
|
|
|
if _, _, _, err := mgr.Login("nobody", "pw"); err == nil {
|
|
t.Error("expected error for unknown user")
|
|
}
|
|
}
|
|
|
|
func TestTokenValidation(t *testing.T) {
|
|
mgr, _ := newTestAuth(t)
|
|
|
|
token, _, _, _ := mgr.Login("testadmin", "adminpass")
|
|
sess, err := mgr.ValidateToken(token)
|
|
if err != nil {
|
|
t.Fatalf("ValidateToken: %v", err)
|
|
}
|
|
if sess.Username != "testadmin" {
|
|
t.Errorf("Session Username = %q", sess.Username)
|
|
}
|
|
if sess.Role != userstore.RoleAdmin {
|
|
t.Errorf("Session Role = %q", sess.Role)
|
|
}
|
|
if sess.JTI == "" {
|
|
t.Error("Session JTI should not be empty")
|
|
}
|
|
}
|
|
|
|
func TestTokenTampering(t *testing.T) {
|
|
mgr, _ := newTestAuth(t)
|
|
|
|
token, _, _, _ := mgr.Login("testadmin", "adminpass")
|
|
tampered := token + "x"
|
|
|
|
if _, err := mgr.ValidateToken(tampered); err == nil {
|
|
t.Error("tampered token should fail validation")
|
|
}
|
|
}
|
|
|
|
func TestLogout(t *testing.T) {
|
|
mgr, _ := newTestAuth(t)
|
|
|
|
token, _, _, _ := mgr.Login("testadmin", "adminpass")
|
|
|
|
// Token valid before logout
|
|
if _, err := mgr.ValidateToken(token); err != nil {
|
|
t.Fatalf("token should be valid before logout: %v", err)
|
|
}
|
|
|
|
if err := mgr.Logout(token); err != nil {
|
|
t.Fatalf("Logout: %v", err)
|
|
}
|
|
|
|
// Token invalid after logout
|
|
if _, err := mgr.ValidateToken(token); err == nil {
|
|
t.Error("token should be invalid after logout")
|
|
}
|
|
}
|
|
|
|
func TestHasRole(t *testing.T) {
|
|
tests := []struct {
|
|
userRole string
|
|
required string
|
|
want bool
|
|
}{
|
|
{userstore.RoleAdmin, userstore.RoleAdmin, true},
|
|
{userstore.RoleAdmin, userstore.RoleAuditor, true},
|
|
{userstore.RoleAdmin, userstore.RoleUser, true},
|
|
{userstore.RoleAuditor, userstore.RoleAdmin, false},
|
|
{userstore.RoleAuditor, userstore.RoleAuditor, true},
|
|
{userstore.RoleAuditor, userstore.RoleUser, true},
|
|
{userstore.RoleUser, userstore.RoleAdmin, false},
|
|
{userstore.RoleUser, userstore.RoleAuditor, false},
|
|
{userstore.RoleUser, userstore.RoleUser, true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := auth.HasRole(tt.userRole, tt.required)
|
|
if got != tt.want {
|
|
t.Errorf("HasRole(%q, %q) = %v, want %v", tt.userRole, tt.required, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMultipleSessionsIndependent(t *testing.T) {
|
|
mgr, _ := newTestAuth(t)
|
|
|
|
token1, _, _, _ := mgr.Login("testadmin", "adminpass")
|
|
token2, _, _, _ := mgr.Login("testadmin", "adminpass")
|
|
|
|
if token1 == token2 {
|
|
t.Error("two logins should produce different tokens (different JTIs)")
|
|
}
|
|
|
|
// Logout session 1, session 2 should still work
|
|
mgr.Logout(token1)
|
|
if _, err := mgr.ValidateToken(token2); err != nil {
|
|
t.Errorf("session 2 should still be valid after session 1 logout: %v", err)
|
|
}
|
|
}
|