Files
openserp/core/browser_test.go
2026-04-15 02:36:55 +03:00

411 lines
12 KiB
Go

//go:build integration
// +build integration
package core
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"testing"
"time"
"github.com/go-rod/rod"
"github.com/karust/openserp/testutil"
)
const botFingerprintTestsEnv = "OPENSERP_BOT_TESTS"
const botFingerprintArtifactDir = "testdata"
var criticalSannysoftChecks = []string{"webdriver"}
const sannysoftURL = "https://bot.sannysoft.com"
func TestCreateBrowser(t *testing.T) {
testutil.RequireIntegration(t)
opts := BrowserOpts{IsHeadless: true, IsLeakless: false}
browser, err := NewBrowser(opts)
if err != nil {
t.Fatalf("Error failed initializing browser: %s", err)
}
page, err := browser.Navigate("about:blank")
if err != nil {
t.Fatalf("navigate about:blank: %v", err)
}
defer closeTestBrowser(t, browser)
defer func() {
if err := page.Close(); err != nil {
t.Logf("close page: %v", err)
}
}()
}
func TestFingerprintSannysoft(t *testing.T) {
testutil.RequireIntegration(t)
if strings.TrimSpace(os.Getenv(botFingerprintTestsEnv)) != "1" {
t.Skipf("set %s=1 to run fingerprint tests", botFingerprintTestsEnv)
}
stealthOn := runSannysoftFingerprint(t, true)
stealthOff := runSannysoftFingerprint(t, false)
improved, regressed, stillDetected := compareSannysoftRuns(stealthOff, stealthOn)
t.Logf("Stealth comparison (OFF -> ON): improved=%d regressed=%d still_detected=%d", len(improved), len(regressed), len(stillDetected))
if len(improved) > 0 {
t.Logf("Stealth fixed: %s", strings.Join(improved, ", "))
}
if len(regressed) > 0 {
t.Logf("WARN: stealth regressions: %s", strings.Join(regressed, ", "))
}
if len(stillDetected) > 0 {
t.Logf("WARN: checks still detected with stealth: %s", strings.Join(stillDetected, ", "))
}
reportPath := filepath.Join(botFingerprintArtifactDir, "fingerprint_sannysoft_report.json")
report := sannysoftReport{
GeneratedAtUTC: time.Now().UTC().Format(time.RFC3339),
URL: sannysoftURL,
Runs: []sannysoftRunSummary{stealthOff, stealthOn},
Comparison: sannysoftComparison{
Baseline: stealthModeLabel(false),
Candidate: stealthModeLabel(true),
Improved: improved,
Regressed: regressed,
StillDetected: stillDetected,
},
}
if err := writeSannysoftReport(reportPath, report); err != nil {
t.Fatalf("write sannysoft report: %v", err)
}
absReportPath, err := filepath.Abs(reportPath)
if err == nil {
t.Logf("Sannysoft report artifact: %s", absReportPath)
} else {
t.Logf("Sannysoft report artifact: core/%s", filepath.ToSlash(reportPath))
}
for _, run := range []sannysoftRunSummary{stealthOff, stealthOn} {
if len(run.CriticalFailures) > 0 {
t.Errorf("CRITICAL fingerprint failures (%s): %v", stealthModeLabel(run.UseStealth), run.CriticalFailures)
}
}
}
type sannysoftCheck struct {
Name string `json:"name"`
Status string `json:"status"`
}
type sannysoftRunSummary struct {
UseStealth bool `json:"use_stealth"`
ScreenshotPath string `json:"screenshot_path"`
Checks []sannysoftCheck `json:"checks"`
Passed int `json:"passed"`
Failed int `json:"failed"`
Unknown int `json:"unknown"`
FailedChecks []string `json:"failed_checks"`
CriticalFailures []string `json:"critical_failures"`
ChecksByName map[string]sannysoftCheck `json:"-"`
}
func runSannysoftFingerprint(t *testing.T, useStealth bool) sannysoftRunSummary {
t.Helper()
label := stealthModeLabel(useStealth)
opts := BrowserOpts{
IsHeadless: true,
IsLeakless: false,
Timeout: 15 * time.Second,
UseStealth: useStealth,
}
browser, err := NewBrowser(opts)
if err != nil {
t.Fatalf("create browser (%s): %v", label, err)
}
defer closeTestBrowser(t, browser)
artifactPath := filepath.Join(botFingerprintArtifactDir, fmt.Sprintf("fingerprint_sannysoft_%s.png", label))
page, err := browser.Navigate(sannysoftURL)
if err != nil {
t.Fatalf("navigate to sannysoft (%s): %v", label, err)
}
defer func() {
if err := page.Close(); err != nil {
t.Logf("close page (%s): %v", label, err)
}
}()
// Keep screenshot defer after page close defer so it runs first (LIFO).
defer saveSannysoftScreenshot(t, page, artifactPath, label)
if err := waitForSannysoftResults(page, 20*time.Second); err != nil {
t.Fatalf("waiting for sannysoft results (%s): %v", label, err)
}
checks, err := extractSannysoftResults(page)
if err != nil {
t.Fatalf("extracting sannysoft results (%s): %v", label, err)
}
if len(checks) == 0 {
t.Fatalf("sannysoft did not return any fingerprint check rows (%s)", label)
}
summary := sannysoftRunSummary{
UseStealth: useStealth,
ScreenshotPath: filepath.ToSlash(filepath.Join("core", artifactPath)),
ChecksByName: make(map[string]sannysoftCheck, len(checks)),
Checks: make([]sannysoftCheck, 0, len(checks)),
}
for _, check := range checks {
summary.Checks = append(summary.Checks, check)
switch check.Status {
case "pass":
summary.Passed++
case "fail":
summary.Failed++
t.Logf("WARN (%s): detected by: %s", label, check.Name)
summary.FailedChecks = append(summary.FailedChecks, check.Name)
if isCriticalFingerprintFailure(check.Name) {
summary.CriticalFailures = append(summary.CriticalFailures, check.Name)
}
default:
summary.Unknown++
}
summary.ChecksByName[normalizeFingerprintCheckName(check.Name)] = check
}
sort.Slice(summary.Checks, func(i, j int) bool {
return normalizeFingerprintCheckName(summary.Checks[i].Name) < normalizeFingerprintCheckName(summary.Checks[j].Name)
})
sort.Strings(summary.FailedChecks)
sort.Strings(summary.CriticalFailures)
total := len(checks)
t.Logf("Sannysoft (%s): %d/%d checks passed (%d failed, %d unknown)", label, summary.Passed, total, summary.Failed, summary.Unknown)
return summary
}
type sannysoftComparison struct {
Baseline string `json:"baseline"`
Candidate string `json:"candidate"`
Improved []string `json:"improved"`
Regressed []string `json:"regressed"`
StillDetected []string `json:"still_detected"`
}
type sannysoftReport struct {
GeneratedAtUTC string `json:"generated_at_utc"`
URL string `json:"url"`
Runs []sannysoftRunSummary `json:"runs"`
Comparison sannysoftComparison `json:"comparison"`
}
func writeSannysoftReport(path string, report sannysoftReport) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create artifact directory: %w", err)
}
data, err := json.MarshalIndent(report, "", " ")
if err != nil {
return fmt.Errorf("marshal report: %w", err)
}
data = append(data, '\n')
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("write report file %s: %w", path, err)
}
return nil
}
func saveSannysoftScreenshot(t *testing.T, page *rod.Page, path, label string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Logf("WARN: create screenshot directory (%s): %v", label, err)
return
}
bytes, err := page.Screenshot(true, nil)
if err != nil {
t.Logf("WARN: capture screenshot (%s): %v", label, err)
return
}
if err := os.WriteFile(path, bytes, 0o644); err != nil {
t.Logf("WARN: write screenshot (%s): %v", label, err)
return
}
absPath, err := filepath.Abs(path)
if err == nil {
t.Logf("Sannysoft screenshot artifact (%s): %s", label, absPath)
return
}
t.Logf("Sannysoft screenshot artifact (%s): core/%s", label, filepath.ToSlash(path))
}
func stealthModeLabel(useStealth bool) string {
if useStealth {
return "stealth-on"
}
return "stealth-off"
}
func compareSannysoftRuns(baseline, candidate sannysoftRunSummary) (improved, regressed, stillDetected []string) {
names := make(map[string]struct{}, len(baseline.ChecksByName)+len(candidate.ChecksByName))
for key := range baseline.ChecksByName {
names[key] = struct{}{}
}
for key := range candidate.ChecksByName {
names[key] = struct{}{}
}
for key := range names {
before, hasBefore := baseline.ChecksByName[key]
after, hasAfter := candidate.ChecksByName[key]
if !hasBefore || !hasAfter {
continue
}
switch {
case before.Status == "fail" && after.Status == "pass":
improved = append(improved, after.Name)
case before.Status == "pass" && after.Status == "fail":
regressed = append(regressed, after.Name)
case before.Status == "fail" && after.Status == "fail":
stillDetected = append(stillDetected, after.Name)
}
}
sort.Strings(improved)
sort.Strings(regressed)
sort.Strings(stillDetected)
return improved, regressed, stillDetected
}
func normalizeFingerprintCheckName(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
func waitForSannysoftResults(page *rod.Page, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
hasRows, _, err := page.Has("table tr")
if err != nil {
return fmt.Errorf("table probe failed: %w", err)
}
if hasRows {
checks, err := extractSannysoftResults(page)
if err == nil && len(checks) >= 5 {
return nil
}
}
time.Sleep(250 * time.Millisecond)
}
return fmt.Errorf("results table not ready after %s", timeout)
}
func extractSannysoftResults(page *rod.Page) ([]sannysoftCheck, error) {
res, err := page.Eval(`() => {
const parseRGB = (value) => {
const match = (value || "").match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
if (!match) return null;
return [parseInt(match[1], 10), parseInt(match[2], 10), parseInt(match[3], 10)];
};
const classify = (text, className, bgColor) => {
const normalizedText = (text || "").toLowerCase();
const normalizedClass = (className || "").toLowerCase();
const rgb = parseRGB(bgColor);
if (/\b(fail(?:ed)?|detected|bot)\b/.test(normalizedText)) return "fail";
if (/\b(pass(?:ed)?|ok|success)\b/.test(normalizedText)) return "pass";
if (/\b(fail(?:ed)?|error|danger|bad|red)\b/.test(normalizedClass)) return "fail";
if (/\b(pass(?:ed)?|success|ok|good|green)\b/.test(normalizedClass)) return "pass";
if (rgb) {
const [r, g, b] = rgb;
if (r > g + 35 && r > b + 35) return "fail";
if (g > r + 20 && g > b + 20) return "pass";
}
return "unknown";
};
const rows = Array.from(document.querySelectorAll("table tr"));
const seen = new Set();
const checks = [];
for (const row of rows) {
const cells = Array.from(row.querySelectorAll("th, td"));
if (cells.length < 2) continue;
const nameCell = cells[0];
const name = (nameCell.innerText || nameCell.textContent || "").replace(/\s+/g, " ").trim();
if (!name) continue;
if (/^(test(\s+name)?|property|status|result)$/i.test(name)) continue;
const resultCells = cells.slice(1);
const statusCell =
resultCells.find((cell) => /\b(result|pass|fail|success|ok)\b/i.test(cell.className || "")) ||
resultCells.find((cell) => {
const bg = window.getComputedStyle(cell).backgroundColor || "";
return bg !== "" && bg !== "transparent" && bg !== "rgba(0, 0, 0, 0)";
}) ||
resultCells[0];
if (!statusCell) continue;
const statusText = (statusCell.innerText || statusCell.textContent || "").replace(/\s+/g, " ").trim();
const className = (row.className || "") + " " + (statusCell.className || "");
const bgColor = window.getComputedStyle(statusCell).backgroundColor || "";
const dedupeKey = name.toLowerCase();
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
checks.push({
name,
status: classify(statusText, className, bgColor),
});
}
return checks;
}`)
if err != nil {
return nil, err
}
var checks []sannysoftCheck
if err := res.Value.Unmarshal(&checks); err != nil {
return nil, fmt.Errorf("decode sannysoft results: %w", err)
}
return checks, nil
}
func isCriticalFingerprintFailure(checkName string) bool {
name := strings.ToLower(strings.TrimSpace(checkName))
return slices.ContainsFunc(criticalSannysoftChecks, func(critical string) bool {
return strings.Contains(name, critical)
})
}
func closeTestBrowser(t *testing.T, browser *Browser) {
t.Helper()
if browser == nil || browser.browser == nil {
return
}
if err := browser.Close(); err != nil {
t.Logf("close browser: %v", err)
}
}