package mailrulesapi import ( "bytes" "context" "encoding/json" "io" "net/http" "net/http/httptest" "strconv" "sync" "testing" "gitea.perlbach24.de/scripte/nexarch/mail/internal/mailrules" "gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth" ) // fakeRulesStore ist ein In-Memory-Fake für RulesStore — reine // Vertragstests brauchen keine echte Postgres-Instanz. type fakeRulesStore struct { mu sync.Mutex nextID int64 byTenant map[string]map[int64]mailrules.Rule } func newFakeRulesStore() *fakeRulesStore { return &fakeRulesStore{byTenant: map[string]map[int64]mailrules.Rule{}} } func (f *fakeRulesStore) Create(_ context.Context, tenantSlug string, rule mailrules.Rule) (int64, error) { f.mu.Lock() defer f.mu.Unlock() f.nextID++ rule.ID = f.nextID if f.byTenant[tenantSlug] == nil { f.byTenant[tenantSlug] = map[int64]mailrules.Rule{} } f.byTenant[tenantSlug][rule.ID] = rule return rule.ID, nil } func (f *fakeRulesStore) List(_ context.Context, tenantSlug string) ([]mailrules.Rule, error) { f.mu.Lock() defer f.mu.Unlock() var rules []mailrules.Rule for _, r := range f.byTenant[tenantSlug] { rules = append(rules, r) } return rules, nil } func (f *fakeRulesStore) Update(_ context.Context, tenantSlug string, id int64, rule mailrules.Rule) error { f.mu.Lock() defer f.mu.Unlock() if _, ok := f.byTenant[tenantSlug][id]; !ok { return mailrules.ErrNotFound } rule.ID = id f.byTenant[tenantSlug][id] = rule return nil } func (f *fakeRulesStore) Delete(_ context.Context, tenantSlug string, id int64) error { f.mu.Lock() defer f.mu.Unlock() delete(f.byTenant[tenantSlug], id) return nil } // testServer buendelt den httptest-Server mit dem Testschluessel, damit // jeder Test eigene Tokens fuer beliebige Mandanten ausstellen kann // (INT-18: der Mandant kommt aus dem Token, kein Testpfad mehr per // Query-Parameter). type testServer struct { url string issuer *moduleauth.TestIssuer } func (ts *testServer) do(t *testing.T, method, path, bearer string, body io.Reader) *http.Response { t.Helper() req, err := http.NewRequest(method, ts.url+path, body) if err != nil { t.Fatalf("request bauen: %v", err) } if bearer != "" { req.Header.Set("Authorization", "Bearer "+bearer) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("%s %s: %v", method, path, err) } return resp } func (ts *testServer) token(subject, tenant string) string { return ts.issuer.Token(subject, tenant, "user", "mail", "") } func setupTestServer(t *testing.T) (*testServer, *fakeRulesStore) { t.Helper() store := newFakeRulesStore() srv := NewServer(store) mux := http.NewServeMux() verifier, issuer := moduleauth.NewTestVerifierAndIssuer() srv.RegisterRoutes(mux, verifier) httpSrv := httptest.NewServer(mux) t.Cleanup(httpSrv.Close) return &testServer{url: httpSrv.URL, issuer: issuer}, store } // TestContract_CreateUpdateDeletePrioritize ist die geforderte // Pflichtprüfung 1 (INT-06): Vertragstest deckt Anlegen/Ändern/Löschen/ // Priorisieren ab, mit einem gültigen Modul-Token für mandant-a. func TestContract_CreateUpdateDeletePrioritize(t *testing.T) { ts, _ := setupTestServer(t) token := ts.token("u1", "mandant-a") // Anlegen. createBody, _ := json.Marshal(ruleDTO{Name: "Rechnungen", SenderPattern: "^rechnung@", Category: "Finanzen", Priority: 10}) resp := ts.do(t, http.MethodPost, "/api/v1/mail/rules", token, bytes.NewReader(createBody)) if resp.StatusCode != http.StatusCreated { t.Fatalf("erwartete 201, habe %d", resp.StatusCode) } var created ruleDTO _ = json.NewDecoder(resp.Body).Decode(&created) _ = resp.Body.Close() if created.ID == 0 { t.Fatalf("erwartete gesetzte id nach anlage") } // Priorisieren/Ändern. updateBody, _ := json.Marshal(ruleDTO{Name: "Rechnungen (neu)", SenderPattern: "^rechnung@", Category: "Finanzen", Priority: 1}) resp = ts.do(t, http.MethodPut, "/api/v1/mail/rules/"+strconv.FormatInt(created.ID, 10), token, bytes.NewReader(updateBody)) if resp.StatusCode != http.StatusOK { t.Fatalf("erwartete 200, habe %d", resp.StatusCode) } var updated ruleDTO _ = json.NewDecoder(resp.Body).Decode(&updated) _ = resp.Body.Close() if updated.Priority != 1 || updated.Name != "Rechnungen (neu)" { t.Fatalf("update nicht angekommen: %+v", updated) } // Einsehen (Liste). resp = ts.do(t, http.MethodGet, "/api/v1/mail/rules", token, nil) var list listRulesResponse _ = json.NewDecoder(resp.Body).Decode(&list) _ = resp.Body.Close() if len(list.Rules) != 1 || list.Rules[0].Priority != 1 { t.Fatalf("liste zeigt nicht die aktualisierte regel: %+v", list.Rules) } // Löschen. resp = ts.do(t, http.MethodDelete, "/api/v1/mail/rules/"+strconv.FormatInt(created.ID, 10), token, nil) if resp.StatusCode != http.StatusNoContent { t.Fatalf("erwartete 204, habe %d", resp.StatusCode) } _ = resp.Body.Close() resp = ts.do(t, http.MethodGet, "/api/v1/mail/rules", token, nil) var listAfter listRulesResponse _ = json.NewDecoder(resp.Body).Decode(&listAfter) _ = resp.Body.Close() if len(listAfter.Rules) != 0 { t.Fatalf("erwartete leere liste nach löschen, habe %+v", listAfter.Rules) } } // TestRoutes_RejectMissingToken deckt Akzeptanzkriterium 1 und // Pflichtpruefung 2 ab (Routenliste ohne ungeschuetzte Route): fuer // JEDE registrierte Route wird ohne gueltiges Modul-Token abgelehnt. func TestRoutes_RejectMissingToken(t *testing.T) { ts, _ := setupTestServer(t) cases := []struct{ method, path string }{ {http.MethodGet, "/api/v1/mail/rules"}, {http.MethodPost, "/api/v1/mail/rules"}, {http.MethodPut, "/api/v1/mail/rules/1"}, {http.MethodDelete, "/api/v1/mail/rules/1"}, } for _, c := range cases { t.Run(c.method+" "+c.path, func(t *testing.T) { resp := ts.do(t, c.method, c.path, "", nil) defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("erwartete 401 ohne token, habe %d", resp.StatusCode) } }) } } // TestListCreate_RejectsWrongAudience ist der Vertragstest fuer die // strukturelle aud=mail-Bindung in RegisterRoutes (echter Fund, siehe // dortigen Kommentar): ein Token mit aud=dms wird abgewiesen. func TestListCreate_RejectsWrongAudience(t *testing.T) { ts, _ := setupTestServer(t) wrongAud := ts.issuer.Token("u1", "mandant-a", "user", "dms", "") resp := ts.do(t, http.MethodGet, "/api/v1/mail/rules", wrongAud, nil) defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("erwartete 401 bei aud=dms, habe %d", resp.StatusCode) } } // TestListCreate_IgnoresTenantQueryParameter ist der Vertragstest für // Akzeptanzkriterium 2: ein "tenant"-Parameter hat keine Wirkung, der // Token-Mandant zählt. func TestListCreate_IgnoresTenantQueryParameter(t *testing.T) { ts, store := setupTestServer(t) if _, err := store.Create(context.Background(), "mandant-a", mailrules.Rule{Name: "A-Regel", Priority: 1}); err != nil { t.Fatalf("fixture anlegen: %v", err) } if _, err := store.Create(context.Background(), "mandant-b", mailrules.Rule{Name: "B-Regel", Priority: 1}); err != nil { t.Fatalf("fixture anlegen: %v", err) } resp := ts.do(t, http.MethodGet, "/api/v1/mail/rules?tenant=mandant-b", ts.token("u1", "mandant-a"), nil) defer func() { _ = resp.Body.Close() }() var list listRulesResponse _ = json.NewDecoder(resp.Body).Decode(&list) if len(list.Rules) != 1 || list.Rules[0].Name != "A-Regel" { t.Fatalf("tenant-parameter haette ignoriert werden muessen, habe: %+v", list.Rules) } }