aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorValentin Anger <syrupthinker@gryphno.de>2025-03-22 21:36:27 +0100
committerGitHub <noreply@github.com>2025-03-22 16:36:27 -0400
commitaf6f05554fe8da112599f30d32524c28a4078cac (patch)
treea63d3490ae3cdf519620a13e0841d55b0917408a /internal
parent1509b06cb921aff842e71fbb6636646be6ed5b46 (diff)
downloadanubis-af6f05554fe8da112599f30d32524c28a4078cac.tar.xz
anubis-af6f05554fe8da112599f30d32524c28a4078cac.zip
internal/test: introduce integration tests using Playwright (#81)
Diffstat (limited to 'internal')
-rw-r--r--internal/test/playwright_test.go276
1 files changed, 276 insertions, 0 deletions
diff --git a/internal/test/playwright_test.go b/internal/test/playwright_test.go
new file mode 100644
index 0000000..156d7df
--- /dev/null
+++ b/internal/test/playwright_test.go
@@ -0,0 +1,276 @@
+//go:build integration
+
+// Integration tests for Anubis, using Playwright.
+//
+// These tests require an already running Anubis and Playwright server.
+//
+// Anubis must be configured to redirect to the server started by the test suite.
+// The bind address and the Anubis server can be specified using the flags `-bind` and `-anubis` respectively.
+//
+// Playwright must be started in server mode using `npx playwright@1.50.1 run-server --port 3000`.
+// The version must match the minor used by the playwright-go package.
+//
+// On unsupported systems you may be able to use a container instead: https://playwright.dev/docs/docker#remote-connection
+//
+// In that case you may need to set the `-playwright` flag to the container's URL, and specify the `--host` the run-server command listens on.
+package test
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "net/http"
+ "net/url"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/playwright-community/playwright-go"
+)
+
+var (
+ anubisServer = flag.String("anubis", "http://localhost:8923", "Anubis server URL")
+ serverBindAddr = flag.String("bind", "localhost:3923", "test server bind address")
+ playwrightServer = flag.String("playwright", "ws://localhost:3000", "Playwright server URL")
+ playwrightMaxTime = flag.Duration("playwright-max-time", 5*time.Second, "maximum time for Playwright requests")
+ playwrightMaxHardTime = flag.Duration("playwright-max-hard-time", 5*time.Minute, "maximum time for hard Playwright requests")
+
+ testCases = []testCase{
+ {name: "firefox", action: actionChallenge, realIP: placeholderIP, userAgent: "Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0"},
+ {name: "headlessChrome", action: actionDeny, realIP: placeholderIP, userAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/120.0.6099.28 Safari/537.36"},
+ {name: "kagiBadIP", action: actionChallenge, isHard: true, realIP: placeholderIP, userAgent: "Mozilla/5.0 (compatible; Kagibot/1.0; +https://kagi.com/bot)"},
+ {name: "kagiGoodIP", action: actionAllow, realIP: "216.18.205.234", userAgent: "Mozilla/5.0 (compatible; Kagibot/1.0; +https://kagi.com/bot)"},
+ {name: "unknownAgent", action: actionAllow, realIP: placeholderIP, userAgent: "AnubisTest/0"},
+ }
+)
+
+const (
+ actionAllow action = "ALLOW"
+ actionDeny action = "DENY"
+ actionChallenge action = "CHALLENGE"
+
+ placeholderIP = "fd11:5ee:bad:c0de::"
+)
+
+type action string
+
+type testCase struct {
+ name string
+ action action
+ isHard bool
+ realIP, userAgent string
+}
+
+func TestPlaywrightBrowser(t *testing.T) {
+ pw := setupPlaywright(t)
+ spawnTestServer(t)
+ browsers := []playwright.BrowserType{pw.Chromium, pw.Firefox, pw.WebKit}
+
+ for _, typ := range browsers {
+ for _, tc := range testCases {
+ name := fmt.Sprintf("%s@%s", tc.name, typ.Name())
+ t.Run(name, func(t *testing.T) {
+ _, hasDeadline := t.Deadline()
+ if tc.isHard && hasDeadline {
+ t.Skip("skipping hard challenge with deadline")
+ }
+
+ perfomedAction := executeTestCase(t, tc, typ)
+
+ if perfomedAction != tc.action {
+ t.Errorf("unexpected test result, expected %s, got %s", tc.action, perfomedAction)
+ } else {
+ t.Logf("test passed")
+ }
+ })
+ }
+ }
+}
+
+func buildBrowserConnect(name string) string {
+ u, _ := url.Parse(*playwrightServer)
+
+ q := u.Query()
+ q.Set("browser", name)
+ u.RawQuery = q.Encode()
+
+ return u.String()
+}
+
+func executeTestCase(t *testing.T, tc testCase, typ playwright.BrowserType) action {
+ deadline, _ := t.Deadline()
+
+ browser, err := typ.Connect(buildBrowserConnect(typ.Name()), playwright.BrowserTypeConnectOptions{
+ ExposeNetwork: playwright.String("<loopback>"),
+ })
+ if err != nil {
+ t.Fatalf("could not connect to remote browser: %v", err)
+ }
+ defer browser.Close()
+
+ ctx, err := browser.NewContext(playwright.BrowserNewContextOptions{
+ AcceptDownloads: playwright.Bool(false),
+ ExtraHttpHeaders: map[string]string{
+ "X-Real-Ip": tc.realIP,
+ },
+ UserAgent: playwright.String(tc.userAgent),
+ })
+ if err != nil {
+ t.Fatalf("could not create context: %v", err)
+ }
+ defer ctx.Close()
+
+ page, err := ctx.NewPage()
+ if err != nil {
+ t.Fatalf("could not create page: %v", err)
+ }
+ defer page.Close()
+
+ // Attempt challenge.
+
+ start := time.Now()
+ _, err = page.Goto(*anubisServer, playwright.PageGotoOptions{
+ Timeout: pwTimeout(tc, deadline),
+ })
+ if err != nil {
+ pwFail(t, page, "could not navigate to test server: %v", err)
+ }
+
+ hadChallenge := false
+ switch tc.action {
+ case actionChallenge:
+ // FIXME: This could race if challenge is completed too quickly.
+ checkImage(t, tc, deadline, page, "#image[src*=pensive], #image[src*=happy]")
+ hadChallenge = true
+ case actionDeny:
+ checkImage(t, tc, deadline, page, "#image[src*=sad]")
+ return actionDeny
+ }
+
+ // Ensure protected resource was provided.
+
+ res, err := page.Locator("#anubis-test").TextContent(playwright.LocatorTextContentOptions{
+ Timeout: pwTimeout(tc, deadline),
+ })
+ end := time.Now()
+ if err != nil {
+ pwFail(t, page, "could not get text content: %v", err)
+ }
+
+ var tm int64
+ if _, err := fmt.Sscanf(res, "%d", &tm); err != nil {
+ pwFail(t, page, "unexpected output: %s", res)
+ }
+
+ if tm < start.Unix() || end.Unix() < tm {
+ pwFail(t, page, "unexpected timestamp in output: %d not in range %d..%d", tm, start.Unix(), end.Unix())
+ }
+
+ if hadChallenge {
+ return actionChallenge
+ } else {
+ return actionAllow
+ }
+}
+
+func checkImage(t *testing.T, tc testCase, deadline time.Time, page playwright.Page, locator string) {
+ image := page.Locator(locator)
+ err := image.WaitFor(playwright.LocatorWaitForOptions{
+ Timeout: pwTimeout(tc, deadline),
+ })
+ if err != nil {
+ pwFail(t, page, "could not wait for result: %v", err)
+ }
+
+ failIsVisible, err := image.IsVisible()
+ if err != nil {
+ pwFail(t, page, "could not check result image: %v", err)
+ }
+
+ if !failIsVisible {
+ pwFail(t, page, "expected result image not visible")
+ }
+}
+
+func pwFail(t *testing.T, page playwright.Page, format string, args ...any) {
+ t.Helper()
+
+ saveScreenshot(t, page)
+ t.Fatalf(format, args...)
+}
+
+func pwTimeout(tc testCase, deadline time.Time) *float64 {
+ max := *playwrightMaxTime
+ if tc.isHard {
+ max = *playwrightMaxHardTime
+ }
+
+ d := deadline.Sub(time.Now())
+ if d <= 0 || d > max {
+ return playwright.Float(float64(max.Milliseconds()))
+ }
+ return playwright.Float(float64(d.Milliseconds()))
+}
+
+func saveScreenshot(t *testing.T, page playwright.Page) {
+ t.Helper()
+
+ data, err := page.Screenshot()
+ if err != nil {
+ t.Logf("could not take screenshot: %v", err)
+ return
+ }
+
+ f, err := os.CreateTemp("", "anubis-test-fail-*.png")
+ if err != nil {
+ t.Logf("could not create temporary file: %v", err)
+ return
+ }
+ defer f.Close()
+
+ _, err = f.Write(data)
+ if err != nil {
+ t.Logf("could not write screenshot: %v", err)
+ return
+ }
+
+ t.Logf("screenshot saved to %s", f.Name())
+}
+
+func setupPlaywright(t *testing.T) *playwright.Playwright {
+ err := playwright.Install(&playwright.RunOptions{
+ SkipInstallBrowsers: true,
+ })
+ if err != nil {
+ t.Fatalf("could not install Playwright: %v", err)
+ }
+
+ pw, err := playwright.Run()
+ if err != nil {
+ t.Fatalf("could not start Playwright: %v", err)
+ }
+ return pw
+}
+
+func spawnTestServer(t *testing.T) {
+ t.Helper()
+
+ s := new(http.Server)
+ s.Addr = *serverBindAddr
+ s.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Add("Content-Type", "text/html")
+ fmt.Fprintf(w, "<html><body><span id=anubis-test>%d</span></body></html>", time.Now().Unix())
+ })
+
+ go func() {
+ if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ t.Logf("test HTTP server terminated unexpectedly: %v", err)
+ }
+ }()
+
+ t.Cleanup(func() {
+ if err := s.Shutdown(context.Background()); err != nil {
+ t.Fatalf("could not shutdown test server: %v", err)
+ }
+ })
+}