- FR-11 rules editor: plain-text area at /rules, saved and versioned (SaveRules appends a version; restore re-saves an old version), parse errors surfaced, one-click re-classify. The reply model now reads the rules document too (FR-11). - FR-7 unsubscribe: parse and store List-Unsubscribe / List-Unsubscribe-Post (migration v6), detect email and web links (internal/unsubscribe), a domain-level ignore list, and an explicit record of what was marked. No code path contacts an unsubscribe link; acting on one needs Sam's approval. - Migration v6 is additive; unsubscribe gains url/updated_at. - Tests: detector, parser, store migration/records/candidates, rules editor, unsubscribe handlers, message-detail rendering. Co-Authored-By: Paperclip <noreply@paperclip.ing>
153 lines
4.7 KiB
Go
153 lines
4.7 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestRulesPageRenders(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
session, _, _ := login(t, srv)
|
|
|
|
rec := getWithSession(t, srv, "/rules", session)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
for _, want := range []string{"Rules editor", "Save new version", "Versions", "Back to inbox", "Unsubscribe"} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("rules page missing %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRulesSaveCreatesVersion(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
session, csrfCookie, csrf := login(t, srv)
|
|
|
|
rule := `from contains "heritagefruittrees.com.au" => category=Action, importance=high, action=file`
|
|
rec := postForm(t, srv, "/rules/save", session, csrfCookie, url.Values{
|
|
"csrf": {csrf}, "text": {rule},
|
|
})
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Fatalf("save status = %d, want 303 (body %s)", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
text, version, err := srv.store.CurrentRules(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("CurrentRules() error = %v", err)
|
|
}
|
|
if text != rule {
|
|
t.Errorf("CurrentRules() text = %q, want the saved rule", text)
|
|
}
|
|
if version != 2 {
|
|
t.Errorf("CurrentRules() version = %d, want 2", version)
|
|
}
|
|
|
|
// The editor shows the new version and active rule count.
|
|
body := getWithSession(t, srv, "/rules", session).Body.String()
|
|
if !strings.Contains(body, "version 2") || !strings.Contains(body, "1 active rule") {
|
|
t.Errorf("rules page did not reflect the saved version; body:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestRulesSaveRequiresCSRF(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
session, _, _ := login(t, srv)
|
|
|
|
rec := postForm(t, srv, "/rules/save", session, nil, url.Values{"text": {"anything"}})
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestRulesPageShowsParseErrors(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
session, csrfCookie, csrf := login(t, srv)
|
|
|
|
rec := postForm(t, srv, "/rules/save", session, csrfCookie, url.Values{
|
|
"csrf": {csrf}, "text": {"this line has no arrow"},
|
|
})
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Fatalf("save status = %d, want 303", rec.Code)
|
|
}
|
|
body := getWithSession(t, srv, "/rules", session).Body.String()
|
|
if !strings.Contains(body, "could not be parsed") || !strings.Contains(body, "missing =>") {
|
|
t.Errorf("rules page did not surface the parse error; body:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestRulesRestoreCreatesNewVersion(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
session, csrfCookie, csrf := login(t, srv)
|
|
ctx := context.Background()
|
|
|
|
first := `from contains "a" => category=Info`
|
|
second := `from contains "b" => category=Action`
|
|
for _, text := range []string{first, second} {
|
|
rec := postForm(t, srv, "/rules/save", session, csrfCookie, url.Values{"csrf": {csrf}, "text": {text}})
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Fatalf("save %q status = %d, want 303", text, rec.Code)
|
|
}
|
|
}
|
|
|
|
// Restore version 2 (the first saved rule) as a new version.
|
|
rec := postForm(t, srv, "/rules/restore", session, csrfCookie, url.Values{
|
|
"csrf": {csrf}, "version": {"2"},
|
|
})
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Fatalf("restore status = %d, want 303", rec.Code)
|
|
}
|
|
text, version, err := srv.store.CurrentRules(ctx)
|
|
if err != nil {
|
|
t.Fatalf("CurrentRules() error = %v", err)
|
|
}
|
|
if text != first {
|
|
t.Errorf("restored text = %q, want %q", text, first)
|
|
}
|
|
if version != 4 {
|
|
t.Errorf("restored version = %d, want 4 (append-only)", version)
|
|
}
|
|
}
|
|
|
|
func TestDashboardLinksRulesAndUnsubscribe(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
session, _, _ := login(t, srv)
|
|
|
|
body := getWithSession(t, srv, "/", session).Body.String()
|
|
for _, want := range []string{`href="/rules"`, `href="/unsubscribe"`} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("dashboard missing %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRulesCountsLines(t *testing.T) {
|
|
text := "# comment\n\nfrom contains \"a\" => category=Info\nfrom contains \"b\" => category=Action"
|
|
if got := countRuleLines(text); got != 2 {
|
|
t.Errorf("countRuleLines() = %d, want 2", got)
|
|
}
|
|
if got := firstRuleLine(text); got != `from contains "a" => category=Info` {
|
|
t.Errorf("firstRuleLine() = %q", got)
|
|
}
|
|
if got := firstRuleLine("# only a comment"); got != "(comments only)" {
|
|
t.Errorf("firstRuleLine(comment only) = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRulesReclassifyWithoutJEVRedirects(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
session, csrfCookie, csrf := login(t, srv)
|
|
|
|
rec := postForm(t, srv, "/rules/reclassify", session, csrfCookie, url.Values{"csrf": {csrf}})
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Fatalf("status = %d, want 303 (body %s)", rec.Code, rec.Body.String())
|
|
}
|
|
if loc := rec.Header().Get("Location"); !strings.Contains(loc, "JEV_API_KEY") {
|
|
t.Errorf("Location = %q, want a JEV-not-configured note", loc)
|
|
}
|
|
}
|