First release of open core
This commit is contained in:
488
pkg/ingest/ingest_test.go
Normal file
488
pkg/ingest/ingest_test.go
Normal file
@@ -0,0 +1,488 @@
|
||||
package ingest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime/debug"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"epigas.gitea.cloud/RiskRancher/core/pkg/datastore"
|
||||
"epigas.gitea.cloud/RiskRancher/core/pkg/domain"
|
||||
)
|
||||
|
||||
func setupTestIngest(t *testing.T) (*Handler, *sql.DB) {
|
||||
db := datastore.InitDB(":memory:")
|
||||
store := datastore.NewSQLiteStore(db)
|
||||
return NewHandler(store), db
|
||||
}
|
||||
|
||||
func GetVIPCookie(store domain.Store) *http.Cookie {
|
||||
|
||||
user, err := store.GetUserByEmail(context.Background(), "vip@RiskRancher.com")
|
||||
if err != nil {
|
||||
user, _ = store.CreateUser(context.Background(), "vip@RiskRancher.com", "Test VIP", "hash", "Sheriff")
|
||||
}
|
||||
|
||||
store.CreateSession(context.Background(), "vip_token_999", user.ID, time.Now().Add(1*time.Hour))
|
||||
return &http.Cookie{Name: "session_token", Value: "vip_token_999"}
|
||||
}
|
||||
|
||||
func TestAutoPatchMissingFindings(t *testing.T) {
|
||||
app, db := setupTestIngest(t)
|
||||
defer db.Close()
|
||||
|
||||
payload1 := []byte(`[
|
||||
{"title": "Vuln A", "severity": "High"},
|
||||
{"title": "Vuln B", "severity": "Medium"}
|
||||
]
|
||||
`)
|
||||
req1 := httptest.NewRequest(http.MethodPost, "/api/ingest", bytes.NewBuffer(payload1))
|
||||
req1.AddCookie(GetVIPCookie(app.Store))
|
||||
rr1 := httptest.NewRecorder()
|
||||
app.HandleIngest(rr1, req1)
|
||||
|
||||
var count int
|
||||
db.QueryRow("SELECT COUNT(*) FROM tickets WHERE status = 'Waiting to be Triaged'").Scan(&count)
|
||||
if count != 2 {
|
||||
t.Fatalf("Expected 2 unpatched tickets, got %d", count)
|
||||
}
|
||||
|
||||
payload2 := []byte(` [
|
||||
{"title": "Vuln A", "severity": "High"}
|
||||
]`)
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/api/ingest", bytes.NewBuffer(payload2))
|
||||
req2.AddCookie(GetVIPCookie(app.Store))
|
||||
rr2 := httptest.NewRecorder()
|
||||
app.HandleIngest(rr2, req2)
|
||||
|
||||
var statusB string
|
||||
var patchedAt sql.NullTime
|
||||
|
||||
err := db.QueryRow("SELECT status, patched_at FROM tickets WHERE title = 'Vuln B'").Scan(&statusB, &patchedAt)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query Vuln B: %v", err)
|
||||
}
|
||||
|
||||
if statusB != "Patched" {
|
||||
t.Errorf("Expected Vuln B status to be 'Patched', got '%s'", statusB)
|
||||
}
|
||||
|
||||
if !patchedAt.Valid {
|
||||
t.Errorf("Expected Vuln B to have a patched_at timestamp, but it was NULL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleIngest(t *testing.T) {
|
||||
a, db := setupTestIngest(t)
|
||||
defer db.Close()
|
||||
|
||||
sendIngestRequest := func(findings []domain.Ticket) *httptest.ResponseRecorder {
|
||||
body, _ := json.Marshal(findings)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/ingest", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
a.HandleIngest(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
t.Run("1. Fresh Ingestion", func(t *testing.T) {
|
||||
findings := []domain.Ticket{
|
||||
{
|
||||
Source: "CrowdStrike",
|
||||
AssetIdentifier: "Server-01",
|
||||
Title: "Malware Detected",
|
||||
Severity: "Critical",
|
||||
},
|
||||
}
|
||||
|
||||
rr := sendIngestRequest(findings)
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 Created, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var count int
|
||||
db.QueryRow("SELECT COUNT(*) FROM tickets").Scan(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 ticket in DB, got %d", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("2. Deduplication", func(t *testing.T) {
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
findings := []domain.Ticket{
|
||||
{
|
||||
Source: "CrowdStrike",
|
||||
AssetIdentifier: "Server-01",
|
||||
Title: "Malware Detected",
|
||||
Severity: "Critical",
|
||||
Description: "Updated Description",
|
||||
},
|
||||
}
|
||||
|
||||
rr := sendIngestRequest(findings)
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 Created, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var count int
|
||||
db.QueryRow("SELECT COUNT(*) FROM tickets").Scan(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("expected still 1 ticket in DB due to dedupe, got %d", count)
|
||||
}
|
||||
|
||||
var desc string
|
||||
db.QueryRow("SELECT description FROM tickets WHERE title = 'Malware Detected'").Scan(&desc)
|
||||
if desc != "Updated Description" {
|
||||
t.Errorf("expected description to update to 'Updated Description', got '%s'", desc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("3. Scoped Auto-Patching", func(t *testing.T) {
|
||||
findings := []domain.Ticket{
|
||||
{
|
||||
Source: "CrowdStrike",
|
||||
AssetIdentifier: "Server-01",
|
||||
Title: "Outdated Antivirus",
|
||||
Severity: "High",
|
||||
},
|
||||
}
|
||||
|
||||
rr := sendIngestRequest(findings)
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 Created, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var totalCount int
|
||||
db.QueryRow("SELECT COUNT(*) FROM tickets").Scan(&totalCount)
|
||||
if totalCount != 2 {
|
||||
t.Errorf("expected 2 total tickets in DB, got %d", totalCount)
|
||||
}
|
||||
|
||||
var status string
|
||||
db.QueryRow("SELECT status FROM tickets WHERE title = 'Malware Detected'").Scan(&status)
|
||||
if status != "Patched" {
|
||||
t.Errorf("expected missing vulnerability to be auto-patched, but status is '%s'", status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCSVIngestion(t *testing.T) {
|
||||
app, db := setupTestIngest(t)
|
||||
defer db.Close()
|
||||
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO data_adapters (
|
||||
id, name, source_name, findings_path,
|
||||
mapping_title, mapping_asset, mapping_severity, mapping_description, mapping_remediation
|
||||
) VALUES (
|
||||
999, 'Legacy Scanner V1', 'LegacyScan', '.',
|
||||
'Vuln_Name', 'Server_IP', 'Risk_Level', 'Details', 'Fix_Steps'
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to setup test adapter: %v", err)
|
||||
}
|
||||
|
||||
rawCSV := `Vuln_Name,Server_IP,Risk_Level,Details,Junk_Column
|
||||
SQL Injection,192.168.1.50,Critical,Found in login form,ignore_this
|
||||
Outdated Apache,192.168.1.50,High,Upgrade to 2.4.50,ignore_this`
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, _ := writer.CreateFormFile("file", "scan_results.csv")
|
||||
part.Write([]byte(rawCSV))
|
||||
|
||||
writer.WriteField("adapter_id", "999")
|
||||
writer.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/ingest/csv", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
app.HandleCSVIngest(rr, req)
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("Expected 201 Created, got %d. Body: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var count int
|
||||
db.QueryRow("SELECT COUNT(*) FROM tickets WHERE source = 'LegacyScan'").Scan(&count)
|
||||
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 tickets ingested from CSV, got %d", count)
|
||||
}
|
||||
|
||||
var title, severity string
|
||||
db.QueryRow("SELECT title, severity FROM tickets WHERE title = 'SQL Injection'").Scan(&title, &severity)
|
||||
if severity != "Critical" {
|
||||
t.Errorf("CSV Mapping failed! Expected severity 'Critical', got '%s'", severity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoPatchEdgeCases(t *testing.T) {
|
||||
h, db := setupTestIngest(t) // Swapped 'app' for 'h'
|
||||
defer db.Close()
|
||||
|
||||
db.Exec(`
|
||||
INSERT INTO tickets (source, title, severity, dedupe_hash, status)
|
||||
VALUES ('App B', 'App B Vuln', 'High', 'hash-app-b', 'Waiting to be Triaged')
|
||||
`)
|
||||
|
||||
payload1 := []byte(`[
|
||||
{"source": "App A", "title": "Vuln 1", "severity": "High"},
|
||||
{"source": "App A", "title": "Vuln 2", "severity": "Medium"}
|
||||
]`)
|
||||
req1 := httptest.NewRequest(http.MethodPost, "/api/ingest", bytes.NewBuffer(payload1))
|
||||
req1.AddCookie(GetVIPCookie(h.Store))
|
||||
req1.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rr1 := httptest.NewRecorder()
|
||||
h.HandleIngest(rr1, req1)
|
||||
|
||||
payload2 := []byte(`[
|
||||
{"source": "App A", "title": "Vuln 1", "severity": "High"}
|
||||
]`)
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/api/ingest", bytes.NewBuffer(payload2))
|
||||
req2.AddCookie(GetVIPCookie(h.Store))
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rr2 := httptest.NewRecorder()
|
||||
h.HandleIngest(rr2, req2)
|
||||
|
||||
var status2 string
|
||||
db.QueryRow("SELECT status FROM tickets WHERE title = 'Vuln 2'").Scan(&status2)
|
||||
if status2 != "Patched" {
|
||||
t.Errorf("Expected Vuln 2 to be 'Patched', got '%s'", status2)
|
||||
}
|
||||
|
||||
var statusB string
|
||||
db.QueryRow("SELECT status FROM tickets WHERE title = 'App B Vuln'").Scan(&statusB)
|
||||
if statusB != "Waiting to be Triaged" {
|
||||
t.Errorf("CRITICAL FAILURE: Blast radius exceeded! App B status changed to '%s'", statusB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleIngest_MultiAssetDiffing(t *testing.T) {
|
||||
// THE GO 1.26 GC TWEAK: Force Go to keep RAM usage under 2GB
|
||||
// This makes the GC run aggressively, trading a tiny bit of CPU for massive RAM savings.
|
||||
previousLimit := debug.SetMemoryLimit(2 * 1024 * 1024 * 1024)
|
||||
defer debug.SetMemoryLimit(previousLimit)
|
||||
|
||||
a, db := setupTestIngest(t)
|
||||
db.Exec(`PRAGMA synchronous = OFF;`)
|
||||
defer db.Close()
|
||||
|
||||
_, err := db.Exec(`INSERT INTO tickets (source, asset_identifier, title, status, severity, dedupe_hash) VALUES
|
||||
('Trivy', 'Server-A', 'Old Vuln A', 'Waiting to be Triaged', 'High', 'hash_A_1'),
|
||||
('Trivy', 'Server-B', 'Old Vuln B', 'Waiting to be Triaged', 'Critical', 'hash_B_1')`)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to seed database: %v", err)
|
||||
}
|
||||
|
||||
incomingPayload := []domain.Ticket{
|
||||
{
|
||||
Source: "Trivy",
|
||||
AssetIdentifier: "Server-A",
|
||||
Title: "New Vuln A",
|
||||
Severity: "High",
|
||||
DedupeHash: "hash_A_2",
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(incomingPayload)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/ingest", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
a.HandleIngest(rr, req)
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("Expected 201 Created, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var statusA string
|
||||
db.QueryRow(`SELECT status FROM tickets WHERE dedupe_hash = 'hash_A_1'`).Scan(&statusA)
|
||||
if statusA != "Patched" {
|
||||
t.Errorf("Expected Server-A's old ticket to be Auto-Patched, got '%s'", statusA)
|
||||
}
|
||||
|
||||
var statusB string
|
||||
db.QueryRow(`SELECT status FROM tickets WHERE dedupe_hash = 'hash_B_1'`).Scan(&statusB)
|
||||
if statusB != "Waiting to be Triaged" {
|
||||
t.Errorf("CRITICAL BUG: Server-B's ticket was altered! Expected 'Waiting to be Triaged', got '%s'", statusB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleIngest_OneMillionTicketStressTest(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping 1-million ticket stress test in short mode")
|
||||
}
|
||||
|
||||
a, db := setupTestIngest(t)
|
||||
defer db.Close()
|
||||
|
||||
numAssets := 10000
|
||||
vulnsPerAsset := 100
|
||||
|
||||
t.Logf("Generating baseline payload for %d tickets...", numAssets*vulnsPerAsset)
|
||||
|
||||
baselinePayload := make([]domain.Ticket, 0, numAssets*vulnsPerAsset)
|
||||
for assetID := 1; assetID <= numAssets; assetID++ {
|
||||
assetName := fmt.Sprintf("Server-%05d", assetID)
|
||||
for vulnID := 1; vulnID <= vulnsPerAsset; vulnID++ {
|
||||
baselinePayload = append(baselinePayload, domain.Ticket{
|
||||
Source: "HeavyLoadTester",
|
||||
AssetIdentifier: assetName,
|
||||
Title: fmt.Sprintf("Vulnerability-%03d", vulnID),
|
||||
Severity: "High",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
t.Log("Marshaling 1M tickets to JSON...")
|
||||
body1, _ := json.Marshal(baselinePayload)
|
||||
req1 := httptest.NewRequest(http.MethodPost, "/api/ingest", bytes.NewBuffer(body1))
|
||||
rr1 := httptest.NewRecorder()
|
||||
|
||||
t.Log("Hitting API with Baseline 1M Scan...")
|
||||
a.HandleIngest(rr1, req1)
|
||||
|
||||
if rr1.Code != http.StatusCreated {
|
||||
t.Fatalf("Baseline ingest failed with status %d", rr1.Code)
|
||||
}
|
||||
|
||||
var count1 int
|
||||
db.QueryRow(`SELECT COUNT(*) FROM tickets`).Scan(&count1)
|
||||
if count1 != 1000000 {
|
||||
t.Fatalf("Expected 1,000,000 tickets inserted, got %d", count1)
|
||||
}
|
||||
|
||||
t.Log("Generating Diff payload...")
|
||||
|
||||
diffPayload := make([]domain.Ticket, 0, numAssets*vulnsPerAsset)
|
||||
for assetID := 1; assetID <= numAssets; assetID++ {
|
||||
assetName := fmt.Sprintf("Server-%05d", assetID)
|
||||
|
||||
for vulnID := 1; vulnID <= 80; vulnID++ {
|
||||
diffPayload = append(diffPayload, domain.Ticket{
|
||||
Source: "HeavyLoadTester",
|
||||
AssetIdentifier: assetName,
|
||||
Title: fmt.Sprintf("Vulnerability-%03d", vulnID),
|
||||
Severity: "High",
|
||||
})
|
||||
}
|
||||
|
||||
for vulnID := 101; vulnID <= 120; vulnID++ {
|
||||
diffPayload = append(diffPayload, domain.Ticket{
|
||||
Source: "HeavyLoadTester",
|
||||
AssetIdentifier: assetName,
|
||||
Title: fmt.Sprintf("Vulnerability-%03d", vulnID),
|
||||
Severity: "Critical",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
t.Log("Marshaling Diff payload to JSON...")
|
||||
body2, _ := json.Marshal(diffPayload)
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/api/ingest", bytes.NewBuffer(body2))
|
||||
rr2 := httptest.NewRecorder()
|
||||
|
||||
t.Log("Hitting API with Diff 1M Scan...")
|
||||
a.HandleIngest(rr2, req2)
|
||||
|
||||
if rr2.Code != http.StatusCreated {
|
||||
t.Fatalf("Diff ingest failed with status %d", rr2.Code)
|
||||
}
|
||||
|
||||
t.Log("Running Assertions...")
|
||||
|
||||
var totalRows int
|
||||
db.QueryRow(`SELECT COUNT(*) FROM tickets`).Scan(&totalRows)
|
||||
if totalRows != 1200000 {
|
||||
t.Errorf("Expected exactly 1,200,000 total rows in DB, got %d", totalRows)
|
||||
}
|
||||
|
||||
var patchedCount int
|
||||
db.QueryRow(`SELECT COUNT(*) FROM tickets WHERE status = 'Patched'`).Scan(&patchedCount)
|
||||
if patchedCount != 200000 {
|
||||
t.Errorf("Expected exactly 200,000 auto-patched tickets, got %d", patchedCount)
|
||||
}
|
||||
|
||||
var openCount int
|
||||
db.QueryRow(`SELECT COUNT(*) FROM tickets WHERE status = 'Waiting to be Triaged'`).Scan(&openCount)
|
||||
if openCount != 1000000 {
|
||||
t.Errorf("Expected exactly 1,000,000 open tickets, got %d", openCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncLogReceipts(t *testing.T) {
|
||||
h, db := setupTestIngest(t)
|
||||
defer db.Close()
|
||||
db.Exec(`CREATE TABLE IF NOT EXISTS sync_logs (id INTEGER PRIMARY KEY, source TEXT, status TEXT, records_processed INTEGER, error_message TEXT)`)
|
||||
|
||||
payload := []byte(`[{"source": "Dependabot", "asset_identifier": "repo-1", "title": "Vuln 1", "severity": "High"}]`)
|
||||
req1 := httptest.NewRequest(http.MethodPost, "/api/ingest", bytes.NewBuffer(payload))
|
||||
req1.AddCookie(GetVIPCookie(h.Store))
|
||||
req1.Header.Set("Content-Type", "application/json")
|
||||
h.HandleIngest(httptest.NewRecorder(), req1)
|
||||
|
||||
badPayload := []byte(`[{"source": "Dependabot", "title": "Vuln 1", "severity": "High", "status": "GarbageStatus"}]`)
|
||||
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/api/ingest", bytes.NewBuffer(badPayload))
|
||||
req2.AddCookie(GetVIPCookie(h.Store))
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
h.HandleIngest(httptest.NewRecorder(), req2)
|
||||
|
||||
var successCount, failCount, processed int
|
||||
db.QueryRow("SELECT COUNT(*), MAX(records_processed) FROM sync_logs WHERE source = 'Dependabot' AND status = 'Success'").Scan(&successCount, &processed)
|
||||
db.QueryRow("SELECT COUNT(*) FROM sync_logs WHERE status = 'Failed'").Scan(&failCount)
|
||||
|
||||
if successCount != 1 || processed != 1 {
|
||||
t.Errorf("System failed to log successful sync receipt. Got count: %d, processed: %d", successCount, processed)
|
||||
}
|
||||
if failCount != 1 {
|
||||
t.Errorf("System failed to log failed sync receipt. Got count: %d", failCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIFileDropIngestion(t *testing.T) {
|
||||
h, db := setupTestIngest(t)
|
||||
defer db.Close()
|
||||
|
||||
res, err := db.Exec(`INSERT INTO data_adapters (name, source_name, mapping_title, mapping_asset, mapping_severity) VALUES ('UI-Tool', 'UITool', 'Name', 'Host', 'Risk')`)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed adapter: %v", err)
|
||||
}
|
||||
adapterID, _ := res.LastInsertId()
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, _ := writer.CreateFormFile("file", "test_findings.csv")
|
||||
part.Write([]byte("Name,Host,Risk\nUnauthorized Access,10.0.0.1,Critical"))
|
||||
|
||||
_ = writer.WriteField("adapter_id", fmt.Sprintf("%d", adapterID))
|
||||
writer.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/ingest/csv", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.AddCookie(GetVIPCookie(h.Store))
|
||||
rr := httptest.NewRecorder()
|
||||
h.HandleCSVIngest(rr, req)
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 Created, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var count int
|
||||
db.QueryRow("SELECT COUNT(*) FROM tickets WHERE source = 'UITool'").Scan(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("UI Drop failed: expected 1 ticket, got %d", count)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user