package agent import ( "os" "path/filepath" "strings" "testing" "time" ) // Real line shapes captured from sing-box v1.13.21 in the B3 spike, // ANSI color sequences included (the core colorizes the level and // the connection id even when its output is redirected to a file). const ( spikeAuthSS = "+0000 2026-10-22 21:54:58 \x1b[36mINFO\x1b[0m [\x1b[38;5;51m1785752244\x1b[0m 0ms] inbound/shadowsocks[ss22]: [bob@example.com] inbound connection to 127.0.0.1:18080" spikeAuthMixed = "+0000 2026-10-22 21:54:58 \x1b[36mINFO\x1b[0m [\x1b[38;5;231m1630640343\x1b[0m 0ms] inbound/mixed[mixed-in]: [carol@example.com] inbound connection to 127.0.0.1:18080" spikeFromLine = "+0000 2026-10-22 21:54:58 \x1b[36mINFO\x1b[0m [\x1b[38;5;51m1785752244\x1b[0m 0ms] inbound/shadowsocks[ss22]: inbound connection from 127.0.0.1:52076" spikeErrorLine = "+0000 2026-10-22 21:54:57 \x1b[31mERROR\x1b[0m [\x1b[38;5;158m398453065\x1b[0m 85ms] inbound/vless[vless-reality]: process connection from 127.0.0.1:42732: TLS handshake: REALITY: processed invalid connection" spikeStartLine = "+0000 2026-10-22 21:53:31 \x1b[36mINFO\x1b[0m inbound/mixed[mixed-in]: tcp server started at 127.0.0.1:24445" ) func TestParseSingboxAuthLine(t *testing.T) { cases := []struct { line string want string }{ {spikeAuthSS, "bob@example.com"}, {spikeAuthMixed, "carol@example.com"}, {spikeFromLine, ""}, // unauthenticated stage: never counts {spikeErrorLine, ""}, // failed handshake: never counts {spikeStartLine, ""}, {"", ""}, } for _, tc := range cases { if got := parseSingboxAuthLine(tc.line); got != tc.want { t.Errorf("parse(%q) = %q, want %q", tc.line, got, tc.want) } } } func TestSingboxOnlineTrackerPollAndGrace(t *testing.T) { path := filepath.Join(t.TempDir(), "singbox.log") now := time.Unix(1_700_000_000, 0) tr := newSingboxOnlineTracker(path) // No file yet: quiet no-op. tr.Poll(now) if got := tr.Online(now); len(got) != 0 { t.Fatalf("online = %v, want empty", got) } content := spikeFromLine + "\n" + spikeAuthSS + "\n" + spikeErrorLine + "\n" if err := os.WriteFile(path, []byte(content), 0o640); err != nil { t.Fatal(err) } tr.Poll(now) got := tr.Online(now) if len(got) != 1 || got[0] != "bob@example.com" { t.Fatalf("online = %v, want [bob@example.com]", got) } // Re-poll without new content: no double counting, same set. tr.Poll(now) if got := tr.Online(now); len(got) != 1 { t.Fatalf("online after idle poll = %v", got) } // Past the grace window the user ages out. if got := tr.Online(now.Add(singboxOnlineGrace + time.Second)); len(got) != 0 { t.Fatalf("online after grace = %v, want empty", got) } // A partial line (no trailing newline) is not consumed until it // completes. (bob is still within his grace window here; only // carol's appearance is being timed.) partial := spikeAuthMixed // no trailing \n yet if err := appendToFile(path, partial); err != nil { t.Fatal(err) } later := now.Add(time.Minute) tr.Poll(later) if got := tr.Online(later); strings.Contains(strings.Join(got, ","), "carol@example.com") { t.Fatalf("partial line must not count, online = %v", got) } if err := appendToFile(path, "\n"); err != nil { t.Fatal(err) } tr.Poll(later) got = tr.Online(later) if !strings.Contains(strings.Join(got, ","), "carol@example.com") { t.Fatalf("online after line completes = %v, want carol@example.com present", got) } } func TestSingboxOnlineTrackerTruncation(t *testing.T) { path := filepath.Join(t.TempDir(), "singbox.log") now := time.Unix(1_700_000_000, 0) tr := newSingboxOnlineTracker(path) if err := os.WriteFile(path, []byte(spikeAuthSS+"\n"+spikeAuthSS+"\n"), 0o640); err != nil { t.Fatal(err) } tr.Poll(now) // File shrinks below the cursor (rotation): rescan from the top. if err := os.WriteFile(path, []byte(spikeAuthMixed+"\n"), 0o640); err != nil { t.Fatal(err) } tr.Poll(now) got := tr.Online(now) joined := strings.Join(got, ",") if !strings.Contains(joined, "carol@example.com") || !strings.Contains(joined, "bob@example.com") { t.Fatalf("online after truncation = %v, want both users", got) } } func appendToFile(path, s string) error { f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0) if err != nil { return err } defer f.Close() _, err = f.WriteString(s) return err }