save progress
This commit is contained in:
@@ -0,0 +1,578 @@
|
||||
using StellaOps.Scanner.EntryTrace.Mesh;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Tests.Mesh;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for DockerComposeParser.
|
||||
/// Part of Sprint 0412 - Task TEST-003.
|
||||
/// </summary>
|
||||
public sealed class DockerComposeParserTests
|
||||
{
|
||||
private readonly DockerComposeParser _parser = new();
|
||||
|
||||
[Fact]
|
||||
public void CanParse_DockerComposeYaml_ReturnsTrue()
|
||||
{
|
||||
// Act
|
||||
Assert.True(_parser.CanParse("docker-compose.yaml"));
|
||||
Assert.True(_parser.CanParse("docker-compose.yml"));
|
||||
Assert.True(_parser.CanParse("compose.yaml"));
|
||||
Assert.True(_parser.CanParse("compose.yml"));
|
||||
Assert.True(_parser.CanParse("docker-compose.prod.yaml"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanParse_NonComposeYaml_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
""";
|
||||
|
||||
// Act & Assert
|
||||
Assert.False(_parser.CanParse("deployment.yaml", content));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_SimpleService_ExtractsService()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
ports:
|
||||
- "80:80"
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(MeshType.DockerCompose, graph.Type);
|
||||
Assert.Single(graph.Services);
|
||||
Assert.Equal("web", graph.Services[0].ServiceId);
|
||||
Assert.Equal("web", graph.Services[0].ContainerName);
|
||||
Assert.Single(graph.Services[0].ExposedPorts);
|
||||
Assert.Contains(80, graph.Services[0].ExposedPorts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_MultipleServices_ExtractsAll()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
ports:
|
||||
- "80:80"
|
||||
api:
|
||||
image: myapi:v1
|
||||
ports:
|
||||
- "8080:8080"
|
||||
db:
|
||||
image: postgres:15
|
||||
expose:
|
||||
- "5432"
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, graph.Services.Length);
|
||||
Assert.Contains(graph.Services, s => s.ServiceId == "web");
|
||||
Assert.Contains(graph.Services, s => s.ServiceId == "api");
|
||||
Assert.Contains(graph.Services, s => s.ServiceId == "db");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_DependsOn_CreatesEdges()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
depends_on:
|
||||
- api
|
||||
api:
|
||||
image: myapi
|
||||
depends_on:
|
||||
- db
|
||||
db:
|
||||
image: postgres
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, graph.Edges.Length);
|
||||
Assert.Contains(graph.Edges, e => e.SourceServiceId == "web" && e.TargetServiceId == "api");
|
||||
Assert.Contains(graph.Edges, e => e.SourceServiceId == "api" && e.TargetServiceId == "db");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_Links_CreatesEdges()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
links:
|
||||
- api:backend
|
||||
api:
|
||||
image: myapi
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Edges);
|
||||
Assert.Equal("web", graph.Edges[0].SourceServiceId);
|
||||
Assert.Equal("api", graph.Edges[0].TargetServiceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_PortMappings_ExtractsAll()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
app:
|
||||
image: myapp
|
||||
ports:
|
||||
- "80:8080"
|
||||
- "443:8443"
|
||||
- "9090:9090"
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
Assert.Equal(3, graph.Services[0].ExposedPorts.Length);
|
||||
Assert.Equal(3, graph.Services[0].PortMappings.Count);
|
||||
Assert.Equal(8080, graph.Services[0].PortMappings[80]);
|
||||
Assert.Equal(8443, graph.Services[0].PortMappings[443]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_Expose_AddsToExposedPorts()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
db:
|
||||
image: postgres
|
||||
expose:
|
||||
- "5432"
|
||||
- "5433"
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, graph.Services[0].ExposedPorts.Length);
|
||||
Assert.Contains(5432, graph.Services[0].ExposedPorts);
|
||||
Assert.Contains(5433, graph.Services[0].ExposedPorts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_ContainerName_OverridesServiceName()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
container_name: my-web-container
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("web", graph.Services[0].ServiceId);
|
||||
Assert.Equal("my-web-container", graph.Services[0].ContainerName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_BuildContext_SetsDigest()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
app:
|
||||
build: ./app
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
Assert.StartsWith("build:", graph.Services[0].ImageDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_BuildWithContext_SetsDigest()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: ./myapp
|
||||
dockerfile: Dockerfile.prod
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
Assert.StartsWith("build:", graph.Services[0].ImageDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_Labels_ExtractsLabels()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
labels:
|
||||
app: web
|
||||
env: production
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, graph.Services[0].Labels.Count);
|
||||
Assert.Equal("web", graph.Services[0].Labels["app"]);
|
||||
Assert.Equal("production", graph.Services[0].Labels["env"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_LabelsListSyntax_ExtractsLabels()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
labels:
|
||||
- "app=web"
|
||||
- "env=production"
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, graph.Services[0].Labels.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_Replicas_ExtractsReplicaCount()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
deploy:
|
||||
replicas: 5
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, graph.Services[0].Replicas);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_InferEdgesFromEnv_FindsServiceReferences()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
environment:
|
||||
- API_URL=http://api:8080
|
||||
api:
|
||||
image: myapi
|
||||
ports:
|
||||
- "8080:8080"
|
||||
""";
|
||||
|
||||
var options = new ManifestParseOptions { InferEdgesFromEnv = true };
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content, options);
|
||||
|
||||
// Assert
|
||||
Assert.Contains(graph.Edges, e =>
|
||||
e.SourceServiceId == "web" &&
|
||||
e.TargetServiceId == "api" &&
|
||||
e.TargetPort == 8080);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_EnvironmentMappingSyntax_Parses()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
app:
|
||||
image: myapp
|
||||
environment:
|
||||
DB_HOST: postgres
|
||||
DB_PORT: "5432"
|
||||
""";
|
||||
|
||||
// Act - Should not throw
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_DependsOnExtendedSyntax_Parses()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
api:
|
||||
image: myapi
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Edges);
|
||||
Assert.Equal("api", graph.Edges[0].TargetServiceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_PortWithProtocol_Parses()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
dns:
|
||||
image: coredns
|
||||
ports:
|
||||
- "53:53/udp"
|
||||
- "53:53/tcp"
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Contains(53, graph.Services[0].ExposedPorts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_LongPortSyntax_Parses()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
ports:
|
||||
- target: 80
|
||||
published: 8080
|
||||
protocol: tcp
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Contains(80, graph.Services[0].ExposedPorts);
|
||||
Assert.Contains(8080, graph.Services[0].PortMappings.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_Networks_Parses()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
networks:
|
||||
- frontend
|
||||
- backend
|
||||
networks:
|
||||
frontend:
|
||||
driver: bridge
|
||||
backend:
|
||||
driver: bridge
|
||||
""";
|
||||
|
||||
// Act - Should not throw
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_Volumes_Parses()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
db:
|
||||
image: postgres
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
volumes:
|
||||
db-data:
|
||||
driver: local
|
||||
""";
|
||||
|
||||
// Act - Should not throw
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_IngressPaths_CreatedFromPorts()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, graph.IngressPaths.Length);
|
||||
Assert.All(graph.IngressPaths, p => Assert.Equal("localhost", p.Host));
|
||||
Assert.All(graph.IngressPaths, p => Assert.Equal("web", p.TargetServiceId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_ImageWithDigest_ExtractsDigest()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
app:
|
||||
image: myapp@sha256:abcdef123456
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("sha256:abcdef123456", graph.Services[0].ImageDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_InternalDns_SetsServiceName()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
my-service:
|
||||
image: app
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services[0].InternalDns);
|
||||
Assert.Contains("my-service", graph.Services[0].InternalDns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseMultipleAsync_CombinesFiles()
|
||||
{
|
||||
// Arrange
|
||||
var manifests = new Dictionary<string, string>
|
||||
{
|
||||
["docker-compose.yaml"] = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
""",
|
||||
["docker-compose.override.yaml"] = """
|
||||
version: "3.8"
|
||||
services:
|
||||
api:
|
||||
image: myapi
|
||||
"""
|
||||
};
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseMultipleAsync(manifests);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, graph.Services.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeshType_IsDockerCompose()
|
||||
{
|
||||
Assert.Equal(MeshType.DockerCompose, _parser.MeshType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
using StellaOps.Scanner.EntryTrace.Mesh;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Tests.Mesh;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for KubernetesManifestParser.
|
||||
/// Part of Sprint 0412 - Task TEST-003.
|
||||
/// </summary>
|
||||
public sealed class KubernetesManifestParserTests
|
||||
{
|
||||
private readonly KubernetesManifestParser _parser = new();
|
||||
|
||||
[Fact]
|
||||
public void CanParse_KubernetesYaml_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: my-app
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = _parser.CanParse("deployment.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanParse_NonKubernetesYaml_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = _parser.CanParse("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_SimpleDeployment_ExtractsServices()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: my-app
|
||||
namespace: default
|
||||
labels:
|
||||
app: my-app
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: my-app
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
image: myapp:v1.0.0@sha256:abc123def456
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
- containerPort: 8443
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("deployment.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
Assert.Equal("default/my-app/app", graph.Services[0].ServiceId);
|
||||
Assert.Equal("sha256:abc123def456", graph.Services[0].ImageDigest);
|
||||
Assert.Equal(2, graph.Services[0].ExposedPorts.Length);
|
||||
Assert.Contains(8080, graph.Services[0].ExposedPorts);
|
||||
Assert.Contains(8443, graph.Services[0].ExposedPorts);
|
||||
Assert.Equal(3, graph.Services[0].Replicas);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_Service_ExtractsServiceInfo()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: my-service
|
||||
namespace: default
|
||||
spec:
|
||||
selector:
|
||||
app: my-app
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8080
|
||||
protocol: TCP
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("service.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(MeshType.Kubernetes, graph.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_IngressNetworkingV1_ExtractsIngress()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: my-ingress
|
||||
namespace: default
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /
|
||||
spec:
|
||||
tls:
|
||||
- secretName: my-tls-secret
|
||||
rules:
|
||||
- host: api.example.com
|
||||
http:
|
||||
paths:
|
||||
- path: /api
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: api-service
|
||||
port:
|
||||
number: 8080
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("ingress.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.IngressPaths);
|
||||
Assert.Equal("my-ingress", graph.IngressPaths[0].IngressName);
|
||||
Assert.Equal("api.example.com", graph.IngressPaths[0].Host);
|
||||
Assert.Equal("/api", graph.IngressPaths[0].Path);
|
||||
Assert.Equal("default/api-service", graph.IngressPaths[0].TargetServiceId);
|
||||
Assert.Equal(8080, graph.IngressPaths[0].TargetPort);
|
||||
Assert.True(graph.IngressPaths[0].TlsEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_MultiDocumentYaml_ParsesAll()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: frontend
|
||||
namespace: default
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: frontend
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: web
|
||||
image: frontend:v1
|
||||
ports:
|
||||
- containerPort: 80
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: backend
|
||||
namespace: default
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: backend
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: api
|
||||
image: backend:v1
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("multi.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, graph.Services.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_NamespaceFilter_FiltersCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: app-a
|
||||
namespace: production
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: a
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: main
|
||||
image: app:v1
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: app-b
|
||||
namespace: staging
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: b
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: main
|
||||
image: app:v1
|
||||
""";
|
||||
|
||||
var options = new ManifestParseOptions { Namespace = "production" };
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("namespaced.yaml", content, options);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
Assert.Contains("production", graph.Services[0].ServiceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_MultiplePorts_ExtractsAll()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: multi-port-app
|
||||
namespace: default
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: multi
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: server
|
||||
image: server:v1
|
||||
ports:
|
||||
- containerPort: 80
|
||||
name: http
|
||||
- containerPort: 443
|
||||
name: https
|
||||
- containerPort: 9090
|
||||
name: metrics
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("ports.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
Assert.Equal(3, graph.Services[0].ExposedPorts.Length);
|
||||
Assert.Contains(80, graph.Services[0].ExposedPorts);
|
||||
Assert.Contains(443, graph.Services[0].ExposedPorts);
|
||||
Assert.Contains(9090, graph.Services[0].ExposedPorts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_SidecarContainers_IncludesAll()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: app-with-sidecar
|
||||
namespace: default
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: main
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: main
|
||||
image: main:v1
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
- name: envoy-proxy
|
||||
image: envoy:v1
|
||||
ports:
|
||||
- containerPort: 15000
|
||||
""";
|
||||
|
||||
var options = new ManifestParseOptions { IncludeSidecars = true };
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("sidecar.yaml", content, options);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, graph.Services.Length);
|
||||
Assert.Contains(graph.Services, s => s.ContainerName == "main");
|
||||
Assert.Contains(graph.Services, s => s.ContainerName == "envoy-proxy");
|
||||
Assert.Contains(graph.Services, s => s.IsSidecar);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_StatefulSet_Parses()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: database
|
||||
namespace: default
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: db
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:15
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("statefulset.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
Assert.Equal("default/database/postgres", graph.Services[0].ServiceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_DaemonSet_Parses()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: apps/v1
|
||||
kind: DaemonSet
|
||||
metadata:
|
||||
name: log-collector
|
||||
namespace: kube-system
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: logs
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: fluentd
|
||||
image: fluentd:v1
|
||||
ports:
|
||||
- containerPort: 24224
|
||||
""";
|
||||
|
||||
var options = new ManifestParseOptions { Namespace = "kube-system" };
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("daemonset.yaml", content, options);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_Pod_Parses()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: debug-pod
|
||||
namespace: default
|
||||
labels:
|
||||
purpose: debug
|
||||
spec:
|
||||
containers:
|
||||
- name: shell
|
||||
image: busybox
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("pod.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
Assert.Equal("default/debug-pod/shell", graph.Services[0].ServiceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_ImageWithoutDigest_UsesUnresolvedDigest()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: app
|
||||
namespace: default
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: main
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: main
|
||||
image: myapp:latest
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("tagonly.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
Assert.StartsWith("unresolved:", graph.Services[0].ImageDigest);
|
||||
Assert.Contains("myapp:latest", graph.Services[0].ImageDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseMultipleAsync_CombinesFiles()
|
||||
{
|
||||
// Arrange
|
||||
var manifests = new Dictionary<string, string>
|
||||
{
|
||||
["deploy.yaml"] = """
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: app
|
||||
namespace: default
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: main
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: main
|
||||
image: app:v1
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
""",
|
||||
["ingress.yaml"] = """
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: main
|
||||
namespace: default
|
||||
spec:
|
||||
rules:
|
||||
- host: app.example.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: app
|
||||
port:
|
||||
number: 8080
|
||||
"""
|
||||
};
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseMultipleAsync(manifests);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
Assert.Single(graph.IngressPaths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_MalformedYaml_SkipsDocument()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
this is: [not valid: yaml
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: valid-app
|
||||
namespace: default
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: valid
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: main
|
||||
image: valid:v1
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("mixed.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.EntryTrace.Mesh;
|
||||
using StellaOps.Scanner.EntryTrace.Semantic;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Tests.Mesh;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for MeshEntrypointAnalyzer.
|
||||
/// Part of Sprint 0412 - Task TEST-003.
|
||||
/// </summary>
|
||||
public sealed class MeshEntrypointAnalyzerTests
|
||||
{
|
||||
private readonly MeshEntrypointAnalyzer _analyzer = new();
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_KubernetesManifest_ProducesResult()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: web
|
||||
namespace: default
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: web
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: main
|
||||
image: webapp:v1
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync("deployment.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Graph);
|
||||
Assert.NotNull(result.Metrics);
|
||||
Assert.Empty(result.Errors);
|
||||
Assert.Single(result.Graph.Services);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_DockerCompose_ProducesResult()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
api:
|
||||
image: myapi
|
||||
depends_on:
|
||||
- db
|
||||
db:
|
||||
image: postgres
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(3, result.Graph.Services.Length);
|
||||
Assert.Single(result.Graph.Edges);
|
||||
Assert.Equal(MeshType.DockerCompose, result.Graph.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_UnrecognizedFormat_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var content = "this is just plain text";
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync("unknown.txt", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.Errors);
|
||||
Assert.Equal("MESH001", result.Errors[0].ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeMultipleAsync_MixedFormats_CombinesResults()
|
||||
{
|
||||
// Arrange
|
||||
var manifests = new Dictionary<string, string>
|
||||
{
|
||||
["k8s.yaml"] = """
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: k8s-app
|
||||
namespace: default
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: k8s
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: main
|
||||
image: k8sapp:v1
|
||||
""",
|
||||
["docker-compose.yaml"] = """
|
||||
version: "3.8"
|
||||
services:
|
||||
compose-app:
|
||||
image: composeapp:v1
|
||||
"""
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeMultipleAsync(manifests);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Graph.Services.Length);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_CalculatesSecurityMetrics()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
api:
|
||||
image: myapi
|
||||
depends_on:
|
||||
- web
|
||||
db:
|
||||
image: postgres
|
||||
depends_on:
|
||||
- api
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, result.Metrics.TotalServices);
|
||||
Assert.Equal(2, result.Metrics.TotalEdges);
|
||||
Assert.True(result.Metrics.ExposedServiceCount >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindVulnerablePaths_FindsPathsToTarget()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateTestGraph();
|
||||
|
||||
// Act
|
||||
var paths = _analyzer.FindVulnerablePaths(graph, "db");
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(paths);
|
||||
Assert.All(paths, p => Assert.Equal("db", p.TargetServiceId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindVulnerablePaths_RespectsMaxResults()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateTestGraph();
|
||||
var criteria = new VulnerablePathCriteria { MaxResults = 1 };
|
||||
|
||||
// Act
|
||||
var paths = _analyzer.FindVulnerablePaths(graph, "db", criteria);
|
||||
|
||||
// Assert
|
||||
Assert.True(paths.Length <= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeBlastRadius_CalculatesReach()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateTestGraph();
|
||||
|
||||
// Act
|
||||
var analysis = _analyzer.AnalyzeBlastRadius(graph, "api");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("api", analysis.CompromisedServiceId);
|
||||
Assert.Contains("db", analysis.DirectlyReachableServices);
|
||||
Assert.True(analysis.TotalReach >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeBlastRadius_DetectsIngressExposure()
|
||||
{
|
||||
// Arrange
|
||||
var services = new[]
|
||||
{
|
||||
CreateServiceNode("web"),
|
||||
CreateServiceNode("api"),
|
||||
CreateServiceNode("db")
|
||||
}.ToImmutableArray();
|
||||
|
||||
var edges = new[]
|
||||
{
|
||||
CreateEdge("web", "api"),
|
||||
CreateEdge("api", "db")
|
||||
}.ToImmutableArray();
|
||||
|
||||
var ingress = new[]
|
||||
{
|
||||
new IngressPath
|
||||
{
|
||||
IngressName = "main",
|
||||
Host = "example.com",
|
||||
Path = "/",
|
||||
TargetServiceId = "web",
|
||||
TargetPort = 80
|
||||
}
|
||||
}.ToImmutableArray();
|
||||
|
||||
var graph = new MeshEntrypointGraph
|
||||
{
|
||||
MeshId = "test",
|
||||
Type = MeshType.Kubernetes,
|
||||
Services = services,
|
||||
Edges = edges,
|
||||
IngressPaths = ingress,
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
|
||||
// Act
|
||||
var analysis = _analyzer.AnalyzeBlastRadius(graph, "web");
|
||||
|
||||
// Assert
|
||||
Assert.Single(analysis.IngressExposure);
|
||||
Assert.True(analysis.Severity >= BlastRadiusSeverity.Medium);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeBlastRadius_IsolatedService_HasNoReach()
|
||||
{
|
||||
// Arrange
|
||||
var services = new[]
|
||||
{
|
||||
CreateServiceNode("isolated"),
|
||||
CreateServiceNode("other")
|
||||
}.ToImmutableArray();
|
||||
|
||||
var graph = new MeshEntrypointGraph
|
||||
{
|
||||
MeshId = "test",
|
||||
Type = MeshType.DockerCompose,
|
||||
Services = services,
|
||||
Edges = [],
|
||||
IngressPaths = [],
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
|
||||
// Act
|
||||
var analysis = _analyzer.AnalyzeBlastRadius(graph, "isolated");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, analysis.TotalReach);
|
||||
Assert.Equal(BlastRadiusSeverity.None, analysis.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_WithOptions_AppliesFilters()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: app
|
||||
namespace: production
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: main
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: main
|
||||
image: app:v1
|
||||
""";
|
||||
|
||||
var options = new MeshAnalysisOptions
|
||||
{
|
||||
Namespace = "production",
|
||||
MeshId = "prod-mesh"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync("deploy.yaml", content, options);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("prod-mesh", result.Graph.MeshId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_EmptyManifests_ReturnsEmptyGraph()
|
||||
{
|
||||
// Arrange
|
||||
var manifests = new Dictionary<string, string>();
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeMultipleAsync(manifests);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.Graph.Services);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlastRadiusSeverity_AllValuesDistinct()
|
||||
{
|
||||
// Assert
|
||||
var values = Enum.GetValues<BlastRadiusSeverity>();
|
||||
var distinctCount = values.Distinct().Count();
|
||||
Assert.Equal(values.Length, distinctCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeshSecurityMetrics_CalculatesRatios()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new MeshSecurityMetrics
|
||||
{
|
||||
TotalServices = 10,
|
||||
TotalEdges = 15,
|
||||
ExposedServiceCount = 3,
|
||||
VulnerableServiceCount = 2,
|
||||
ExposureRatio = 0.3,
|
||||
VulnerableRatio = 0.2,
|
||||
OverallRiskScore = 45.0
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0.3, metrics.ExposureRatio);
|
||||
Assert.Equal(0.2, metrics.VulnerableRatio);
|
||||
Assert.Equal(45.0, metrics.OverallRiskScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VulnerablePathCriteria_DefaultValues()
|
||||
{
|
||||
// Arrange
|
||||
var criteria = VulnerablePathCriteria.Default;
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, criteria.MaxDepth);
|
||||
Assert.Equal(10, criteria.MaxResults);
|
||||
Assert.Equal(10, criteria.MinimumScore);
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static MeshEntrypointGraph CreateTestGraph()
|
||||
{
|
||||
var services = new[]
|
||||
{
|
||||
CreateServiceNode("web"),
|
||||
CreateServiceNode("api"),
|
||||
CreateServiceNode("db")
|
||||
}.ToImmutableArray();
|
||||
|
||||
var edges = new[]
|
||||
{
|
||||
CreateEdge("web", "api"),
|
||||
CreateEdge("api", "db")
|
||||
}.ToImmutableArray();
|
||||
|
||||
var ingress = new[]
|
||||
{
|
||||
new IngressPath
|
||||
{
|
||||
IngressName = "main",
|
||||
Host = "example.com",
|
||||
Path = "/",
|
||||
TargetServiceId = "web",
|
||||
TargetPort = 80
|
||||
}
|
||||
}.ToImmutableArray();
|
||||
|
||||
return new MeshEntrypointGraph
|
||||
{
|
||||
MeshId = "test",
|
||||
Type = MeshType.Kubernetes,
|
||||
Services = services,
|
||||
Edges = edges,
|
||||
IngressPaths = ingress,
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
}
|
||||
|
||||
private static ServiceNode CreateServiceNode(string serviceId)
|
||||
{
|
||||
return new ServiceNode
|
||||
{
|
||||
ServiceId = serviceId,
|
||||
ContainerName = serviceId,
|
||||
ImageDigest = $"sha256:{serviceId}",
|
||||
Entrypoints = [],
|
||||
ExposedPorts = [8080]
|
||||
};
|
||||
}
|
||||
|
||||
private static CrossContainerEdge CreateEdge(string from, string to)
|
||||
{
|
||||
return new CrossContainerEdge
|
||||
{
|
||||
EdgeId = $"{from}->{to}",
|
||||
SourceServiceId = from,
|
||||
TargetServiceId = to,
|
||||
TargetPort = 8080
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.EntryTrace.Mesh;
|
||||
using StellaOps.Scanner.EntryTrace.Semantic;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Tests.Mesh;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for MeshEntrypointGraph and related types.
|
||||
/// Part of Sprint 0412 - Task TEST-002.
|
||||
/// </summary>
|
||||
public sealed class MeshEntrypointGraphTests
|
||||
{
|
||||
[Fact]
|
||||
public void MeshEntrypointGraph_Creation_SetsProperties()
|
||||
{
|
||||
// Arrange & Act
|
||||
var graph = new MeshEntrypointGraph
|
||||
{
|
||||
MeshId = "test-mesh",
|
||||
Type = MeshType.Kubernetes,
|
||||
Namespace = "default",
|
||||
Services = CreateServiceNodes(3),
|
||||
Edges = [],
|
||||
IngressPaths = [],
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal("test-mesh", graph.MeshId);
|
||||
Assert.Equal(MeshType.Kubernetes, graph.Type);
|
||||
Assert.Equal("default", graph.Namespace);
|
||||
Assert.Equal(3, graph.Services.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeshEntrypointGraph_FindPathsToService_FindsDirectPath()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServiceNodes(3);
|
||||
var edges = new[]
|
||||
{
|
||||
new CrossContainerEdge
|
||||
{
|
||||
EdgeId = "a->b",
|
||||
SourceServiceId = "svc-0",
|
||||
TargetServiceId = "svc-1",
|
||||
TargetPort = 8080
|
||||
},
|
||||
new CrossContainerEdge
|
||||
{
|
||||
EdgeId = "b->c",
|
||||
SourceServiceId = "svc-1",
|
||||
TargetServiceId = "svc-2",
|
||||
TargetPort = 8080
|
||||
}
|
||||
}.ToImmutableArray();
|
||||
|
||||
var ingressPaths = new[]
|
||||
{
|
||||
new IngressPath
|
||||
{
|
||||
IngressName = "main-ingress",
|
||||
Host = "example.com",
|
||||
Path = "/",
|
||||
TargetServiceId = "svc-0",
|
||||
TargetPort = 8080
|
||||
}
|
||||
}.ToImmutableArray();
|
||||
|
||||
var graph = new MeshEntrypointGraph
|
||||
{
|
||||
MeshId = "test",
|
||||
Type = MeshType.Kubernetes,
|
||||
Services = services,
|
||||
Edges = edges,
|
||||
IngressPaths = ingressPaths,
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
|
||||
// Act
|
||||
var paths = graph.FindPathsToService("svc-2", maxDepth: 5);
|
||||
|
||||
// Assert
|
||||
Assert.Single(paths);
|
||||
Assert.Equal(2, paths[0].Hops.Length);
|
||||
Assert.True(paths[0].IsExternallyExposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeshEntrypointGraph_FindPathsToService_RespectsMaxDepth()
|
||||
{
|
||||
// Arrange - Long chain of services
|
||||
var services = CreateServiceNodes(10);
|
||||
var edges = new List<CrossContainerEdge>();
|
||||
for (var i = 0; i < 9; i++)
|
||||
{
|
||||
edges.Add(new CrossContainerEdge
|
||||
{
|
||||
EdgeId = $"svc-{i}->svc-{i + 1}",
|
||||
SourceServiceId = $"svc-{i}",
|
||||
TargetServiceId = $"svc-{i + 1}",
|
||||
TargetPort = 8080
|
||||
});
|
||||
}
|
||||
|
||||
var graph = new MeshEntrypointGraph
|
||||
{
|
||||
MeshId = "test",
|
||||
Type = MeshType.Kubernetes,
|
||||
Services = services,
|
||||
Edges = edges.ToImmutableArray(),
|
||||
IngressPaths = [],
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
|
||||
// Act - Limit depth to 3
|
||||
var paths = graph.FindPathsToService("svc-9", maxDepth: 3);
|
||||
|
||||
// Assert - Should not find path since it requires 9 hops
|
||||
Assert.Empty(paths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeshEntrypointGraph_FindPathsToService_NoPathExists()
|
||||
{
|
||||
// Arrange - Disconnected services
|
||||
var services = CreateServiceNodes(2);
|
||||
var graph = new MeshEntrypointGraph
|
||||
{
|
||||
MeshId = "test",
|
||||
Type = MeshType.Kubernetes,
|
||||
Services = services,
|
||||
Edges = [],
|
||||
IngressPaths = [],
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
|
||||
// Act
|
||||
var paths = graph.FindPathsToService("svc-1", maxDepth: 5);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(paths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServiceNode_Creation_SetsProperties()
|
||||
{
|
||||
// Arrange & Act
|
||||
var node = new ServiceNode
|
||||
{
|
||||
ServiceId = "my-service",
|
||||
ContainerName = "app",
|
||||
ImageDigest = "sha256:abc123",
|
||||
ImageReference = "myapp:v1.0.0",
|
||||
Entrypoints = [],
|
||||
ExposedPorts = [8080, 8443],
|
||||
InternalDns = ["my-service.default.svc.cluster.local"],
|
||||
Labels = new Dictionary<string, string> { ["app"] = "my-app" }.ToImmutableDictionary(),
|
||||
Replicas = 3
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal("my-service", node.ServiceId);
|
||||
Assert.Equal("app", node.ContainerName);
|
||||
Assert.Equal(2, node.ExposedPorts.Length);
|
||||
Assert.Equal(3, node.Replicas);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CrossContainerEdge_Creation_SetsProperties()
|
||||
{
|
||||
// Arrange & Act
|
||||
var edge = new CrossContainerEdge
|
||||
{
|
||||
EdgeId = "frontend->backend",
|
||||
SourceServiceId = "frontend",
|
||||
TargetServiceId = "backend",
|
||||
SourcePort = 0,
|
||||
TargetPort = 8080,
|
||||
Protocol = "http",
|
||||
IsExplicit = true
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal("frontend->backend", edge.EdgeId);
|
||||
Assert.Equal("frontend", edge.SourceServiceId);
|
||||
Assert.Equal("backend", edge.TargetServiceId);
|
||||
Assert.Equal(8080, edge.TargetPort);
|
||||
Assert.True(edge.IsExplicit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CrossContainerPath_TracksHops()
|
||||
{
|
||||
// Arrange
|
||||
var hops = new[]
|
||||
{
|
||||
new CrossContainerEdge
|
||||
{
|
||||
EdgeId = "a->b",
|
||||
SourceServiceId = "a",
|
||||
TargetServiceId = "b",
|
||||
TargetPort = 8080
|
||||
},
|
||||
new CrossContainerEdge
|
||||
{
|
||||
EdgeId = "b->c",
|
||||
SourceServiceId = "b",
|
||||
TargetServiceId = "c",
|
||||
TargetPort = 9090
|
||||
}
|
||||
}.ToImmutableArray();
|
||||
|
||||
// Act
|
||||
var path = new CrossContainerPath
|
||||
{
|
||||
PathId = "path-1",
|
||||
SourceServiceId = "a",
|
||||
TargetServiceId = "c",
|
||||
Hops = hops,
|
||||
IsExternallyExposed = true,
|
||||
VulnerableComponents = ["pkg:npm/lodash@4.17.20"],
|
||||
TotalLatencyEstimateMs = 10
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, path.Hops.Length);
|
||||
Assert.True(path.IsExternallyExposed);
|
||||
Assert.Single(path.VulnerableComponents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IngressPath_TracksExternalExposure()
|
||||
{
|
||||
// Arrange & Act
|
||||
var ingress = new IngressPath
|
||||
{
|
||||
IngressName = "main-ingress",
|
||||
Host = "api.example.com",
|
||||
Path = "/v1",
|
||||
TargetServiceId = "api-gateway",
|
||||
TargetPort = 8080,
|
||||
TlsEnabled = true,
|
||||
TlsSecretName = "api-tls-secret",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["nginx.ingress.kubernetes.io/rewrite-target"] = "/"
|
||||
}.ToImmutableDictionary()
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal("main-ingress", ingress.IngressName);
|
||||
Assert.Equal("api.example.com", ingress.Host);
|
||||
Assert.True(ingress.TlsEnabled);
|
||||
Assert.NotNull(ingress.TlsSecretName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeshEntrypointGraphBuilder_BuildsGraph()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new MeshEntrypointGraphBuilder("test-mesh", MeshType.DockerCompose);
|
||||
|
||||
// Act
|
||||
var graph = builder
|
||||
.WithNamespace("my-project")
|
||||
.WithService(new ServiceNode
|
||||
{
|
||||
ServiceId = "web",
|
||||
ContainerName = "web",
|
||||
ImageDigest = "sha256:abc",
|
||||
Entrypoints = [],
|
||||
ExposedPorts = [80]
|
||||
})
|
||||
.WithService(new ServiceNode
|
||||
{
|
||||
ServiceId = "db",
|
||||
ContainerName = "db",
|
||||
ImageDigest = "sha256:def",
|
||||
Entrypoints = [],
|
||||
ExposedPorts = [5432]
|
||||
})
|
||||
.WithEdge(new CrossContainerEdge
|
||||
{
|
||||
EdgeId = "web->db",
|
||||
SourceServiceId = "web",
|
||||
TargetServiceId = "db",
|
||||
TargetPort = 5432
|
||||
})
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("test-mesh", graph.MeshId);
|
||||
Assert.Equal(MeshType.DockerCompose, graph.Type);
|
||||
Assert.Equal(2, graph.Services.Length);
|
||||
Assert.Single(graph.Edges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeshType_AllValuesAreDistinct()
|
||||
{
|
||||
// Assert
|
||||
var values = Enum.GetValues<MeshType>();
|
||||
var distinctCount = values.Distinct().Count();
|
||||
Assert.Equal(values.Length, distinctCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeshEntrypointGraph_MultiplePaths_FindsAll()
|
||||
{
|
||||
// Arrange - Diamond pattern: A -> B -> D, A -> C -> D
|
||||
var services = new[]
|
||||
{
|
||||
CreateServiceNode("A"),
|
||||
CreateServiceNode("B"),
|
||||
CreateServiceNode("C"),
|
||||
CreateServiceNode("D")
|
||||
}.ToImmutableArray();
|
||||
|
||||
var edges = new[]
|
||||
{
|
||||
CreateEdge("A", "B"),
|
||||
CreateEdge("A", "C"),
|
||||
CreateEdge("B", "D"),
|
||||
CreateEdge("C", "D")
|
||||
}.ToImmutableArray();
|
||||
|
||||
var ingress = new[]
|
||||
{
|
||||
new IngressPath
|
||||
{
|
||||
IngressName = "main",
|
||||
Host = "test.com",
|
||||
Path = "/",
|
||||
TargetServiceId = "A",
|
||||
TargetPort = 80
|
||||
}
|
||||
}.ToImmutableArray();
|
||||
|
||||
var graph = new MeshEntrypointGraph
|
||||
{
|
||||
MeshId = "diamond",
|
||||
Type = MeshType.Kubernetes,
|
||||
Services = services,
|
||||
Edges = edges,
|
||||
IngressPaths = ingress,
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
|
||||
// Act
|
||||
var paths = graph.FindPathsToService("D", maxDepth: 5);
|
||||
|
||||
// Assert - Should find both paths: A->B->D and A->C->D
|
||||
Assert.Equal(2, paths.Length);
|
||||
Assert.All(paths, p => Assert.True(p.IsExternallyExposed));
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static ImmutableArray<ServiceNode> CreateServiceNodes(int count)
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<ServiceNode>(count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
builder.Add(CreateServiceNode($"svc-{i}"));
|
||||
}
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static ServiceNode CreateServiceNode(string serviceId)
|
||||
{
|
||||
return new ServiceNode
|
||||
{
|
||||
ServiceId = serviceId,
|
||||
ContainerName = serviceId,
|
||||
ImageDigest = $"sha256:{serviceId}",
|
||||
ImageReference = $"{serviceId}:latest",
|
||||
Entrypoints = [],
|
||||
ExposedPorts = [8080]
|
||||
};
|
||||
}
|
||||
|
||||
private static CrossContainerEdge CreateEdge(string from, string to)
|
||||
{
|
||||
return new CrossContainerEdge
|
||||
{
|
||||
EdgeId = $"{from}->{to}",
|
||||
SourceServiceId = from,
|
||||
TargetServiceId = to,
|
||||
TargetPort = 8080
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.EntryTrace.Semantic;
|
||||
using StellaOps.Scanner.EntryTrace.Temporal;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Tests.Temporal;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for InMemoryTemporalEntrypointStore.
|
||||
/// Part of Sprint 0412 - Task TEST-001.
|
||||
/// </summary>
|
||||
public sealed class InMemoryTemporalEntrypointStoreTests
|
||||
{
|
||||
private readonly InMemoryTemporalEntrypointStore _store = new();
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSnapshotAsync_StoresAndReturnsGraph()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = CreateSnapshot("v1.0.0", "sha256:abc123", 2);
|
||||
|
||||
// Act
|
||||
var graph = await _store.StoreSnapshotAsync("my-service", snapshot);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(graph);
|
||||
Assert.Equal("my-service", graph.ServiceId);
|
||||
Assert.Single(graph.Snapshots);
|
||||
Assert.Equal("v1.0.0", graph.CurrentVersion);
|
||||
Assert.Null(graph.PreviousVersion);
|
||||
Assert.Null(graph.Delta);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSnapshotAsync_MultipleVersions_CreatesDelta()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot1 = CreateSnapshot("v1.0.0", "sha256:abc", 2);
|
||||
var snapshot2 = CreateSnapshot("v2.0.0", "sha256:def", 3);
|
||||
|
||||
// Act
|
||||
await _store.StoreSnapshotAsync("my-service", snapshot1);
|
||||
var graph = await _store.StoreSnapshotAsync("my-service", snapshot2);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(graph);
|
||||
Assert.Equal(2, graph.Snapshots.Length);
|
||||
Assert.Equal("v2.0.0", graph.CurrentVersion);
|
||||
Assert.Equal("v1.0.0", graph.PreviousVersion);
|
||||
Assert.NotNull(graph.Delta);
|
||||
Assert.Equal("v1.0.0", graph.Delta.FromVersion);
|
||||
Assert.Equal("v2.0.0", graph.Delta.ToVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetGraphAsync_ReturnsStoredGraph()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = CreateSnapshot("v1.0.0", "sha256:abc", 2);
|
||||
await _store.StoreSnapshotAsync("my-service", snapshot);
|
||||
|
||||
// Act
|
||||
var graph = await _store.GetGraphAsync("my-service");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(graph);
|
||||
Assert.Equal("my-service", graph.ServiceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetGraphAsync_NonExistentService_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var graph = await _store.GetGraphAsync("non-existent");
|
||||
|
||||
// Assert
|
||||
Assert.Null(graph);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeDeltaAsync_CalculatesDifferences()
|
||||
{
|
||||
// Arrange
|
||||
var oldEntrypoints = CreateEntrypoints(2);
|
||||
var newEntrypoints = CreateEntrypoints(3);
|
||||
|
||||
var oldSnapshot = new EntrypointSnapshot
|
||||
{
|
||||
Version = "v1.0.0",
|
||||
ImageDigest = "sha256:old",
|
||||
AnalyzedAt = DateTime.UtcNow.AddDays(-1).ToString("O"),
|
||||
Entrypoints = oldEntrypoints,
|
||||
ContentHash = EntrypointSnapshot.ComputeHash(oldEntrypoints)
|
||||
};
|
||||
|
||||
var newSnapshot = new EntrypointSnapshot
|
||||
{
|
||||
Version = "v2.0.0",
|
||||
ImageDigest = "sha256:new",
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O"),
|
||||
Entrypoints = newEntrypoints,
|
||||
ContentHash = EntrypointSnapshot.ComputeHash(newEntrypoints)
|
||||
};
|
||||
|
||||
// Act
|
||||
var delta = await _store.ComputeDeltaAsync(oldSnapshot, newSnapshot);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(delta);
|
||||
Assert.Equal("v1.0.0", delta.FromVersion);
|
||||
Assert.Equal("v2.0.0", delta.ToVersion);
|
||||
// Since we use different entrypoint IDs, all new ones are "added" and old ones "removed"
|
||||
Assert.True(delta.AddedEntrypoints.Length > 0 || delta.RemovedEntrypoints.Length > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeDeltaAsync_SameContent_ReturnsNoDrift()
|
||||
{
|
||||
// Arrange
|
||||
var entrypoints = CreateEntrypoints(2);
|
||||
|
||||
var snapshot1 = new EntrypointSnapshot
|
||||
{
|
||||
Version = "v1.0.0",
|
||||
ImageDigest = "sha256:same",
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O"),
|
||||
Entrypoints = entrypoints,
|
||||
ContentHash = EntrypointSnapshot.ComputeHash(entrypoints)
|
||||
};
|
||||
|
||||
var snapshot2 = new EntrypointSnapshot
|
||||
{
|
||||
Version = "v1.0.1",
|
||||
ImageDigest = "sha256:same2",
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O"),
|
||||
Entrypoints = entrypoints,
|
||||
ContentHash = EntrypointSnapshot.ComputeHash(entrypoints)
|
||||
};
|
||||
|
||||
// Act
|
||||
var delta = await _store.ComputeDeltaAsync(snapshot1, snapshot2);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(delta);
|
||||
Assert.Empty(delta.AddedEntrypoints);
|
||||
Assert.Empty(delta.RemovedEntrypoints);
|
||||
Assert.Empty(delta.ModifiedEntrypoints);
|
||||
Assert.Equal(EntrypointDrift.None, delta.DriftCategories);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PruneSnapshotsAsync_RemovesOldSnapshots()
|
||||
{
|
||||
// Arrange
|
||||
for (var i = 0; i < 15; i++)
|
||||
{
|
||||
var snapshot = CreateSnapshot($"v{i}.0.0", $"sha256:hash{i}", 2);
|
||||
await _store.StoreSnapshotAsync("my-service", snapshot);
|
||||
}
|
||||
|
||||
// Act - Keep only last 5
|
||||
var prunedCount = await _store.PruneSnapshotsAsync("my-service", keepCount: 5);
|
||||
var graph = await _store.GetGraphAsync("my-service");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(10, prunedCount);
|
||||
Assert.NotNull(graph);
|
||||
Assert.Equal(5, graph.Snapshots.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PruneSnapshotsAsync_NonExistentService_ReturnsZero()
|
||||
{
|
||||
// Act
|
||||
var prunedCount = await _store.PruneSnapshotsAsync("non-existent", keepCount: 5);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, prunedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSnapshotAsync_DetectsIntentChange()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot1 = new EntrypointSnapshot
|
||||
{
|
||||
Version = "v1.0.0",
|
||||
ImageDigest = "sha256:old",
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O"),
|
||||
Entrypoints =
|
||||
[
|
||||
new SemanticEntrypoint
|
||||
{
|
||||
EntrypointId = "ep-1",
|
||||
FilePath = "/app/main.py",
|
||||
FunctionName = "handle",
|
||||
Intent = ApplicationIntent.ApiEndpoint,
|
||||
Capabilities = [CapabilityClass.NetworkListener],
|
||||
ThreatVectors = [],
|
||||
Confidence = new SemanticConfidence { Overall = 0.9 }
|
||||
}
|
||||
],
|
||||
ContentHash = "hash1"
|
||||
};
|
||||
|
||||
var snapshot2 = new EntrypointSnapshot
|
||||
{
|
||||
Version = "v2.0.0",
|
||||
ImageDigest = "sha256:new",
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O"),
|
||||
Entrypoints =
|
||||
[
|
||||
new SemanticEntrypoint
|
||||
{
|
||||
EntrypointId = "ep-1",
|
||||
FilePath = "/app/main.py",
|
||||
FunctionName = "handle",
|
||||
Intent = ApplicationIntent.Worker, // Changed!
|
||||
Capabilities = [CapabilityClass.NetworkListener],
|
||||
ThreatVectors = [],
|
||||
Confidence = new SemanticConfidence { Overall = 0.9 }
|
||||
}
|
||||
],
|
||||
ContentHash = "hash2"
|
||||
};
|
||||
|
||||
// Act
|
||||
await _store.StoreSnapshotAsync("svc", snapshot1);
|
||||
var graph = await _store.StoreSnapshotAsync("svc", snapshot2);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(graph.Delta);
|
||||
Assert.True(graph.Delta.DriftCategories.HasFlag(EntrypointDrift.IntentChanged));
|
||||
Assert.Single(graph.Delta.ModifiedEntrypoints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSnapshotAsync_DetectsCapabilitiesExpanded()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot1 = new EntrypointSnapshot
|
||||
{
|
||||
Version = "v1.0.0",
|
||||
ImageDigest = "sha256:old",
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O"),
|
||||
Entrypoints =
|
||||
[
|
||||
new SemanticEntrypoint
|
||||
{
|
||||
EntrypointId = "ep-1",
|
||||
FilePath = "/app/main.py",
|
||||
FunctionName = "handle",
|
||||
Intent = ApplicationIntent.ApiEndpoint,
|
||||
Capabilities = [CapabilityClass.NetworkListener],
|
||||
ThreatVectors = [],
|
||||
Confidence = new SemanticConfidence { Overall = 0.9 }
|
||||
}
|
||||
],
|
||||
ContentHash = "hash1"
|
||||
};
|
||||
|
||||
var snapshot2 = new EntrypointSnapshot
|
||||
{
|
||||
Version = "v2.0.0",
|
||||
ImageDigest = "sha256:new",
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O"),
|
||||
Entrypoints =
|
||||
[
|
||||
new SemanticEntrypoint
|
||||
{
|
||||
EntrypointId = "ep-1",
|
||||
FilePath = "/app/main.py",
|
||||
FunctionName = "handle",
|
||||
Intent = ApplicationIntent.ApiEndpoint,
|
||||
Capabilities = [CapabilityClass.NetworkListener, CapabilityClass.FileSystemAccess], // Added!
|
||||
ThreatVectors = [],
|
||||
Confidence = new SemanticConfidence { Overall = 0.9 }
|
||||
}
|
||||
],
|
||||
ContentHash = "hash2"
|
||||
};
|
||||
|
||||
// Act
|
||||
await _store.StoreSnapshotAsync("svc", snapshot1);
|
||||
var graph = await _store.StoreSnapshotAsync("svc", snapshot2);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(graph.Delta);
|
||||
Assert.True(graph.Delta.DriftCategories.HasFlag(EntrypointDrift.CapabilitiesExpanded));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSnapshotAsync_DetectsAttackSurfaceGrew()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot1 = new EntrypointSnapshot
|
||||
{
|
||||
Version = "v1.0.0",
|
||||
ImageDigest = "sha256:old",
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O"),
|
||||
Entrypoints =
|
||||
[
|
||||
new SemanticEntrypoint
|
||||
{
|
||||
EntrypointId = "ep-1",
|
||||
FilePath = "/app/main.py",
|
||||
FunctionName = "handle",
|
||||
Intent = ApplicationIntent.ApiEndpoint,
|
||||
Capabilities = [CapabilityClass.NetworkListener],
|
||||
ThreatVectors = [ThreatVector.NetworkExposure],
|
||||
Confidence = new SemanticConfidence { Overall = 0.9 }
|
||||
}
|
||||
],
|
||||
ContentHash = "hash1"
|
||||
};
|
||||
|
||||
var snapshot2 = new EntrypointSnapshot
|
||||
{
|
||||
Version = "v2.0.0",
|
||||
ImageDigest = "sha256:new",
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O"),
|
||||
Entrypoints =
|
||||
[
|
||||
new SemanticEntrypoint
|
||||
{
|
||||
EntrypointId = "ep-1",
|
||||
FilePath = "/app/main.py",
|
||||
FunctionName = "handle",
|
||||
Intent = ApplicationIntent.ApiEndpoint,
|
||||
Capabilities = [CapabilityClass.NetworkListener],
|
||||
ThreatVectors = [ThreatVector.NetworkExposure, ThreatVector.FilePathTraversal], // Added!
|
||||
Confidence = new SemanticConfidence { Overall = 0.9 }
|
||||
}
|
||||
],
|
||||
ContentHash = "hash2"
|
||||
};
|
||||
|
||||
// Act
|
||||
await _store.StoreSnapshotAsync("svc", snapshot1);
|
||||
var graph = await _store.StoreSnapshotAsync("svc", snapshot2);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(graph.Delta);
|
||||
Assert.True(graph.Delta.DriftCategories.HasFlag(EntrypointDrift.AttackSurfaceGrew));
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static EntrypointSnapshot CreateSnapshot(string version, string digest, int entrypointCount)
|
||||
{
|
||||
var entrypoints = CreateEntrypoints(entrypointCount);
|
||||
return new EntrypointSnapshot
|
||||
{
|
||||
Version = version,
|
||||
ImageDigest = digest,
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O"),
|
||||
Entrypoints = entrypoints,
|
||||
ContentHash = EntrypointSnapshot.ComputeHash(entrypoints)
|
||||
};
|
||||
}
|
||||
|
||||
private static ImmutableArray<SemanticEntrypoint> CreateEntrypoints(int count)
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<SemanticEntrypoint>(count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
builder.Add(new SemanticEntrypoint
|
||||
{
|
||||
EntrypointId = $"ep-{Guid.NewGuid():N}",
|
||||
FilePath = $"/app/handler{i}.py",
|
||||
FunctionName = $"handle_{i}",
|
||||
Intent = ApplicationIntent.ApiEndpoint,
|
||||
Capabilities = [CapabilityClass.NetworkListener],
|
||||
ThreatVectors = [ThreatVector.NetworkExposure],
|
||||
Confidence = new SemanticConfidence
|
||||
{
|
||||
Overall = 0.9,
|
||||
IntentConfidence = 0.95,
|
||||
CapabilityConfidence = 0.85
|
||||
}
|
||||
});
|
||||
}
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.EntryTrace.Semantic;
|
||||
using StellaOps.Scanner.EntryTrace.Temporal;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Tests.Temporal;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for TemporalEntrypointGraph and related types.
|
||||
/// Part of Sprint 0412 - Task TEST-001.
|
||||
/// </summary>
|
||||
public sealed class TemporalEntrypointGraphTests
|
||||
{
|
||||
[Fact]
|
||||
public void TemporalEntrypointGraph_Creation_SetsProperties()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot1 = CreateSnapshot("v1.0.0", "sha256:abc123", 2);
|
||||
var snapshot2 = CreateSnapshot("v1.1.0", "sha256:def456", 3);
|
||||
|
||||
// Act
|
||||
var graph = new TemporalEntrypointGraph
|
||||
{
|
||||
ServiceId = "my-service",
|
||||
Snapshots = [snapshot1, snapshot2],
|
||||
CurrentVersion = "v1.1.0",
|
||||
PreviousVersion = "v1.0.0"
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal("my-service", graph.ServiceId);
|
||||
Assert.Equal(2, graph.Snapshots.Length);
|
||||
Assert.Equal("v1.1.0", graph.CurrentVersion);
|
||||
Assert.Equal("v1.0.0", graph.PreviousVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntrypointSnapshot_ContentHash_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var entrypoints = CreateEntrypoints(3);
|
||||
|
||||
// Act
|
||||
var snapshot1 = new EntrypointSnapshot
|
||||
{
|
||||
Version = "v1.0.0",
|
||||
ImageDigest = "sha256:abc123",
|
||||
AnalyzedAt = "2025-01-01T00:00:00Z",
|
||||
Entrypoints = entrypoints,
|
||||
ContentHash = EntrypointSnapshot.ComputeHash(entrypoints)
|
||||
};
|
||||
|
||||
var snapshot2 = new EntrypointSnapshot
|
||||
{
|
||||
Version = "v1.0.0",
|
||||
ImageDigest = "sha256:abc123",
|
||||
AnalyzedAt = "2025-01-01T12:00:00Z", // Different time
|
||||
Entrypoints = entrypoints,
|
||||
ContentHash = EntrypointSnapshot.ComputeHash(entrypoints)
|
||||
};
|
||||
|
||||
// Assert - Same content should produce same hash
|
||||
Assert.Equal(snapshot1.ContentHash, snapshot2.ContentHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntrypointSnapshot_ContentHash_DiffersForDifferentContent()
|
||||
{
|
||||
// Arrange
|
||||
var entrypoints1 = CreateEntrypoints(2);
|
||||
var entrypoints2 = CreateEntrypoints(3);
|
||||
|
||||
// Act
|
||||
var hash1 = EntrypointSnapshot.ComputeHash(entrypoints1);
|
||||
var hash2 = EntrypointSnapshot.ComputeHash(entrypoints2);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(hash1, hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntrypointDelta_TracksChanges()
|
||||
{
|
||||
// Arrange
|
||||
var added = CreateEntrypoints(1);
|
||||
var removed = CreateEntrypoints(1);
|
||||
var modified = new EntrypointModification
|
||||
{
|
||||
EntrypointId = "ep-1",
|
||||
OldIntent = ApplicationIntent.ApiEndpoint,
|
||||
NewIntent = ApplicationIntent.Worker,
|
||||
OldCapabilities = ImmutableArray<CapabilityClass>.Empty,
|
||||
NewCapabilities = [CapabilityClass.NetworkListener],
|
||||
Drift = EntrypointDrift.IntentChanged
|
||||
};
|
||||
|
||||
// Act
|
||||
var delta = new EntrypointDelta
|
||||
{
|
||||
FromVersion = "v1.0.0",
|
||||
ToVersion = "v2.0.0",
|
||||
FromDigest = "sha256:old",
|
||||
ToDigest = "sha256:new",
|
||||
AddedEntrypoints = added,
|
||||
RemovedEntrypoints = removed,
|
||||
ModifiedEntrypoints = [modified],
|
||||
DriftCategories = EntrypointDrift.IntentChanged
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, delta.AddedEntrypoints.Length);
|
||||
Assert.Equal(1, delta.RemovedEntrypoints.Length);
|
||||
Assert.Equal(1, delta.ModifiedEntrypoints.Length);
|
||||
Assert.True(delta.DriftCategories.HasFlag(EntrypointDrift.IntentChanged));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TemporalEntrypointGraphBuilder_BuildsGraph()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new TemporalEntrypointGraphBuilder("test-service");
|
||||
|
||||
var snapshot1 = CreateSnapshot("v1.0.0", "sha256:abc", 2);
|
||||
var snapshot2 = CreateSnapshot("v2.0.0", "sha256:def", 3);
|
||||
|
||||
// Act
|
||||
var graph = builder
|
||||
.WithSnapshot(snapshot1)
|
||||
.WithSnapshot(snapshot2)
|
||||
.WithCurrentVersion("v2.0.0")
|
||||
.WithPreviousVersion("v1.0.0")
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("test-service", graph.ServiceId);
|
||||
Assert.Equal(2, graph.Snapshots.Length);
|
||||
Assert.Equal("v2.0.0", graph.CurrentVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntrypointDrift_IsRiskIncrease_DetectsRiskyChanges()
|
||||
{
|
||||
// Arrange
|
||||
var riskIncrease = EntrypointDrift.AttackSurfaceGrew |
|
||||
EntrypointDrift.PrivilegeEscalation;
|
||||
|
||||
var riskDecrease = EntrypointDrift.AttackSurfaceShrank |
|
||||
EntrypointDrift.CapabilitiesReduced;
|
||||
|
||||
// Act & Assert
|
||||
Assert.True(riskIncrease.IsRiskIncrease());
|
||||
Assert.False(riskDecrease.IsRiskIncrease());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntrypointDrift_IsMaterialChange_DetectsMaterialChanges()
|
||||
{
|
||||
// Arrange
|
||||
var material = EntrypointDrift.IntentChanged;
|
||||
var nonMaterial = EntrypointDrift.None;
|
||||
|
||||
// Act & Assert
|
||||
Assert.True(material.IsMaterialChange());
|
||||
Assert.False(nonMaterial.IsMaterialChange());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntrypointDrift_ToDescription_FormatsCategories()
|
||||
{
|
||||
// Arrange
|
||||
var drift = EntrypointDrift.IntentChanged | EntrypointDrift.PortsAdded;
|
||||
|
||||
// Act
|
||||
var description = drift.ToDescription();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("IntentChanged", description);
|
||||
Assert.Contains("PortsAdded", description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntrypointDrift_AllRiskFlags_AreConsistent()
|
||||
{
|
||||
// Arrange
|
||||
var allRisks = EntrypointDrift.AttackSurfaceGrew |
|
||||
EntrypointDrift.CapabilitiesExpanded |
|
||||
EntrypointDrift.PrivilegeEscalation |
|
||||
EntrypointDrift.PortsAdded |
|
||||
EntrypointDrift.SecurityContextWeakened |
|
||||
EntrypointDrift.NewVulnerableComponent |
|
||||
EntrypointDrift.ExposedToIngress;
|
||||
|
||||
// Act
|
||||
var isRisk = allRisks.IsRiskIncrease();
|
||||
|
||||
// Assert
|
||||
Assert.True(isRisk);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntrypointSnapshot_EmptyEntrypoints_ProducesValidHash()
|
||||
{
|
||||
// Arrange
|
||||
var emptyEntrypoints = ImmutableArray<SemanticEntrypoint>.Empty;
|
||||
|
||||
// Act
|
||||
var hash = EntrypointSnapshot.ComputeHash(emptyEntrypoints);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(hash);
|
||||
Assert.NotEmpty(hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TemporalEntrypointGraph_WithDelta_TracksVersionDiff()
|
||||
{
|
||||
// Arrange
|
||||
var oldEntrypoints = CreateEntrypoints(2);
|
||||
var newEntrypoints = CreateEntrypoints(3);
|
||||
|
||||
var delta = new EntrypointDelta
|
||||
{
|
||||
FromVersion = "v1",
|
||||
ToVersion = "v2",
|
||||
FromDigest = "sha256:old",
|
||||
ToDigest = "sha256:new",
|
||||
AddedEntrypoints = newEntrypoints.Skip(2).ToImmutableArray(),
|
||||
RemovedEntrypoints = ImmutableArray<SemanticEntrypoint>.Empty,
|
||||
ModifiedEntrypoints = ImmutableArray<EntrypointModification>.Empty,
|
||||
DriftCategories = EntrypointDrift.AttackSurfaceGrew
|
||||
};
|
||||
|
||||
// Act
|
||||
var graph = new TemporalEntrypointGraph
|
||||
{
|
||||
ServiceId = "svc",
|
||||
Snapshots = [],
|
||||
CurrentVersion = "v2",
|
||||
PreviousVersion = "v1",
|
||||
Delta = delta
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(graph.Delta);
|
||||
Assert.Equal("v1", graph.Delta.FromVersion);
|
||||
Assert.Equal("v2", graph.Delta.ToVersion);
|
||||
Assert.True(graph.Delta.DriftCategories.HasFlag(EntrypointDrift.AttackSurfaceGrew));
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static EntrypointSnapshot CreateSnapshot(string version, string digest, int entrypointCount)
|
||||
{
|
||||
var entrypoints = CreateEntrypoints(entrypointCount);
|
||||
return new EntrypointSnapshot
|
||||
{
|
||||
Version = version,
|
||||
ImageDigest = digest,
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O"),
|
||||
Entrypoints = entrypoints,
|
||||
ContentHash = EntrypointSnapshot.ComputeHash(entrypoints)
|
||||
};
|
||||
}
|
||||
|
||||
private static ImmutableArray<SemanticEntrypoint> CreateEntrypoints(int count)
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<SemanticEntrypoint>(count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
builder.Add(new SemanticEntrypoint
|
||||
{
|
||||
EntrypointId = $"ep-{i}",
|
||||
FilePath = $"/app/handler{i}.py",
|
||||
FunctionName = $"handle_{i}",
|
||||
Intent = ApplicationIntent.ApiEndpoint,
|
||||
Capabilities = [CapabilityClass.NetworkListener],
|
||||
ThreatVectors = [ThreatVector.NetworkExposure],
|
||||
Confidence = new SemanticConfidence
|
||||
{
|
||||
Overall = 0.9,
|
||||
IntentConfidence = 0.95,
|
||||
CapabilityConfidence = 0.85
|
||||
}
|
||||
});
|
||||
}
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -228,6 +228,29 @@ public sealed class FileSurfaceManifestStoreTests : IAsyncDisposable
|
||||
var hash = await ComputeHashAsync(stream, algorithmId, cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
// Purpose-based methods (delegate to algorithm-based methods for test purposes)
|
||||
public byte[] ComputeHashForPurpose(ReadOnlySpan<byte> data, string purpose)
|
||||
=> ComputeHash(data);
|
||||
|
||||
public string ComputeHashHexForPurpose(ReadOnlySpan<byte> data, string purpose)
|
||||
=> ComputeHashHex(data);
|
||||
|
||||
public string ComputeHashBase64ForPurpose(ReadOnlySpan<byte> data, string purpose)
|
||||
=> ComputeHashBase64(data);
|
||||
|
||||
public ValueTask<byte[]> ComputeHashForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
|
||||
=> ComputeHashAsync(stream, null, cancellationToken);
|
||||
|
||||
public ValueTask<string> ComputeHashHexForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
|
||||
=> ComputeHashHexAsync(stream, null, cancellationToken);
|
||||
|
||||
public string GetAlgorithmForPurpose(string purpose) => "SHA-256";
|
||||
|
||||
public string GetHashPrefix(string purpose) => "sha256:";
|
||||
|
||||
public string ComputePrefixedHashForPurpose(ReadOnlySpan<byte> data, string purpose)
|
||||
=> $"{GetHashPrefix(purpose)}{ComputeHashHex(data)}";
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
|
||||
Reference in New Issue
Block a user