Files
hamlab/internal/vpn/vpn_test.go
T
Mitchell-JohnsonandClaude Opus 5 32fdbf169a Fix the blank VPN tab, and stop one page taking the console with it
On a machine with wireguard-tools installed but no tunnel up — core, which is
the machine the console opens on — the tunnel list was a nil slice, which is
null in JSON, and the page took its length. That throws, React unmounts the
whole tree, and what is left is a blank window with nothing to go on.

Two fixes, because either alone leaves the failure possible. The parse returns
an empty list rather than nil, with a test that pins the marshalled shape; and
the page treats a missing list as an empty one, so a node still running an older
build cannot blank the console either.

And the reason a small mistake had such a large blast radius: nothing caught it.
Each page now renders inside a boundary, so a fault costs that page and says
what it was, while the rail, the switcher and every other tab carry on. The
terminals are deliberately left outside it — they hold live WebSockets, and
remounting them to recover from somebody else's error would drop the sessions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 22:18:59 -07:00

194 lines
6.5 KiB
Go

package vpn
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// Real output of `wg show all dump`, from the two machines here: gate, where devices dial in,
// and hamtv, which dials out through Mullvad. Keys are the real public ones, which are public.
const gateDump = "wg0\tPRIVATEKEYWOULDBEHERE=\t4VwqgzHyelmVmNY+EANRWQggSSxvYYguJQMvUty9Sns=\t51820\toff\n" +
"wg0\tNeWxo9wfqgipBQLuP676WFcMlW5RDrOcanc9y8sT7kU=\t3KWsu0mdVb2l5B/j0qHE03yE9LXi3FJ1WAjWMuxaLAw=\t10.0.0.33:63573\t172.21.46.3/32\t1790565744\t134192164\t415977404\toff\n" +
"wg0\twUnRD990c6aglU0TCREtmfuUTPzqSmHrd3qTORpRJBg=\t4Pd5u2t6U9EdHlFlneg+hMr0G2v+IvoE/EqGfFdDPI8=\t(none)\t172.21.46.2/32\t0\t0\t0\toff\n"
const hamtvDump = "wg0\tPRIVATEKEYWOULDBEHERE=\tF/rgcHi+ZiaWPoSQ9iPcldIUbbMotE1hwSB/74Y9Qi4=\t39281\toff\n" +
"wg0\tzqsfGglzJPY657WMRxf/S4omG7+ZkSDIpDq+ggbc9yo=\t(none)\t23.234.72.2:51820\t0.0.0.0/0,::/0\t1790571246\t18206675520\t2687837240\t25\n"
func TestParseServerDump(t *testing.T) {
ts := parseDump(gateDump)
if len(ts) != 1 {
t.Fatalf("got %d tunnels, want 1", len(ts))
}
w := ts[0]
if w.Name != "wg0" || w.ListenPort != 51820 {
t.Errorf("interface = %s:%d, want wg0:51820", w.Name, w.ListenPort)
}
if w.PublicKey != "4VwqgzHyelmVmNY+EANRWQggSSxvYYguJQMvUty9Sns=" {
t.Errorf("public key = %q; the third column is the public one", w.PublicKey)
}
// The private key is column two of the interface line and must not appear anywhere.
if w.PublicKey == "PRIVATEKEYWOULDBEHERE=" || w.FwMark == "PRIVATEKEYWOULDBEHERE=" {
t.Error("the private key was kept")
}
if len(w.Peers) != 2 {
t.Fatalf("got %d peers, want 2", len(w.Peers))
}
p := w.Peers[0]
if p.PublicKey != "NeWxo9wfqgipBQLuP676WFcMlW5RDrOcanc9y8sT7kU=" || p.Endpoint != "10.0.0.33:63573" {
t.Errorf("first peer = %+v", p)
}
if !p.PSK {
t.Error("first peer has a pre-shared key and should say so")
}
if got := p.AllowedIPs; len(got) != 1 || got[0] != "172.21.46.3/32" {
t.Errorf("allowed ips = %v", got)
}
if p.Rx != 134192164 || p.Tx != 415977404 {
t.Errorf("counters = %d/%d", p.Rx, p.Tx)
}
if p.Handshake == nil || p.Handshake.Unix() != 1790565744 {
t.Errorf("handshake = %v", p.Handshake)
}
// The second peer has never connected: no endpoint, no handshake, nothing moved.
if q := w.Peers[1]; q.Endpoint != "" || q.Handshake != nil || q.Rx != 0 {
t.Errorf("second peer = %+v, want one that has never been heard from", q)
}
if kind, exit := classify(w); kind != "server" || exit != "" {
t.Errorf("classify = %q/%q, want server", kind, exit)
}
}
func TestParseClientDump(t *testing.T) {
ts := parseDump(hamtvDump)
if len(ts) != 1 || len(ts[0].Peers) != 1 {
t.Fatalf("got %d tunnels", len(ts))
}
w := ts[0]
p := w.Peers[0]
if len(p.AllowedIPs) != 2 || p.AllowedIPs[0] != "0.0.0.0/0" {
t.Errorf("allowed ips = %v, want everything", p.AllowedIPs)
}
if p.Keepalive != 25 {
t.Errorf("keepalive = %d, want 25", p.Keepalive)
}
if p.PSK {
t.Error("this peer has no pre-shared key")
}
// It routes everything and has an endpoint, so this machine goes out through it.
kind, exit := classify(w)
if kind != "client" || exit != "23.234.72.2:51820" {
t.Errorf("classify = %q/%q, want client through the endpoint", kind, exit)
}
}
// A tunnel that is up but has no peers at all is neither, and must not be guessed at.
func TestClassifyUndecided(t *testing.T) {
if kind, _ := classify(Tunnel{Name: "wg0", ListenPort: 51820}); kind != "" {
t.Errorf("classify with no peers = %q, want no opinion", kind)
}
}
// Peers are named by the comment above them in a wg-quick file. The public key's own base64
// padding is an '=' too, which is the thing to get wrong.
func TestConfNames(t *testing.T) {
dir := t.TempDir()
conf := `[Interface]
Address = 172.21.46.1/24
PrivateKey = SECRETSECRETSECRETSECRETSECRETSECRETSECRET=
ListenPort = 51820
# phone
[Peer]
PublicKey = NeWxo9wfqgipBQLuP676WFcMlW5RDrOcanc9y8sT7kU=
AllowedIPs = 172.21.46.3/32
# the laptop
[Peer]
PublicKey = wUnRD990c6aglU0TCREtmfuUTPzqSmHrd3qTORpRJBg=
AllowedIPs = 172.21.46.2/32
`
if err := os.WriteFile(filepath.Join(dir, "wg0.conf"), []byte(conf), 0o600); err != nil {
t.Fatal(err)
}
old := confDir
confDir = dir
defer func() { confDir = old }()
names := confNames()
if got := names["NeWxo9wfqgipBQLuP676WFcMlW5RDrOcanc9y8sT7kU="]; got != "phone" {
t.Errorf("first peer named %q, want phone", got)
}
if got := names["wUnRD990c6aglU0TCREtmfuUTPzqSmHrd3qTORpRJBg="]; got != "the laptop" {
t.Errorf("second peer named %q, want the laptop", got)
}
for k := range names {
if k == "SECRETSECRETSECRETSECRETSECRETSECRETSECRET=" {
t.Error("the private key was read as if it were a peer")
}
}
if len(names) != 2 {
t.Errorf("%d names, want 2: %v", len(names), names)
}
}
func TestOnlineIsRecentHandshake(t *testing.T) {
for _, tc := range []struct {
name string
hs time.Time
want bool
}{
{"just now", time.Now().Add(-30 * time.Second), true},
{"a while back", time.Now().Add(-10 * time.Minute), false},
{"never", time.Time{}, false},
} {
got := !tc.hs.IsZero() && time.Since(tc.hs) < onlineWithin
if got != tc.want {
t.Errorf("%s: online = %v, want %v", tc.name, got, tc.want)
}
}
}
// A peer that has never handshaken must carry no time at all, not the zero one. omitempty
// does nothing for a time.Time, so this is the difference between the page saying "never
// connected" and saying the device was last seen in the year one.
func TestNeverConnectedHasNoTimestamp(t *testing.T) {
w := parseDump(gateDump)[0]
b, err := json.Marshal(w.Peers[1])
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), "handshake") {
t.Errorf("json = %s, want no handshake field at all", b)
}
if strings.Contains(string(b), "0001-01-01") {
t.Errorf("json = %s, want no year-one timestamp", b)
}
// and the one that has connected still carries its time
if b, _ := json.Marshal(w.Peers[0]); !strings.Contains(string(b), "handshake") {
t.Errorf("json = %s, want the handshake of a peer that has one", b)
}
}
// A machine with wireguard-tools installed but no tunnel up produces an empty dump. That has
// to marshal as [] and not as null: the page takes the length of it, and null blanks the
// whole console rather than saying "no tunnels".
func TestEmptyDumpIsAnEmptyList(t *testing.T) {
for _, dump := range []string{"", "\n", " \n\n"} {
got := parseDump(dump)
if got == nil {
t.Fatalf("parseDump(%q) = nil, want an empty list", dump)
}
b, err := json.Marshal(got)
if err != nil {
t.Fatal(err)
}
if string(b) != "[]" {
t.Errorf("parseDump(%q) marshals to %s, want []", dump, b)
}
}
}