Files

266 lines
7.8 KiB
Go
Raw Permalink Normal View History

2026-07-18 10:02:43 +03:00
package main
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
func TestRandMagicRange(t *testing.T) {
for i := 0; i < 100000; i++ {
v := randMagic()
if v < 5 || v > 2147483647 {
t.Fatalf("randMagic out of range: %d", v)
}
}
}
func TestRandRange(t *testing.T) {
for i := 0; i < 100000; i++ {
v := randRange(8, 32)
if v < 8 || v > 32 {
t.Fatalf("randRange out of [8,32]: %d", v)
}
}
// single-value range
if v := randRange(15, 15); v != 15 {
t.Fatalf("randRange(15,15)=%d, want 15", v)
}
// inverted range returns min (with a warning)
if v := randRange(50, 10); v != 50 {
t.Fatalf("randRange(50,10)=%d, want 50", v)
}
}
func TestIncrementIP(t *testing.T) {
cases := []struct{ cur, net, want string }{
{"", "10.0.0.0/24", "10.0.0.2"},
{"10.0.0.2", "10.0.0.0/24", "10.0.0.3"},
{"192.168.5.9", "192.168.5.0/24", "192.168.5.10"},
}
for _, c := range cases {
if got := incrementIP(c.cur, c.net); got != c.want {
t.Errorf("incrementIP(%q,%q)=%q, want %q", c.cur, c.net, got, c.want)
}
}
}
func TestServerAndFirstIP(t *testing.T) {
if got := serverIP("10.110.90.0/24"); got != "10.110.90.1" {
t.Errorf("serverIP=%q", got)
}
if got := getFirstClientIP("10.110.90.0/24"); got != "10.110.90.2" {
t.Errorf("getFirstClientIP=%q", got)
}
}
func TestSanitize(t *testing.T) {
cases := map[string]string{
"phone": "phone",
"my client!": "my_client_",
"a/b\\c": "a_b_c",
"ok.name-1_2": "ok.name-1_2",
}
for in, want := range cases {
if got := sanitize(in); got != want {
t.Errorf("sanitize(%q)=%q, want %q", in, got, want)
}
}
}
func TestConfigRoundTrip(t *testing.T) {
dir := t.TempDir()
configFile = filepath.Join(dir, "awg_config")
in := &Config{
Network: "10.0.0.0/24", Interface: "awg0", Port: "51820",
PublicIP: "203.0.113.9", ServerPriv: "PRIV==", ServerPub: "PUB==",
DNS: "1.1.1.1", MTU: "1420",
Jc: "6", Jmin: "20", Jmax: "120", S1: "90", S2: "100",
H1: "11", H2: "22", H3: "33", H4: "44",
}
writeConfig(in)
out := loadConfig()
if *out != *in {
t.Fatalf("round-trip mismatch:\n in=%+v\nout=%+v", *in, *out)
}
// The written file must remain shell-sourceable (KEY="value" form).
data, _ := os.ReadFile(configFile)
if want := `SERVER_INTERFACE="awg0"`; !contains(string(data), want) {
t.Errorf("config missing %q", want)
}
}
func TestHidePrivateKey(t *testing.T) {
in := "[Interface]\nPrivateKey = SECRETKEY==\nAddress = 10.0.0.1/24\n"
out := hidePrivateKey(in)
if contains(out, "SECRETKEY") {
t.Errorf("private key not hidden: %q", out)
}
if !contains(out, "<hidden>") {
t.Errorf("expected <hidden> marker: %q", out)
}
}
func TestStateRoundTrip(t *testing.T) {
dir := t.TempDir()
stateFile = filepath.Join(dir, "awg_state.json")
if loadState().DepsInstalled {
t.Fatal("fresh state should not report deps installed")
}
saveState(&State{DepsInstalled: true, OSID: "debian", OSVersion: "12"})
got := loadState()
if !got.DepsInstalled || got.OSID != "debian" {
t.Fatalf("state round-trip failed: %+v", got)
}
}
// TestStatsAccumulation exercises the durable, per-peer traffic accumulation:
// normal deltas, counter-reset detection, peers missing from a dump, survival of
// a restart (re-read from disk), and reset. Each sampleStats call round-trips
// through statsFile, so persistence is tested implicitly.
func TestStatsAccumulation(t *testing.T) {
dir := t.TempDir()
dataDir = dir
statsFile = filepath.Join(dir, "awg_stats.json")
const k = "PEERKEY="
live := func(rx, tx int64) map[string]PeerStat {
return map[string]PeerStat{k: {PublicKey: k, TransferRx: rx, TransferTx: tx}}
}
rec := func() StatRecord {
statsLock.Lock()
defer statsLock.Unlock()
return loadStatsLocked().Peers[k]
}
// First sighting: counts from now, so totals start at 0 with the raw counter
// captured as the baseline.
sampleStats(live(100, 40))
if r := rec(); r.TotalRx != 0 || r.TotalTx != 0 || r.LastRx != 100 || r.LastTx != 40 {
t.Fatalf("first sighting: got %+v", r)
}
if rec().Since == 0 {
t.Fatal("first sighting must set Since")
}
// Normal growth: +200 rx, +60 tx.
sampleStats(live(300, 100))
if r := rec(); r.TotalRx != 200 || r.TotalTx != 60 || r.LastRx != 300 {
t.Fatalf("delta: got %+v", r)
}
// Counter reset (reading dropped): the whole current value is new traffic.
sampleStats(live(40, 10))
if r := rec(); r.TotalRx != 240 || r.TotalTx != 70 || r.LastRx != 40 {
t.Fatalf("reset branch: got %+v", r)
}
// Peer missing from a non-empty dump must not touch the record.
sampleStats(map[string]PeerStat{"OTHER=": {PublicKey: "OTHER="}})
if r := rec(); r.TotalRx != 240 || r.LastRx != 40 {
t.Fatalf("missing peer changed record: got %+v", r)
}
// Simulate an app restart where the interface counter restarted near zero:
// the persisted total must keep growing, never drop.
before := rec().TotalRx
sampleStats(live(15, 5))
if r := rec(); r.TotalRx != before+15 {
t.Fatalf("post-restart total must not drop: before=%d got %+v", before, r)
}
// Reset rebaselines to the current live counter and zeroes the totals.
resetStats(k, 15, 5)
if r := rec(); r.TotalRx != 0 || r.TotalTx != 0 || r.LastRx != 15 || r.LastTx != 5 {
t.Fatalf("reset: got %+v", r)
}
// A subsequent unchanged reading adds nothing.
sampleStats(live(15, 5))
if r := rec(); r.TotalRx != 0 || r.TotalTx != 0 {
t.Fatalf("post-reset unchanged sample added traffic: got %+v", r)
}
// Delete removes the record.
deleteStats(k)
if _, ok := func() (StatRecord, bool) {
statsLock.Lock()
defer statsLock.Unlock()
r, ok := loadStatsLocked().Peers[k]
return r, ok
}(); ok {
t.Fatal("deleteStats left the record behind")
}
}
// TestRequireSlash24 checks the CIDR guard added after review.md flagged that
// serverIP/incrementIP/awgConfHeader all hardcode /24 regardless of what the
// operator types in — so anything but a /24 must be rejected up front.
func TestRequireSlash24(t *testing.T) {
for _, v := range []string{"10.0.0.0/24", "192.168.5.0/24"} {
requireSlash24(v) // must not die
}
for _, v := range []string{
"10.0.0.0/16", // wrong prefix length
"10.0.0.5/24", // not the network base address
"not-a-cidr", // unparseable
"2001:db8::/24", // IPv6, not IPv4
} {
func() {
webMode = true // turns die() into a recoverable panic
defer func() { webMode = false; recover() }()
requireSlash24(v)
t.Errorf("requireSlash24(%q) should have been rejected", v)
}()
}
}
// TestCsrfSafe checks the CSRF guard added after review.md flagged that
// Basic-auth-only endpoints were reachable via a blind cross-site <form>
// POST/DELETE. HTML forms can never set Content-Type: application/json, so
// requiring it on every state-changing request blocks exactly that attack.
func TestCsrfSafe(t *testing.T) {
get := httptest.NewRequest(http.MethodGet, "/api/status", nil)
if !csrfSafe(get) {
t.Error("GET must always be csrf-safe")
}
postNoCT := httptest.NewRequest(http.MethodPost, "/api/server/stop", nil)
if csrfSafe(postNoCT) {
t.Error("POST without Content-Type must be rejected")
}
postForm := httptest.NewRequest(http.MethodPost, "/api/server/stop", nil)
postForm.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if csrfSafe(postForm) {
t.Error("POST with a form content-type (the only kind an HTML form can send) must be rejected")
}
postJSON := httptest.NewRequest(http.MethodPost, "/api/server/stop", nil)
postJSON.Header.Set("Content-Type", "application/json")
if !csrfSafe(postJSON) {
t.Error("POST with application/json must be accepted")
}
del := httptest.NewRequest(http.MethodDelete, "/api/clients/1", nil)
del.Header.Set("Content-Type", "application/json")
if !csrfSafe(del) {
t.Error("DELETE with application/json must be accepted")
}
}
func contains(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}