Files
patrickandClaude Sonnet 5 b938634d2f
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
UX-11: test erwartet 422 statt 404 fuer nicht mehr im klaerungskorb befindliche nachricht
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 12:03:24 +02:00

277 lines
9.9 KiB
Go

package mailimportapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/documenttype"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/folder"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/mailimport"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/tag"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/testenv"
)
func newTestServer(t *testing.T) (*httptest.Server, *testenv.Env) {
t.Helper()
e := testenv.New(t)
store := &mailimport.Store{Pool: e.Pool, Crypto: e.Crypto, TenantSlug: testenv.TenantSlug}
mux := http.NewServeMux()
NewServer(store).RegisterRoutes(mux)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, e
}
func asMap(t *testing.T, v any) map[string]any {
t.Helper()
m, ok := v.(map[string]any)
if !ok {
t.Fatalf("erwartete ein objekt, bekam: %v", v)
}
return m
}
func doRequest(t *testing.T, srv *httptest.Server, method, path string, body any) (int, any) {
t.Helper()
var r *bytes.Reader
if body != nil {
b, _ := json.Marshal(body)
r = bytes.NewReader(b)
} else {
r = bytes.NewReader(nil)
}
req, _ := http.NewRequest(method, srv.URL+path, r)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
var out any
_ = json.NewDecoder(resp.Body).Decode(&out)
return resp.StatusCode, out
}
// Akzeptanzkriterium 1: Postfächer lassen sich anlegen, ändern, testen und
// deaktivieren, ohne dass ein Kennwort je zurückgegeben wird.
func TestMailAccounts_LifecycleNeverReturnsPassword(t *testing.T) {
srv, e := newTestServer(t)
caller := "?caller=" + e.UserID
create := map[string]any{
"name": "Postfach A", "host": "mail.example.test", "port": 993, "tls": "tls",
"username": "a@example.test", "password": "geheim123", "mailbox": "INBOX",
"import_user_id": e.UserID, "poll_interval_seconds": 300, "enabled": true,
}
status, createdRaw := doRequest(t, srv, http.MethodPost, "/mail-accounts"+caller, create)
if status != http.StatusCreated {
t.Fatalf("anlegen: %d %v", status, createdRaw)
}
created := asMap(t, createdRaw)
if v, has := created["password"]; has && v != "" {
t.Fatalf("kennwort in der antwort: %v", created)
}
id := created["id"].(string)
status, list := doRequest(t, srv, http.MethodGet, "/mail-accounts"+caller, nil)
_ = status
body, _ := json.Marshal(list)
if bytes.Contains(body, []byte("geheim123")) {
t.Fatalf("kennwort in der liste: %s", body)
}
update := map[string]any{
"name": "Postfach A geändert", "host": "mail.example.test", "port": 993, "tls": "tls",
"username": "a@example.test", "mailbox": "INBOX", "import_user_id": e.UserID,
"poll_interval_seconds": 600, "enabled": false,
}
status, updatedRaw := doRequest(t, srv, http.MethodPut, "/mail-accounts/"+id+caller, update)
if status != http.StatusOK {
t.Fatalf("ändern: %d %v", status, updatedRaw)
}
updated := asMap(t, updatedRaw)
if updated["enabled"] != false {
t.Errorf("deaktivieren griff nicht: %v", updated)
}
if updated["name"] != "Postfach A geändert" {
t.Errorf("änderung griff nicht: %v", updated)
}
status, testRaw := doRequest(t, srv, http.MethodPost, "/mail-accounts/"+id+"/test"+caller, nil)
if status != http.StatusOK {
t.Fatalf("testverbindung: %d %v", status, testRaw)
}
test := asMap(t, testRaw)
if test["ok"] != false {
t.Errorf("testverbindung gegen unerreichbaren host sollte fehlschlagen, nicht abstuerzen: %v", test)
}
if errStr, _ := test["error"].(string); bytes.Contains([]byte(errStr), []byte("geheim123")) {
t.Fatalf("kennwort im testergebnis: %v", test)
}
status, _ = doRequest(t, srv, http.MethodGet, "/mail-accounts"+caller, nil)
if status != http.StatusOK {
t.Fatalf("liste nach aenderung: %d", status)
}
}
func TestMailAccounts_RequireCallerOrToken(t *testing.T) {
srv, _ := newTestServer(t)
if status, _ := doRequest(t, srv, http.MethodGet, "/mail-accounts", nil); status != http.StatusUnauthorized {
t.Errorf("ohne identitaet: %d, want 401", status)
}
}
// Akzeptanzkriterium 1 (Regeln, Konflikte/Prioritaet sichtbar ueber die
// Reihenfolge der Liste) und Akzeptanzkriterium 2 (Klaerungskorb).
func TestMailRulesAndFilters_CRUD(t *testing.T) {
srv, e := newTestServer(t)
tg, err := tag.Create(t.Context(), e.Pool, "rechnung", "", nil)
if err != nil {
t.Fatal(err)
}
caller := "?caller=" + e.UserID
rule := map[string]any{"name": "Rechnung", "priority": 10, "enabled": true, "sender_pattern": "buchhaltung@.*", "add_tag_ids": []string{tg.ID}}
status, createdRaw := doRequest(t, srv, http.MethodPost, "/mail-rules"+caller, rule)
if status != http.StatusCreated {
t.Fatalf("regel anlegen: %d %v", status, createdRaw)
}
id := asMap(t, createdRaw)["id"].(string)
status, list := doRequest(t, srv, http.MethodGet, "/mail-rules"+caller, nil)
if status != http.StatusOK {
t.Fatalf("regeln lesen: %d", status)
}
_ = list
status, _ = doRequest(t, srv, http.MethodDelete, "/mail-rules/"+id+caller, nil)
if status != http.StatusNoContent {
t.Fatalf("regel loeschen: %d", status)
}
filter := map[string]any{"name": "Werbung", "enabled": true, "filename_pattern": "(?i)werbung"}
status, createdFilterRaw := doRequest(t, srv, http.MethodPost, "/mail-filters"+caller, filter)
if status != http.StatusCreated {
t.Fatalf("filter anlegen: %d %v", status, createdFilterRaw)
}
fid := asMap(t, createdFilterRaw)["id"].(string)
status, _ = doRequest(t, srv, http.MethodDelete, "/mail-filters/"+fid+caller, nil)
if status != http.StatusNoContent {
t.Fatalf("filter loeschen: %d", status)
}
}
// Akzeptanzkriterium 2: der Klärungskorb zeigt Nachrichten ohne passende
// Regel und erlaubt die Zuordnung mit Wirkung auf die Nachricht.
func TestClarification_ApplyAssignsAndClearsBasket(t *testing.T) {
srv, e := newTestServer(t)
caller := "?caller=" + e.UserID
tg, err := tag.Create(t.Context(), e.Pool, "sonstiges", "", nil)
if err != nil {
t.Fatal(err)
}
typ, err := documenttype.Create(t.Context(), e.Pool, "Sonstiges", nil, nil, nil)
if err != nil {
t.Fatal(err)
}
f, err := folder.Create(t.Context(), e.Pool, "Klaerung", nil, e.UserID)
if err != nil {
t.Fatal(err)
}
var accountID, messageID, documentID string
if err := e.Pool.QueryRow(t.Context(), `
INSERT INTO mail_accounts (name, host, port, tls_mode, username, password_ciphertext, password_wrapped_dek, mailbox, import_user_id)
VALUES ('K', 'h', 993, 'tls', 'u', '\x00', '\x00', 'INBOX', $1) RETURNING id
`, e.UserID).Scan(&accountID); err != nil {
t.Fatal(err)
}
if err := e.Pool.QueryRow(t.Context(), `
INSERT INTO mail_messages (account_id, uid_validity, uid, from_address, subject, clarification, clarification_reason)
VALUES ($1, 1, 1, 'unbekannt@example.test', 'Unklar', true, 'keine regel getroffen') RETURNING id
`, accountID).Scan(&messageID); err != nil {
t.Fatal(err)
}
if err := e.Pool.QueryRow(t.Context(), `
INSERT INTO documents (title, created_by) VALUES ('Unklares Dokument', $1) RETURNING id
`, e.UserID).Scan(&documentID); err != nil {
t.Fatal(err)
}
if _, err := e.Pool.Exec(t.Context(), `
INSERT INTO mail_message_parts (message_id, part_no, kind, filename, status, document_id)
VALUES ($1, 0, 'attachment', 'unklar.pdf', 'imported', $2)
`, messageID, documentID); err != nil {
t.Fatal(err)
}
status, list := doRequest(t, srv, http.MethodGet, "/clarification"+caller, nil)
if status != http.StatusOK {
t.Fatalf("klaerungskorb lesen: %d", status)
}
items, _ := json.Marshal(list)
if !bytes.Contains(items, []byte(messageID)) {
t.Fatalf("nachricht fehlt im klaerungskorb: %s", items)
}
apply := map[string]any{"document_type_id": typ.ID, "confirm_type_change": true, "add_tag_ids": []string{tg.ID}, "folder_id": f.ID}
status, resp := doRequest(t, srv, http.MethodPost, "/clarification/"+messageID+"/apply"+caller, apply)
if status != http.StatusNoContent {
t.Fatalf("zuordnen: %d %v", status, resp)
}
var clarification bool
if err := e.Pool.QueryRow(t.Context(), `SELECT clarification FROM mail_messages WHERE id = $1`, messageID).Scan(&clarification); err != nil {
t.Fatal(err)
}
if clarification {
t.Error("nachricht bleibt im klaerungskorb, obwohl zugeordnet")
}
var docType, docFolder string
if err := e.Pool.QueryRow(t.Context(), `SELECT document_type_id::text, folder_id::text FROM documents WHERE id = $1`, documentID).Scan(&docType, &docFolder); err != nil {
t.Fatal(err)
}
if docType != typ.ID {
t.Errorf("dokumenttyp nicht gesetzt: %s", docType)
}
if docFolder != f.ID {
t.Errorf("ordner nicht gesetzt: %s", docFolder)
}
var tagged int
if err := e.Pool.QueryRow(t.Context(), `SELECT count(*) FROM document_tags WHERE document_id = $1 AND tag_id = $2`, documentID, tg.ID).Scan(&tagged); err != nil {
t.Fatal(err)
}
if tagged != 1 {
t.Errorf("tag nicht zugeordnet: %d", tagged)
}
status, _ = doRequest(t, srv, http.MethodPost, "/clarification/"+messageID+"/apply"+caller, apply)
if status != http.StatusUnprocessableEntity {
t.Errorf("erneutes zuordnen einer bereits erledigten nachricht: %d, want 422 (nicht mehr im klaerungskorb)", status)
}
}
func TestApplyClarification_RejectsEmptyAssignment(t *testing.T) {
srv, e := newTestServer(t)
caller := "?caller=" + e.UserID
var accountID, messageID string
if err := e.Pool.QueryRow(t.Context(), `
INSERT INTO mail_accounts (name, host, port, tls_mode, username, password_ciphertext, password_wrapped_dek, mailbox, import_user_id)
VALUES ('K', 'h', 993, 'tls', 'u', '\x00', '\x00', 'INBOX', $1) RETURNING id
`, e.UserID).Scan(&accountID); err != nil {
t.Fatal(err)
}
if err := e.Pool.QueryRow(t.Context(), `
INSERT INTO mail_messages (account_id, uid_validity, uid, clarification) VALUES ($1, 1, 2, true) RETURNING id
`, accountID).Scan(&messageID); err != nil {
t.Fatal(err)
}
status, resp := doRequest(t, srv, http.MethodPost, "/clarification/"+messageID+"/apply"+caller, map[string]any{})
if status != http.StatusUnprocessableEntity {
t.Errorf("leere zuordnung: %d %v, want 422", status, resp)
}
}