Files
felhom-controller/controller/internal/upwatch/line_test.go
T
admin a9c1997fbc
gates / gates (push) Successful in 1m17s
v0.342.0 (unreleased): the upload and CPU watch — an app that sends or burns far more than its own numbers explain is one operator-only alarm; the line learnt from the off-site push; qBittorrent's default upload limit (never over the household's)
internal/upwatch: exact per-container counters from the Docker Engine API, per-app judging (upload >= max(5 Mbit/s,
25% of the line) for 15 min; CPU >= max(150%, 50% x cores) for 30 min), explainers qBittorrent / Transmission / Plex,
app_unexplained_upload / app_unexplained_cpu + _cleared (hub 0.169.0). The off-site push is sampled once a second
for the line (a lower bound). qBittorrent limit = 50% of the line, the household's limit always wins. 20 red-proofs.
Spike + design: felhom.eu audits/SPIKE-upload-watch-2026-10-23.md, architecture/08 section 9. Not released.
2026-10-23 21:26:09 +02:00

148 lines
5.9 KiB
Go

package upwatch
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
"time"
)
// The best ≥ 10-second rate, and a push only COUNTS when that window moved ≥ 10 MB.
// RED-PROOF: compute the peak over 1-second windows → the 4 MB burst reads as 33 Mbit/s → fails.
func TestLine_SummarizePeakAndCounts(t *testing.T) {
series := func(perSecond []uint64) []Point {
t0 := time.Date(2026, 10, 24, 2, 16, 0, 0, time.UTC)
pts := []Point{{t0, 0}}
for i, b := range perSecond {
pts = append(pts, Point{t0.Add(time.Duration(i+1) * time.Second), pts[len(pts)-1].V + b})
}
return pts
}
steady := make([]uint64, 60)
for i := range steady {
steady[i] = 50e6 / 8
}
if r := Summarize(series(steady), time.Now()); r.Peak10Mbps < 49.9 || r.Peak10Mbps > 50.1 || !r.Counts || r.Seconds != 60 {
t.Fatalf("steady 50 Mbit/s push: %+v", r)
}
burst := make([]uint64, 30)
burst[3] = 4 << 20
if r := Summarize(series(burst), time.Now()); r.Counts || r.Peak10Mbps > 4 {
t.Fatalf("a 4 MB one-second push must not set the line: %+v", r)
}
}
// The sampler itself: readings from a live (fast) ticker reach the summary.
func TestLine_PushSamplerCollects(t *testing.T) {
var mu sync.Mutex
var v uint64
p := &PushSampler{Every: time.Millisecond, Read: func() (uint64, error) { mu.Lock(); defer mu.Unlock(); v += 1000; return v, nil }}
stop := p.Start()
time.Sleep(30 * time.Millisecond)
r := stop()
if r.Bytes < 2000 {
t.Fatalf("sampler collected %d bytes", r.Bytes)
}
}
func TestLine_FromRecordsUsesOnlyCountingNightsAndKeepsFourteen(t *testing.T) {
if _, ok := LineFromRecords(nil); ok {
t.Fatal("no record: the line must be unknown")
}
recs := []LineRecord{{Peak10Mbps: 90, Counts: false}, {Peak10Mbps: 40, Counts: true}, {Peak10Mbps: 55, Counts: true}}
if l, ok := LineFromRecords(recs); !ok || l != 55 {
t.Fatalf("line = %v %v, want 55 (the 90 did not count)", l, ok)
}
var keep []LineRecord
for i := 0; i < 20; i++ {
keep = AddRecord(keep, LineRecord{Peak10Mbps: float64(i), Counts: true})
}
if len(keep) != KeepNights || keep[0].Peak10Mbps != 6 {
t.Fatalf("kept %d, first %v", len(keep), keep[0].Peak10Mbps)
}
}
func TestLine_TxFromNetDevSkipsLoopback(t *testing.T) {
p := filepath.Join(t.TempDir(), "dev")
os.WriteFile(p, []byte(`Inter-| Receive | Transmit
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
lo: 9000 10 0 0 0 0 0 0 9000 10 0 0 0 0 0 0
eth0: 100 1 0 0 0 0 0 0 774708 5 0 0 0 0 0 0
eth1: 1 1 0 0 0 0 0 0 1000 1 0 0 0 0 0 0
`), 0o644)
v, err := txFromNetDev(p)
if err != nil || v != 775708 {
t.Fatalf("tx = %d %v, want 775708 (eth0+eth1, not lo)", v, err)
}
}
// ── qBittorrent's default limit ──
type fakeQB struct {
cur int64
sets []int64
}
func (f *fakeQB) UploadLimit(context.Context, map[string]string) (int64, error) { return f.cur, nil }
func (f *fakeQB) SetUploadLimit(_ context.Context, b int64) (int64, error) {
f.sets = append(f.sets, b)
f.cur = b / 1024 * 1024 // qBittorrent keeps KiB/s
return f.cur, nil
}
// RED-PROOF per row: drop the household check → the „household" rows set a limit → fail; compare against the
// requested value instead of the stored one → the box's own rounding reads as a household change → the re-set row fails.
func TestQBLimit_TheHouseholdAlwaysWins(t *testing.T) {
now := time.Date(2026, 10, 24, 3, 0, 0, 0, time.UTC)
ctx := context.Background()
// 1. Unlimited, never set, line 100 Mbit/s, share 50 % → 6 250 000 B/s requested, stored rounded.
q := &fakeQB{}
st, act, err := ApplyQBLimit(ctx, q, QBLimitState{}, 100, true, 0.5, now)
if err != nil || act != "set" || len(q.sets) != 1 || q.sets[0] != 6250000 || st.BoxSetBytes != 6249472 {
t.Fatalf("first set: %s %v %+v sets=%v", act, err, st, q.sets)
}
// 2. Same line, qBittorrent still holds the box's (rounded) value → kept, no write.
st2, act, _ := ApplyQBLimit(ctx, q, st, 105, true, 0.5, now)
if act != "kept" || len(q.sets) != 1 {
t.Fatalf("unchanged: %s sets=%v", act, q.sets)
}
// 3. The line moved > 20 % → re-set.
st3, act, _ := ApplyQBLimit(ctx, q, st2, 200, true, 0.5, now)
if act != "set" || len(q.sets) != 2 || st3.LineMbps != 200 {
t.Fatalf("line moved: %s %+v", act, st3)
}
// 4. The household changed it in qBittorrent → theirs, never touched again, even when the line moves.
q.cur = 1000000
st4, act, _ := ApplyQBLimit(ctx, q, st3, 200, true, 0.5, now)
if act != "household" || !st4.Household {
t.Fatalf("household change: %s %+v", act, st4)
}
if _, act, _ := ApplyQBLimit(ctx, q, st4, 50, true, 0.5, now); act != "household" || len(q.sets) != 2 {
t.Fatalf("after the household took it: %s sets=%v", act, q.sets)
}
// 5. The household REMOVED the box's limit (0) → theirs too.
q2 := &fakeQB{}
s, _, _ := ApplyQBLimit(ctx, q2, QBLimitState{}, 100, true, 0.5, now)
q2.cur = 0
if s2, act, _ := ApplyQBLimit(ctx, q2, s, 100, true, 0.5, now); act != "household" || !s2.Household {
t.Fatalf("household removed the limit: %s", act)
}
// 6. A limit already there before the box ever looked → the household's.
q3 := &fakeQB{cur: 500000}
if s3, act, _ := ApplyQBLimit(ctx, q3, QBLimitState{}, 100, true, 0.5, now); act != "household" || !s3.Household || len(q3.sets) != 0 {
t.Fatalf("pre-existing limit: %s", act)
}
// 6b. That household later REMOVES its own limit (unlimited again) → still theirs: the box does not step in.
q3.cur = 0
if _, act, _ := ApplyQBLimit(ctx, q3, QBLimitState{Household: true}, 100, true, 0.5, now); act != "household" || len(q3.sets) != 0 {
t.Fatalf("household set unlimited after its own limit: %s sets=%v — the box overrode their choice", act, q3.sets)
}
// 7. No line measured → nothing set.
q4 := &fakeQB{}
if _, act, _ := ApplyQBLimit(ctx, q4, QBLimitState{}, 0, false, 0.5, now); act != "no-line" || len(q4.sets) != 0 {
t.Fatalf("no line: %s", act)
}
}