mirror of
https://github.com/lunchcat/sif.git
synced 2026-06-12 11:01:24 -07:00
test(modules): cover matchers, extractors, loader and executor
the yaml module engine (the user-facing extensibility surface) had 0% test coverage. add table-driven tests for the matcher types (status/word/regex + and/or + negative), checkWords/checkRegex (incl invalid-pattern fail-closed under AND, skip under OR), runExtractors (regex capture groups, group-index bounds, part selection), substituteVariables and generateHTTPRequests (path x payload expansion), and ParseYAMLModule on valid + malformed yaml. drive ExecuteHTTPModule end-to-end against an httptest server through the shared httpx client so matcher hits and extractor captures are exercised for real. coverage 0% -> 93.7%. also: ExecuteDNSModule/ExecuteTCPModule were stubs returning an empty result with nil error, so a type:dns/type:tcp module silently reported "0 findings" - indistinguishable from a real clean scan. make them return ErrUnsupportedModuleType (sentinel, wrapped with the module id) so the existing caller logs a clear failure instead. a test pins the new behavior. bodyclose is excluded for test files in .golangci.yml: the synthetic *http.Response fixtures carry no socket, mirroring the existing _test.go slack for errcheck/noctx/gosec.
This commit is contained in:
@@ -89,6 +89,7 @@ linters:
|
||||
- errcheck
|
||||
- noctx
|
||||
- gosec # fake credentials in secret-scanner fixtures are not real keys
|
||||
- bodyclose # synthetic *http.Response fixtures carry no socket to close
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 50
|
||||
|
||||
@@ -14,6 +14,7 @@ package modules
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -26,6 +27,11 @@ import (
|
||||
// MaxBodySize limits response body to prevent memory exhaustion.
|
||||
const MaxBodySize = 5 * 1024 * 1024
|
||||
|
||||
// ErrUnsupportedModuleType signals an executor for a module type that is not
|
||||
// yet implemented. Returning it (rather than an empty result) keeps callers
|
||||
// from mistaking "not implemented" for "scanned, found nothing".
|
||||
var ErrUnsupportedModuleType = errors.New("unsupported module type")
|
||||
|
||||
// httpRequest represents a generated HTTP request.
|
||||
type httpRequest struct {
|
||||
Method string
|
||||
@@ -379,22 +385,16 @@ func truncateEvidence(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// ExecuteDNSModule runs a DNS-based module (stub for now).
|
||||
func ExecuteDNSModule(ctx context.Context, target string, def *YAMLModule, opts Options) (*Result, error) {
|
||||
// TODO: Implement DNS module execution
|
||||
return &Result{
|
||||
ModuleID: def.ID,
|
||||
Target: target,
|
||||
Findings: []Finding{},
|
||||
}, nil
|
||||
// ExecuteDNSModule runs a DNS-based module (not yet implemented).
|
||||
// returns ErrUnsupportedModuleType so the caller logs a clear failure rather
|
||||
// than reporting an empty (but successful-looking) result.
|
||||
func ExecuteDNSModule(_ context.Context, _ string, def *YAMLModule, _ Options) (*Result, error) {
|
||||
return nil, fmt.Errorf("dns module %q: %w", def.ID, ErrUnsupportedModuleType)
|
||||
}
|
||||
|
||||
// ExecuteTCPModule runs a TCP-based module (stub for now).
|
||||
func ExecuteTCPModule(ctx context.Context, target string, def *YAMLModule, opts Options) (*Result, error) {
|
||||
// TODO: Implement TCP module execution
|
||||
return &Result{
|
||||
ModuleID: def.ID,
|
||||
Target: target,
|
||||
Findings: []Finding{},
|
||||
}, nil
|
||||
// ExecuteTCPModule runs a TCP-based module (not yet implemented).
|
||||
// returns ErrUnsupportedModuleType so the caller logs a clear failure rather
|
||||
// than reporting an empty (but successful-looking) result.
|
||||
func ExecuteTCPModule(_ context.Context, _ string, def *YAMLModule, _ Options) (*Result, error) {
|
||||
return nil, fmt.Errorf("tcp module %q: %w", def.ID, ErrUnsupportedModuleType)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package modules
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dropalldatabases/sif/internal/httpx"
|
||||
)
|
||||
|
||||
const testTimeout = 5 * time.Second
|
||||
|
||||
// TestExecuteHTTPModuleMatchAndExtract drives the full executor against a live
|
||||
// httptest server: a request hits a path, a matcher fires, an extractor captures.
|
||||
func TestExecuteHTTPModuleMatchAndExtract(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/admin" {
|
||||
w.Header().Set("X-App", "demo")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`flag{found-it} session=sess-4242`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
def := &YAMLModule{
|
||||
ID: "test-http-hit",
|
||||
Type: TypeHTTP,
|
||||
Info: YAMLModuleInfo{Severity: "high"},
|
||||
HTTP: &HTTPConfig{
|
||||
Method: "GET",
|
||||
Paths: []string{"{{BaseURL}}/admin", "{{BaseURL}}/missing"},
|
||||
Matchers: []Matcher{
|
||||
{Type: "status", Status: []int{200}},
|
||||
{Type: "word", Part: "body", Words: []string{"flag{found-it}"}},
|
||||
},
|
||||
Extractors: []Extractor{
|
||||
{Type: "regex", Name: "session", Part: "body", Regex: []string{`session=(\S+)`}, Group: 1},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// route through the shared httpx client so proxy/-H/-rate-limit would apply.
|
||||
opts := Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}
|
||||
|
||||
result, err := ExecuteHTTPModule(context.Background(), srv.URL, def, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteHTTPModule: %v", err)
|
||||
}
|
||||
|
||||
// only /admin satisfies status+word, /missing returns 404.
|
||||
if len(result.Findings) != 1 {
|
||||
t.Fatalf("got %d findings, want 1", len(result.Findings))
|
||||
}
|
||||
f := result.Findings[0]
|
||||
if f.Severity != "high" {
|
||||
t.Errorf("severity = %q, want high (carried from Info)", f.Severity)
|
||||
}
|
||||
if f.Extracted["session"] != "sess-4242" {
|
||||
t.Errorf("extracted session = %q, want sess-4242", f.Extracted["session"])
|
||||
}
|
||||
if f.URL != srv.URL+"/admin" {
|
||||
t.Errorf("finding url = %q, want %q", f.URL, srv.URL+"/admin")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteHTTPModuleNoMatch confirms a module that matches nothing reports
|
||||
// zero findings without erroring.
|
||||
func TestExecuteHTTPModuleNoMatch(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("nothing interesting"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
def := &YAMLModule{
|
||||
ID: "test-http-miss",
|
||||
Type: TypeHTTP,
|
||||
HTTP: &HTTPConfig{
|
||||
Paths: []string{"{{BaseURL}}/"},
|
||||
Matchers: []Matcher{
|
||||
{Type: "word", Part: "body", Words: []string{"never-present"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ExecuteHTTPModule(context.Background(), srv.URL, def, Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteHTTPModule: %v", err)
|
||||
}
|
||||
if len(result.Findings) != 0 {
|
||||
t.Fatalf("got %d findings, want 0", len(result.Findings))
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteHTTPModulePayloadExpansion verifies payload templates reach the
|
||||
// server and the matching response is captured.
|
||||
func TestExecuteHTTPModulePayloadExpansion(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// only the "boom" payload triggers the vulnerable branch.
|
||||
if r.URL.Query().Get("q") == "boom" {
|
||||
_, _ = w.Write([]byte("error: sql syntax near boom"))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
def := &YAMLModule{
|
||||
ID: "test-http-payload",
|
||||
Type: TypeHTTP,
|
||||
HTTP: &HTTPConfig{
|
||||
Paths: []string{"{{BaseURL}}/search?q={{payload}}"},
|
||||
Payloads: []string{"safe", "boom"},
|
||||
Matchers: []Matcher{
|
||||
{Type: "word", Part: "body", Words: []string{"sql syntax"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ExecuteHTTPModule(context.Background(), srv.URL, def, Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteHTTPModule: %v", err)
|
||||
}
|
||||
if len(result.Findings) != 1 {
|
||||
t.Fatalf("got %d findings, want 1 (only boom payload)", len(result.Findings))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteHTTPModuleNoConfig(t *testing.T) {
|
||||
def := &YAMLModule{ID: "x", Type: TypeHTTP}
|
||||
if _, err := ExecuteHTTPModule(context.Background(), "http://h", def, Options{}); err == nil {
|
||||
t.Fatal("expected error when HTTP config is nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteHTTPModuleContextCancel pins the cancellation path. The dispatch
|
||||
// loop selects between ctx.Done() and the concurrency semaphore, so a cancelled
|
||||
// context can either short-circuit with ctx.Err() or let the in-flight request
|
||||
// fail on the dead context. Both are correct: the contract is "never hang, never
|
||||
// invent a finding", which is what we assert here rather than forcing one race
|
||||
// winner (that made this test flaky under -count).
|
||||
func TestExecuteHTTPModuleContextCancel(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
def := &YAMLModule{
|
||||
ID: "test-http-cancel",
|
||||
Type: TypeHTTP,
|
||||
HTTP: &HTTPConfig{
|
||||
Paths: []string{"{{BaseURL}}/a"},
|
||||
Matchers: []Matcher{{Type: "status", Status: []int{200}}},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ExecuteHTTPModule(ctx, srv.URL, def, Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)})
|
||||
if err != nil {
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err = %v, want context.Canceled or nil", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
// no error means the request was dispatched but failed on the dead context;
|
||||
// either way a cancelled scan must not surface findings.
|
||||
if len(result.Findings) != 0 {
|
||||
t.Fatalf("cancelled scan produced %d findings, want 0", len(result.Findings))
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteDNSModuleUnsupported pins the current behavior: DNS execution is
|
||||
// not implemented and must signal it via ErrUnsupportedModuleType, not by
|
||||
// quietly returning an empty (successful-looking) result.
|
||||
func TestExecuteDNSModuleUnsupported(t *testing.T) {
|
||||
def := &YAMLModule{ID: "dns-mod", Type: TypeDNS, DNS: &DNSConfig{Type: "A"}}
|
||||
result, err := ExecuteDNSModule(context.Background(), "example.com", def, Options{})
|
||||
if result != nil {
|
||||
t.Errorf("result = %v, want nil for unsupported type", result)
|
||||
}
|
||||
if !errors.Is(err, ErrUnsupportedModuleType) {
|
||||
t.Fatalf("err = %v, want ErrUnsupportedModuleType", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteTCPModuleUnsupported(t *testing.T) {
|
||||
def := &YAMLModule{ID: "tcp-mod", Type: TypeTCP, TCP: &TCPConfig{Port: 22}}
|
||||
result, err := ExecuteTCPModule(context.Background(), "example.com", def, Options{})
|
||||
if result != nil {
|
||||
t.Errorf("result = %v, want nil for unsupported type", result)
|
||||
}
|
||||
if !errors.Is(err, ErrUnsupportedModuleType) {
|
||||
t.Fatalf("err = %v, want ErrUnsupportedModuleType", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapperExecuteRoutesByType confirms the Module wrapper dispatches each
|
||||
// type to the right executor and propagates the unsupported-type sentinel.
|
||||
func TestWrapperExecuteRoutesByType(t *testing.T) {
|
||||
t.Run("dns routes to unsupported", func(t *testing.T) {
|
||||
def := &YAMLModule{ID: "d", Type: TypeDNS, DNS: &DNSConfig{}}
|
||||
w := newYAMLModuleWrapper(def, "d.yaml")
|
||||
if _, err := w.Execute(context.Background(), "t", Options{}); !errors.Is(err, ErrUnsupportedModuleType) {
|
||||
t.Fatalf("err = %v, want ErrUnsupportedModuleType", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tcp routes to unsupported", func(t *testing.T) {
|
||||
def := &YAMLModule{ID: "t", Type: TypeTCP, TCP: &TCPConfig{}}
|
||||
w := newYAMLModuleWrapper(def, "t.yaml")
|
||||
if _, err := w.Execute(context.Background(), "t", Options{}); !errors.Is(err, ErrUnsupportedModuleType) {
|
||||
t.Fatalf("err = %v, want ErrUnsupportedModuleType", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing http config errors", func(t *testing.T) {
|
||||
def := &YAMLModule{ID: "h", Type: TypeHTTP}
|
||||
w := newYAMLModuleWrapper(def, "h.yaml")
|
||||
if _, err := w.Execute(context.Background(), "t", Options{}); err == nil {
|
||||
t.Fatal("expected error for missing http config")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown type errors", func(t *testing.T) {
|
||||
def := &YAMLModule{ID: "z", Type: ModuleType("bogus")}
|
||||
w := newYAMLModuleWrapper(def, "z.yaml")
|
||||
if _, err := w.Execute(context.Background(), "t", Options{}); err == nil {
|
||||
t.Fatal("expected error for unknown module type")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestTruncateEvidence(t *testing.T) {
|
||||
short := "short evidence"
|
||||
if got := truncateEvidence(short); got != short {
|
||||
t.Errorf("short evidence changed: %q", got)
|
||||
}
|
||||
|
||||
long := make([]byte, 600)
|
||||
for i := range long {
|
||||
long[i] = 'a'
|
||||
}
|
||||
got := truncateEvidence(string(long))
|
||||
// 500 chars of content plus the ellipsis marker.
|
||||
if len(got) != 503 {
|
||||
t.Errorf("truncated len = %d, want 503", len(got))
|
||||
}
|
||||
if got[len(got)-3:] != "..." {
|
||||
t.Errorf("truncated evidence missing ellipsis: %q", got[len(got)-3:])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package modules
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeModule drops a yaml file into a temp dir and returns its path.
|
||||
func writeModule(t *testing.T, dir, name, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write module: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestParseYAMLModuleValid(t *testing.T) {
|
||||
const doc = `id: example-http
|
||||
type: http
|
||||
info:
|
||||
name: Example
|
||||
author: azzie
|
||||
severity: medium
|
||||
description: a test module
|
||||
tags: [test, demo]
|
||||
http:
|
||||
method: GET
|
||||
paths:
|
||||
- "{{BaseURL}}/admin"
|
||||
matchers:
|
||||
- type: status
|
||||
status: [200]
|
||||
- type: word
|
||||
part: body
|
||||
words: ["admin"]
|
||||
condition: and
|
||||
extractors:
|
||||
- type: regex
|
||||
name: token
|
||||
part: body
|
||||
regex: ["token=(\\w+)"]
|
||||
group: 1
|
||||
`
|
||||
dir := t.TempDir()
|
||||
path := writeModule(t, dir, "ok.yaml", doc)
|
||||
|
||||
def, err := ParseYAMLModule(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseYAMLModule: %v", err)
|
||||
}
|
||||
if def.ID != "example-http" {
|
||||
t.Errorf("id = %q, want example-http", def.ID)
|
||||
}
|
||||
if def.Type != TypeHTTP {
|
||||
t.Errorf("type = %q, want http", def.Type)
|
||||
}
|
||||
if def.Info.Severity != "medium" {
|
||||
t.Errorf("severity = %q, want medium", def.Info.Severity)
|
||||
}
|
||||
if def.HTTP == nil {
|
||||
t.Fatal("http config not parsed")
|
||||
}
|
||||
if len(def.HTTP.Matchers) != 2 {
|
||||
t.Errorf("got %d matchers, want 2", len(def.HTTP.Matchers))
|
||||
}
|
||||
if len(def.HTTP.Extractors) != 1 || def.HTTP.Extractors[0].Group != 1 {
|
||||
t.Errorf("extractor not parsed correctly: %+v", def.HTTP.Extractors)
|
||||
}
|
||||
if len(def.Info.Tags) != 2 {
|
||||
t.Errorf("got %d tags, want 2", len(def.Info.Tags))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseYAMLModuleErrors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{
|
||||
name: "missing id",
|
||||
content: "type: http\nhttp:\n paths: [\"/\"]\n",
|
||||
},
|
||||
{
|
||||
name: "missing type",
|
||||
content: "id: no-type\nhttp:\n paths: [\"/\"]\n",
|
||||
},
|
||||
{
|
||||
name: "malformed yaml",
|
||||
content: "id: bad\ntype: http\n paths: [unbalanced\n : nope\n",
|
||||
},
|
||||
{
|
||||
// a scalar where a mapping is expected must fail to unmarshal.
|
||||
name: "type mismatch",
|
||||
content: "id: bad-shape\ntype: http\nhttp: \"should-be-a-map\"\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path := writeModule(t, dir, tt.name+".yaml", tt.content)
|
||||
if _, err := ParseYAMLModule(path); err == nil {
|
||||
t.Fatalf("expected error for %s", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseYAMLModuleMissingFile(t *testing.T) {
|
||||
if _, err := ParseYAMLModule(filepath.Join(t.TempDir(), "does-not-exist.yaml")); err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestYAMLModuleWrapperInfoAndType(t *testing.T) {
|
||||
def := &YAMLModule{
|
||||
ID: "wrap-test",
|
||||
Type: TypeHTTP,
|
||||
Info: YAMLModuleInfo{
|
||||
Name: "Wrapped",
|
||||
Author: "azzie",
|
||||
Severity: "low",
|
||||
Description: "desc",
|
||||
Tags: []string{"a", "b"},
|
||||
},
|
||||
}
|
||||
w := newYAMLModuleWrapper(def, "wrap.yaml")
|
||||
|
||||
if w.Type() != TypeHTTP {
|
||||
t.Errorf("Type() = %q, want http", w.Type())
|
||||
}
|
||||
info := w.Info()
|
||||
if info.ID != "wrap-test" || info.Name != "Wrapped" || info.Severity != "low" {
|
||||
t.Errorf("Info() mismatch: %+v", info)
|
||||
}
|
||||
if len(info.Tags) != 2 {
|
||||
t.Errorf("Info().Tags = %v, want 2 entries", info.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoaderLoadAll exercises the directory walk: a valid module registers, a
|
||||
// malformed one is skipped without aborting the walk.
|
||||
func TestLoaderLoadAll(t *testing.T) {
|
||||
Clear()
|
||||
t.Cleanup(Clear)
|
||||
|
||||
dir := t.TempDir()
|
||||
writeModule(t, dir, "good.yaml", "id: good-mod\ntype: http\nhttp:\n paths: [\"{{BaseURL}}/\"]\n matchers:\n - type: status\n status: [200]\n")
|
||||
writeModule(t, dir, "bad.yml", "id: bad-mod\n") // missing type -> skipped
|
||||
writeModule(t, dir, "ignore.txt", "not a module")
|
||||
|
||||
l := &Loader{builtinDir: dir, userDir: filepath.Join(dir, "nonexistent-user")}
|
||||
if err := l.LoadAll(); err != nil {
|
||||
t.Fatalf("LoadAll: %v", err)
|
||||
}
|
||||
|
||||
// only the good module loads; the malformed one is logged and skipped.
|
||||
if l.Loaded() != 1 {
|
||||
t.Errorf("Loaded() = %d, want 1", l.Loaded())
|
||||
}
|
||||
if _, ok := Get("good-mod"); !ok {
|
||||
t.Error("good-mod not registered")
|
||||
}
|
||||
if _, ok := Get("bad-mod"); ok {
|
||||
t.Error("bad-mod should not have registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLoaderDirs(t *testing.T) {
|
||||
l, err := NewLoader()
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoader: %v", err)
|
||||
}
|
||||
if l.BuiltinDir() == "" {
|
||||
t.Error("BuiltinDir is empty")
|
||||
}
|
||||
if l.UserDir() == "" {
|
||||
t.Error("UserDir is empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry exercises the package-level registry: register, get, dedupe by
|
||||
// id, filter by tag and type, count and clear.
|
||||
func TestRegistry(t *testing.T) {
|
||||
Clear()
|
||||
t.Cleanup(Clear)
|
||||
|
||||
http1 := newYAMLModuleWrapper(&YAMLModule{ID: "h1", Type: TypeHTTP, Info: YAMLModuleInfo{Tags: []string{"web", "cve"}}}, "h1")
|
||||
http2 := newYAMLModuleWrapper(&YAMLModule{ID: "h2", Type: TypeHTTP, Info: YAMLModuleInfo{Tags: []string{"web"}}}, "h2")
|
||||
dns1 := newYAMLModuleWrapper(&YAMLModule{ID: "d1", Type: TypeDNS, Info: YAMLModuleInfo{Tags: []string{"dns"}}}, "d1")
|
||||
|
||||
Register(http1)
|
||||
Register(http2)
|
||||
Register(dns1)
|
||||
|
||||
if Count() != 3 {
|
||||
t.Fatalf("Count() = %d, want 3", Count())
|
||||
}
|
||||
|
||||
got, ok := Get("h1")
|
||||
if !ok || got.Info().ID != "h1" {
|
||||
t.Errorf("Get(h1) = %v, %v", got, ok)
|
||||
}
|
||||
if _, ok := Get("missing"); ok {
|
||||
t.Error("Get(missing) should report not found")
|
||||
}
|
||||
|
||||
if n := len(ByType(TypeHTTP)); n != 2 {
|
||||
t.Errorf("ByType(http) = %d, want 2", n)
|
||||
}
|
||||
if n := len(ByType(TypeDNS)); n != 1 {
|
||||
t.Errorf("ByType(dns) = %d, want 1", n)
|
||||
}
|
||||
if n := len(ByTag("web")); n != 2 {
|
||||
t.Errorf("ByTag(web) = %d, want 2", n)
|
||||
}
|
||||
if n := len(ByTag("cve")); n != 1 {
|
||||
t.Errorf("ByTag(cve) = %d, want 1", n)
|
||||
}
|
||||
if n := len(ByTag("none")); n != 0 {
|
||||
t.Errorf("ByTag(none) = %d, want 0", n)
|
||||
}
|
||||
if n := len(All()); n != 3 {
|
||||
t.Errorf("All() = %d, want 3", n)
|
||||
}
|
||||
|
||||
// re-registering the same id overwrites rather than duplicating.
|
||||
Register(newYAMLModuleWrapper(&YAMLModule{ID: "h1", Type: TypeHTTP}, "h1-v2"))
|
||||
if Count() != 3 {
|
||||
t.Errorf("Count() after re-register = %d, want 3", Count())
|
||||
}
|
||||
|
||||
Clear()
|
||||
if Count() != 0 {
|
||||
t.Errorf("Count() after Clear = %d, want 0", Count())
|
||||
}
|
||||
}
|
||||
|
||||
// TestResultType pins the ScanResult interface bridge.
|
||||
func TestResultType(t *testing.T) {
|
||||
r := &Result{ModuleID: "abc"}
|
||||
if r.ResultType() != "abc" {
|
||||
t.Errorf("ResultType() = %q, want abc", r.ResultType())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoaderScriptStubNoop confirms the go-script loader is currently a no-op
|
||||
// that registers nothing and returns no error.
|
||||
func TestLoaderScriptStubNoop(t *testing.T) {
|
||||
l := &Loader{}
|
||||
if err := l.loadScript("anything.go"); err != nil {
|
||||
t.Errorf("loadScript stub returned error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package modules
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeResponse builds a minimal *http.Response for matcher/extractor tests.
|
||||
// it carries no real socket (Body is http.NoBody), so there is nothing to
|
||||
// close; bodyclose is excluded for test files in .golangci.yml. header drives
|
||||
// the header/all parts without a live server; matchers read the body string
|
||||
// argument, not resp.Body.
|
||||
func fakeResponse(t *testing.T, status int, header http.Header) *http.Response {
|
||||
t.Helper()
|
||||
if header == nil {
|
||||
header = http.Header{}
|
||||
}
|
||||
return &http.Response{StatusCode: status, Header: header, Body: http.NoBody}
|
||||
}
|
||||
|
||||
func TestCheckMatcherStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
want []int
|
||||
expect bool
|
||||
}{
|
||||
{name: "single match", status: 200, want: []int{200}, expect: true},
|
||||
{name: "one of many", status: 404, want: []int{200, 301, 404}, expect: true},
|
||||
{name: "no match", status: 500, want: []int{200, 404}, expect: false},
|
||||
{name: "empty status list", status: 200, want: nil, expect: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m := &Matcher{Type: "status", Status: tt.want}
|
||||
resp := fakeResponse(t, tt.status, nil)
|
||||
if got := checkMatcher(m, resp, ""); got != tt.expect {
|
||||
t.Errorf("checkMatcher status = %v, want %v", got, tt.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMatcherWord(t *testing.T) {
|
||||
const body = "welcome admin dashboard"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
words []string
|
||||
condition string
|
||||
expect bool
|
||||
}{
|
||||
{name: "and all present", words: []string{"admin", "dashboard"}, condition: "and", expect: true},
|
||||
{name: "and one missing", words: []string{"admin", "missing"}, condition: "and", expect: false},
|
||||
{name: "default is and", words: []string{"admin", "missing"}, condition: "", expect: false},
|
||||
{name: "or one present", words: []string{"missing", "admin"}, condition: "or", expect: true},
|
||||
{name: "or none present", words: []string{"missing", "absent"}, condition: "or", expect: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m := &Matcher{Type: "word", Part: "body", Words: tt.words, Condition: tt.condition}
|
||||
resp := fakeResponse(t, 200, nil)
|
||||
if got := checkMatcher(m, resp, body); got != tt.expect {
|
||||
t.Errorf("checkMatcher word = %v, want %v", got, tt.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMatcherRegex(t *testing.T) {
|
||||
const body = "version 1.2.3 build 99"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
patterns []string
|
||||
condition string
|
||||
expect bool
|
||||
}{
|
||||
{name: "and all match", patterns: []string{`version \d`, `build \d+`}, condition: "and", expect: true},
|
||||
{name: "and one fails", patterns: []string{`version \d`, `nope\d`}, condition: "and", expect: false},
|
||||
{name: "or one matches", patterns: []string{`nope`, `build \d+`}, condition: "or", expect: true},
|
||||
{name: "or none match", patterns: []string{`nope`, `zilch`}, condition: "or", expect: false},
|
||||
// an invalid pattern under AND must fail closed, not panic.
|
||||
{name: "and invalid pattern fails closed", patterns: []string{`version \d`, `(`}, condition: "and", expect: false},
|
||||
// under OR an invalid pattern is skipped, a later valid one can still hit.
|
||||
{name: "or invalid pattern skipped", patterns: []string{`(`, `build \d+`}, condition: "or", expect: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m := &Matcher{Type: "regex", Part: "body", Regex: tt.patterns, Condition: tt.condition}
|
||||
resp := fakeResponse(t, 200, nil)
|
||||
if got := checkMatcher(m, resp, body); got != tt.expect {
|
||||
t.Errorf("checkMatcher regex = %v, want %v", got, tt.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMatcherHeaderPart(t *testing.T) {
|
||||
header := http.Header{"X-Powered-By": []string{"PHP/8.1"}}
|
||||
resp := fakeResponse(t, 200, header)
|
||||
|
||||
m := &Matcher{Type: "word", Part: "header", Words: []string{"PHP/8.1"}}
|
||||
if !checkMatcher(m, resp, "body-content") {
|
||||
t.Error("expected header-part word matcher to hit on header value")
|
||||
}
|
||||
|
||||
// the same word lives only in the header, so a body-part matcher must miss.
|
||||
mBody := &Matcher{Type: "word", Part: "body", Words: []string{"PHP/8.1"}}
|
||||
if checkMatcher(mBody, resp, "body-content") {
|
||||
t.Error("body-part matcher should not see header-only value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMatcherUnknownType(t *testing.T) {
|
||||
m := &Matcher{Type: "size", Part: "body"}
|
||||
resp := fakeResponse(t, 200, nil)
|
||||
if checkMatcher(m, resp, "anything") {
|
||||
t.Error("unknown matcher type should not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMatchers(t *testing.T) {
|
||||
resp := fakeResponse(t, 200, http.Header{"Server": []string{"nginx"}})
|
||||
const body = "secret token here"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
matchers []Matcher
|
||||
expect bool
|
||||
}{
|
||||
{
|
||||
name: "empty matchers never match",
|
||||
matchers: nil,
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "all matchers pass (AND across matchers)",
|
||||
matchers: []Matcher{
|
||||
{Type: "status", Status: []int{200}},
|
||||
{Type: "word", Part: "body", Words: []string{"secret"}},
|
||||
},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "one matcher fails breaks AND",
|
||||
matchers: []Matcher{
|
||||
{Type: "status", Status: []int{200}},
|
||||
{Type: "word", Part: "body", Words: []string{"absent"}},
|
||||
},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "negative inverts a non-match into a pass",
|
||||
matchers: []Matcher{
|
||||
{Type: "word", Part: "body", Words: []string{"absent"}, Negative: true},
|
||||
},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "negative inverts a match into a fail",
|
||||
matchers: []Matcher{
|
||||
{Type: "word", Part: "body", Words: []string{"secret"}, Negative: true},
|
||||
},
|
||||
expect: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := checkMatchers(tt.matchers, resp, body); got != tt.expect {
|
||||
t.Errorf("checkMatchers = %v, want %v", got, tt.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckWords(t *testing.T) {
|
||||
const content = "alpha beta gamma"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
words []string
|
||||
condition string
|
||||
expect bool
|
||||
}{
|
||||
{name: "and all present", words: []string{"alpha", "gamma"}, condition: "and", expect: true},
|
||||
{name: "and missing", words: []string{"alpha", "delta"}, condition: "and", expect: false},
|
||||
{name: "or present", words: []string{"delta", "beta"}, condition: "or", expect: true},
|
||||
{name: "or absent", words: []string{"delta", "epsilon"}, condition: "or", expect: false},
|
||||
{name: "empty under and matches vacuously", words: nil, condition: "and", expect: true},
|
||||
{name: "empty under or matches nothing", words: nil, condition: "or", expect: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := checkWords(content, tt.words, tt.condition); got != tt.expect {
|
||||
t.Errorf("checkWords = %v, want %v", got, tt.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRegex(t *testing.T) {
|
||||
const content = "id=42 name=root"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
patterns []string
|
||||
condition string
|
||||
expect bool
|
||||
}{
|
||||
{name: "and all match", patterns: []string{`id=\d+`, `name=\w+`}, condition: "and", expect: true},
|
||||
{name: "and one fails", patterns: []string{`id=\d+`, `zzz`}, condition: "and", expect: false},
|
||||
{name: "or first matches", patterns: []string{`id=\d+`, `zzz`}, condition: "or", expect: true},
|
||||
{name: "or none match", patterns: []string{`xxx`, `zzz`}, condition: "or", expect: false},
|
||||
{name: "and bad regex fails closed", patterns: []string{`(`}, condition: "and", expect: false},
|
||||
{name: "or bad regex skipped then match", patterns: []string{`(`, `name=\w+`}, condition: "or", expect: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := checkRegex(content, tt.patterns, tt.condition); got != tt.expect {
|
||||
t.Errorf("checkRegex = %v, want %v", got, tt.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPart(t *testing.T) {
|
||||
header := http.Header{"Server": []string{"nginx"}}
|
||||
resp := fakeResponse(t, 200, header)
|
||||
const body = "page body"
|
||||
|
||||
if got := getPart("body", resp, body); got != body {
|
||||
t.Errorf("getPart body = %q, want %q", got, body)
|
||||
}
|
||||
|
||||
headerPart := getPart("header", resp, body)
|
||||
if !strings.Contains(headerPart, "Server") || !strings.Contains(headerPart, "nginx") {
|
||||
t.Errorf("getPart header = %q, want it to include the header", headerPart)
|
||||
}
|
||||
if strings.Contains(headerPart, body) {
|
||||
t.Errorf("getPart header should not include body, got %q", headerPart)
|
||||
}
|
||||
|
||||
all := getPart("all", resp, body)
|
||||
if !strings.Contains(all, "nginx") || !strings.Contains(all, body) {
|
||||
t.Errorf("getPart all = %q, want both header and body", all)
|
||||
}
|
||||
|
||||
// an unrecognised part falls back to the body.
|
||||
if got := getPart("weird", resp, body); got != body {
|
||||
t.Errorf("getPart fallback = %q, want body %q", got, body)
|
||||
}
|
||||
|
||||
// empty part behaves like "all".
|
||||
if got := getPart("", resp, body); !strings.Contains(got, "nginx") || !strings.Contains(got, body) {
|
||||
t.Errorf("getPart empty = %q, want both header and body", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExtractors(t *testing.T) {
|
||||
resp := fakeResponse(t, 200, http.Header{"X-Token": []string{"abc123"}})
|
||||
const body = `{"session":"sess-7788","role":"admin"}`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
extractors []Extractor
|
||||
wantKey string
|
||||
wantVal string
|
||||
wantNil bool
|
||||
}{
|
||||
{
|
||||
name: "no extractors yields nil",
|
||||
extractors: nil,
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "regex capture group on body",
|
||||
extractors: []Extractor{
|
||||
{Type: "regex", Name: "session", Part: "body", Regex: []string{`"session":"([^"]+)"`}, Group: 1},
|
||||
},
|
||||
wantKey: "session",
|
||||
wantVal: "sess-7788",
|
||||
},
|
||||
{
|
||||
name: "group zero is the whole match",
|
||||
extractors: []Extractor{
|
||||
{Type: "regex", Name: "role", Part: "body", Regex: []string{`role":"admin`}, Group: 0},
|
||||
},
|
||||
wantKey: "role",
|
||||
wantVal: `role":"admin`,
|
||||
},
|
||||
{
|
||||
name: "extract from header part",
|
||||
extractors: []Extractor{
|
||||
{Type: "regex", Name: "token", Part: "header", Regex: []string{`X-Token: (\S+)`}, Group: 1},
|
||||
},
|
||||
wantKey: "token",
|
||||
wantVal: "abc123",
|
||||
},
|
||||
{
|
||||
name: "first matching pattern wins",
|
||||
extractors: []Extractor{
|
||||
{Type: "regex", Name: "session", Part: "body", Regex: []string{`nomatch(\d+)`, `"session":"([^"]+)"`}, Group: 1},
|
||||
},
|
||||
wantKey: "session",
|
||||
wantVal: "sess-7788",
|
||||
},
|
||||
{
|
||||
name: "group index out of range is skipped",
|
||||
extractors: []Extractor{
|
||||
{Type: "regex", Name: "session", Part: "body", Regex: []string{`"session":"([^"]+)"`}, Group: 5},
|
||||
},
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "invalid pattern is skipped, no capture",
|
||||
extractors: []Extractor{
|
||||
{Type: "regex", Name: "session", Part: "body", Regex: []string{`(`}, Group: 1},
|
||||
},
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "non-regex extractor type is ignored",
|
||||
extractors: []Extractor{
|
||||
{Type: "kval", Name: "session", Part: "body", Regex: []string{`"session":"([^"]+)"`}, Group: 1},
|
||||
},
|
||||
wantNil: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := runExtractors(tt.extractors, resp, body)
|
||||
if tt.wantNil {
|
||||
if len(got) != 0 {
|
||||
t.Errorf("runExtractors = %v, want empty", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got[tt.wantKey] != tt.wantVal {
|
||||
t.Errorf("runExtractors[%q] = %q, want %q", tt.wantKey, got[tt.wantKey], tt.wantVal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubstituteVariables(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
template string
|
||||
baseURL string
|
||||
payload string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "baseurl both cases",
|
||||
template: "{{BaseURL}}/x and {{baseurl}}/y",
|
||||
baseURL: "http://h",
|
||||
want: "http://h/x and http://h/y",
|
||||
},
|
||||
{
|
||||
name: "payload both cases",
|
||||
template: "q={{payload}}&r={{Payload}}",
|
||||
payload: "<script>",
|
||||
want: "q=<script>&r=<script>",
|
||||
},
|
||||
{
|
||||
name: "combined base and payload",
|
||||
template: "{{BaseURL}}/search?q={{payload}}",
|
||||
baseURL: "http://h",
|
||||
payload: "x",
|
||||
want: "http://h/search?q=x",
|
||||
},
|
||||
{
|
||||
name: "no placeholders untouched",
|
||||
template: "/static/path",
|
||||
baseURL: "http://h",
|
||||
want: "/static/path",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := substituteVariables(tt.template, tt.baseURL, tt.payload); got != tt.want {
|
||||
t.Errorf("substituteVariables = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateHTTPRequests(t *testing.T) {
|
||||
t.Run("paths without payloads", func(t *testing.T) {
|
||||
cfg := &HTTPConfig{
|
||||
Paths: []string{"{{BaseURL}}/a", "{{BaseURL}}/b"},
|
||||
}
|
||||
// trailing slash on the target must be trimmed before substitution.
|
||||
got := generateHTTPRequests("http://h/", cfg)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d requests, want 2", len(got))
|
||||
}
|
||||
if got[0].Method != "GET" {
|
||||
t.Errorf("default method = %q, want GET", got[0].Method)
|
||||
}
|
||||
if got[0].URL != "http://h/a" || got[1].URL != "http://h/b" {
|
||||
t.Errorf("urls = %q,%q", got[0].URL, got[1].URL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("payload expansion is path x payload", func(t *testing.T) {
|
||||
cfg := &HTTPConfig{
|
||||
Method: "POST",
|
||||
Paths: []string{"{{BaseURL}}/q?x={{payload}}"},
|
||||
Payloads: []string{"1", "2", "3"},
|
||||
Body: "data={{payload}}",
|
||||
}
|
||||
got := generateHTTPRequests("http://h", cfg)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("got %d requests, want 3", len(got))
|
||||
}
|
||||
for i, want := range []string{"1", "2", "3"} {
|
||||
if got[i].Payload != want {
|
||||
t.Errorf("req %d payload = %q, want %q", i, got[i].Payload, want)
|
||||
}
|
||||
if got[i].URL != "http://h/q?x="+want {
|
||||
t.Errorf("req %d url = %q", i, got[i].URL)
|
||||
}
|
||||
if got[i].Body != "data="+want {
|
||||
t.Errorf("req %d body = %q", i, got[i].Body)
|
||||
}
|
||||
if got[i].Method != "POST" {
|
||||
t.Errorf("req %d method = %q, want POST", i, got[i].Method)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple paths times multiple payloads", func(t *testing.T) {
|
||||
cfg := &HTTPConfig{
|
||||
Paths: []string{"{{BaseURL}}/a", "{{BaseURL}}/b"},
|
||||
Payloads: []string{"x", "y"},
|
||||
}
|
||||
got := generateHTTPRequests("http://h", cfg)
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("got %d requests, want 4 (2 paths x 2 payloads)", len(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user