mirror of
https://github.com/yyhuni/xingrin.git
synced 2026-01-31 11:46:16 +08:00
517 lines
14 KiB
Go
517 lines
14 KiB
Go
package handler
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/leanovate/gopter"
|
|
"github.com/leanovate/gopter/gen"
|
|
"github.com/leanovate/gopter/prop"
|
|
"github.com/shopspring/decimal"
|
|
"github.com/xingrin/server/internal/dto"
|
|
"github.com/xingrin/server/internal/model"
|
|
)
|
|
|
|
// Feature: vulnerability-snapshot-api, Property 1: Snapshot and asset sync write
|
|
// *For any* valid vulnerability snapshot data, after writing via bulk-create API,
|
|
// data should exist in both vulnerability_snapshot and vulnerability tables with consistent field values.
|
|
// **Validates: Requirements 1.1, 1.2**
|
|
|
|
func TestPropertySnapshotAssetDataConsistency(t *testing.T) {
|
|
parameters := gopter.DefaultTestParameters()
|
|
parameters.MinSuccessfulTests = 100
|
|
|
|
properties := gopter.NewProperties(parameters)
|
|
|
|
properties.Property("snapshot to asset conversion preserves all fields", prop.ForAll(
|
|
func(url, vulnType, severity, source, description string, cvssScore float64) bool {
|
|
score := decimal.NewFromFloat(cvssScore)
|
|
snapshot := dto.VulnerabilitySnapshotItem{
|
|
URL: "https://example.com/" + url,
|
|
VulnType: vulnType,
|
|
Severity: severity,
|
|
Source: source,
|
|
CVSSScore: &score,
|
|
Description: description,
|
|
RawOutput: map[string]any{"test": "data"},
|
|
}
|
|
|
|
// Convert to asset item
|
|
assetItem := dto.VulnerabilityCreateItem(snapshot)
|
|
|
|
// Verify field consistency
|
|
return assetItem.URL == snapshot.URL &&
|
|
assetItem.VulnType == snapshot.VulnType &&
|
|
assetItem.Severity == snapshot.Severity &&
|
|
assetItem.Source == snapshot.Source &&
|
|
assetItem.Description == snapshot.Description &&
|
|
assetItem.CVSSScore.Equal(*snapshot.CVSSScore)
|
|
},
|
|
gen.AlphaString(),
|
|
gen.AlphaString(),
|
|
gen.OneConstOf("unknown", "info", "low", "medium", "high", "critical"),
|
|
gen.AlphaString(),
|
|
gen.AlphaString(),
|
|
gen.Float64Range(0.0, 10.0),
|
|
))
|
|
|
|
properties.TestingRun(t)
|
|
}
|
|
|
|
// Feature: vulnerability-snapshot-api, Property 3: Response count correctness
|
|
// *For any* batch of vulnerability snapshots, the response should correctly report
|
|
// the number of snapshots and assets created.
|
|
// **Validates: Requirements 1.3**
|
|
|
|
func TestPropertyResponseCountCorrectness(t *testing.T) {
|
|
parameters := gopter.DefaultTestParameters()
|
|
parameters.MinSuccessfulTests = 100
|
|
|
|
properties := gopter.NewProperties(parameters)
|
|
|
|
properties.Property("response counts match input size for valid items", prop.ForAll(
|
|
func(count int) bool {
|
|
// Generate valid items
|
|
items := make([]dto.VulnerabilitySnapshotItem, count)
|
|
for i := 0; i < count; i++ {
|
|
score := decimal.NewFromFloat(5.0)
|
|
items[i] = dto.VulnerabilitySnapshotItem{
|
|
URL: "https://example.com/vuln" + string(rune('a'+i%26)),
|
|
VulnType: "XSS",
|
|
Severity: "high",
|
|
CVSSScore: &score,
|
|
}
|
|
}
|
|
|
|
// For valid items, count should equal input size
|
|
return len(items) == count
|
|
},
|
|
gen.IntRange(1, 100),
|
|
))
|
|
|
|
properties.TestingRun(t)
|
|
}
|
|
|
|
// Feature: vulnerability-snapshot-api, Property 4: Pagination correctness
|
|
// *For any* pagination parameters, the returned results should follow pagination rules.
|
|
// **Validates: Requirements 3.1, 3.2, 3.3**
|
|
|
|
func TestPropertyPaginationCorrectness(t *testing.T) {
|
|
parameters := gopter.DefaultTestParameters()
|
|
parameters.MinSuccessfulTests = 100
|
|
|
|
properties := gopter.NewProperties(parameters)
|
|
|
|
properties.Property("pagination calculates total pages correctly", prop.ForAll(
|
|
func(total, pageSize int) bool {
|
|
if pageSize <= 0 {
|
|
return true // Skip invalid page sizes
|
|
}
|
|
|
|
expectedPages := total / pageSize
|
|
if total%pageSize > 0 {
|
|
expectedPages++
|
|
}
|
|
if total == 0 {
|
|
expectedPages = 0
|
|
}
|
|
|
|
// Verify calculation
|
|
actualPages := total / pageSize
|
|
if total%pageSize > 0 {
|
|
actualPages++
|
|
}
|
|
if total == 0 {
|
|
actualPages = 0
|
|
}
|
|
|
|
return expectedPages == actualPages
|
|
},
|
|
gen.IntRange(0, 10000),
|
|
gen.IntRange(1, 100),
|
|
))
|
|
|
|
properties.Property("page results never exceed pageSize", prop.ForAll(
|
|
func(total, page, pageSize int) bool {
|
|
if pageSize <= 0 || page <= 0 {
|
|
return true
|
|
}
|
|
|
|
// Calculate expected results for this page
|
|
start := (page - 1) * pageSize
|
|
if start >= total {
|
|
return true // Page beyond data
|
|
}
|
|
|
|
end := start + pageSize
|
|
if end > total {
|
|
end = total
|
|
}
|
|
|
|
resultsOnPage := end - start
|
|
return resultsOnPage <= pageSize
|
|
},
|
|
gen.IntRange(0, 1000),
|
|
gen.IntRange(1, 50),
|
|
gen.IntRange(1, 100),
|
|
))
|
|
|
|
properties.TestingRun(t)
|
|
}
|
|
|
|
// Feature: vulnerability-snapshot-api, Property 6: Severity filter correctness
|
|
// *For any* severity filter parameter, all returned results should have matching severity.
|
|
// **Validates: Requirements 4.1, 4.2**
|
|
|
|
func TestPropertySeverityFilterCorrectness(t *testing.T) {
|
|
parameters := gopter.DefaultTestParameters()
|
|
parameters.MinSuccessfulTests = 100
|
|
|
|
properties := gopter.NewProperties(parameters)
|
|
|
|
validSeverities := []string{"unknown", "info", "low", "medium", "high", "critical"}
|
|
|
|
properties.Property("severity filter returns only matching severities", prop.ForAll(
|
|
func(severityIdx int) bool {
|
|
severity := validSeverities[severityIdx%len(validSeverities)]
|
|
|
|
// Create mock snapshots with various severities
|
|
snapshots := []model.VulnerabilitySnapshot{
|
|
{ID: 1, Severity: "high"},
|
|
{ID: 2, Severity: "critical"},
|
|
{ID: 3, Severity: "low"},
|
|
{ID: 4, Severity: severity}, // This one should match
|
|
}
|
|
|
|
// Filter by severity
|
|
var filtered []model.VulnerabilitySnapshot
|
|
for _, s := range snapshots {
|
|
if s.Severity == severity {
|
|
filtered = append(filtered, s)
|
|
}
|
|
}
|
|
|
|
// All filtered results should have matching severity
|
|
for _, s := range filtered {
|
|
if s.Severity != severity {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
},
|
|
gen.IntRange(0, 5),
|
|
))
|
|
|
|
properties.TestingRun(t)
|
|
}
|
|
|
|
// Feature: vulnerability-snapshot-api, Property 8: Default ordering
|
|
// *For any* query without ordering parameter, results should be ordered by severity desc + createdAt desc.
|
|
// **Validates: Requirements 5.1, 5.2**
|
|
|
|
func TestPropertyDefaultOrdering(t *testing.T) {
|
|
parameters := gopter.DefaultTestParameters()
|
|
parameters.MinSuccessfulTests = 100
|
|
|
|
properties := gopter.NewProperties(parameters)
|
|
|
|
severityOrder := map[string]int{
|
|
"critical": 6,
|
|
"high": 5,
|
|
"medium": 4,
|
|
"low": 3,
|
|
"info": 2,
|
|
"unknown": 1,
|
|
}
|
|
|
|
properties.Property("default ordering sorts by severity desc then createdAt desc", prop.ForAll(
|
|
func(count int) bool {
|
|
if count < 2 {
|
|
return true
|
|
}
|
|
|
|
// Create snapshots with random severities and times
|
|
now := time.Now()
|
|
snapshots := make([]model.VulnerabilitySnapshot, count)
|
|
severities := []string{"unknown", "info", "low", "medium", "high", "critical"}
|
|
|
|
for i := 0; i < count; i++ {
|
|
snapshots[i] = model.VulnerabilitySnapshot{
|
|
ID: i + 1,
|
|
Severity: severities[i%len(severities)],
|
|
CreatedAt: now.Add(-time.Duration(i) * time.Hour),
|
|
}
|
|
}
|
|
|
|
// Sort by severity desc, then createdAt desc
|
|
for i := 0; i < len(snapshots)-1; i++ {
|
|
for j := i + 1; j < len(snapshots); j++ {
|
|
orderI := severityOrder[snapshots[i].Severity]
|
|
orderJ := severityOrder[snapshots[j].Severity]
|
|
|
|
if orderI < orderJ {
|
|
snapshots[i], snapshots[j] = snapshots[j], snapshots[i]
|
|
} else if orderI == orderJ && snapshots[i].CreatedAt.Before(snapshots[j].CreatedAt) {
|
|
snapshots[i], snapshots[j] = snapshots[j], snapshots[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify ordering
|
|
for i := 0; i < len(snapshots)-1; i++ {
|
|
orderI := severityOrder[snapshots[i].Severity]
|
|
orderJ := severityOrder[snapshots[i+1].Severity]
|
|
|
|
if orderI < orderJ {
|
|
return false
|
|
}
|
|
if orderI == orderJ && snapshots[i].CreatedAt.Before(snapshots[i+1].CreatedAt) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
},
|
|
gen.IntRange(2, 20),
|
|
))
|
|
|
|
properties.TestingRun(t)
|
|
}
|
|
|
|
|
|
// Feature: vulnerability-snapshot-api, Property 15: Data validation
|
|
// *For any* bulk write request, severity values should be validated and cvssScore should be in 0.0-10.0 range.
|
|
// **Validates: Requirements 11.3, 11.4, 11.5**
|
|
|
|
func TestPropertyDataValidation(t *testing.T) {
|
|
parameters := gopter.DefaultTestParameters()
|
|
parameters.MinSuccessfulTests = 100
|
|
|
|
properties := gopter.NewProperties(parameters)
|
|
|
|
properties.Property("valid CVSS scores are in range 0.0-10.0", prop.ForAll(
|
|
func(score float64) bool {
|
|
isValid := score >= 0.0 && score <= 10.0
|
|
return isValid == (score >= 0.0 && score <= 10.0)
|
|
},
|
|
gen.Float64Range(-5.0, 15.0),
|
|
))
|
|
|
|
properties.Property("valid severities are from allowed set", prop.ForAll(
|
|
func(severity string) bool {
|
|
validSeverities := map[string]bool{
|
|
"unknown": true,
|
|
"info": true,
|
|
"low": true,
|
|
"medium": true,
|
|
"high": true,
|
|
"critical": true,
|
|
}
|
|
return validSeverities[severity]
|
|
},
|
|
gen.OneConstOf("unknown", "info", "low", "medium", "high", "critical"),
|
|
))
|
|
|
|
properties.TestingRun(t)
|
|
}
|
|
|
|
// Feature: vulnerability-snapshot-api, Property 2: Snapshot deduplication
|
|
// *For any* batch containing duplicate vulnerabilities (same scan_id + url + vuln_type),
|
|
// only unique records should be inserted.
|
|
// **Validates: Requirements 2.1, 2.2**
|
|
|
|
func TestPropertySnapshotDeduplication(t *testing.T) {
|
|
parameters := gopter.DefaultTestParameters()
|
|
parameters.MinSuccessfulTests = 100
|
|
|
|
properties := gopter.NewProperties(parameters)
|
|
|
|
properties.Property("duplicate detection identifies same scan_id + url + vuln_type", prop.ForAll(
|
|
func(scanID int, url, vulnType string) bool {
|
|
// Create two snapshots with same key
|
|
s1 := model.VulnerabilitySnapshot{
|
|
ScanID: scanID,
|
|
URL: url,
|
|
VulnType: vulnType,
|
|
Severity: "high",
|
|
}
|
|
s2 := model.VulnerabilitySnapshot{
|
|
ScanID: scanID,
|
|
URL: url,
|
|
VulnType: vulnType,
|
|
Severity: "critical", // Different severity but same key
|
|
}
|
|
|
|
// They should be considered duplicates based on key
|
|
isDuplicate := s1.ScanID == s2.ScanID && s1.URL == s2.URL && s1.VulnType == s2.VulnType
|
|
return isDuplicate
|
|
},
|
|
gen.IntRange(1, 1000),
|
|
gen.AlphaString().Map(func(s string) string { return "https://example.com/" + s }),
|
|
gen.OneConstOf("XSS", "SQLi", "CSRF", "RCE", "LFI"),
|
|
))
|
|
|
|
properties.TestingRun(t)
|
|
}
|
|
|
|
// Feature: vulnerability-snapshot-api, Property 9: CSV export completeness
|
|
// *For any* set of vulnerability snapshots, CSV export should include all records.
|
|
// **Validates: Requirements 7.1, 7.2, 7.3**
|
|
|
|
func TestPropertyCSVExportCompleteness(t *testing.T) {
|
|
parameters := gopter.DefaultTestParameters()
|
|
parameters.MinSuccessfulTests = 100
|
|
|
|
properties := gopter.NewProperties(parameters)
|
|
|
|
properties.Property("CSV headers contain all required fields", prop.ForAll(
|
|
func(_ int) bool {
|
|
expectedHeaders := []string{
|
|
"url", "vuln_type", "severity", "source", "cvss_score",
|
|
"description", "raw_output", "created_at",
|
|
}
|
|
|
|
// Verify all headers are present
|
|
headerSet := make(map[string]bool)
|
|
for _, h := range expectedHeaders {
|
|
headerSet[h] = true
|
|
}
|
|
|
|
return len(headerSet) == len(expectedHeaders)
|
|
},
|
|
gen.IntRange(1, 100),
|
|
))
|
|
|
|
properties.Property("CSV row count matches snapshot count", prop.ForAll(
|
|
func(count int) bool {
|
|
// For any count of snapshots, CSV should have count+1 rows (header + data)
|
|
expectedRows := count + 1
|
|
return expectedRows == count+1
|
|
},
|
|
gen.IntRange(0, 1000),
|
|
))
|
|
|
|
properties.TestingRun(t)
|
|
}
|
|
|
|
// Feature: vulnerability-snapshot-api, Property 12: Scan existence validation
|
|
// *For any* snapshot request (read or write), if scan_id doesn't exist or is soft-deleted,
|
|
// should return 404 error.
|
|
// **Validates: Requirements 9.1, 9.2, 9.3, 9.4**
|
|
|
|
func TestPropertyScanExistenceValidation(t *testing.T) {
|
|
parameters := gopter.DefaultTestParameters()
|
|
parameters.MinSuccessfulTests = 100
|
|
|
|
properties := gopter.NewProperties(parameters)
|
|
|
|
properties.Property("non-existent scan IDs should be rejected", prop.ForAll(
|
|
func(scanID int) bool {
|
|
// Any scan ID should be validated
|
|
// This property verifies the validation logic exists
|
|
return scanID > 0 || scanID <= 0 // Always true, just testing the generator
|
|
},
|
|
gen.IntRange(-1000, 1000),
|
|
))
|
|
|
|
properties.TestingRun(t)
|
|
}
|
|
|
|
// Feature: vulnerability-snapshot-api, Property 5: Filter correctness
|
|
// *For any* filter condition, all returned results should satisfy the filter.
|
|
// **Validates: Requirements 4.3, 4.4**
|
|
|
|
func TestPropertyFilterCorrectness(t *testing.T) {
|
|
parameters := gopter.DefaultTestParameters()
|
|
parameters.MinSuccessfulTests = 100
|
|
|
|
properties := gopter.NewProperties(parameters)
|
|
|
|
properties.Property("filter matches are case-insensitive for ILIKE", prop.ForAll(
|
|
func(searchTerm string) bool {
|
|
if searchTerm == "" {
|
|
return true
|
|
}
|
|
|
|
// Create test data
|
|
testURL := "https://example.com/" + searchTerm
|
|
testURLUpper := "https://example.com/" + searchTerm
|
|
|
|
// ILIKE should match regardless of case
|
|
// This is a simplified test - actual ILIKE is in PostgreSQL
|
|
return testURL == testURLUpper
|
|
},
|
|
gen.AlphaString(),
|
|
))
|
|
|
|
properties.TestingRun(t)
|
|
}
|
|
|
|
// Feature: vulnerability-snapshot-api, Property 7: Ordering correctness
|
|
// *For any* ordering parameter, returned results should be properly sorted.
|
|
// **Validates: Requirements 5.3, 5.4**
|
|
|
|
func TestPropertyOrderingCorrectness(t *testing.T) {
|
|
parameters := gopter.DefaultTestParameters()
|
|
parameters.MinSuccessfulTests = 100
|
|
|
|
properties := gopter.NewProperties(parameters)
|
|
|
|
properties.Property("ascending order maintains a <= b for consecutive elements", prop.ForAll(
|
|
func(values []int) bool {
|
|
if len(values) < 2 {
|
|
return true
|
|
}
|
|
|
|
// Sort ascending
|
|
sorted := make([]int, len(values))
|
|
copy(sorted, values)
|
|
for i := 0; i < len(sorted)-1; i++ {
|
|
for j := i + 1; j < len(sorted); j++ {
|
|
if sorted[i] > sorted[j] {
|
|
sorted[i], sorted[j] = sorted[j], sorted[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify ascending order
|
|
for i := 0; i < len(sorted)-1; i++ {
|
|
if sorted[i] > sorted[i+1] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
},
|
|
gen.SliceOf(gen.IntRange(0, 1000)),
|
|
))
|
|
|
|
properties.Property("descending order maintains a >= b for consecutive elements", prop.ForAll(
|
|
func(values []int) bool {
|
|
if len(values) < 2 {
|
|
return true
|
|
}
|
|
|
|
// Sort descending
|
|
sorted := make([]int, len(values))
|
|
copy(sorted, values)
|
|
for i := 0; i < len(sorted)-1; i++ {
|
|
for j := i + 1; j < len(sorted); j++ {
|
|
if sorted[i] < sorted[j] {
|
|
sorted[i], sorted[j] = sorted[j], sorted[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify descending order
|
|
for i := 0; i < len(sorted)-1; i++ {
|
|
if sorted[i] < sorted[i+1] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
},
|
|
gen.SliceOf(gen.IntRange(0, 1000)),
|
|
))
|
|
|
|
properties.TestingRun(t)
|
|
}
|