A test that slept a fixed time and then asserted what a goroutine, process or watcher did failed under the checker's load. Each now waits for its positive evidence (a started baseline, a call counted, a state reached) within 30 s, and the concurrency-limit tests meet at a barrier instead of overlapping by sleep. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
252 lines
8.7 KiB
Go
252 lines
8.7 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const meshFile = "# Managed by the mesh\nnameserver 10.42.0.2\noptions timeout:1 attempts:2 edns0\n"
|
|
|
|
// vpnFile is a VPN client's file as one writes it; the addresses and domains are documentation's.
|
|
const vpnFile = "# Dynamic resolv.conf(5) file for glibc resolver(3) generated by forticlient\n" +
|
|
"nameserver 192.0.2.53\nnameserver 192.0.2.54\nsearch corp.example cloud.example\n"
|
|
|
|
// aGuardedMachine is a guard over a temporary /etc and /run, with a clock the test moves.
|
|
func aGuardedMachine(t *testing.T) (*Guard, *time.Time, *fakeResolved) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
for _, d := range []string{"etc/node-resolver", "run"} {
|
|
if err := os.MkdirAll(filepath.Join(dir, d), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
write(t, filepath.Join(dir, "etc/node-resolver/resolv.conf"), meshFile)
|
|
write(t, filepath.Join(dir, "etc/resolv.conf"), meshFile)
|
|
now := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC)
|
|
f := &fakeResolved{}
|
|
g := &Guard{Path: filepath.Join(dir, "etc/resolv.conf"), KeptPath: filepath.Join(dir, "etc/node-resolver/resolv.conf"),
|
|
Dir: filepath.Join(dir, "run"), Hold: Hold, Now: func() time.Time { return now },
|
|
Resolver: aMachine(t, f, "tun0"), Log: t.Logf, wake: make(chan struct{}, 1)}
|
|
return g, &now, f
|
|
}
|
|
|
|
func write(t *testing.T, path, content string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func contentOf(t *testing.T, path string) string {
|
|
t.Helper()
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(raw)
|
|
}
|
|
|
|
// The module's own file is left alone, and nothing is said.
|
|
func TestTheModulesOwnFileIsLeftAlone(t *testing.T) {
|
|
g, _, _ := aGuardedMachine(t)
|
|
if did := g.Tick(); did != "" || g.Displaced() != nil {
|
|
t.Errorf("the module's own file was taken for an outside write: %q", did)
|
|
}
|
|
}
|
|
|
|
// A write a module takes: kept for it to read, with its writer named from its header; once taken, the
|
|
// module's own file is back at the next look; and the history says when, who and what became of it —
|
|
// never a server or a domain.
|
|
func TestATakenWriteIsPutBackAtOnce(t *testing.T) {
|
|
g, now, _ := aGuardedMachine(t)
|
|
write(t, g.Path, vpnFile)
|
|
if did := g.Tick(); did != "holding" {
|
|
t.Fatalf("the write was %q, not held for a taker", did)
|
|
}
|
|
d := g.Displaced()
|
|
if d == nil || d.Writer != "FortiClient" || d.Content != vpnFile {
|
|
t.Fatalf("the write is not kept as written, naming its writer: %+v", d)
|
|
}
|
|
kept := filepath.Join(g.Dir, "displaced", d.ID+".conf")
|
|
if fi, err := os.Stat(kept); err != nil || fi.Mode().Perm() != 0o600 || contentOf(t, kept) != vpnFile {
|
|
t.Errorf("the write is not kept where only root reads it: %v", err)
|
|
}
|
|
*now = now.Add(2 * time.Second)
|
|
if err := g.Take(d.ID, "forticlient"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if did := g.Tick(); did != "put back, taken" {
|
|
t.Fatalf("a taken write was %q", did)
|
|
}
|
|
if contentOf(t, g.Path) != meshFile {
|
|
t.Errorf("the module's file is not back:\n%s", contentOf(t, g.Path))
|
|
}
|
|
if fi, _ := os.Stat(g.Path); fi.Mode().Perm() != 0o644 {
|
|
t.Errorf("the file is %v, not readable by everyone", fi.Mode().Perm())
|
|
}
|
|
history := contentOf(t, filepath.Join(g.Dir, "history.json"))
|
|
for _, never := range []string{"192.0.2", "corp.example", "nameserver"} {
|
|
if strings.Contains(history, never) {
|
|
t.Errorf("the history says %q, which stays on the machine:\n%s", never, history)
|
|
}
|
|
}
|
|
list := ReadHistory(filepath.Join(g.Dir, "history.json"))
|
|
if len(list) != 1 || list[0].TakenBy != "forticlient" || list[0].Ended == nil || !strings.Contains(list[0].How, "taken") {
|
|
t.Errorf("the history is %+v", list)
|
|
}
|
|
if g.Tick() != "" {
|
|
t.Error("the module's own file, back, was taken for another write")
|
|
}
|
|
}
|
|
|
|
// A write nobody takes stands for Hold — long enough for the node-engine to see it twice and say it —
|
|
// and is then put back.
|
|
func TestAWriteNobodyTakesStandsUntilItIsSaidThenGoes(t *testing.T) {
|
|
g, now, _ := aGuardedMachine(t)
|
|
write(t, g.Path, "# written by another program\nnameserver 198.51.100.1\n")
|
|
g.Tick()
|
|
*now = now.Add(61 * time.Second)
|
|
if did := g.Tick(); did != "holding" || contentOf(t, g.Path) == meshFile {
|
|
t.Fatalf("an untaken write was put back after a minute, before the node-engine's second look: %q", did)
|
|
}
|
|
if Hold < 75*time.Second {
|
|
t.Errorf("Hold is %s; two of the node-engine's 30 s looks need more", Hold)
|
|
}
|
|
*now = now.Add(Hold)
|
|
if did := g.Tick(); did != "put back, held" || contentOf(t, g.Path) != meshFile {
|
|
t.Fatalf("an untaken write was not put back after Hold: %q", did)
|
|
}
|
|
if h := ReadHistory(filepath.Join(g.Dir, "history.json")); len(h) != 1 || h[0].TakenBy != "" || h[0].Writer != "" {
|
|
t.Errorf("the history is %+v", h)
|
|
}
|
|
}
|
|
|
|
// A write the reconcile or the writer itself undoes is closed as written back by another; one written
|
|
// over before it was put back is a new one; a link in the file's place is a write too.
|
|
func TestWritesUndoneAndWrittenOverAreSaid(t *testing.T) {
|
|
g, now, _ := aGuardedMachine(t)
|
|
write(t, g.Path, vpnFile)
|
|
g.Tick()
|
|
write(t, g.Path, meshFile)
|
|
*now = now.Add(time.Second)
|
|
if did := g.Tick(); did != "ended" {
|
|
t.Errorf("a write undone by another was %q", did)
|
|
}
|
|
write(t, g.Path, vpnFile)
|
|
g.Tick()
|
|
first := g.Displaced().ID
|
|
*now = now.Add(time.Second)
|
|
write(t, g.Path, vpnFile+"search more.example\n")
|
|
g.Tick()
|
|
if g.Displaced().ID == first {
|
|
t.Error("a second write was taken for the first")
|
|
}
|
|
if err := g.Take(first, "forticlient"); err == nil {
|
|
t.Error("a write that no longer stands was taken")
|
|
}
|
|
_ = os.Remove(g.Path)
|
|
if err := os.Symlink(g.KeptPath, g.Path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
*now = now.Add(time.Second)
|
|
g.Tick()
|
|
if d := g.Displaced(); d == nil || !strings.Contains(d.Content, "a link to") {
|
|
t.Errorf("a link in the file's place was not a write: %+v", d)
|
|
}
|
|
*now = now.Add(Hold)
|
|
g.Tick()
|
|
if fi, err := os.Lstat(g.Path); err != nil || fi.Mode()&os.ModeSymlink != 0 {
|
|
t.Errorf("the link was not replaced by the module's file: %v", err)
|
|
}
|
|
}
|
|
|
|
// Nothing is kept before the mesh rendered the module's file: there is nothing to keep it to.
|
|
func TestNothingIsGuardedBeforeTheFileIsRendered(t *testing.T) {
|
|
g, _, _ := aGuardedMachine(t)
|
|
_ = os.Remove(g.KeptPath)
|
|
write(t, g.Path, vpnFile)
|
|
if g.Tick() != "" || contentOf(t, g.Path) != vpnFile {
|
|
t.Error("the guard acted with no file of its own to keep")
|
|
}
|
|
}
|
|
|
|
// The socket: a module routes and takes the write standing now in one call, and the module's file is
|
|
// back; a bad request and an unknown verb are answered, not dropped; the socket is root's alone.
|
|
func TestTheVerbsOnTheMachine(t *testing.T) {
|
|
g, _, f := aGuardedMachine(t)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
sock := filepath.Join(g.Dir, "verbs.sock")
|
|
go func() { _ = g.Serve(ctx, sock) }()
|
|
for i := 0; i < 3000; i++ {
|
|
if _, err := os.Stat(sock); err == nil {
|
|
break
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if fi, err := os.Stat(sock); err != nil || fi.Mode().Perm() != 0o600 {
|
|
t.Fatalf("the socket is not root's alone: %v", err)
|
|
}
|
|
call := func(line string) Reply {
|
|
t.Helper()
|
|
c, err := net.Dial("unix", sock)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer c.Close()
|
|
if _, err := c.Write([]byte(line + "\n")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, err := bufio.NewReader(c).ReadBytes('\n')
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var r Reply
|
|
if err := json.Unmarshal(raw, &r); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return r
|
|
}
|
|
write(t, g.Path, vpnFile)
|
|
g.Tick()
|
|
shown := call(`{"verb":"displaced"}`)
|
|
pending, _ := shown.Result.(map[string]any)
|
|
if pending == nil || pending["content"] != vpnFile {
|
|
t.Fatalf("the write standing now is not shown on the machine: %+v", shown)
|
|
}
|
|
req, _ := json.Marshal(Request{Verb: "route", Args: map[string]any{"link": "tun0",
|
|
"domains": []any{"corp.example", "cloud.example"}, "servers": "192.0.2.53 192.0.2.54",
|
|
"takes": pending["id"], "by": "forticlient"}})
|
|
if r := call(string(req)); r.Error != "" {
|
|
t.Fatalf("route and take: %s", r.Error)
|
|
}
|
|
if len(f.changed) != 3 {
|
|
t.Errorf("resolved was asked %v", f.changed)
|
|
}
|
|
select {
|
|
case <-g.wake:
|
|
g.Tick()
|
|
case <-time.After(30 * time.Second):
|
|
t.Fatal("taking the write did not wake the guard")
|
|
}
|
|
if contentOf(t, g.Path) != meshFile {
|
|
t.Error("the module's file is not back once its write was taken")
|
|
}
|
|
if r := call(`not json`); !strings.Contains(r.Error, "JSON") {
|
|
t.Errorf("a bad request: %+v", r)
|
|
}
|
|
if r := call(`{"verb":"flush"}`); !strings.Contains(r.Error, "not a verb") {
|
|
t.Errorf("an unknown verb: %+v", r)
|
|
}
|
|
if r := call(`{"verb":"route","args":{"link":"tun0","domains":"internal","servers":"192.0.2.53"}}`); !strings.Contains(r.Error, "mesh's own") {
|
|
t.Errorf("the mesh's domain over the socket: %+v", r)
|
|
}
|
|
}
|