Files
mesh-catalog/modules/systemd-resolved/cmd/resolver-tools/guard_test.go
T
jschoubben b3fcf408f2 Make tests that wait for concurrent work wait on the event or a condition with a long deadline (issue 1814)
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
2026-11-01 17:24:19 +01:00

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)
}
}