@@ -93,6 +93,26 @@ func (m *mockStorage) ListScanJobs(serverName string) ([]*ScanJob, error) {
9393 return result , nil
9494}
9595
96+ func (m * mockStorage ) ListScanJobMetas (serverName string ) ([]* ScanJobMeta , error ) {
97+ m .mu .Lock ()
98+ defer m .mu .Unlock ()
99+ var result []* ScanJobMeta
100+ for _ , j := range m .jobs {
101+ if serverName != "" && j .ServerName != serverName {
102+ continue
103+ }
104+ result = append (result , & ScanJobMeta {
105+ ID : j .ID ,
106+ ServerName : j .ServerName ,
107+ Status : j .Status ,
108+ ScanPass : j .ScanPass ,
109+ StartedAt : j .StartedAt ,
110+ CompletedAt : j .CompletedAt ,
111+ })
112+ }
113+ return result , nil
114+ }
115+
96116func (m * mockStorage ) GetLatestScanJob (serverName string ) (* ScanJob , error ) {
97117 m .mu .Lock ()
98118 defer m .mu .Unlock ()
@@ -1545,12 +1565,14 @@ func TestScanFindingScanPassTag(t *testing.T) {
15451565 }
15461566}
15471567
1548- // countingStorage wraps any Storage and counts ListScanJobs invocations.
1549- // Used for verifying the GetScanSummary negative-cache behavior introduced in
1550- // spec 047.
1568+ // countingStorage wraps any Storage and counts storage probes. listCalls counts
1569+ // the heavy full-history ListScanJobs path (MCP-2205 asserts this stays 0 on the
1570+ // report hot paths); metaCalls counts the lightweight ListScanJobMetas index
1571+ // path used by the GetScanSummary negative-cache behavior (spec 047).
15511572type countingStorage struct {
15521573 Storage
15531574 listCalls atomic.Int64
1575+ metaCalls atomic.Int64
15541576}
15551577
15561578func newCountingStorage (inner Storage ) * countingStorage {
@@ -1562,6 +1584,11 @@ func (c *countingStorage) ListScanJobs(serverName string) ([]*ScanJob, error) {
15621584 return c .Storage .ListScanJobs (serverName )
15631585}
15641586
1587+ func (c * countingStorage ) ListScanJobMetas (serverName string ) ([]* ScanJobMeta , error ) {
1588+ c .metaCalls .Add (1 )
1589+ return c .Storage .ListScanJobMetas (serverName )
1590+ }
1591+
15651592// erroringStorage returns a transient error from ListScanJobs while delegating
15661593// other calls to the inner Storage. Used to verify that non-errNoScans errors
15671594// do NOT populate the negative cache.
@@ -1576,6 +1603,11 @@ func (e *erroringStorage) ListScanJobs(string) ([]*ScanJob, error) {
15761603 return nil , e .err
15771604}
15781605
1606+ func (e * erroringStorage ) ListScanJobMetas (string ) ([]* ScanJobMeta , error ) {
1607+ e .listCalls .Add (1 )
1608+ return nil , e .err
1609+ }
1610+
15791611// Spec 047 — Phase 3 (US1): cache the "no scans found" sentinel.
15801612
15811613func TestGetScanSummary_CachesNegativeResult (t * testing.T ) {
@@ -1590,8 +1622,8 @@ func TestGetScanSummary_CachesNegativeResult(t *testing.T) {
15901622 }
15911623
15921624 // Without the negative-cache fix, this would be N storage calls.
1593- if got := store .listCalls .Load (); got != 1 {
1594- t .Errorf ("expected exactly 1 ListScanJobs call after %d GetScanSummary invocations, got %d" , N , got )
1625+ if got := store .metaCalls .Load (); got != 1 {
1626+ t .Errorf ("expected exactly 1 ListScanJobMetas call after %d GetScanSummary invocations, got %d" , N , got )
15951627 }
15961628}
15971629
@@ -1606,7 +1638,7 @@ func TestGetScanSummary_DoesNotCacheOnTransientError(t *testing.T) {
16061638
16071639 // Transient error must NOT populate the negative cache: every call retries.
16081640 if got := store .listCalls .Load (); got != int64 (N ) {
1609- t .Errorf ("expected %d ListScanJobs calls (no caching of transient errors), got %d" , N , got )
1641+ t .Errorf ("expected %d storage probes (no caching of transient errors), got %d" , N , got )
16101642 }
16111643}
16121644
@@ -1619,8 +1651,8 @@ func TestGetScanSummary_OverwritesNilSentinelOnRealScan(t *testing.T) {
16191651 if got := svc .GetScanSummary (context .Background (), "later-scanned" ); got != nil {
16201652 t .Fatalf ("expected nil summary on first call, got %+v" , got )
16211653 }
1622- if got := store .listCalls .Load (); got != 1 {
1623- t .Fatalf ("expected 1 ListScanJobs call after first GetScanSummary, got %d" , got )
1654+ if got := store .metaCalls .Load (); got != 1 {
1655+ t .Fatalf ("expected 1 ListScanJobMetas call after first GetScanSummary, got %d" , got )
16241656 }
16251657
16261658 // Simulate a real scan landing for that server: insert a completed Pass-1 job
@@ -1646,3 +1678,95 @@ func TestGetScanSummary_OverwritesNilSentinelOnRealScan(t *testing.T) {
16461678 t .Errorf ("expected real summary {Status: clean}, got %+v" , got )
16471679 }
16481680}
1681+
1682+ // TestGetScanReportByJobID_LatencyIndependentOfHistory verifies the MCP-2205
1683+ // fix: aggregating a Pass-1 report must NOT deserialize the full per-server scan
1684+ // history (ListScanJobs), whose job payloads carry large stdout/stderr. The
1685+ // companion Pass-2 lookup uses the lightweight metadata index instead, so report
1686+ // latency does not grow with how many times a server has been scanned.
1687+ func TestGetScanReportByJobID_LatencyIndependentOfHistory (t * testing.T ) {
1688+ mock := newMockStorage ()
1689+ store := newCountingStorage (mock )
1690+ svc := NewService (store , NewRegistry (t .TempDir (), zap .NewNop ()), nil , t .TempDir (), zap .NewNop ())
1691+
1692+ now := time .Now ()
1693+
1694+ // Pass-1 job under inspection + its report.
1695+ pass1 := & ScanJob {ID : "p1" , ServerName : "srv" , Status : ScanJobStatusCompleted , ScanPass : ScanPassSecurityScan , StartedAt : now .Add (- 10 * time .Minute )}
1696+ _ = mock .SaveScanJob (pass1 )
1697+ _ = mock .SaveScanReport (& ScanReport {ID : "r1" , JobID : "p1" , ServerName : "srv" , ScannerID : "s" ,
1698+ Findings : []ScanFinding {{RuleID : "T1" , Title : "tool poisoning" , Scanner : "s" , ThreatLevel : ThreatLevelDangerous }}})
1699+
1700+ // Companion Pass-2 job that started after Pass-1 + its report.
1701+ pass2 := & ScanJob {ID : "p2" , ServerName : "srv" , Status : ScanJobStatusCompleted , ScanPass : ScanPassSupplyChainAudit , StartedAt : now .Add (- 8 * time .Minute )}
1702+ _ = mock .SaveScanJob (pass2 )
1703+ _ = mock .SaveScanReport (& ScanReport {ID : "r2" , JobID : "p2" , ServerName : "srv" , ScannerID : "s" ,
1704+ Findings : []ScanFinding {{RuleID : "CVE-1" , Title : "known cve" , Scanner : "s" , ThreatLevel : ThreatLevelWarning }}})
1705+
1706+ // Large historical backlog for the same server (the symptom: reports get
1707+ // slower the more a server is scanned).
1708+ for i := 0 ; i < 100 ; i ++ {
1709+ _ = mock .SaveScanJob (& ScanJob {
1710+ ID : fmt .Sprintf ("noise-%d" , i ),
1711+ ServerName : "srv" ,
1712+ Status : ScanJobStatusCompleted ,
1713+ ScanPass : ScanPassSecurityScan ,
1714+ StartedAt : now .Add (time .Duration (- 20 - i ) * time .Minute ),
1715+ })
1716+ }
1717+
1718+ agg , err := svc .GetScanReportByJobID (context .Background (), "p1" )
1719+ if err != nil {
1720+ t .Fatalf ("GetScanReportByJobID: %v" , err )
1721+ }
1722+
1723+ // Correctness: companion Pass-2 still merged in.
1724+ if ! agg .Pass1Complete || ! agg .Pass2Complete {
1725+ t .Errorf ("expected both passes complete, got pass1=%v pass2=%v" , agg .Pass1Complete , agg .Pass2Complete )
1726+ }
1727+ if len (agg .Findings ) != 2 {
1728+ t .Fatalf ("expected 2 merged findings (Pass-1 + companion Pass-2), got %d" , len (agg .Findings ))
1729+ }
1730+
1731+ // Latency guard: must not scan the full per-server job history.
1732+ if got := store .listCalls .Load (); got != 0 {
1733+ t .Errorf ("expected 0 ListScanJobs (full-history) calls in report path, got %d" , got )
1734+ }
1735+ }
1736+
1737+ // TestGetScanReport_LatestLatencyIndependentOfHistory verifies the MCP-2205 fix
1738+ // also covers the "latest report" path (GetScanReport by server name, used by the
1739+ // Web UI server-detail view). Resolving the latest Pass-1/Pass-2 jobs must use the
1740+ // lightweight metadata index plus targeted GetScanJob loads (at most two full job
1741+ // deserializations), not a full ListScanJobs over the server's scan history.
1742+ func TestGetScanReport_LatestLatencyIndependentOfHistory (t * testing.T ) {
1743+ mock := newMockStorage ()
1744+ store := newCountingStorage (mock )
1745+ svc := NewService (store , NewRegistry (t .TempDir (), zap .NewNop ()), nil , t .TempDir (), zap .NewNop ())
1746+
1747+ now := time .Now ()
1748+
1749+ // Latest Pass-1 + Pass-2 with reports.
1750+ _ = mock .SaveScanJob (& ScanJob {ID : "latest-p1" , ServerName : "srv" , Status : ScanJobStatusCompleted , ScanPass : ScanPassSecurityScan , StartedAt : now .Add (- 2 * time .Minute )})
1751+ _ = mock .SaveScanReport (& ScanReport {ID : "lr1" , JobID : "latest-p1" , ServerName : "srv" , ScannerID : "s" ,
1752+ Findings : []ScanFinding {{RuleID : "T1" , Title : "tp" , Scanner : "s" , ThreatLevel : ThreatLevelDangerous }}})
1753+ _ = mock .SaveScanJob (& ScanJob {ID : "latest-p2" , ServerName : "srv" , Status : ScanJobStatusCompleted , ScanPass : ScanPassSupplyChainAudit , StartedAt : now .Add (- 1 * time .Minute )})
1754+ _ = mock .SaveScanReport (& ScanReport {ID : "lr2" , JobID : "latest-p2" , ServerName : "srv" , ScannerID : "s" ,
1755+ Findings : []ScanFinding {{RuleID : "CVE-9" , Title : "cve" , Scanner : "s" , ThreatLevel : ThreatLevelWarning }}})
1756+
1757+ // Large historical backlog.
1758+ for i := 0 ; i < 100 ; i ++ {
1759+ _ = mock .SaveScanJob (& ScanJob {ID : fmt .Sprintf ("old-%d" , i ), ServerName : "srv" , Status : ScanJobStatusCompleted , ScanPass : ScanPassSecurityScan , StartedAt : now .Add (time .Duration (- 10 - i ) * time .Minute )})
1760+ }
1761+
1762+ report , err := svc .GetScanReport (context .Background (), "srv" )
1763+ if err != nil {
1764+ t .Fatalf ("GetScanReport: %v" , err )
1765+ }
1766+ if len (report .Findings ) != 2 {
1767+ t .Fatalf ("expected 2 merged findings, got %d" , len (report .Findings ))
1768+ }
1769+ if got := store .listCalls .Load (); got != 0 {
1770+ t .Errorf ("expected 0 ListScanJobs (full-history) calls in latest-report path, got %d" , got )
1771+ }
1772+ }
0 commit comments