package main import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "time" "migadu_counters/internal/api" ) var now = time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) func i64(n int64) *int64 { return &n } func sampleResponse() api.CountersResponse { return api.CountersResponse{ Query: "me@example.com", Mode: "address", Date: "2026-10-13", Managed: true, Domain: "example.com", OrgID: i64(1234), Levels: []api.CountersLevel{ {Level: "A", ID: "me@example.com", In: 12, InLimit: i64(500), Out: 200, OutLimit: i64(200)}, {Level: "D", ID: "example.com", In: 40, InLimit: i64(1000), Out: 9, OutLimit: nil}, {Level: "O", ID: "1234", In: 120, InLimit: i64(5000), Out: 30, OutLimit: i64(1000)}, }, } } // fakeAPI serves GET /counters and records what it was asked. type fakeAPI struct { mu sync.Mutex requests []*http.Request status int body any } func (f *fakeAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) { f.mu.Lock() f.requests = append(f.requests, r) f.mu.Unlock() if f.status == 0 { f.status = 200 } w.Header().Set("Content-Type", "application/json") w.WriteHeader(f.status) _ = json.NewEncoder(w).Encode(f.body) } func (f *fakeAPI) last() *http.Request { f.mu.Lock() defer f.mu.Unlock() return f.requests[len(f.requests)-1] } func writeConfig(t *testing.T, body string) string { t.Helper() p := filepath.Join(t.TempDir(), "config.toml") if err := os.WriteFile(p, []byte(body), 0o600); err != nil { t.Fatal(err) } return p } func noEnv(string) string { return "" } func runCLI(t *testing.T, env func(string) string, args ...string) (int, string, string) { t.Helper() var out, errb bytes.Buffer code := run(args, &out, &errb, env, func() time.Time { return now }) return code, out.String(), errb.String() } func TestRun_Table(t *testing.T) { f := &fakeAPI{body: sampleResponse()} s := httptest.NewServer(f) defer s.Close() cfg := writeConfig(t, "addr = \":8095\"\nauth_token = \"filetoken\"\n") code, out, errOut := runCLI(t, noEnv, "--config", cfg, "--url", s.URL, "--address", "Me@Example.com") if code != 0 { t.Fatalf("exit %d, stderr %q", code, errOut) } req := f.last() if req.URL.Path != "/counters" || req.URL.Query().Get("address") != "me@example.com" || req.URL.Query().Has("date") || req.URL.Query().Has("domain") { t.Errorf("request = %s", req.URL) } if req.Header.Get("Authorization") != "Bearer filetoken" { t.Errorf("Authorization = %q", req.Header.Get("Authorization")) } lines := strings.Split(strings.TrimSpace(out), "\n") if len(lines) != 5 { t.Fatalf("want header + column line + 3 rows, got %d lines:\n%s", len(lines), out) } for _, want := range []string{"me@example.com", "date 2026-10-13 (UTC)", "domain example.com", "org 1234"} { if !strings.Contains(lines[0], want) { t.Errorf("header %q lacks %q", lines[0], want) } } if cols := strings.Fields(lines[1]); strings.Join(cols, " ") != "LEVEL ID IN LIMIT OUT LIMIT" { t.Errorf("column line = %q", lines[1]) } rows := [][]string{strings.Fields(lines[2]), strings.Fields(lines[3]), strings.Fields(lines[4])} want := [][]string{ {"A", "me@example.com", "12", "500", "200!", "200"}, // out reached its limit {"D", "example.com", "40", "1000", "9", "-"}, // unlimited out {"O", "1234", "120", "5000", "30", "1000"}, } for i := range want { if strings.Join(rows[i], " ") != strings.Join(want[i], " ") { t.Errorf("row %d = %v, want %v", i, rows[i], want[i]) } } if errOut != "" { t.Errorf("unexpected stderr: %q", errOut) } } func TestRun_UnmanagedAndExemptNotes(t *testing.T) { f := &fakeAPI{body: api.CountersResponse{Query: "x@gmail.com", Mode: "address", Date: "2026-10-13", Levels: []api.CountersLevel{{Level: "A", ID: "x@gmail.com"}, {Level: "D", ID: "gmail.com"}}}} s := httptest.NewServer(f) defer s.Close() cfg := writeConfig(t, "auth_token = \"t\"\n") code, out, _ := runCLI(t, noEnv, "--config", cfg, "--url", s.URL, "--address", "x@gmail.com") if code != 0 || !strings.Contains(out, "not in rspamd_domains_db") || !strings.Contains(out, "org -") { t.Errorf("exit %d output:\n%s", code, out) } f.body = api.CountersResponse{Query: "support@migadu.com", Mode: "address", Date: "2026-10-13", Managed: true, Exempt: true, Domain: "migadu.com", OrgID: i64(3743), Levels: []api.CountersLevel{}} code, out, _ = runCLI(t, noEnv, "--config", cfg, "--url", s.URL, "--address", "support@migadu.com") if code != 0 || !strings.Contains(out, "exempt from counting") { t.Errorf("exit %d output:\n%s", code, out) } } func TestRun_JSON(t *testing.T) { f := &fakeAPI{body: sampleResponse()} s := httptest.NewServer(f) defer s.Close() cfg := writeConfig(t, "auth_token = \"t\"\n") code, out, _ := runCLI(t, noEnv, "--config", cfg, "--url", s.URL, "--address", "me@example.com", "--json") if code != 0 { t.Fatalf("exit %d", code) } var got api.CountersResponse if err := json.Unmarshal([]byte(out), &got); err != nil { t.Fatalf("output is not the API JSON: %v\n%s", err, out) } if got.Query != "me@example.com" || len(got.Levels) != 3 || *got.Levels[0].InLimit != 500 { t.Errorf("decoded = %+v", got) } } func TestRun_DomainMode(t *testing.T) { f := &fakeAPI{body: api.CountersResponse{Query: "example.com", Mode: "domain", Date: "2026-10-13", Managed: true, Domain: "example.com", OrgID: i64(1234), Levels: []api.CountersLevel{{Level: "D", ID: "example.com", In: 1, InLimit: i64(5), Out: 2, OutLimit: i64(6)}, {Level: "O", ID: "1234"}}}} s := httptest.NewServer(f) defer s.Close() cfg := writeConfig(t, "auth_token = \"t\"\n") code, out, _ := runCLI(t, noEnv, "--config", cfg, "--url", s.URL, "--domain", "Example.COM") if code != 0 { t.Fatalf("exit %d\n%s", code, out) } if q := f.last().URL.Query(); q.Get("domain") != "example.com" || q.Has("address") { t.Errorf("query = %v", q) } if !strings.Contains(out, "D example.com") && !strings.Contains(out, "D example.com") { t.Errorf("table lacks the D row:\n%s", out) } } func TestRun_Date(t *testing.T) { f := &fakeAPI{body: sampleResponse()} s := httptest.NewServer(f) defer s.Close() cfg := writeConfig(t, "auth_token = \"t\"\n") code, _, errOut := runCLI(t, noEnv, "--config", cfg, "--url", s.URL, "--address", "me@example.com", "--date", "2026-10-01") if code != 0 || f.last().URL.Query().Get("date") != "2026-10-01" { t.Errorf("exit %d stderr %q query %v", code, errOut, f.last().URL.Query()) } if errOut != "" { t.Errorf("12 days back should not warn: %q", errOut) } // Older than the 31-day key TTL: still queried, but warned about. code, _, errOut = runCLI(t, noEnv, "--config", cfg, "--url", s.URL, "--address", "me@example.com", "--date", "2026-09-01") if code != 0 || !strings.Contains(errOut, "31 days") { t.Errorf("exit %d stderr %q, want a 31-day warning", code, errOut) } for _, bad := range []struct{ date, want string }{{"2099-01-01", "future"}, {"13.10.2026", "YYYY-MM-DD"}, {"2026-1-5", "YYYY-MM-DD"}} { before := len(f.requests) code, _, errOut = runCLI(t, noEnv, "--config", cfg, "--url", s.URL, "--address", "me@example.com", "--date", bad.date) if code != 1 || !strings.Contains(errOut, bad.want) { t.Errorf("--date %s: exit %d stderr %q, want exit 1 mentioning %q", bad.date, code, errOut, bad.want) } if len(f.requests) != before { t.Errorf("--date %s: request was sent despite the invalid date", bad.date) } } } func TestRun_Usage(t *testing.T) { cfg := writeConfig(t, "auth_token = \"t\"\n") for _, args := range [][]string{ {"--config", cfg}, {"--config", cfg, "--address", "a@b.c", "--domain", "b.c"}, {"--config", cfg, "--address", "not-an-address"}, {"--config", cfg, "--bogus"}, } { code, _, errOut := runCLI(t, noEnv, args...) if code != 1 || errOut == "" { t.Errorf("%v: exit %d stderr %q, want 1 with a message", args, code, errOut) } } } func TestRun_ServerErrors(t *testing.T) { f := &fakeAPI{status: 401, body: api.ErrorResponse{Error: "unauthorized"}} s := httptest.NewServer(f) defer s.Close() cfg := writeConfig(t, "auth_token = \"wrong\"\n") code, _, errOut := runCLI(t, noEnv, "--config", cfg, "--url", s.URL, "--address", "me@example.com") if code != 1 || !strings.Contains(errOut, "401") || !strings.Contains(errOut, "unauthorized") { t.Errorf("exit %d stderr %q", code, errOut) } code, _, errOut = runCLI(t, noEnv, "--config", cfg, "--url", "http://127.0.0.1:1", "--address", "me@example.com") if code != 1 || errOut == "" { t.Errorf("unreachable: exit %d stderr %q", code, errOut) } } func TestRun_ConfigAndEnv(t *testing.T) { f := &fakeAPI{body: sampleResponse()} s := httptest.NewServer(f) defer s.Close() cfg := writeConfig(t, "addr = \":8095\"\nauth_token = \"filetoken\"\n") env := func(k string) string { if k == "MIGADU_COUNTERS_AUTH_TOKEN" { return "envtoken" } return "" } if code, _, errOut := runCLI(t, env, "--config", cfg, "--url", s.URL, "--address", "me@example.com"); code != 0 { t.Fatalf("exit %d %q", code, errOut) } if got := f.last().Header.Get("Authorization"); got != "Bearer envtoken" { t.Errorf("env token not used: %q", got) } // Missing config file is an error naming the path. if code, _, errOut := runCLI(t, noEnv, "--config", "/nonexistent/x.toml", "--address", "me@example.com"); code != 1 || !strings.Contains(errOut, "/nonexistent/x.toml") { t.Errorf("exit %d stderr %q", code, errOut) } } func TestBaseURL(t *testing.T) { for in, want := range map[string]string{ ":5555": "http://localhost:5555", "10.0.0.5:5555": "http://10.0.0.5:5555", "localhost:1": "http://localhost:1", "http://x:1": "http://x:1", "https://x.example/": "https://x.example", "": "http://localhost:5555", } { if got := baseURL(in); got != want { t.Errorf("baseURL(%q) = %q, want %q", in, got, want) } } } // --in / --out restrict the table to one flow; both or neither give the full table. func TestRun_FlowFlags(t *testing.T) { f := &fakeAPI{body: sampleResponse()} s := httptest.NewServer(f) defer s.Close() cfg := writeConfig(t, "auth_token = \"t\"\n") base := []string{"--config", cfg, "--url", s.URL, "--address", "me@example.com"} code, out, errOut := runCLI(t, noEnv, append(base, "--in")...) if code != 0 || errOut != "" { t.Fatalf("--in: exit %d stderr %q", code, errOut) } lines := strings.Split(strings.TrimSpace(out), "\n") if len(lines) != 5 || strings.Join(strings.Fields(lines[1]), " ") != "LEVEL ID IN LIMIT" { t.Errorf("--in table:\n%s", out) } wantIn := [][]string{{"A", "me@example.com", "12", "500"}, {"D", "example.com", "40", "1000"}, {"O", "1234", "120", "5000"}} for i, w := range wantIn { if got := strings.Join(strings.Fields(lines[2+i]), " "); got != strings.Join(w, " ") { t.Errorf("--in row %d = %q, want %v", i, got, w) } } code, out, _ = runCLI(t, noEnv, append(base, "--out")...) if code != 0 { t.Fatalf("--out: exit %d", code) } lines = strings.Split(strings.TrimSpace(out), "\n") if len(lines) != 5 || strings.Join(strings.Fields(lines[1]), " ") != "LEVEL ID OUT LIMIT" { t.Errorf("--out table:\n%s", out) } wantOut := [][]string{{"A", "me@example.com", "200!", "200"}, {"D", "example.com", "9", "-"}, {"O", "1234", "30", "1000"}} for i, w := range wantOut { if got := strings.Join(strings.Fields(lines[2+i]), " "); got != strings.Join(w, " ") { t.Errorf("--out row %d = %q, want %v", i, got, w) } } _, full, _ := runCLI(t, noEnv, base...) _, both, _ := runCLI(t, noEnv, append(base, "--in", "--out")...) if both != full { t.Errorf("--in --out should equal the full table:\n%s\nvs\n%s", both, full) } // --json is the raw response whatever the flow flags say. code, out, _ = runCLI(t, noEnv, append(base, "--out", "--json")...) var got api.CountersResponse if code != 0 || json.Unmarshal([]byte(out), &got) != nil || len(got.Levels) != 3 || got.Levels[0].In != 12 { t.Errorf("--out --json: exit %d output %s", code, out) } } func TestRun_Version(t *testing.T) { code, out, _ := runCLI(t, noEnv, "--version") if code != 0 || !strings.HasPrefix(out, "counterctl ") { t.Errorf("exit %d output %q", code, out) } }