package sww_test import ( "bytes" "context" "crypto/rand" "io" "net/http" "os" "os/exec" "path/filepath" "regexp" "strings" "syscall" "testing" "time" // The time zone database, so that the process TestMainRunsWAF starts // finds the zone TZ names on any host. _ "time/tzdata" "sneak.berlin/go/smallwebwaf/internal/sww" "sneak.berlin/go/smallwebwaf/internal/waf" ) // commandForMain, while set in the environment, has this test binary run Main // with the command it gives, in place of the tests, so that a test can run // Main in a process of its own. const commandForMain = "SWW_TEST_COMMAND" func TestMain(m *testing.M) { command, set := os.LookupEnv(commandForMain) if !set { os.Exit(m.Run()) } os.Args = strings.Fields("sww " + command) os.Exit(sww.Main(testVersion)) } // startProcess starts cmd and returns a channel that gets its exit status. As // the test ends, cmd is killed and the test waits for it to exit. func startProcess(t *testing.T, cmd *exec.Cmd) <-chan int { t.Helper() err := cmd.Start() if err != nil { t.Fatalf("start: %v", err) } exited := make(chan int, 1) waitReturned := make(chan struct{}) go func() { _ = cmd.Wait() exited <- cmd.ProcessState.ExitCode() close(waitReturned) }() t.Cleanup(func() { _ = cmd.Process.Kill() <-waitReturned }) return exited } func TestMainRunsWAF(t *testing.T) { t.Parallel() // Kiritimati is 14 hours ahead of UTC; every time sww gives stays in UTC. const zone = "Pacific/Kiritimati" _, err := time.LoadLocation(zone) if err != nil { t.Fatalf("load %s: %v", zone, err) } binary, err := os.Executable() if err != nil { t.Fatalf("find the test binary: %v", err) } dir := t.TempDir() out := &output{} //nolint:gosec // this test binary cmd := exec.CommandContext(t.Context(), binary) cmd.Stdout = out cmd.Env = append(os.Environ(), "TZ="+zone, commandForMain+"="+wafCommand, listenAddr+"="+localhost+":0", stateDir+"="+dir, cacheDir+"="+dir, rulesDir+"="+t.TempDir(), lookupSource+"=off") exited := startProcess(t, cmd) starting := out.lineBeforeExit(t, "starting", exited) addr, _ := starting["address"].(string) ask(t, addr, "/", http.StatusOK, waf.DispositionAllow) // The files are written as sww stops. err = cmd.Process.Signal(syscall.SIGTERM) if err != nil { t.Fatalf("signal: %v", err) } select { case status := <-exited: if status != 0 { t.Fatalf( "exit status %d, want 0; output:\n%s", status, out.text(), ) } case <-time.After(waitLimit): t.Fatal("still running after being told to stop") } wantUTC(t, "the process line", starting["time"]) wantUTC(t, "the request line", out.line(t, "type", "request")["time"]) path := filepath.Join(dir, "clients.json") //nolint:gosec // a file in the test's directory clients, err := os.ReadFile(path) if err != nil { t.Fatalf("read clients.json: %v", err) } stamp := regexp.MustCompile(`"\d{4}-\d\d-\d\dT[^"]*"`) times := stamp.FindAllString(string(clients), -1) if len(times) == 0 { t.Errorf("clients.json holds no time:\n%s", clients) } for _, stored := range times { wantUTC(t, "clients.json", strings.Trim(stored, `"`)) } } // wantUTC checks that value, a time read from the log line or file that // where names, is in UTC. func wantUTC(t *testing.T, where string, value any) { t.Helper() text, _ := value.(string) if !strings.HasSuffix(text, "Z") { t.Errorf("%s gives the time %v, want one in UTC", where, value) } } func TestMainWithoutACommandItKnowsPrintsTheCommands(t *testing.T) { t.Parallel() binary, err := os.Executable() if err != nil { t.Fatalf("find the test binary: %v", err) } for _, command := range []string{"", "serve"} { t.Run("command "+command, func(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(t.Context(), waitLimit) defer cancel() var stdout, stderr bytes.Buffer cmd := exec.CommandContext(ctx, binary) cmd.Stdout, cmd.Stderr = &stdout, &stderr cmd.Env = append(os.Environ(), commandForMain+"="+command) err := cmd.Run() if code := cmd.ProcessState.ExitCode(); code != 2 { t.Errorf("exit status %d (%v), want 2", code, err) } for _, name := range []string{"rproxy", wafCommand, "healthcheck"} { if !strings.Contains(stderr.String(), name) { t.Errorf( "stderr %q does not name %s", stderr.String(), name, ) } } if stdout.Len() != 0 { t.Errorf("stdout %q, want nothing", stdout.String()) } }) } } // ask sends waf at addr rproxy's question about a request for path from // placed, checks the answer's status and disposition, and closes it. func ask(t *testing.T, addr, path string, status int, disposition string) { t.Helper() req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://"+addr+path, http.NoBody) if err != nil { t.Fatalf("new request: %v", err) } req.Header.Set(waf.ClientHeader, placed) req.Header.Set("X-Request-ID", rand.Text()) transport := &http.Transport{} defer transport.CloseIdleConnections() res, err := (&http.Client{Transport: transport}).Do(req) if err != nil { t.Fatalf("question: %v", err) } _, _ = io.Copy(io.Discard, res.Body) _ = res.Body.Close() if res.StatusCode != status || res.Header.Get(waf.DispositionHeader) != disposition { t.Errorf( "answered %d with disposition %q, want %d with %q", res.StatusCode, res.Header.Get(waf.DispositionHeader), status, disposition, ) } } // runWAF runs `sww waf` with the settings in env, has use // send it requests at the address it listens on, stops it as SIGTERM does, and // checks that it stopped in order. func runWAF(t *testing.T, env map[string]string, use func(addr string)) { t.Helper() ctx, stop := context.WithCancel(t.Context()) out := &output{} exited := make(chan int, 1) go func() { exited <- run(ctx, env, out) }() addr, _ := out.lineBeforeExit(t, "starting", exited)["address"].(string) use(addr) stop() select { case status := <-exited: if status != 0 { t.Fatalf( "exit status %d, want 0; output:\n%s", status, out.text(), ) } case <-time.After(waitLimit): t.Fatal("still running after being told to stop") } out.line(t, "msg", "stopped") } func TestWAFKeepsItsBansAcrossARestartAndNeedsNoApp(t *testing.T) { t.Parallel() env := map[string]string{ listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: filepath.Join("..", "..", "share", "rules.d"), } runWAF(t, env, func(addr string) { ask(t, addr, "/", http.StatusOK, waf.DispositionAllow) // The default rule file bans a probe for this. ask(t, addr, "/.env", http.StatusForbidden, waf.DispositionDeny) }) runWAF(t, env, func(addr string) { ask(t, addr, "/", http.StatusForbidden, waf.DispositionDeny) }) }