Files
felhom-controller/controller/internal/upwatch/explain_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

141 lines
4.8 KiB
Go

package upwatch
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type envBox map[string]string
func (b envBox) Env(c, n string) (string, error) {
if v, ok := b[c+"/"+n]; ok {
return v, nil
}
return "", fmt.Errorf("no %s in %s", n, c)
}
func (b envBox) Running(string) bool { return true }
// qBittorrent as measured on 9202: a login is 204 + a SID cookie, and only when Referer equals the base; transfer/info
// carries up_info_data; a stale session is 403.
func fakeQBServer(t *testing.T, pw string, logins *int, expireNext *bool, limit *int64) *httptest.Server {
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v2/auth/login":
r.ParseForm()
if r.Header.Get("Referer") != srv.URL || r.Form.Get("username") != "admin" || r.Form.Get("password") != pw {
w.WriteHeader(401)
return
}
*logins++
http.SetCookie(w, &http.Cookie{Name: "QBT_SID_8080", Value: fmt.Sprint("s", *logins)})
w.WriteHeader(204)
case "/api/v2/transfer/info":
if *expireNext {
*expireNext = false
w.WriteHeader(403)
return
}
if !strings.HasPrefix(r.Header.Get("Cookie"), "QBT_SID_8080=") {
w.WriteHeader(403)
return
}
fmt.Fprint(w, `{"up_info_data":123456789,"up_info_speed":16384000,"up_rate_limit":0}`)
case "/api/v2/transfer/uploadLimit":
fmt.Fprint(w, *limit)
case "/api/v2/transfer/setUploadLimit":
r.ParseForm()
var v int64
fmt.Sscan(r.Form.Get("limit"), &v)
*limit = v / 1024 * 1024
w.WriteHeader(200)
default:
w.WriteHeader(404)
}
}))
return srv
}
// RED-PROOF: send no Referer → 401 → fails; log in every minute instead of keeping the session → logins == 3 → fails.
func TestQBittorrent_AskKeepsTheSessionAndReLogsInOn403(t *testing.T) {
logins, expire, limit := 0, false, int64(0)
srv := fakeQBServer(t, "pw-canary", &logins, &expire, &limit)
defer srv.Close()
q := &QBittorrent{Box: envBox{"qbittorrent/QBT_ADMIN_PASSWORD": "pw-canary"}, URL: srv.URL}
for i := 0; i < 3; i++ {
r, err := q.Ask(context.Background())
if err != nil || !r.HasUploadCounter || r.UploadCounter != 123456789 || !r.HasLimit {
t.Fatalf("ask %d: %+v %v", i, r, err)
}
}
if logins != 1 {
t.Fatalf("logins = %d for three minutes — the session must be kept", logins)
}
expire = true
if _, err := q.Ask(context.Background()); err != nil || logins != 2 {
t.Fatalf("after a 403: err=%v logins=%d — want one fresh login", err, logins)
}
got, err := q.SetUploadLimit(context.Background(), 2500000)
if err != nil || got != 2499584 {
t.Fatalf("SetUploadLimit = %d %v, want the stored 2499584", got, err)
}
}
// Transmission's RPC: a 409 hands out the session id; Basic auth with the catalog's PASS.
func TestTransmission_AskFollowsTheSessionID(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Basic "+base64.StdEncoding.EncodeToString([]byte("admin:tr-pw")) {
w.WriteHeader(401)
return
}
if r.Header.Get("X-Transmission-Session-Id") != "sid-1" {
w.Header().Set("X-Transmission-Session-Id", "sid-1")
w.WriteHeader(409)
return
}
fmt.Fprint(w, `{"arguments":{"current-stats":{"uploadedBytes":987654321},"uploadSpeed":0},"result":"success"}`)
}))
defer srv.Close()
tr := &Transmission{Box: envBox{"transmission/PASS": "tr-pw"}, URL: srv.URL}
r, err := tr.Ask(context.Background())
if err != nil || r.UploadCounter != 987654321 || !r.HasUploadCounter {
t.Fatalf("Ask = %+v %v", r, err)
}
}
// Plex: the token goes along only when the server is claimed; both session lists are read.
// RED-PROOF: always send the header → the unclaimed case sends "X-Plex-Token: " → fails.
func TestPlex_AskReadsSessionsWithTheTokenOnlyWhenClaimed(t *testing.T) {
var seen []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, has := r.Header["X-Plex-Token"]
seen = append(seen, fmt.Sprintf("%s token=%v", r.URL.Path, has))
switch r.URL.Path {
case "/status/sessions":
fmt.Fprint(w, `{"MediaContainer":{"size":2}}`)
case "/transcode/sessions":
fmt.Fprint(w, `{"MediaContainer":{"size":1}}`)
}
}))
defer srv.Close()
for _, tok := range []string{"", "plex-token-canary"} {
seen = nil
p := &Plex{Token: func() string { return tok }, URL: func(int) (string, error) { return srv.URL, nil }}
r, err := p.Ask(context.Background())
if err != nil || r.Viewers != 2 || r.Transcodes != 1 || !r.HasViewerCount || !r.HasTranscodeCount {
t.Fatalf("Ask = %+v %v", r, err)
}
want := fmt.Sprintf("token=%v", tok != "")
for _, s := range seen {
if !strings.HasSuffix(s, want) {
t.Fatalf("claimed=%v: request %q", tok != "", s)
}
}
}
}