check / check (push) Waiting to run
Each test in internal/sww that waits for the starting line now waits until the line is written or sww exits, so a slow Core Rule Set compile on a loaded host no longer fails it. The two tests that run sww as a child process get its exit status from a goroutine that waits on it, and no longer kill it after 30 seconds; the waits after SIGTERM still end after waitLimit. startRProxyProcess returns a stop function in place of the process and its output. Model: opus-5-5
256 lines
6.2 KiB
Go
256 lines
6.2 KiB
Go
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))
|
|
}
|
|
|
|
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{}
|
|
cmd := exec.CommandContext(t.Context(), binary) //nolint:gosec // this test binary
|
|
cmd.Stdout = out
|
|
|
|
cmd.Env = append(os.Environ(),
|
|
"TZ="+zone,
|
|
commandForMain+"="+wafCommand,
|
|
listenAddr+"="+localhost+":0",
|
|
stateDir+"="+dir,
|
|
rulesDir+"="+t.TempDir(),
|
|
lookupSource+"=off")
|
|
|
|
err = cmd.Start()
|
|
if err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
|
|
exited := make(chan int, 1)
|
|
|
|
go func() {
|
|
_ = cmd.Wait()
|
|
exited <- cmd.ProcessState.ExitCode()
|
|
}()
|
|
|
|
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")
|
|
|
|
clients, err := os.ReadFile(path) //nolint:gosec // a file in the test's directory
|
|
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)
|
|
})
|
|
}
|