da647e87d0
The trust boundary is zenmoney, not maven — they already hold his bank sessions. So the poller reads /v8/diff/ and writes totals as facts(kind=env, source=poll:zenmoney); core reads those back when he asks and never sees the token. internal/zenmoney sums transactions per currency over a window, skipping tombstoned rows and transfers between his own accounts, and refuses to encode a summary built from zero transactions. That refusal is the whole design: a failed or empty read writes nothing and leaves the last good total alone, because a zero recited as fact is worse than silence. No currency conversion either — a figure he can check against his bank beats one he cannot. Off unless configured, and the token is read from a FILE rather than a flag so it never lands in `ps`, in docker-compose.yml, or in shell history. Nothing about the money is search input, no tick rule reads the keys, and the log lines name keys, never figures. The live-credential half is BLOCKED: there is no zenmoney account or token here, so everything is verified against a recorded diff fixture.
191 lines
5.7 KiB
Go
191 lines
5.7 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/zenmoney"
|
|
)
|
|
|
|
func TestMaxSeverity(t *testing.T) {
|
|
parse := func(s string) netdataAlarms {
|
|
var a netdataAlarms
|
|
if err := json.Unmarshal([]byte(s), &a); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return a
|
|
}
|
|
cases := []struct{ body, want string }{
|
|
{`{"alarms":{}}`, "clear"},
|
|
{`{"alarms":{"a":{"status":"WARNING"}}}`, "warning"},
|
|
{`{"alarms":{"a":{"status":"WARNING"},"b":{"status":"CRITICAL"}}}`, "critical"},
|
|
{`{"alarms":{"a":{"status":"CLEAR"}}}`, "clear"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := maxSeverity(parse(c.body)); got != c.want {
|
|
t.Errorf("maxSeverity(%s) = %q, want %q", c.body, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestKumaAnyDown(t *testing.T) {
|
|
cases := []struct {
|
|
body string
|
|
down, seen bool
|
|
}{
|
|
{"", false, false},
|
|
{`monitor_status{monitor_name="web"} 1`, false, true},
|
|
{`monitor_status{monitor_name="web"} 1` + "\n" + `monitor_status{monitor_name="db"} 0`, true, true},
|
|
{`monitor_status{monitor_name="mnt"} 3`, false, true}, // maintenance ≠ down
|
|
{`# HELP monitor_status ...`, false, false},
|
|
}
|
|
for _, c := range cases {
|
|
down, seen := kumaAnyDown([]byte(c.body))
|
|
if down != c.down || seen != c.seen {
|
|
t.Errorf("kumaAnyDown(%q) = (%v,%v), want (%v,%v)", c.body, down, seen, c.down, c.seen)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseMaxHandshake(t *testing.T) {
|
|
cases := []struct {
|
|
out string
|
|
want int64
|
|
}{
|
|
{"", 0},
|
|
{"pubkeyAAA\t0\n", 0}, // never handshaked
|
|
{"pubkeyAAA\t1700000000\n", 1700000000},
|
|
{"pubkeyAAA\t1700000000\npubkeyBBB\t1700000500\n", 1700000500}, // max wins
|
|
{"wg0\tpubkeyAAA\t1700000000\nwg0\tpubkeyBBB\t0\n", 1700000000}, // 'all' 3-field form
|
|
}
|
|
for _, c := range cases {
|
|
if got := parseMaxHandshake(c.out); got != c.want {
|
|
t.Errorf("parseMaxHandshake(%q) = %d, want %d", c.out, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- zenmoney (Vikunja #125) ----------------------------------------------
|
|
|
|
// factCore records the facts the poller wrote and answers "no fact yet".
|
|
type factCore struct {
|
|
ipc.UnimplementedCoreAPI
|
|
|
|
written []ipc.WriteFactReq
|
|
prev map[string]string
|
|
}
|
|
|
|
func (c *factCore) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) {
|
|
if v, ok := c.prev[key+"|"+source]; ok {
|
|
return ipc.Fact{Key: key, Source: source, Value: v}, nil
|
|
}
|
|
return ipc.Fact{}, ipc.ErrNoFact
|
|
}
|
|
|
|
func (c *factCore) WriteFact(_ context.Context, req ipc.WriteFactReq) (int64, error) {
|
|
c.written = append(c.written, req)
|
|
return int64(len(c.written)), nil
|
|
}
|
|
|
|
func zenFixtureServer(t *testing.T, body []byte, status int) *httptest.Server {
|
|
t.Helper()
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if status != http.StatusOK {
|
|
w.WriteHeader(status)
|
|
return
|
|
}
|
|
w.Write(body)
|
|
}))
|
|
}
|
|
|
|
func TestPollZenmoneyWritesMoneyFacts(t *testing.T) {
|
|
body, err := os.ReadFile("../../internal/zenmoney/testdata/diff.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := zenFixtureServer(t, body, http.StatusOK)
|
|
defer srv.Close()
|
|
zen, err := zenmoney.New("tok", srv.URL, time.Second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
core := &factCore{}
|
|
p := &poller{core: core, zen: zen}
|
|
now := time.Date(2026, 8, 1, 21, 0, 0, 0, time.UTC)
|
|
if err := p.pollZenmoney(context.Background(), now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(core.written) != 2 {
|
|
t.Fatalf("wrote %d facts, want today + month", len(core.written))
|
|
}
|
|
for _, f := range core.written {
|
|
if f.Kind != "env" || f.Source != zenmoney.Source {
|
|
t.Errorf("fact = %+v, want kind=env source=%s", f, zenmoney.Source)
|
|
}
|
|
if _, err := zenmoney.ParseFactValue(f.Value); err != nil {
|
|
t.Errorf("fact value %q does not decode: %v", f.Value, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A read that returns nothing for the window writes NOTHING. Silence, not a
|
|
// zero: an invented 0 would be recited back to him as fact.
|
|
func TestPollZenmoneyWritesNothingWhenEmpty(t *testing.T) {
|
|
srv := zenFixtureServer(t, []byte(`{"serverTimestamp":1,"instrument":[],"transaction":[]}`), http.StatusOK)
|
|
defer srv.Close()
|
|
zen, _ := zenmoney.New("tok", srv.URL, time.Second)
|
|
core := &factCore{}
|
|
p := &poller{core: core, zen: zen}
|
|
if err := p.pollZenmoney(context.Background(), time.Now()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(core.written) != 0 {
|
|
t.Errorf("wrote %+v, want no fact at all", core.written)
|
|
}
|
|
}
|
|
|
|
// An API failure must not overwrite the last good total either.
|
|
func TestPollZenmoneyFailureWritesNothing(t *testing.T) {
|
|
srv := zenFixtureServer(t, nil, http.StatusUnauthorized)
|
|
defer srv.Close()
|
|
zen, _ := zenmoney.New("bad", srv.URL, time.Second)
|
|
core := &factCore{}
|
|
p := &poller{core: core, zen: zen}
|
|
if err := p.pollZenmoney(context.Background(), time.Now()); err == nil {
|
|
t.Error("want the 401 reported")
|
|
}
|
|
if len(core.written) != 0 {
|
|
t.Errorf("wrote %+v on a failed read", core.written)
|
|
}
|
|
}
|
|
|
|
// Unchanged totals do not churn the facts table.
|
|
func TestWriteIfChangedRawSkipsUnchanged(t *testing.T) {
|
|
core := &factCore{prev: map[string]string{
|
|
zenmoney.KeySpentToday + "|" + zenmoney.Source: `{"count":1}`,
|
|
}}
|
|
p := &poller{core: core}
|
|
if err := p.writeIfChangedRaw(context.Background(), zenmoney.KeySpentToday, zenmoney.Source, `{"count":1}`, time.Now()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(core.written) != 0 {
|
|
t.Errorf("wrote %+v for an unchanged value", core.written)
|
|
}
|
|
}
|
|
|
|
// Money tracking is off unless configured: no token file, no zenmoney client,
|
|
// and the poller still refuses to start with nothing at all to poll.
|
|
func TestRunRequiresSomethingToPoll(t *testing.T) {
|
|
err := run([]string{"-socket", "/tmp/nope.sock", "-netdata", "", "-kuma", "", "-wg", ""})
|
|
if err == nil || !strings.Contains(err.Error(), "nothing to poll") {
|
|
t.Errorf("err = %v, want a 'nothing to poll' refusal", err)
|
|
}
|
|
}
|