From ef401bec60912de583979f19cf7fb2a346671a7c Mon Sep 17 00:00:00 2001 From: patrick Date: Thu, 12 Mar 2026 16:26:32 +0100 Subject: [PATCH] Dateien nach "/" hochladen --- Makefile | 59 ++++++++++++ api_test.go | 220 +++++++++++++++++++++++++++++++++++++++++++++ config.test.yml | 39 ++++++++ docker-compose.yml | 45 ++++++++++ parser_test.go | 100 +++++++++++++++++++++ 5 files changed, 463 insertions(+) create mode 100644 Makefile create mode 100644 api_test.go create mode 100644 config.test.yml create mode 100644 docker-compose.yml create mode 100644 parser_test.go diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..435b71c --- /dev/null +++ b/Makefile @@ -0,0 +1,59 @@ +APP = mailarchived +IMPORTER= mailarchive-import +VERSION ?= 0.1.0 +LDFLAGS = -X main.Version=$(VERSION) -s -w + +.PHONY: all build build-xapian clean install + +all: build + +# Default build — Bleve index (pure Go, no system deps) +build: + go build -ldflags "$(LDFLAGS)" -o bin/$(APP) ./cmd/mailarchived + go build -ldflags "$(LDFLAGS)" -o bin/$(IMPORTER) ./cmd/importer + +# Xapian build — faster at scale, requires: apt install libxapian-dev +build-xapian: + go build -tags xapian -ldflags "$(LDFLAGS)" -o bin/$(APP)-xapian ./cmd/mailarchived + go build -tags xapian -ldflags "$(LDFLAGS)" -o bin/$(IMPORTER) ./cmd/importer + +clean: + rm -rf bin/ + +test: + go test -v -race -count=1 ./internal/storage/... ./internal/userstore/... \ + ./internal/auth/... ./internal/audit/... ./internal/index/... \ + ./pkg/mailparser/... + +test-all: + go test -v -race -count=1 ./... + +test-short: + go test -short ./... + +test-cover: + go test -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + @echo "Coverage report: coverage.html" + +smoke: build + @echo "Starting daemon in background for smoke test..." + mkdir -p /tmp/mailarchive-test/{store,index,logs} + ./bin/mailarchived --config config/config.test.yml & + sleep 2 + bash test/smoke_test.sh + pkill mailarchived || true + +vet: + go vet ./... + +install: build + install -D -m 755 bin/$(APP) /usr/bin/$(APP) + install -D -m 755 bin/$(IMPORTER) /usr/bin/$(IMPORTER) + install -D -m 644 config/config.yml /etc/mailarchive/config.yml + install -D -m 644 debian/mailarchive.service /lib/systemd/system/mailarchive.service + useradd --system --no-create-home --shell /usr/sbin/nologin mailarchive 2>/dev/null || true + mkdir -p /var/lib/mailarchive/{store,index,attachments,meta} + mkdir -p /var/log/mailarchive + chown -R mailarchive:mailarchive /var/lib/mailarchive /var/log/mailarchive + systemctl daemon-reload diff --git a/api_test.go b/api_test.go new file mode 100644 index 0000000..db8faa2 --- /dev/null +++ b/api_test.go @@ -0,0 +1,220 @@ +package api_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "log/slog" + "os" + + "github.com/mailarchive/config" + "github.com/mailarchive/internal/api" + "github.com/mailarchive/internal/audit" + "github.com/mailarchive/internal/auth" + "github.com/mailarchive/internal/index" + "github.com/mailarchive/internal/storage" + "github.com/mailarchive/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, "bleve") + if err != nil { t.Fatal(err) } + + users, err := userstore.New(filepath.Join(dir, "users.db")) + if err != nil { t.Fatal(err) } + + audlog, err := audit.New(filepath.Join(dir, "audit.db"), dir, logger) + if err != nil { t.Fatal(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() + }) + + 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") + } +} diff --git a/config.test.yml b/config.test.yml new file mode 100644 index 0000000..2076f43 --- /dev/null +++ b/config.test.yml @@ -0,0 +1,39 @@ +# config/config.test.yml +# Local test configuration — uses /tmp, no system paths, no root required. +# Usage: ./bin/mailarchived --config config/config.test.yml + +storage: + path: /tmp/mailarchive-test/store + users_db: /tmp/mailarchive-test/users.db + audit_db: /tmp/mailarchive-test/audit.db + max_size_mb: 100 + compression: false + +smtp: + enabled: true + bind: ":2525" + domain: "localhost" + tls_cert: "" + tls_key: "" + max_size_mb: 10 + +# No IMAP for local testing +imap: [] + +api: + bind: ":8080" + secret: "dev-secret-change-in-production-min32" + tls: false + +index: + path: /tmp/mailarchive-test/index + backend: bleve + batch_size: 10 + async_queue_size: 100 + +auth: + ldap: null # disabled for local testing + +logging: + path: /tmp/mailarchive-test/logs + level: debug diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..aa3be73 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +version: "3.9" + +services: + # Build and run the mailarchive daemon + mailarchive: + build: + context: . + dockerfile: Dockerfile.dev + container_name: mailarchive + ports: + - "8080:8080" # Web UI + REST API + - "2525:2525" # SMTP gateway + volumes: + - maildata:/var/lib/mailarchive + - maillogs:/var/log/mailarchive + - ./config/config.yml:/etc/mailarchive/config.yml:ro + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:8080/api/health"] + interval: 10s + timeout: 5s + retries: 3 + + # Send a test mail to the SMTP gateway + mailtest: + image: alpine:latest + depends_on: + mailarchive: + condition: service_healthy + command: > + sh -c " + apk add --no-cache swaks && + swaks + --to archive@example.com + --from sender@example.com + --server mailarchive:2525 + --header 'Subject: Test Invoice 001' + --body 'Please find attached the invoice.' && + echo 'Test mail sent!' + " + profiles: ["test"] + +volumes: + maildata: + maillogs: diff --git a/parser_test.go b/parser_test.go new file mode 100644 index 0000000..671b649 --- /dev/null +++ b/parser_test.go @@ -0,0 +1,100 @@ +package mailparser_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mailarchive/pkg/mailparser" +) + +func readFixture(t *testing.T, name string) []byte { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatalf("readFixture %s: %v", name, err) + } + return data +} + +func TestParseSimple(t *testing.T) { + raw := readFixture(t, "simple.eml") + p, err := mailparser.Parse(raw) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + if p.From != "alice@example.com" { + t.Errorf("From = %q, want alice@example.com", p.From) + } + if len(p.To) != 2 { + t.Errorf("To: got %d recipients, want 2", len(p.To)) + } + if len(p.CC) != 1 { + t.Errorf("CC: got %d, want 1", len(p.CC)) + } + if p.Subject != "Test Invoice Q1-2026" { + t.Errorf("Subject = %q", p.Subject) + } + if p.MessageID != "test-001@example.com" { + t.Errorf("MessageID = %q", p.MessageID) + } + if !strings.Contains(p.TextBody, "invoice") { + t.Errorf("TextBody missing 'invoice': %q", p.TextBody) + } + if p.Date.IsZero() { + t.Error("Date is zero") + } +} + +func TestParseMultipartWithAttachment(t *testing.T) { + raw := readFixture(t, "multipart.eml") + p, err := mailparser.Parse(raw) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + if p.From != "sender@corp.de" { + t.Errorf("From = %q", p.From) + } + if len(p.Attachments) != 1 { + t.Fatalf("expected 1 attachment, got %d", len(p.Attachments)) + } + att := p.Attachments[0] + if att.Filename != "angebot.pdf" { + t.Errorf("Attachment filename = %q, want angebot.pdf", att.Filename) + } + if !strings.Contains(att.ContentType, "pdf") { + t.Errorf("ContentType = %q, want pdf", att.ContentType) + } + if att.Size == 0 { + t.Error("attachment size is 0") + } +} + +func TestParseRawInline(t *testing.T) { + raw := []byte("From: test@example.com\r\nTo: dest@example.com\r\nSubject: Hello\r\n\r\nBody text here") + p, err := mailparser.Parse(raw) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if p.From != "test@example.com" { + t.Errorf("From = %q", p.From) + } + if len(p.Attachments) != 0 { + t.Errorf("expected 0 attachments, got %d", len(p.Attachments)) + } +} + +func TestParseMissingDate(t *testing.T) { + raw := []byte("From: test@example.com\r\nSubject: No Date\r\n\r\nNo date header") + p, err := mailparser.Parse(raw) + if err != nil { + t.Fatalf("Parse: %v", err) + } + // Should fall back to time.Now(), so should not be zero + if p.Date.IsZero() { + t.Error("Date should fall back to now, not zero") + } +}