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") 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") } }