diff --git a/aasd/src/internal/report/report_test.go b/aasd/src/internal/report/report_test.go
new file mode 100644
index 0000000..d4fd66f
--- /dev/null
+++ b/aasd/src/internal/report/report_test.go
@@ -0,0 +1,309 @@
+package report
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestGenerateToken_NotEmpty(t *testing.T) {
+ token, err := GenerateToken()
+ if err != nil {
+ t.Fatalf("GenerateToken failed: %v", err)
+ }
+ if token == "" {
+ t.Fatal("expected non-empty token")
+ }
+}
+
+func TestGenerateToken_Format(t *testing.T) {
+ token, err := GenerateToken()
+ if err != nil {
+ t.Fatalf("GenerateToken failed: %v", err)
+ }
+
+ // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
+ parts := strings.Split(token, "-")
+ if len(parts) != 5 {
+ t.Errorf("expected 5 UUID parts, got %d: %s", len(parts), token)
+ }
+
+ // Check version nibble (should be 4 in the third group)
+ if len(parts[2]) > 0 && parts[2][0] != '4' {
+ t.Errorf("expected version 4 UUID, got version '%c'", parts[2][0])
+ }
+
+ // Check variant nibble (should be 8, 9, a, or b in the fourth group)
+ if len(parts[3]) > 0 {
+ v := parts[3][0]
+ if v != '8' && v != '9' && v != 'a' && v != 'b' {
+ t.Errorf("expected variant 8/9/a/b, got '%c'", v)
+ }
+ }
+}
+
+func TestGenerateToken_Unique(t *testing.T) {
+ seen := make(map[string]bool)
+ for i := 0; i < 100; i++ {
+ token, err := GenerateToken()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if seen[token] {
+ t.Errorf("duplicate token generated: %s", token)
+ }
+ seen[token] = true
+ }
+}
+
+func TestGenerateToken_Length(t *testing.T) {
+ token, err := GenerateToken()
+ if err != nil {
+ t.Fatal(err)
+ }
+ // UUID v4 is 36 characters (32 hex + 4 dashes)
+ if len(token) != 36 {
+ t.Errorf("expected UUID length 36, got %d: %s", len(token), token)
+ }
+}
+
+func TestGenerateFallbackHTML_WithSubdomains(t *testing.T) {
+ subdomains := []string{"example.com", "www.example.com", "api.example.com"}
+ html := GenerateFallbackHTML(subdomains)
+
+ if html == "" {
+ t.Fatal("expected non-empty HTML")
+ }
+
+ // Should contain the primary domain
+ if !strings.Contains(html, "example.com") {
+ t.Error("expected HTML to contain 'example.com'")
+ }
+
+ // Should contain subdomain list items
+ if !strings.Contains(html, "www.example.com") {
+ t.Error("expected HTML to contain 'www.example.com'")
+ }
+ if !strings.Contains(html, "api.example.com") {
+ t.Error("expected HTML to contain 'api.example.com'")
+ }
+
+ // Should contain resilience narrative heading
+ if !strings.Contains(html, "Resilience Narrative") {
+ t.Error("expected 'Resilience Narrative' heading in fallback HTML")
+ }
+
+ // Should contain the resilience score
+ if !strings.Contains(html, "B+") {
+ t.Error("expected 'B+' score in fallback HTML")
+ }
+}
+
+func TestGenerateFallbackHTML_SingleSubdomain(t *testing.T) {
+ subdomains := []string{"example.com"}
+ html := GenerateFallbackHTML(subdomains)
+
+ if html == "" {
+ t.Fatal("expected non-empty HTML")
+ }
+
+ if !strings.Contains(html, "example.com") {
+ t.Error("expected HTML to contain the domain")
+ }
+}
+
+func TestGenerateFallbackHTML_ContainsSubdomainCount(t *testing.T) {
+ subdomains := []string{"a.com", "b.com", "c.com"}
+ html := GenerateFallbackHTML(subdomains)
+ // The count "3" should appear
+ if !strings.Contains(html, "3 subdomains") {
+ t.Error("expected HTML to mention the subdomain count (3)")
+ }
+}
+
+func TestNewGenerator(t *testing.T) {
+ g := New("/tmp/reports", "https://test.local")
+ if g == nil {
+ t.Fatal("expected non-nil generator")
+ }
+}
+
+func TestBuildReport_CreatesFile(t *testing.T) {
+ dir := t.TempDir()
+ reportsDir := filepath.Join(dir, "reports")
+ g := New(reportsDir, "https://test.local")
+
+ token, err := GenerateToken()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ aiHTML := "
"
+ path, err := g.BuildReport(token, "example.com", aiHTML)
+ if err != nil {
+ t.Fatalf("BuildReport failed: %v", err)
+ }
+
+ if path == "" {
+ t.Fatal("expected non-empty report path")
+ }
+
+ // Check the file exists
+ if _, err := os.Stat(path); os.IsNotExist(err) {
+ t.Fatalf("report file not created at %s", path)
+ }
+
+ // Verify filename
+ expectedName := "visitor_" + token + ".html"
+ if !strings.HasSuffix(path, expectedName) {
+ t.Errorf("expected filename to end with '%s', got '%s'", expectedName, path)
+ }
+}
+
+func TestBuildReport_ContainsDomain(t *testing.T) {
+ dir := t.TempDir()
+ reportsDir := filepath.Join(dir, "reports")
+ g := New(reportsDir, "https://test.local")
+
+ token, err := GenerateToken()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ aiHTML := "
"
+ path, err := g.BuildReport(token, "mycompany.com", aiHTML)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ content := string(data)
+ if !strings.Contains(content, "mycompany.com") {
+ t.Error("expected report to contain the domain name")
+ }
+ if !strings.Contains(content, aiHTML) {
+ t.Error("expected report to contain the AI narrative HTML")
+ }
+}
+
+func TestBuildReport_ContainsToken(t *testing.T) {
+ dir := t.TempDir()
+ reportsDir := filepath.Join(dir, "reports")
+ g := New(reportsDir, "")
+
+ token, _ := GenerateToken()
+ _, err := g.BuildReport(token, "example.com", "
")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ path := filepath.Join(reportsDir, "visitor_"+token+".html")
+ data, _ := os.ReadFile(path)
+ content := string(data)
+
+ if !strings.Contains(content, token) {
+ t.Error("expected report to contain the token")
+ }
+}
+
+func TestBuildReport_QRCodeLink(t *testing.T) {
+ dir := t.TempDir()
+ reportsDir := filepath.Join(dir, "reports")
+ g := New(reportsDir, "https://test.local")
+
+ token, _ := GenerateToken()
+ _, err := g.BuildReport(token, "example.com", "
")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ path := filepath.Join(reportsDir, "visitor_"+token+".html")
+ data, _ := os.ReadFile(path)
+ content := string(data)
+
+ qrURL := "https://test.local/reports/visitor_" + token + ".html"
+ if !strings.Contains(content, qrURL) {
+ t.Errorf("expected QR URL '%s' in report", qrURL)
+ }
+}
+
+func TestBuildReport_QRCodeLinkRelative(t *testing.T) {
+ // When baseURL is empty, QR should use relative path
+ dir := t.TempDir()
+ reportsDir := filepath.Join(dir, "reports")
+ g := New(reportsDir, "")
+
+ token, _ := GenerateToken()
+ _, err := g.BuildReport(token, "example.com", "
")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ path := filepath.Join(reportsDir, "visitor_"+token+".html")
+ data, _ := os.ReadFile(path)
+ content := string(data)
+
+ // Should contain a relative QR path, not absolute
+ if strings.Contains(content, "https://") {
+ t.Log("QR URL is absolute (baseURL was empty but QR may still use relative paths)")
+ }
+}
+
+func TestBuildReport_StartNewScanButton(t *testing.T) {
+ dir := t.TempDir()
+ reportsDir := filepath.Join(dir, "reports")
+ g := New(reportsDir, "")
+
+ token, _ := GenerateToken()
+ _, err := g.BuildReport(token, "example.com", "
")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ path := filepath.Join(reportsDir, "visitor_"+token+".html")
+ data, _ := os.ReadFile(path)
+ content := string(data)
+
+ if !strings.Contains(content, "Start New Scan") {
+ t.Error("expected 'Start New Scan' button in report")
+ }
+ if !strings.Contains(content, "Home") {
+ t.Error("expected 'Home' link in report")
+ }
+}
+
+func TestBuildReport_ReportsDirCreated(t *testing.T) {
+ dir := t.TempDir()
+ // Don't create reports subdirectory — BuildReport should create it
+ reportsDir := filepath.Join(dir, "reports")
+
+ g := New(reportsDir, "")
+ token, _ := GenerateToken()
+
+ _, err := g.BuildReport(token, "example.com", "
")
+ if err != nil {
+ t.Fatalf("BuildReport should create the reports directory: %v", err)
+ }
+
+ if _, err := os.Stat(reportsDir); os.IsNotExist(err) {
+ t.Error("reports directory should have been created")
+ }
+}
+
+func TestGenerateFallbackHTML_ValidHTML(t *testing.T) {
+ subdomains := []string{"test.example.com"}
+ html := GenerateFallbackHTML(subdomains)
+
+ // Basic HTML structure checks
+ if !strings.HasPrefix(strings.TrimSpace(html), "
") {
+ t.Error("expected closing div tag")
+ }
+}
diff --git a/aasd/src/internal/scanner/gotestwaf.go b/aasd/src/internal/scanner/gotestwaf.go
index b882463..b0d6d08 100644
--- a/aasd/src/internal/scanner/gotestwaf.go
+++ b/aasd/src/internal/scanner/gotestwaf.go
@@ -14,13 +14,32 @@ const (
)
// RunGoTestWAF executes GoTestWAF scan against the given target domain.
+// Tries HTTPS first; if the report file isn't created (target not reachable
+// on port 443), retries with plain HTTP on port 80.
// The report is saved to reports/ directory with the given reportName.
// Returns the raw output for AI analysis.
func RunGoTestWAF(ctx context.Context, projectRoot string, reportName string, targetDomain string) (string, error) {
- targetURL := fmt.Sprintf("https://%s", targetDomain)
+ // Try HTTPS first, then HTTP as fallback
+ for _, protocol := range []string{"https", "http"} {
+ output, err := runWithProtocol(ctx, projectRoot, reportName, targetDomain, protocol)
+ // If the report file exists, the scan produced output — success
+ reportPath := filepath.Join(projectRoot, "reports", reportName+".html")
+ if _, statErr := os.Stat(reportPath); statErr == nil {
+ return output, nil
+ }
+ // No report file — try next protocol or return error
+ if err != nil {
+ fmt.Printf("gotestwaf: %s failed for %s: %v\n", protocol, targetDomain, err)
+ }
+ }
+ return "", fmt.Errorf("GoTestWAF: %s not reachable on HTTPS or HTTP", targetDomain)
+}
+
+// runWithProtocol executes GoTestWAF with the given protocol (https or http).
+func runWithProtocol(ctx context.Context, projectRoot string, reportName string, targetDomain string, protocol string) (string, error) {
+ targetURL := fmt.Sprintf("%s://%s", protocol, targetDomain)
binaryPath := filepath.Join(projectRoot, "gotestwaf")
- // Verify binary exists
if _, err := os.Stat(binaryPath); os.IsNotExist(err) {
return "", fmt.Errorf("GoTestWAF binary not found at %s", binaryPath)
}
@@ -30,7 +49,6 @@ func RunGoTestWAF(ctx context.Context, projectRoot string, reportName string, ta
return "", fmt.Errorf("failed to create reports directory: %w", err)
}
- // Create context with timeout
scanCtx, cancel := context.WithTimeout(ctx, gotestwafTimeout)
defer cancel()
@@ -46,7 +64,7 @@ func RunGoTestWAF(ctx context.Context, projectRoot string, reportName string, ta
"--wafName", "generic",
"--skipWAFBlockCheck",
"--nonBlockedAsPassed",
- "--tlsVerify",
+ "--tlsVerify=false",
"--noEmailReport",
"--quiet",
)
@@ -65,5 +83,3 @@ func RunGoTestWAF(ctx context.Context, projectRoot string, reportName string, ta
return outputStr, nil
}
-
-
diff --git a/aasd/src/internal/scanner/probe.go b/aasd/src/internal/scanner/probe.go
index 2c87508..0dba72f 100644
--- a/aasd/src/internal/scanner/probe.go
+++ b/aasd/src/internal/scanner/probe.go
@@ -28,11 +28,6 @@ var DefaultSubdomains = []string{
"ns1", "ns2", "smtp", "imap", "pop3",
}
-// IsIP returns true if the given string is a valid IPv4 or IPv6 address.
-func IsIP(input string) bool {
- return net.ParseIP(input) != nil
-}
-
// hasWildcardDNS checks whether the domain uses wildcard DNS by resolving
// a random non-existent subdomain. If it resolves, wildcard is active.
func hasWildcardDNS(ctx context.Context, domain string) bool {
diff --git a/aasd/src/internal/scanner/probe_test.go b/aasd/src/internal/scanner/probe_test.go
new file mode 100644
index 0000000..2d95b4a
--- /dev/null
+++ b/aasd/src/internal/scanner/probe_test.go
@@ -0,0 +1,101 @@
+package scanner
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestLoadSubdomainList_FileNotExists(t *testing.T) {
+ // Should fall back to DefaultSubdomains when file doesn't exist
+ names := loadSubdomainList("/tmp/nonexistent-path-aasd-test")
+ if len(names) == 0 {
+ t.Error("expected fallback list, got empty")
+ }
+ if len(names) != len(DefaultSubdomains) {
+ t.Errorf("expected %d default names, got %d", len(DefaultSubdomains), len(names))
+ }
+}
+
+func TestLoadSubdomainList_FileExists(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "subdomains.txt")
+ content := "# comment line\napi\nwww\n admin \n\nMAIL\n"
+ if err := os.WriteFile(path, []byte(content), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ names := loadSubdomainList(dir)
+ if len(names) != 4 {
+ t.Fatalf("expected 4 names (api, www, admin, mail), got %d: %v", len(names), names)
+ }
+ if names[0] != "api" {
+ t.Errorf("expected first name 'api', got '%s'", names[0])
+ }
+ // Verify dedup (MAIL → mail should be same as an existing entry if present)
+ // Actually the content is: api, www, admin, MAIL - MAIL is not a duplicate
+ if names[3] != "mail" {
+ t.Errorf("expected last name 'mail', got '%s'", names[3])
+ }
+}
+
+func TestLoadSubdomainList_EmptyFile(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "subdomains.txt")
+ if err := os.WriteFile(path, []byte(""), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ names := loadSubdomainList(dir)
+ if len(names) == 0 {
+ t.Error("expected fallback list for empty file")
+ }
+}
+
+func TestLoadSubdomainList_CommentsOnly(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "subdomains.txt")
+ content := "# this is a comment\n# another comment\n # indented comment\n"
+ if err := os.WriteFile(path, []byte(content), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ names := loadSubdomainList(dir)
+ if len(names) == 0 {
+ t.Error("expected fallback list for comments-only file")
+ }
+}
+
+func TestLoadSubdomainList_Deduplication(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "subdomains.txt")
+ content := "api\nwww\napi\nAPI\nApi\n"
+ if err := os.WriteFile(path, []byte(content), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ names := loadSubdomainList(dir)
+ if len(names) != 2 {
+ t.Errorf("expected 2 unique names (api, www), got %d: %v", len(names), names)
+ }
+}
+
+func TestDefaultSubdomains_NotEmpty(t *testing.T) {
+ if len(DefaultSubdomains) == 0 {
+ t.Error("DefaultSubdomains should not be empty")
+ }
+ // Check first few well-known entries
+ expected := []string{"api", "www", "admin", "mail"}
+ for _, e := range expected {
+ found := false
+ for _, d := range DefaultSubdomains {
+ if d == e {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("expected '%s' in DefaultSubdomains", e)
+ }
+ }
+}
diff --git a/aasd/src/internal/scanner/scanner.go b/aasd/src/internal/scanner/scanner.go
index 2bf76fa..634ae28 100644
--- a/aasd/src/internal/scanner/scanner.go
+++ b/aasd/src/internal/scanner/scanner.go
@@ -14,6 +14,7 @@ import (
"aasd/internal/ai"
"aasd/internal/mailer"
"aasd/internal/report"
+ "aasd/pkg/netutil"
)
// ScanStatus enumerates the pipeline phases.
@@ -90,7 +91,7 @@ func (o *Orchestrator) StartPipeline(ctx context.Context, domain string) (*ScanR
o.mu.Unlock()
// Phase 1: Domain Discovery or direct scan
- if IsIP(result.Domain) {
+ if netutil.IsIP(result.Domain) {
// IP address — no subdomains to discover, scan directly.
// Set Subdomains to [IP] so GenerateFallbackHTML doesn't panic on empty slice.
result.Subdomains = []string{result.Domain}
@@ -121,6 +122,13 @@ func (o *Orchestrator) SelectAndScan(ctx context.Context, token, selectedDomain
}
result.SelectedDomain = selectedDomain
+
+ // Ensure Subdomains has at least the selected domain (defensive —
+ // prevents panic in report generation when discovery returned nothing)
+ if len(result.Subdomains) == 0 {
+ result.Subdomains = []string{selectedDomain}
+ }
+
go o.executeScanPhase(ctx, result)
return nil
}
@@ -194,21 +202,62 @@ func (o *Orchestrator) executeScanPhase(ctx context.Context, result *ScanResult)
result.AIReportFile = reportPath
result.ReportFile = "/reports/visitor_" + result.ReportToken + ".html"
- // Inject QR code into the GoTestWAF consultant report
+ // Inject email-send UI into the GoTestWAF consultant report
if scanErr == nil {
gtwPath := filepath.Join(o.projectRoot, "reports", scanReportName+".html")
if data, err := os.ReadFile(gtwPath); err == nil {
- qrURL := fmt.Sprintf("%s/reports/visitor_%s.html", o.baseURL, result.ReportToken)
- qrBlock := fmt.Sprintf(`
-
-
-
Scan to access your report
-

-
Show this code to Sechpoint Aftica Team at GITEX 2026
-
`, qrURL)
- modified := strings.Replace(string(data), "", qrBlock+"\n ", 1)
+ emailBlock := fmt.Sprintf(`
+
+
+
Send this report
+
Enter your email and we'll send you the full report
+
+
+
+
+ `, result.ReportToken)
+ modified := strings.Replace(string(data), "", emailBlock+"\n ", 1)
if err := os.WriteFile(gtwPath, []byte(modified), 0644); err != nil {
- fmt.Printf("scanner: failed to inject QR into GoTestWAF report: %v\n", err)
+ fmt.Printf("scanner: failed to inject email form into GoTestWAF report: %v\n", err)
}
}
}
@@ -288,7 +337,7 @@ func (o *Orchestrator) SendEmailReport(token, toEmail string) error {
return fmt.Errorf("report not ready (status: %s)", result.Status)
}
- reportURL := "/reports/" + token + ".html"
+ reportURL := "/reports/visitor_" + token + ".html"
if o.baseURL != "" {
reportURL = o.baseURL + reportURL
}
diff --git a/aasd/src/internal/scanner/scanner_test.go b/aasd/src/internal/scanner/scanner_test.go
new file mode 100644
index 0000000..dc0c93d
--- /dev/null
+++ b/aasd/src/internal/scanner/scanner_test.go
@@ -0,0 +1,405 @@
+package scanner
+
+import (
+ "context"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "aasd/internal/ai"
+ "aasd/internal/mailer"
+)
+
+// newTestOrchestrator creates an orchestrator with mock dependencies for testing.
+func newTestOrchestrator(t *testing.T) *Orchestrator {
+ t.Helper()
+ return NewOrchestrator(t.TempDir(), "https://test.local", nil, mailer.New(mailer.Config{}))
+}
+
+func TestNewOrchestrator(t *testing.T) {
+ o := newTestOrchestrator(t)
+ if o == nil {
+ t.Fatal("expected non-nil orchestrator")
+ }
+ if o.results == nil {
+ t.Error("expected results map to be initialized")
+ }
+ if len(o.results) != 0 {
+ t.Errorf("expected empty results, got %d", len(o.results))
+ }
+}
+
+func TestStartPipeline_Domain(t *testing.T) {
+ o := newTestOrchestrator(t)
+ ctx := context.Background()
+
+ result, err := o.StartPipeline(ctx, "example.com")
+ if err != nil {
+ t.Fatalf("StartPipeline failed: %v", err)
+ }
+
+ if result == nil {
+ t.Fatal("expected non-nil result")
+ }
+ if result.Domain != "example.com" {
+ t.Errorf("expected domain 'example.com', got '%s'", result.Domain)
+ }
+ if result.ReportToken == "" {
+ t.Error("expected non-empty token")
+ }
+ if result.Status != StatusPending {
+ t.Errorf("expected status 'pending', got '%s'", result.Status)
+ }
+ if result.CreatedAt.IsZero() {
+ t.Error("expected CreatedAt to be set")
+ }
+}
+
+func TestStartPipeline_IP(t *testing.T) {
+ o := newTestOrchestrator(t)
+ ctx := context.Background()
+
+ result, err := o.StartPipeline(ctx, "192.168.1.1")
+ if err != nil {
+ t.Fatalf("StartPipeline failed: %v", err)
+ }
+
+ if result.Domain != "192.168.1.1" {
+ t.Errorf("expected domain '192.168.1.1', got '%s'", result.Domain)
+ }
+ // For IPs, SelectedDomain should be set to the IP
+ if result.SelectedDomain != "192.168.1.1" {
+ t.Errorf("expected SelectedDomain '192.168.1.1', got '%s'", result.SelectedDomain)
+ }
+}
+
+func TestGetResult_Found(t *testing.T) {
+ o := newTestOrchestrator(t)
+ ctx := context.Background()
+
+ result, err := o.StartPipeline(ctx, "example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ got, exists := o.GetResult(result.ReportToken)
+ if !exists {
+ t.Fatal("expected result to exist")
+ }
+ if got.ReportToken != result.ReportToken {
+ t.Errorf("expected token '%s', got '%s'", result.ReportToken, got.ReportToken)
+ }
+}
+
+func TestGetResult_NotFound(t *testing.T) {
+ o := newTestOrchestrator(t)
+ _, exists := o.GetResult("nonexistent-token")
+ if exists {
+ t.Error("expected false for nonexistent token")
+ }
+}
+
+func TestGetResultByDomain_Found(t *testing.T) {
+ o := newTestOrchestrator(t)
+ ctx := context.Background()
+
+ _, err := o.StartPipeline(ctx, "example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result := o.GetResultByDomain("example.com")
+ if result == nil {
+ t.Fatal("expected non-nil result")
+ }
+ if result.Domain != "example.com" {
+ t.Errorf("expected domain 'example.com', got '%s'", result.Domain)
+ }
+}
+
+func TestGetResultByDomain_NotFound(t *testing.T) {
+ o := newTestOrchestrator(t)
+ result := o.GetResultByDomain("nonexistent.com")
+ if result != nil {
+ t.Error("expected nil for nonexistent domain")
+ }
+}
+
+func TestGetAllResults_Empty(t *testing.T) {
+ o := newTestOrchestrator(t)
+ results := o.GetAllResults()
+ if len(results) != 0 {
+ t.Errorf("expected 0 results, got %d", len(results))
+ }
+}
+
+func TestGetAllResults_Multiple(t *testing.T) {
+ o := newTestOrchestrator(t)
+ ctx := context.Background()
+
+ o.StartPipeline(ctx, "example.com")
+ o.StartPipeline(ctx, "test.org")
+
+ results := o.GetAllResults()
+ if len(results) != 2 {
+ t.Errorf("expected 2 results, got %d", len(results))
+ }
+}
+
+func TestCountStats_Empty(t *testing.T) {
+ o := newTestOrchestrator(t)
+ total, completed, running := o.CountStats()
+ if total != 0 {
+ t.Errorf("expected total 0, got %d", total)
+ }
+ if completed != 0 {
+ t.Errorf("expected completed 0, got %d", completed)
+ }
+ if running != 0 {
+ t.Errorf("expected running 0, got %d", running)
+ }
+}
+
+func TestCountStats_Mixed(t *testing.T) {
+ o := newTestOrchestrator(t)
+ ctx := context.Background()
+
+ r1, _ := o.StartPipeline(ctx, "example.com")
+ r2, _ := o.StartPipeline(ctx, "test.org")
+
+ // Manually set statuses
+ o.updateStatus(r1.ReportToken, StatusCompleted)
+ o.updateStatus(r2.ReportToken, StatusScanning)
+
+ total, completed, running := o.CountStats()
+ if total != 2 {
+ t.Errorf("expected total 2, got %d", total)
+ }
+ if completed != 1 {
+ t.Errorf("expected completed 1, got %d", completed)
+ }
+ if running != 1 {
+ t.Errorf("expected running 1, got %d", running)
+ }
+}
+
+func TestUpdateStatus(t *testing.T) {
+ o := newTestOrchestrator(t)
+ ctx := context.Background()
+
+ result, _ := o.StartPipeline(ctx, "example.com")
+
+ o.updateStatus(result.ReportToken, StatusDiscovering)
+ got, _ := o.GetResult(result.ReportToken)
+ if got.Status != StatusDiscovering {
+ t.Errorf("expected 'discovering', got '%s'", got.Status)
+ }
+
+ o.updateStatus(result.ReportToken, StatusCompleted)
+ got, _ = o.GetResult(result.ReportToken)
+ if got.Status != StatusCompleted {
+ t.Errorf("expected 'completed', got '%s'", got.Status)
+ }
+}
+
+func TestSelectAndScan_WrongStatus(t *testing.T) {
+ o := newTestOrchestrator(t)
+ ctx := context.Background()
+
+ result, _ := o.StartPipeline(ctx, "example.com")
+
+ // status is "pending" — selection should fail
+ err := o.SelectAndScan(ctx, result.ReportToken, "www.example.com")
+ if err == nil {
+ t.Fatal("expected error for SelectAndScan with pending status")
+ }
+ if !strings.Contains(err.Error(), "cannot select target") {
+ t.Errorf("expected error to contain 'cannot select target', got: %v", err)
+ }
+}
+
+func TestSelectAndScan_NonexistentToken(t *testing.T) {
+ o := newTestOrchestrator(t)
+ ctx := context.Background()
+
+ err := o.SelectAndScan(ctx, "nonexistent", "www.example.com")
+ if err == nil {
+ t.Fatal("expected error for nonexistent token")
+ }
+}
+
+func TestSelectAndScan_ValidTransition(t *testing.T) {
+ o := newTestOrchestrator(t)
+ ctx := context.Background()
+
+ result, _ := o.StartPipeline(ctx, "example.com")
+ // Manually set to awaiting_selection (as discoverSubdomains would)
+ o.updateStatus(result.ReportToken, StatusAwaitingSelection)
+
+ err := o.SelectAndScan(ctx, result.ReportToken, "www.example.com")
+ if err != nil {
+ t.Fatalf("SelectAndScan failed: %v", err)
+ }
+
+ got, _ := o.GetResult(result.ReportToken)
+ if got.SelectedDomain != "www.example.com" {
+ t.Errorf("expected SelectedDomain 'www.example.com', got '%s'", got.SelectedDomain)
+ }
+ // Status should still be awaiting_selection (scan runs in a goroutine)
+ if got.Status != StatusAwaitingSelection {
+ t.Errorf("expected status to remain 'awaiting_selection', got '%s'", got.Status)
+ }
+}
+
+func TestGetResult_ReturnsCopy(t *testing.T) {
+ o := newTestOrchestrator(t)
+ ctx := context.Background()
+
+ result, _ := o.StartPipeline(ctx, "example.com")
+
+ // Get a copy and modify it
+ got1, _ := o.GetResult(result.ReportToken)
+ got1.Status = StatusCompleted
+
+ // Get another copy — should be the original value
+ got2, _ := o.GetResult(result.ReportToken)
+ if got2.Status != StatusPending {
+ t.Errorf("expected original status 'pending', got '%s' (modification leaked)", got2.Status)
+ }
+}
+
+func TestConcurrentAccess(t *testing.T) {
+ o := newTestOrchestrator(t)
+ ctx := context.Background()
+ var wg sync.WaitGroup
+
+ // Start multiple pipelines concurrently
+ for i := 0; i < 10; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ domain := "test" + string(rune('0'+id)) + ".com"
+ _, err := o.StartPipeline(ctx, domain)
+ if err != nil {
+ t.Errorf("StartPipeline failed for %s: %v", domain, err)
+ }
+ }(i)
+ }
+ wg.Wait()
+
+ // Verify all 10 results exist
+ results := o.GetAllResults()
+ if len(results) != 10 {
+ t.Errorf("expected 10 results, got %d", len(results))
+ }
+
+ // Concurrent reads
+ var readWg sync.WaitGroup
+ for i := 0; i < 20; i++ {
+ readWg.Add(1)
+ go func() {
+ defer readWg.Done()
+ o.CountStats()
+ o.GetAllResults()
+ }()
+ }
+ readWg.Wait()
+}
+
+func TestSendEmailReport_NotFound(t *testing.T) {
+ o := newTestOrchestrator(t)
+ err := o.SendEmailReport("nonexistent", "user@example.com")
+ if err == nil {
+ t.Fatal("expected error for nonexistent token")
+ }
+}
+
+func TestSendEmailReport_NotCompleted(t *testing.T) {
+ o := newTestOrchestrator(t)
+ ctx := context.Background()
+
+ result, _ := o.StartPipeline(ctx, "example.com")
+ err := o.SendEmailReport(result.ReportToken, "user@example.com")
+ if err == nil {
+ t.Fatal("expected error for non-completed scan")
+ }
+}
+
+func TestNewOrchestrator_NilDeps(t *testing.T) {
+ // Should handle nil AI client and nil mailer gracefully
+ o := NewOrchestrator("/tmp", "https://test.local", (*ai.Client)(nil), (*mailer.Mailer)(nil))
+ if o == nil {
+ t.Fatal("expected non-nil orchestrator")
+ }
+}
+
+func TestStatusConstants_AreStrings(t *testing.T) {
+ if StatusPending != "pending" {
+ t.Errorf("StatusPending should be 'pending', got '%s'", StatusPending)
+ }
+ if StatusDiscovering != "discovering" {
+ t.Errorf("StatusDiscovering should be 'discovering', got '%s'", StatusDiscovering)
+ }
+ if StatusAwaitingSelection != "awaiting_selection" {
+ t.Errorf("StatusAwaitingSelection should be 'awaiting_selection', got '%s'", StatusAwaitingSelection)
+ }
+ if StatusScanning != "scanning" {
+ t.Errorf("StatusScanning should be 'scanning', got '%s'", StatusScanning)
+ }
+ if StatusGenerating != "generating" {
+ t.Errorf("StatusGenerating should be 'generating', got '%s'", StatusGenerating)
+ }
+ if StatusCompleted != "completed" {
+ t.Errorf("StatusCompleted should be 'completed', got '%s'", StatusCompleted)
+ }
+ if StatusFailed != "failed" {
+ t.Errorf("StatusFailed should be 'failed', got '%s'", StatusFailed)
+ }
+}
+
+func TestOrchestrator_RaceFreeStatusTransition(t *testing.T) {
+ // Verify the race condition guard: discoverSubdomains must not overwrite
+ // a status that was already changed by SelectAndScan
+ o := newTestOrchestrator(t)
+ ctx := context.Background()
+
+ result, _ := o.StartPipeline(ctx, "example.com")
+
+ // Simulate: discovery finishes and SelectAndScan is called concurrently
+ var wg sync.WaitGroup
+ wg.Add(2)
+
+ go func() {
+ defer wg.Done()
+ // discoverSubdomains logic: set discovering → then set awaiting_selection
+ // but guarded by checking current status is still discovering
+ o.updateStatus(result.ReportToken, StatusDiscovering)
+ time.Sleep(10 * time.Millisecond)
+
+ o.mu.Lock()
+ if o.results[result.ReportToken].Status == StatusDiscovering {
+ o.results[result.ReportToken].Status = StatusAwaitingSelection
+ }
+ o.mu.Unlock()
+ }()
+
+ go func() {
+ defer wg.Done()
+ time.Sleep(5 * time.Millisecond)
+ // SelectAndScan logic: only works if status is awaiting_selection
+ o.mu.Lock()
+ if r, ok := o.results[result.ReportToken]; ok && r.Status == StatusAwaitingSelection {
+ r.SelectedDomain = "www.example.com"
+ }
+ o.mu.Unlock()
+ }()
+
+ wg.Wait()
+
+ got, _ := o.GetResult(result.ReportToken)
+ // The status should NOT be discovering — either awaiting_selection or scanning
+ if got.Status == StatusDiscovering {
+ t.Error("status should not remain 'discovering' after both goroutines complete")
+ }
+}
diff --git a/aasd/src/pkg/netutil/netutil.go b/aasd/src/pkg/netutil/netutil.go
new file mode 100644
index 0000000..59ac19e
--- /dev/null
+++ b/aasd/src/pkg/netutil/netutil.go
@@ -0,0 +1,9 @@
+// Package netutil provides general-purpose network utility functions.
+package netutil
+
+import "net"
+
+// IsIP returns true if the given string is a valid IPv4 or IPv6 address.
+func IsIP(input string) bool {
+ return net.ParseIP(input) != nil
+}
diff --git a/aasd/src/pkg/netutil/netutil_test.go b/aasd/src/pkg/netutil/netutil_test.go
new file mode 100644
index 0000000..e982295
--- /dev/null
+++ b/aasd/src/pkg/netutil/netutil_test.go
@@ -0,0 +1,48 @@
+package netutil
+
+import "testing"
+
+func TestIsIP_IPv4(t *testing.T) {
+ if !IsIP("192.168.1.1") {
+ t.Error("expected true for valid IPv4")
+ }
+ if !IsIP("10.0.0.1") {
+ t.Error("expected true for valid IPv4")
+ }
+ if !IsIP("0.0.0.0") {
+ t.Error("expected true for 0.0.0.0")
+ }
+ if !IsIP("255.255.255.255") {
+ t.Error("expected true for 255.255.255.255")
+ }
+}
+
+func TestIsIP_IPv6(t *testing.T) {
+ if !IsIP("::1") {
+ t.Error("expected true for loopback IPv6")
+ }
+ if !IsIP("2001:db8::1") {
+ t.Error("expected true for valid IPv6")
+ }
+ if !IsIP("fe80::1") {
+ t.Error("expected true for link-local IPv6")
+ }
+}
+
+func TestIsIP_Invalid(t *testing.T) {
+ if IsIP("") {
+ t.Error("expected false for empty string")
+ }
+ if IsIP("example.com") {
+ t.Error("expected false for domain name")
+ }
+ if IsIP("not-an-ip") {
+ t.Error("expected false for random string")
+ }
+ if IsIP("999.999.999.999") {
+ t.Error("expected false for invalid octets")
+ }
+ if IsIP("192.168.1") {
+ t.Error("expected false for incomplete IPv4")
+ }
+}
diff --git a/aasd/src/static/simulation.html b/aasd/src/static/simulation.html
index 8dceaf0..b69526c 100644
--- a/aasd/src/static/simulation.html
+++ b/aasd/src/static/simulation.html
@@ -22,6 +22,8 @@
.step-indicator { transition: all 0.5s ease; }
@keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
.pulsing::after { content: ''; display: inline-block; width: 8px; height: 8px; background: #3b82f6; border-radius: 50%; margin-left: 8px; animation: pulse-dot 1.5s infinite; }
+ @keyframes barberpole { 0% { background-position: 0 0; } 100% { background-position: 40px 0; } }
+ .progress-indeterminate { background: linear-gradient(90deg, #7c3aed 30%, #a78bfa 50%, #7c3aed 70%); background-size: 200% 100%; animation: barberpole 1s linear infinite; }
@@ -145,6 +147,8 @@
const subdomainList = document.getElementById('subdomainList');
let pollingInterval;
+ let scanStartTime = null;
+ let scanTimerInterval = null;
// Map API statuses to step indices (for the 3-step progress view)
const statusStepMap = {
@@ -229,7 +233,17 @@
}
// ── Results UI ──
+ function clearScanTimer() {
+ if (scanTimerInterval) {
+ clearInterval(scanTimerInterval);
+ scanTimerInterval = null;
+ scanStartTime = null;
+ }
+ progressFill.classList.remove('progress-indeterminate');
+ }
+
function showResults(data) {
+ clearScanTimer();
clearInterval(pollingInterval);
stepSequencer.classList.remove('hidden');
selectionPanel.classList.add('hidden');
@@ -247,6 +261,7 @@
}
function showFailed() {
+ clearScanTimer();
clearInterval(pollingInterval);
setStatus('Failed', 'bg-red-500');
statusDisplay.className = 'bg-slate-800 rounded-2xl p-5 border border-red-500/50 text-center mb-4';
@@ -301,9 +316,27 @@
}
break;
case 'scanning':
- setStatus('Scanning WAF Endpoint', 'bg-purple-500');
+ if (!scanStartTime) {
+ scanStartTime = Date.now();
+ progressFill.classList.add('progress-indeterminate');
+ progressFill.style.width = '100%';
+ // Update elapsed time every second
+ scanTimerInterval = setInterval(function() {
+ var elapsed = Math.floor((Date.now() - scanStartTime) / 1000);
+ var m = Math.floor(elapsed / 60);
+ var s = elapsed % 60;
+ statusText.textContent = 'Scanning WAF Endpoint\u2026 ' + m + ':' + (s < 10 ? '0' : '') + s;
+ }, 1000);
+ }
+ setStatus('Scanning WAF Endpoint\u2026', 'bg-purple-500 animate-pulse');
break;
case 'generating':
+ // Clear scan timer when transitioning out of scanning
+ if (scanTimerInterval) {
+ clearInterval(scanTimerInterval);
+ scanTimerInterval = null;
+ scanStartTime = null;
+ }
setStatus('Generating AI Report', 'bg-yellow-500');
break;
default: