package upwatch import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" ) // fakeQBPrefs is qBittorrent 5.2.4's limit API as measured on 2026-10-25 (felhom.eu // `audits/night-2026-10-25/r1261-qbittorrent-api-measured.txt`): transfer/uploadLimit and setUploadLimit act on the // slot of the CURRENT speed mode (setUploadLimit rounds to whole KiB); app/preferences shows up_limit and alt_up_limit // in B/s; app/setPreferences writes either slot exactly, in either mode. type fakeQBPrefs struct { up, alt int64 altMode bool } func (f *fakeQBPrefs) server(t *testing.T, pw string) *httptest.Server { var srv *httptest.Server srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/v2/auth/login" { r.ParseForm() if r.Header.Get("Referer") != srv.URL || r.Form.Get("password") != pw { w.WriteHeader(401) return } http.SetCookie(w, &http.Cookie{Name: "QBT_SID_8080", Value: "s1"}) w.WriteHeader(204) return } active := &f.up if f.altMode { active = &f.alt } switch r.URL.Path { case "/api/v2/transfer/speedLimitsMode": if f.altMode { fmt.Fprint(w, "1") } else { fmt.Fprint(w, "0") } case "/api/v2/transfer/toggleSpeedLimitsMode": f.altMode = !f.altMode case "/api/v2/transfer/uploadLimit": fmt.Fprint(w, *active) case "/api/v2/transfer/setUploadLimit": r.ParseForm() var v int64 fmt.Sscan(r.Form.Get("limit"), &v) *active = v / 1024 * 1024 case "/api/v2/app/preferences": json.NewEncoder(w).Encode(map[string]any{"up_limit": f.up, "alt_up_limit": f.alt, "scheduler_enabled": false}) case "/api/v2/app/setPreferences": r.ParseForm() var m map[string]int64 if err := json.Unmarshal([]byte(r.Form.Get("json")), &m); err != nil { w.WriteHeader(400) return } if v, ok := m["up_limit"]; ok { f.up = v } if v, ok := m["alt_up_limit"]; ok { f.alt = v } default: w.WriteHeader(404) } })) return srv } // R-1261 edge 1: two seeders behind one uplink. Holding only the first left the second seeding, so the agent refused // the test „not quiet" every 15 minutes and it was never counted. // RED-PROOF: hold only the first running app (the 0.343.0 loop's `break`) → the second is never held → fails. func TestR1261_EverySeederIsHeldAndEveryLimitComesBack(t *testing.T) { qb := &fakeTorrent{limit: HeldLimit{Bytes: 2_499_584}} tr := &fakeTorrent{name: "transmission", limit: HeldLimit{Enabled: true, KBps: 500}} store := &memStore{} runs := 0 svc := &Service{Store: store, Torrents: []Holdable{qb, tr}, TorrentRunning: func(string) bool { return true }, LineWaiting: func(context.Context) bool { return true }, LineRun: func(context.Context) (LineRunResult, error) { runs++ if qb.limit != (HeldLimit{Bytes: 1024}) || tr.limit != (HeldLimit{Bytes: 1024}) { t.Fatalf("during the test qbittorrent=%+v transmission=%+v — every seeder must be at the test value", qb.limit, tr.limit) } if n := len(heldList(decode(store.GetUpwatch()))); n != 2 { t.Fatalf("during the test %d record(s), want 2 — a restart now could not put both back", n) } return LineRunResult{OK: true, Mbps: 92}, nil }} svc.SeedLineTestNow(context.Background()) if runs != 1 { t.Fatalf("agent asked %d times, want 1", runs) } if qb.limit != (HeldLimit{Bytes: 2_499_584}) || tr.limit != (HeldLimit{Enabled: true, KBps: 500}) { t.Fatalf("after the test qbittorrent=%+v transmission=%+v, want both limits back", qb.limit, tr.limit) } if n := len(heldList(decode(store.GetUpwatch()))); n != 0 { t.Fatalf("%d record(s) left after the test", n) } } // R-1261 edge 2: an app removed while held. The record was kept „until it runs" — forever — and qBittorrent's 50 % // rule never ran again. The consequence asserted: the record goes, and the next hold can run. // RED-PROOF: drop the TorrentInstalled check in RecoverHold → the record stays, no test runs → fails. func TestR1261_ARecordOfAnAppRemovedWhileHeldIsDropped(t *testing.T) { r := newHoldRig(t) r.store.UpdateUpwatch(func(json.RawMessage) (json.RawMessage, error) { return json.Marshal(State{Holds: []LineHold{{App: "qbittorrent", Prev: HeldLimit{Bytes: 2_499_584}, Held: HeldLimit{Bytes: 1024}}}}) }) r.running = false installed := false r.svc.TorrentInstalled = func(string) bool { return installed } if !r.svc.RecoverHold(context.Background()) || r.record() != nil { t.Fatalf("the record of a removed app must be dropped, got %+v", r.record()) } // Still installed but stopped: the record is kept for when it runs again (the old rule, unchanged). r.store.UpdateUpwatch(func(json.RawMessage) (json.RawMessage, error) { return json.Marshal(State{Holds: []LineHold{{App: "qbittorrent", Prev: HeldLimit{Bytes: 2_499_584}, Held: HeldLimit{Bytes: 1024}}}}) }) installed = true if r.svc.RecoverHold(context.Background()) || r.record() == nil { t.Fatal("a stopped but installed app keeps its record") } // The app is reinstalled and runs: the hold runs again (the next weekly test is no longer blocked). r.store.UpdateUpwatch(func(json.RawMessage) (json.RawMessage, error) { return json.Marshal(State{}) }) r.running = true r.svc.SeedLineTestNow(context.Background()) if r.runs != 1 { t.Fatalf("after the record is gone the line test runs again; runs=%d", r.runs) } } // R-1261 edge 3: the household switches qBittorrent to its alternative speed during the test. transfer/uploadLimit // then reads the ALTERNATIVE slot, the release took it for „the household's change", and the normal slot kept 1 KiB/s. // Now the hold names its slot and the release reads and writes that slot. // RED-PROOF: read the current mode's slot in ReadLimitLike (the old behaviour) → up_limit stays 1024 → fails. func TestR1261_ASpeedModeSwitchDuringTheTestNeverLeavesTheTestValue(t *testing.T) { for _, startAlt := range []bool{false, true} { t.Run(fmt.Sprintf("hold in alt mode=%v", startAlt), func(t *testing.T) { fq := &fakeQBPrefs{up: 2_499_584, alt: 3_072_000, altMode: startAlt} srv := fq.server(t, "pw-canary") defer srv.Close() qb := &QBittorrent{Box: envBox{"qbittorrent/QBT_ADMIN_PASSWORD": "pw-canary"}, URL: srv.URL} store := &memStore{} svc := &Service{Store: store, Torrents: []Holdable{qb}, TorrentRunning: func(string) bool { return true }, LineWaiting: func(context.Context) bool { return true }, LineRun: func(context.Context) (LineRunResult, error) { held := fq.up if startAlt { held = fq.alt } if held != 1024 { t.Fatalf("during the test the active slot holds %d, want 1024", held) } fq.altMode = !fq.altMode // the household presses the turtle return LineRunResult{OK: true, Mbps: 92}, nil }} svc.SeedLineTestNow(context.Background()) if fq.up != 2_499_584 || fq.alt != 3_072_000 { t.Fatalf("after the test up_limit=%d alt_up_limit=%d, want 2499584 and 3072000 — never the 1 KiB/s test value", fq.up, fq.alt) } if n := len(heldList(decode(store.GetUpwatch()))); n != 0 { t.Fatalf("%d record(s) left", n) } }) } } // A record that controller 0.343.0 left (the single `line_test_hold`) is honoured and cleared. func TestR1261_ARecordThe0343ControllerLeftIsPutBack(t *testing.T) { r := newHoldRig(t) r.app.limit = HeldLimit{Bytes: 1024} r.store.UpdateUpwatch(func(json.RawMessage) (json.RawMessage, error) { return json.Marshal(State{Hold: &LineHold{App: "qbittorrent", Prev: HeldLimit{Bytes: 2_499_584}, Held: HeldLimit{Bytes: 1024}}}) }) if !r.svc.RecoverHold(context.Background()) || r.app.limit != (HeldLimit{Bytes: 2_499_584}) { t.Fatalf("limit %+v, want the 0.343.0 record's limit back", r.app.limit) } if st := decode(r.store.GetUpwatch()); st.Hold != nil || len(st.Holds) != 0 { t.Fatalf("the old record must be cleared, got %+v", st) } }