check / check (push) Waiting to run
Every *.rules file in SWWAF_RULES_DIR is read at start and on each change. Each request is checked against the rules after the rate limits: log notes a match, block refuses with 403, ban refuses and bans the netblock for SWWAF_ATTACK_BAN_DURATION, made permanent by its next request or attack. path, query and uri are matched as the request line sent them; header:Host and header:Transfer-Encoding are refused. Bans gain a cause. The image ships 00-default.rules. Judgement call: a header sent twice is matched with its values joined by ", ". Judgement call: SWWAF_MAX_BAN_DURATION does not cap a ban for an attack. Not in this unit: offences for rule matches, with the error burst. Model: opus-5-5
612 lines
18 KiB
Go
612 lines
18 KiB
Go
package rules_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"maps"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"testing"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
|
)
|
|
|
|
const (
|
|
// What the process log says once Watch watches the directory, after
|
|
// each reading of the rule files, and for one that has an error.
|
|
watching = "watching the rule files for edits"
|
|
read = "read the rule files"
|
|
hasError = "a rule file has an error, and the rules stay as they were"
|
|
// maxLogLines is how many lines of the process log wait for a test to
|
|
// read them.
|
|
maxLogLines = 64
|
|
// browser is the user agent of an ordinary visitor.
|
|
browser = "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0"
|
|
// testFile is the rule file of a test that needs only one, and
|
|
// firstFile the first of a test's rule files.
|
|
testFile = "test.rules"
|
|
firstFile = "00-a.rules"
|
|
// userAgent is the header that carries the user agent.
|
|
userAgent = "User-Agent"
|
|
)
|
|
|
|
func TestEachTargetMatchesWhatItNames(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
rule string // its target, action and regex
|
|
uri string // the request's path and query
|
|
header http.Header
|
|
want bool
|
|
}{
|
|
{"path as received", `path log ^/%2eenv$`, "/%2eenv", nil, true},
|
|
{"path not decoded", `path log ^/\.env$`, "/%2eenv", nil, false},
|
|
{"path without the query", `path log ^/a$`, "/a?b=c", nil, true},
|
|
{"query as received", `query log ^b=%2e$`, "/a?b=%2e", nil, true},
|
|
{"uri as received", `uri log ^/a\?b=%2e$`, "/a?b=%2e", nil, true},
|
|
{"uri decoded", `uri log (\.\./){2}`, "/a?f=%2e%2e%2f%2e%2e%2f", nil, true},
|
|
{
|
|
"uri decoded past malformed escapes", `uri log (\.\./){2}&h=%$`,
|
|
"/a?g=%zz&f=%2e%2e%2f%2e%2e%2f&h=%", nil, true,
|
|
},
|
|
{"uri decoded only once", `uri log ^/a\.b$`, "/a%252eb", nil, false},
|
|
{"method", `method log ^PUT$`, "/", nil, true},
|
|
{"host", `host log ^app\.example$`, "/", nil, true},
|
|
{
|
|
"user_agent", `user_agent log ^sqlmap/`, "/",
|
|
http.Header{userAgent: {"sqlmap/1.8"}}, true,
|
|
},
|
|
{
|
|
"user_agent sent twice", `user_agent log ^curl/8, sqlmap/`, "/",
|
|
http.Header{userAgent: {"curl/8", "sqlmap/1.8"}}, true,
|
|
},
|
|
{"user_agent missing", `user_agent log ^$`, "/", nil, true},
|
|
{
|
|
"referer", `referer log ^https://spam\.example/`, "/",
|
|
http.Header{"Referer": {"https://spam.example/buy"}}, true,
|
|
},
|
|
{
|
|
"a header sent twice", `header:x-api-version log ^2, 3$`, "/",
|
|
http.Header{"X-Api-Version": {"2", "3"}}, true,
|
|
},
|
|
{"a header missing", `header:X-Api-Version log ^$`, "/", nil, true},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
files := load(t, ruleFiles{testFile: "a-rule " + tc.rule + "\n"})
|
|
|
|
// Every request is a PUT, which the method rule looks for.
|
|
r := httptest.NewRequestWithContext(t.Context(), http.MethodPut,
|
|
"http://app.example"+tc.uri, nil)
|
|
maps.Copy(r.Header, tc.header)
|
|
|
|
got := len(files.Match(r)) == 1
|
|
if got != tc.want {
|
|
t.Errorf("%s matches %s: %t, want %t", tc.rule, tc.uri, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPathMatchedAsTheClientSentIt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Each path holds a character Go's URL type would escape again, \ or
|
|
// a non-ASCII byte, and each rule is written for the path as sent.
|
|
for _, tc := range []struct {
|
|
rule string // its target, action and regex
|
|
sent string // the path and query the client sent
|
|
}{
|
|
{`path log ^/\.\.\\\.\.\\windows\\win\.ini$`, `/..\..\windows\win.ini`},
|
|
{`path log ^/%2e%2e\\%2e%2e\\windows\\win\.ini$`, `/%2e%2e\%2e%2e\windows\win.ini`},
|
|
{`path log ^/café$`, "/café?x=1"},
|
|
{`uri log ^/%2e%2e\\%2e%2e\\boot\.ini\?x=1$`, `/%2e%2e\%2e%2e\boot.ini?x=1`},
|
|
} {
|
|
files := load(t, ruleFiles{testFile: "as-sent " + tc.rule + "\n"})
|
|
|
|
// The target in origin form, as traefik sends it, in absolute form,
|
|
// as a client sends it to a proxy, and with a scheme but no host,
|
|
// which Go reads as absolute form with no host, sending the app
|
|
// the path.
|
|
for _, target := range []string{
|
|
tc.sent, "http://app.example" + tc.sent, "http:" + tc.sent, "foo:" + tc.sent,
|
|
} {
|
|
r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
|
|
wantMatched(t, files, r, "as-sent")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMatchingStopsAtTheFirstRuleThatRefuses(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
files := load(t, ruleFiles{testFile: `
|
|
every-path path log ^/
|
|
no-path path log ^$
|
|
first-refusal path block ^/probe
|
|
later-ban path ban ^/probe
|
|
after path log ^/
|
|
`})
|
|
|
|
// Every log rule that matches is noted, and the block rule ends the
|
|
// matching.
|
|
wantMatched(t, files, get(t, "/probe"), "every-path", "first-refusal")
|
|
wantMatched(t, files, get(t, "/page"), "every-path", "after")
|
|
|
|
// A ban rule ends it too.
|
|
files = load(t, ruleFiles{testFile: "ban path ban ^/\nlater path block ^/\n"})
|
|
wantMatched(t, files, get(t, "/"), "ban")
|
|
}
|
|
|
|
func TestFilesReadInNameOrderThenLineOrder(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
files := load(t, ruleFiles{
|
|
"50-b.rules": "b1 path log ^/\n\n# a comment\n # an indented one\nb2 path log ^/\n",
|
|
firstFile: "a1 path log ^/\r\n",
|
|
// None is a rule file.
|
|
"notes.txt": "notes, not rules\n",
|
|
"10-c.rules.bak": "an old copy\n",
|
|
"20-d.rules/keep": "a file in a directory\n",
|
|
})
|
|
|
|
wantMatched(t, files, get(t, "/"), "a1", "b1", "b2")
|
|
|
|
if files.Len() != 3 {
|
|
t.Errorf("%d rules loaded, want 3", files.Len())
|
|
}
|
|
}
|
|
|
|
func TestFaultStopsTheStartNamingTheFileAndLine(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
content string
|
|
line int
|
|
want string
|
|
}{
|
|
{
|
|
"too few fields", "env-file path ban\n", 1,
|
|
"is not a rule: an id, a target, an action and a regex, " +
|
|
"separated by spaces or tabs",
|
|
},
|
|
{
|
|
"an id of other characters", "# ids\n\nenv.file path ban ^/\n", 3,
|
|
`the id "env.file" is not an id of letters, digits, - and _`,
|
|
},
|
|
{
|
|
"an unknown target", "env-file paths ban ^/\n", 1,
|
|
`the target "paths" is not path, query, uri, method, host, ` +
|
|
"user_agent, referer or header:<Name>",
|
|
},
|
|
{
|
|
"a header without a name", "env-file header: ban ^/\n", 1,
|
|
`the target "header:" is not path, query, uri, method, host, ` +
|
|
"user_agent, referer or header:<Name>",
|
|
},
|
|
{
|
|
"the Host header", "host-header header:host block ^$\n", 1,
|
|
`the target "header:host" names a header that Go's HTTP server ` +
|
|
"takes out of every request, so a rule never sees it; " +
|
|
"the request's host is the target host",
|
|
},
|
|
{
|
|
"the Transfer-Encoding header",
|
|
"# bodies sent in chunks\nchunked header:Transfer-Encoding block ^chunked$\n", 2,
|
|
`the target "header:Transfer-Encoding" names a header that Go's ` +
|
|
"HTTP server takes out of every request, so a rule never sees it",
|
|
},
|
|
{
|
|
"an unknown action", "env-file path deny ^/\n", 1,
|
|
`the action "deny" is not log, block or ban`,
|
|
},
|
|
{
|
|
"a regex that does not compile", "env-file path ban ^/(\n", 1,
|
|
"the regex does not compile: error parsing regexp: " +
|
|
"missing closing ): `^/(`",
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := writeFiles(t, ruleFiles{"00-default.rules": tc.content})
|
|
path := filepath.Join(dir, "00-default.rules")
|
|
|
|
wantRefused(t, dir, path+", line "+strconv.Itoa(tc.line)+": "+tc.want)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIDUsedTwiceStopsTheStartNamingBothPlaces(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := writeFiles(t, ruleFiles{
|
|
"00-a.rules": "probe path log ^/a\n",
|
|
"50-b.rules": "other path log ^/b\nprobe path ban ^/c\n",
|
|
})
|
|
|
|
wantRefused(t, dir, filepath.Join(dir, "50-b.rules")+`, line 2: the id "probe" `+
|
|
"is already the id of the rule at "+filepath.Join(dir, "00-a.rules")+", line 1")
|
|
}
|
|
|
|
func TestDirectoryThatDoesNotExistStopsTheStart(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := filepath.Join(t.TempDir(), "rules.d")
|
|
|
|
wantRefused(t, dir, "SWWAF_RULES_DIR cannot be read: open "+dir+
|
|
": no such file or directory")
|
|
}
|
|
|
|
func TestEmptyDirectoryLoadsNoRulesAndSaysSo(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
params, lines := newParams(writeFiles(t, ruleFiles{"00-default.rules": "# none\n"}))
|
|
|
|
files, err := rules.Load(params)
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
|
|
line := lines.waitFor(t, read)
|
|
if files.Len() != 0 || line["rules"] != 0.0 {
|
|
t.Errorf("%d rules loaded, and the log says %v, want none", files.Len(), line)
|
|
}
|
|
}
|
|
|
|
func TestRuleFilesOffReadNothing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// SWWAF_RULES_DIR does not exist, which would stop the start.
|
|
params, _ := newParams(filepath.Join(t.TempDir(), "rules.d"))
|
|
params.Enabled = false
|
|
|
|
files, err := rules.Load(params)
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
|
|
if files.Len() != 0 || files.Match(get(t, "/")) != nil {
|
|
t.Errorf("%d rules loaded with the rule files off", files.Len())
|
|
}
|
|
|
|
// It would watch until the test ends.
|
|
files.Watch(t.Context())
|
|
}
|
|
|
|
func TestEditsTakenInWhileRunning(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := writeFiles(t, ruleFiles{firstFile: "first path block ^/first\n"})
|
|
files, lines := watch(t, dir)
|
|
|
|
// matches reports whether path matches a rule.
|
|
matches := func(path string) bool { return len(files.Match(get(t, path))) == 1 }
|
|
|
|
// A file added.
|
|
save(t, dir, "50-b.rules", "second path block ^/second\n")
|
|
lines.waitUntil(t, func() bool { return matches("/second") })
|
|
wantMatched(t, files, get(t, "/first"), "first")
|
|
|
|
// A file edited.
|
|
save(t, dir, firstFile, "first path block ^/edited\n")
|
|
lines.waitUntil(t, func() bool { return !matches("/first") })
|
|
wantMatched(t, files, get(t, "/edited"), "first")
|
|
|
|
// A file removed.
|
|
err := os.Remove(filepath.Join(dir, "50-b.rules"))
|
|
if err != nil {
|
|
t.Fatalf("remove: %v", err)
|
|
}
|
|
|
|
lines.waitUntil(t, func() bool { return !matches("/second") })
|
|
wantMatched(t, files, get(t, "/edited"), "first")
|
|
}
|
|
|
|
func TestBrokenEditKeepsTheRulesAsTheyWere(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := writeFiles(t, ruleFiles{firstFile: "first path block ^/first\n"})
|
|
files, lines := watch(t, dir)
|
|
|
|
// The edit's second line has an unknown action, so the rules stay as
|
|
// they were, the first line's earlier version included.
|
|
save(t, dir, firstFile, "first path block ^/edited\nsecond path bann ^/second\n")
|
|
|
|
line := lines.waitFor(t, hasError)
|
|
want := filepath.Join(dir, firstFile) +
|
|
`, line 2: the action "bann" is not log, block or ban`
|
|
|
|
if line["error"] != want || line["level"] != "ERROR" {
|
|
t.Errorf("logged %v, want an error %q", line, want)
|
|
}
|
|
|
|
wantMatched(t, files, get(t, "/first"), "first")
|
|
wantMatched(t, files, get(t, "/second"))
|
|
|
|
// Once mended, the file is read again.
|
|
save(t, dir, firstFile, "first path block ^/edited\nsecond path ban ^/second\n")
|
|
lines.waitUntil(t, func() bool { return len(files.Match(get(t, "/second"))) == 1 })
|
|
wantMatched(t, files, get(t, "/edited"), "first")
|
|
}
|
|
|
|
func TestDefaultFileBansProbesAtTheSiteRootAlone(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
params, _ := newParams(filepath.Join("..", "..", "share", "rules.d"))
|
|
|
|
files, err := rules.Load(params)
|
|
if err != nil {
|
|
t.Fatalf("load the default file: %v", err)
|
|
}
|
|
|
|
// Probes sent by a browser, by the rule that refuses them.
|
|
for rule, targets := range map[string][]string{
|
|
"env-file": {"/.env", "/.env.production", "/.ENV"},
|
|
"vcs-dir": {"/.git/config", "/.git", "/.svn/entries"},
|
|
"secrets-dir": {"/.aws/credentials", "/.ssh/id_rsa"},
|
|
"secret-file": {"/.htpasswd", "/.DS_Store", "/.git-credentials"},
|
|
"editor-dir": {"/.vscode/sftp.json"},
|
|
"backup-file": {
|
|
"/wp-config.php.bak", "/index.php~", "/dump.sql", "/backup.sql.gz",
|
|
},
|
|
"log-file": {"/debug.log"},
|
|
"compose-file": {"/docker-compose.yml", "/compose.yaml"},
|
|
"php-shell": {"/shell.php"},
|
|
"path-traversal": {
|
|
"/static/../../etc/passwd", "/f?f=%2e%2e%2f%2e%2e%2fetc%2fpasswd",
|
|
},
|
|
} {
|
|
for _, target := range targets {
|
|
wantRefusedBy(t, files, target, browser, rule)
|
|
}
|
|
}
|
|
|
|
// Scanners, by their user agents.
|
|
for _, scanner := range []string{
|
|
"sqlmap/1.8.4#stable (https://sqlmap.org)",
|
|
"Mozilla/5.0 (compatible; Nuclei - Open-source project)",
|
|
} {
|
|
wantRefusedBy(t, files, "/", scanner, "scanner-agent")
|
|
}
|
|
|
|
// Ordinary requests to a code forge for files of those names deeper
|
|
// in its paths, and for other files at its root.
|
|
for _, target := range []string{
|
|
"/owner/repo/src/branch/main/.env.example",
|
|
"/owner/repo/src/branch/main/.env",
|
|
"/owner/repo/src/branch/main/.github/workflows/ci.yml",
|
|
"/owner/repo/src/branch/main/.vscode/settings.json",
|
|
"/owner/repo/src/branch/main/.htaccess",
|
|
"/owner/repo/src/branch/main/docker-compose.yml",
|
|
"/owner/repo/src/branch/main/db/schema.sql",
|
|
"/owner/repo/raw/branch/main/debug.log",
|
|
"/owner/repo.git/info/refs?service=git-upload-pack",
|
|
"/owner/repo/src/branch/main/docs/../README.md",
|
|
"/user/login?redirect_to=%2fowner%2frepo",
|
|
"/index.php",
|
|
"/.well-known/security.txt",
|
|
} {
|
|
r := get(t, target)
|
|
r.Header.Set(userAgent, browser)
|
|
|
|
matched := files.Match(r)
|
|
if len(matched) != 0 {
|
|
t.Errorf("%s matched %v, want no rule", target, ids(matched))
|
|
}
|
|
}
|
|
|
|
// A request without a user agent is only noted.
|
|
wantMatched(t, files, get(t, "/"), "empty-agent")
|
|
}
|
|
|
|
// ruleFiles are files to write into a directory of rule files, by name.
|
|
type ruleFiles map[string]string
|
|
|
|
// writeFiles writes files into a new directory, and returns it.
|
|
func writeFiles(t *testing.T, files ruleFiles) string {
|
|
t.Helper()
|
|
|
|
dir := t.TempDir()
|
|
|
|
for name, content := range files {
|
|
path := filepath.Join(dir, name)
|
|
|
|
err := os.MkdirAll(filepath.Dir(path), 0o700)
|
|
if err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
|
|
err = os.WriteFile(path, []byte(content), 0o600)
|
|
if err != nil {
|
|
t.Fatalf("write %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
return dir
|
|
}
|
|
|
|
// save writes content to the rule file name in dir as an editor that
|
|
// saves by renaming does, so that the file is never seen half written.
|
|
func save(t *testing.T, dir, name, content string) {
|
|
t.Helper()
|
|
|
|
path := filepath.Join(dir, name)
|
|
|
|
err := os.WriteFile(path+".tmp", []byte(content), 0o600)
|
|
if err != nil {
|
|
t.Fatalf("write %s: %v", name, err)
|
|
}
|
|
|
|
err = os.Rename(path+".tmp", path)
|
|
if err != nil {
|
|
t.Fatalf("rename: %v", err)
|
|
}
|
|
}
|
|
|
|
// newParams returns Params for the rule files in dir, switched on, with
|
|
// the process log in the processLog returned.
|
|
func newParams(dir string) (rules.Params, processLog) {
|
|
lines := make(processLog, maxLogLines)
|
|
|
|
return rules.Params{
|
|
Dir: dir,
|
|
Enabled: true,
|
|
ProcessLog: slog.New(slog.NewJSONHandler(lines, nil)),
|
|
}, lines
|
|
}
|
|
|
|
// load writes files into a new directory and loads the rules in it.
|
|
func load(t *testing.T, files ruleFiles) *rules.Files {
|
|
t.Helper()
|
|
|
|
params, _ := newParams(writeFiles(t, files))
|
|
params.ProcessLog = slog.New(slog.DiscardHandler)
|
|
|
|
loaded, err := rules.Load(params)
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
|
|
return loaded
|
|
}
|
|
|
|
// watch loads the rules in dir, runs their Watch until the test ends, and
|
|
// waits until it watches the directory.
|
|
func watch(t *testing.T, dir string) (*rules.Files, processLog) {
|
|
t.Helper()
|
|
|
|
params, lines := newParams(dir)
|
|
|
|
files, err := rules.Load(params)
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
|
|
ctx, stop := context.WithCancel(t.Context())
|
|
stopped := make(chan struct{})
|
|
|
|
go func() {
|
|
files.Watch(ctx)
|
|
close(stopped)
|
|
}()
|
|
|
|
t.Cleanup(func() {
|
|
stop()
|
|
<-stopped
|
|
})
|
|
|
|
lines.waitFor(t, watching)
|
|
|
|
return files, lines
|
|
}
|
|
|
|
// wantRefused checks that loading the rule files in dir fails with the
|
|
// error want.
|
|
func wantRefused(t *testing.T, dir, want string) {
|
|
t.Helper()
|
|
|
|
params, _ := newParams(dir)
|
|
|
|
_, err := rules.Load(params)
|
|
if err == nil || err.Error() != want {
|
|
t.Errorf("error %v, want %s", err, want)
|
|
}
|
|
}
|
|
|
|
// get returns a GET request for target, a path and an optional query, as
|
|
// smallwebwaf's server reads it, without a user agent.
|
|
func get(t *testing.T, target string) *http.Request {
|
|
t.Helper()
|
|
|
|
return httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
|
"http://app.example"+target, nil)
|
|
}
|
|
|
|
// wantRefusedBy checks that a GET request for target with the user agent
|
|
// sent matches rule alone, and that rule refuses it.
|
|
func wantRefusedBy(t *testing.T, files *rules.Files, target, sent, rule string) {
|
|
t.Helper()
|
|
|
|
r := get(t, target)
|
|
r.Header.Set(userAgent, sent)
|
|
|
|
matched := files.Match(r)
|
|
if len(matched) != 1 || matched[0].ID != rule || matched[0].Action == rules.ActionLog {
|
|
t.Errorf("%s from %q matched %v, want %s alone, refusing it", target,
|
|
sent, ids(matched), rule)
|
|
}
|
|
}
|
|
|
|
// wantMatched checks the ids of the rules r matches, in order.
|
|
func wantMatched(t *testing.T, files *rules.Files, r *http.Request, want ...string) {
|
|
t.Helper()
|
|
|
|
got := ids(files.Match(r))
|
|
if !slices.Equal(got, want) {
|
|
t.Errorf("%s matched %v, want %v", r.URL, got, want)
|
|
}
|
|
}
|
|
|
|
// ids returns the ids of matched.
|
|
func ids(matched []rules.Rule) []string {
|
|
got := make([]string, 0, len(matched))
|
|
for _, rule := range matched {
|
|
got = append(got, rule.ID)
|
|
}
|
|
|
|
return got
|
|
}
|
|
|
|
// processLog receives the lines of a process log, each a JSON object, for
|
|
// a test to wait for.
|
|
type processLog chan string
|
|
|
|
// Write receives a line of the process log.
|
|
func (l processLog) Write(line []byte) (int, error) {
|
|
l <- string(line)
|
|
|
|
return len(line), nil
|
|
}
|
|
|
|
// waitFor returns the next line of the process log whose message is msg,
|
|
// passing over the lines before it. It waits as long as that takes, so
|
|
// that a slow test process cannot fail the test.
|
|
func (l processLog) waitFor(t *testing.T, msg string) map[string]any {
|
|
t.Helper()
|
|
|
|
for line := range l {
|
|
var fields map[string]any
|
|
|
|
err := json.Unmarshal([]byte(line), &fields)
|
|
if err != nil {
|
|
t.Fatalf("process log line %q is not JSON: %v", line, err)
|
|
}
|
|
|
|
if fields["msg"] == msg {
|
|
return fields
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// waitUntil waits for the rule files to be read until done reports true,
|
|
// as it does once they have been read after the test's last change. One
|
|
// change can be seen more than once, and so read more than once.
|
|
func (l processLog) waitUntil(t *testing.T, done func() bool) {
|
|
t.Helper()
|
|
|
|
for !done() {
|
|
l.waitFor(t, read)
|
|
}
|
|
}
|