2de340573b
F-01: err.Error() wird nicht mehr an HTTP-Clients gesendet.
Stattdessen generische Fehlermeldungen + Server-Log.
Betrifft: handleCreateUser, handleUpdateUser, handleDeleteUser,
handleSyncNow, handleSecurityConfig, handleUpload.
F-02: Login-Audit-Log enthält keinen rohen err.Error() mehr.
Neue classifyLoginError() Funktion: invalid_password / ldap_error /
account_disabled / unknown — schützt vor LDAP-Info-Leak via Audit-API.
W-03: remoteIP() trimmt jetzt Leerzeichen aus X-Forwarded-For.
Vollständige Lösung erfordert Proxy-Konfiguration (W-03 bleibt WARN).
W-04: Attachment-Dateiname wird durch sanitizeFilename() bereinigt.
Nur [a-zA-Z0-9._- ] erlaubt — verhindert Header-Injection.
PROJ-24: TOTP 2FA vollständig implementiert:
- internal/auth/totp.go: GenerateSecret, ValidateTOTP, QRCodeSVG
- internal/api/totp_handlers.go: Setup, Login-Step2, Admin-Reset
- internal/userstore: SetTOTPSecret, EnableTOTP, DisableTOTP, ResetTOTP
- Login-Flow: totp_pending JWT → /api/auth/totp → vollwertiger JWT
- AES-256-GCM verschlüsseltes Secret in users.totp_secret
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
270 lines
7.2 KiB
Go
270 lines
7.2 KiB
Go
package api_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"github.com/archivmail/config"
|
|
"github.com/archivmail/internal/api"
|
|
"github.com/archivmail/internal/audit"
|
|
"github.com/archivmail/internal/auth"
|
|
"github.com/archivmail/internal/index"
|
|
"github.com/archivmail/internal/storage"
|
|
"github.com/archivmail/internal/userstore"
|
|
)
|
|
|
|
type testEnv struct {
|
|
server *api.Server
|
|
users *userstore.Store
|
|
store *storage.Store
|
|
idx index.Indexer
|
|
}
|
|
|
|
func newTestEnv(t *testing.T) *testEnv {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
logger := slog.New(slog.NewTextHandler(os.Discard, nil))
|
|
|
|
store, err := storage.New(filepath.Join(dir, "store"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
idx, err := index.New(filepath.Join(dir, "index"), 100, "xapian")
|
|
if err != nil {
|
|
t.Skip("xapian not available:", err)
|
|
}
|
|
|
|
dsn := os.Getenv("TEST_DATABASE_URL")
|
|
if dsn == "" {
|
|
t.Skip("TEST_DATABASE_URL not set — skipping (needs PostgreSQL)")
|
|
}
|
|
|
|
// Create isolated schemas for this test
|
|
schemaUsers := "apitest_users_" + strings.ToLower(strings.ReplaceAll(t.Name(), "/", "_"))
|
|
schemaAudit := "apitest_audit_" + strings.ToLower(strings.ReplaceAll(t.Name(), "/", "_"))
|
|
if len(schemaUsers) > 63 {
|
|
schemaUsers = schemaUsers[:63]
|
|
}
|
|
if len(schemaAudit) > 63 {
|
|
schemaAudit = schemaAudit[:63]
|
|
}
|
|
|
|
ctx := context.Background()
|
|
conn, err := pgx.Connect(ctx, dsn)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
conn.Exec(ctx, "CREATE SCHEMA IF NOT EXISTS "+schemaUsers)
|
|
conn.Exec(ctx, "CREATE SCHEMA IF NOT EXISTS "+schemaAudit)
|
|
conn.Close(ctx)
|
|
|
|
sep := "?"
|
|
if strings.Contains(dsn, "?") {
|
|
sep = "&"
|
|
}
|
|
usersDSN := dsn + sep + "search_path=" + schemaUsers
|
|
auditDSN := dsn + sep + "search_path=" + schemaAudit
|
|
|
|
users, err := userstore.New(usersDSN)
|
|
if err != nil {
|
|
t.Fatalf("userstore.New: %v", err)
|
|
}
|
|
|
|
audlog, err := audit.New(auditDSN, dir, logger)
|
|
if err != nil {
|
|
t.Fatalf("audit.New: %v", err)
|
|
}
|
|
|
|
// Seed users
|
|
users.Create(userstore.CreateUserRequest{Username: "admin", Email: "admin@x.com", Password: "adminpass", Role: userstore.RoleAdmin})
|
|
users.Create(userstore.CreateUserRequest{Username: "auditor", Email: "auditor@x.com", Password: "auditorpass", Role: userstore.RoleAuditor})
|
|
users.Create(userstore.CreateUserRequest{Username: "user1", Email: "user1@x.com", Password: "userpass", Role: userstore.RoleUser})
|
|
|
|
authMgr := auth.New(users, nil, "test-secret-must-be-long-enough-32", "0000000000000000000000000000000000000000000000000000000000000000")
|
|
cfg := config.APIConfig{Bind: ":18080", Secret: "test-secret-must-be-long-enough-32"}
|
|
srv := api.New(cfg, store, idx, authMgr, users, audlog, logger)
|
|
|
|
t.Cleanup(func() {
|
|
idx.Close()
|
|
users.Close()
|
|
audlog.Close()
|
|
conn2, _ := pgx.Connect(context.Background(), dsn)
|
|
if conn2 != nil {
|
|
conn2.Exec(context.Background(), "DROP SCHEMA "+schemaUsers+" CASCADE")
|
|
conn2.Exec(context.Background(), "DROP SCHEMA "+schemaAudit+" CASCADE")
|
|
conn2.Close(context.Background())
|
|
}
|
|
})
|
|
|
|
return &testEnv{server: srv, users: users, store: store, idx: idx}
|
|
}
|
|
|
|
func (e *testEnv) do(t *testing.T, method, path string, body interface{}, token string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
if body != nil {
|
|
json.NewEncoder(&buf).Encode(body)
|
|
}
|
|
req := httptest.NewRequest(method, path, &buf)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
e.server.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
func (e *testEnv) login(t *testing.T, username, password string) string {
|
|
t.Helper()
|
|
w := e.do(t, "POST", "/api/auth/login",
|
|
map[string]string{"username": username, "password": password}, "")
|
|
if w.Code != 200 {
|
|
t.Fatalf("login %s: status %d, body: %s", username, w.Code, w.Body.String())
|
|
}
|
|
var resp map[string]interface{}
|
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
|
return resp["token"].(string)
|
|
}
|
|
|
|
// ---- Tests ----
|
|
|
|
func TestHealth(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
w := env.do(t, "GET", "/api/health", nil, "")
|
|
if w.Code != 200 {
|
|
t.Errorf("health: status %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestLoginAndMe(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
token := env.login(t, "admin", "adminpass")
|
|
|
|
w := env.do(t, "GET", "/api/auth/me", nil, token)
|
|
if w.Code != 200 {
|
|
t.Fatalf("me: status %d", w.Code)
|
|
}
|
|
var resp map[string]interface{}
|
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
|
if resp["username"] != "admin" {
|
|
t.Errorf("me username = %q", resp["username"])
|
|
}
|
|
if resp["role"] != "admin" {
|
|
t.Errorf("me role = %q", resp["role"])
|
|
}
|
|
}
|
|
|
|
func TestLoginWrongCredentials(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
w := env.do(t, "POST", "/api/auth/login",
|
|
map[string]string{"username": "admin", "password": "wrong"}, "")
|
|
if w.Code != 401 {
|
|
t.Errorf("expected 401, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestUnauthenticatedSearchBlocked(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
w := env.do(t, "GET", "/api/search?q=test", nil, "")
|
|
if w.Code != 401 {
|
|
t.Errorf("expected 401, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestLogout(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
token := env.login(t, "admin", "adminpass")
|
|
|
|
w := env.do(t, "POST", "/api/auth/logout", nil, token)
|
|
if w.Code != 200 {
|
|
t.Fatalf("logout: status %d", w.Code)
|
|
}
|
|
|
|
// Token should now be invalid
|
|
w2 := env.do(t, "GET", "/api/auth/me", nil, token)
|
|
if w2.Code != 401 {
|
|
t.Errorf("after logout, me should return 401, got %d", w2.Code)
|
|
}
|
|
}
|
|
|
|
func TestAdminUserCRUD(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
token := env.login(t, "admin", "adminpass")
|
|
|
|
// List users
|
|
w := env.do(t, "GET", "/api/users", nil, token)
|
|
if w.Code != 200 {
|
|
t.Fatalf("list users: status %d", w.Code)
|
|
}
|
|
var users []map[string]interface{}
|
|
json.Unmarshal(w.Body.Bytes(), &users)
|
|
if len(users) != 3 { // admin + auditor + user1
|
|
t.Errorf("expected 3 users, got %d", len(users))
|
|
}
|
|
|
|
// Create user
|
|
w = env.do(t, "POST", "/api/users",
|
|
map[string]string{"username": "newuser", "email": "new@x.com", "password": "pw123", "role": "user"},
|
|
token)
|
|
if w.Code != 201 {
|
|
t.Fatalf("create user: status %d, body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestNonAdminCannotManageUsers(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
token := env.login(t, "user1", "userpass")
|
|
|
|
w := env.do(t, "GET", "/api/users", nil, token)
|
|
if w.Code != 403 {
|
|
t.Errorf("user role should not list users, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestAuditorCanAccessAuditLog(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
token := env.login(t, "auditor", "auditorpass")
|
|
|
|
w := env.do(t, "GET", "/api/audit", nil, token)
|
|
if w.Code != 200 {
|
|
t.Errorf("auditor should access audit log, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestUserCannotAccessAuditLog(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
token := env.login(t, "user1", "userpass")
|
|
|
|
w := env.do(t, "GET", "/api/audit", nil, token)
|
|
if w.Code != 403 {
|
|
t.Errorf("user role should not access audit log, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestSearchReturnsResults(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
token := env.login(t, "admin", "adminpass")
|
|
|
|
w := env.do(t, "GET", "/api/search?q=test", nil, token)
|
|
if w.Code != 200 {
|
|
t.Fatalf("search: status %d, body: %s", w.Code, w.Body.String())
|
|
}
|
|
var result map[string]interface{}
|
|
json.Unmarshal(w.Body.Bytes(), &result)
|
|
if _, ok := result["total"]; !ok {
|
|
t.Error("search response missing 'total' field")
|
|
}
|
|
}
|