work work ... haaaard work

This commit is contained in:
StellaOps Bot
2025-11-24 00:34:20 +02:00
parent 0d4a986b7b
commit bb709b643e
36 changed files with 933 additions and 197 deletions

View File

@@ -0,0 +1,61 @@
using System.Net;
using System.Net.Http.Json;
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Tests;
public class EntrypointEndpointsTests : IClassFixture<SbomServiceWebApplicationFactory>
{
private readonly SbomServiceWebApplicationFactory _factory;
public EntrypointEndpointsTests(SbomServiceWebApplicationFactory factory)
{
_factory = factory;
}
[Fact]
public async Task Get_entrypoints_requires_tenant()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/entrypoints");
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task Get_entrypoints_returns_seeded_list()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/entrypoints?tenant=tenant-a");
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<EntrypointListResponse>();
payload.Should().NotBeNull();
payload!.Tenant.Should().Be("tenant-a");
payload.Items.Should().NotBeEmpty();
payload.Items.Select(e => e.Artifact).Should().Contain("ghcr.io/stellaops/sample-api");
}
[Fact]
public async Task Post_entrypoints_upserts_and_returns_ordered_list()
{
var client = _factory.CreateClient();
var upsert = new EntrypointUpsertRequest(
Tenant: "tenant-a",
Artifact: "ghcr.io/stellaops/sample-api",
Service: "web",
Path: "/api/v2",
Scope: "runtime",
RuntimeFlag: true);
var post = await client.PostAsJsonAsync("/entrypoints", upsert);
post.EnsureSuccessStatusCode();
var payload = await post.Content.ReadFromJsonAsync<EntrypointListResponse>();
payload.Should().NotBeNull();
payload!.Items.First(e => e.Service == "web").Path.Should().Be("/api/v2");
payload.Items.Should().BeInAscendingOrder(e => e.Artifact);
}
}

View File

@@ -3,6 +3,7 @@ using System.Net.Http.Json;
using System.Reflection;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

View File

@@ -23,7 +23,8 @@ public class SbomEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
var response = await client.GetAsync("/sbom/paths");
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var body = await response.Content.ReadAsStringAsync();
response.StatusCode.Should().Be(HttpStatusCode.BadRequest, body);
}
[Fact]
@@ -47,7 +48,7 @@ public class SbomEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
var client = _factory.CreateClient();
var response = await client.GetAsync("/sbom/versions?artifact=ghcr.io/stellaops/sample-api");
response.EnsureSuccessStatusCode();
response.StatusCode.Should().Be(HttpStatusCode.OK, await response.Content.ReadAsStringAsync());
var payload = await response.Content.ReadFromJsonAsync<SbomTimelineResult>();
payload.Should().NotBeNull();

View File

@@ -31,9 +31,10 @@ public class SbomEventEndpointsTests : IClassFixture<WebApplicationFactory<Progr
var events = await client.GetFromJsonAsync<List<SbomVersionCreatedEvent>>("/internal/sbom/events");
events.Should().NotBeNull();
events!.Should().HaveCount(1);
events[0].SnapshotId.Should().Be("snap-001");
events[0].TenantId.Should().Be("tenant-a");
var nonNullEvents = events!;
nonNullEvents.Should().HaveCount(1);
nonNullEvents[0].SnapshotId.Should().Be("snap-001");
nonNullEvents[0].TenantId.Should().Be("tenant-a");
// Requesting the projection should not duplicate events.
var projectionResponse = await client.GetAsync("/sboms/snap-001/projection?tenant=tenant-a");

View File

@@ -0,0 +1,20 @@
namespace StellaOps.SbomService.Models;
public sealed record Entrypoint(
string Artifact,
string Service,
string Path,
string Scope,
bool RuntimeFlag);
public sealed record EntrypointUpsertRequest(
string Tenant,
string Artifact,
string Service,
string Path,
string Scope,
bool RuntimeFlag);
public sealed record EntrypointListResponse(
string Tenant,
IReadOnlyList<Entrypoint> Items);

View File

@@ -21,4 +21,19 @@ internal static class SbomMetrics
public static readonly Counter<long> TimelineQueryTotal =
Meter.CreateCounter<long>("sbom_timeline_queries_total",
description: "Total SBOM timeline queries");
}
public static readonly Histogram<double> ProjectionLatencySeconds =
Meter.CreateHistogram<double>("sbom_projection_seconds", unit: "s",
description: "Latency for SBOM projection reads");
public static readonly Histogram<long> ProjectionSizeBytes =
Meter.CreateHistogram<long>("sbom_projection_size_bytes", unit: "By",
description: "Payload size of SBOM projections returned");
public static readonly Counter<long> ProjectionQueryTotal =
Meter.CreateCounter<long>("sbom_projection_queries_total",
description: "Total SBOM projection queries");
public static readonly Histogram<long> EventBacklogSize =
Meter.CreateHistogram<long>("sbom_events_backlog", unit: "events",
description: "Observed size of the SBOM event outbox (in-memory)

View File

@@ -0,0 +1,9 @@
using System.Diagnostics;
namespace StellaOps.SbomService.Observability;
internal static class SbomTracing
{
public const string SourceName = "StellaOps.SbomService";
public static readonly ActivitySource Source = new(SourceName);
}

View File

@@ -7,22 +7,24 @@ using StellaOps.SbomService.Services;
using StellaOps.SbomService.Observability;
using StellaOps.SbomService.Repositories;
using System.Text.Json;
using System.Diagnostics;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables("SBOM_");
var builder = WebApplication.CreateBuilder(args);
builder.Configuration
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables("SBOM_");
builder.Services.AddOptions();
builder.Services.AddLogging();
builder.Services.AddOptions();
builder.Services.AddLogging();
// Register SBOM query services (InMemory seed; replace with Mongo-backed repository later).
builder.Services.AddSingleton<IComponentLookupRepository>(_ => new InMemoryComponentLookupRepository());
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddSingleton<ISbomEventStore, InMemorySbomEventStore>();
builder.Services.AddSingleton<ISbomEventPublisher>(sp => sp.GetRequiredService<ISbomEventStore>());
builder.Services.AddSingleton<ISbomQueryService, InMemorySbomQueryService>();
builder.Services.AddSingleton<IEntrypointRepository, InMemoryEntrypointRepository>();
builder.Services.AddSingleton<IProjectionRepository>(sp =>
{
@@ -54,16 +56,85 @@ builder.Services.AddSingleton<IProjectionRepository>(sp =>
return new FileProjectionRepository(string.Empty);
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.Use(async (context, next) =>
{
try
{
await next();
}
catch (Exception ex)
{
Console.WriteLine($"[dev-exception] {ex}");
throw;
}
});
app.UseDeveloperExceptionPage();
}
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
app.MapGet("/readyz", () => Results.Ok(new { status = "warming" }));
app.MapGet("/entrypoints", async Task<IResult> (
[FromServices] IEntrypointRepository repo,
[FromQuery] string? tenant,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant is required" });
}
var tenantId = tenant.Trim();
using var activity = SbomTracing.Source.StartActivity("entrypoints.list", ActivityKind.Server);
activity?.SetTag("tenant", tenantId);
var items = await repo.ListAsync(tenantId, cancellationToken);
return Results.Ok(new EntrypointListResponse(tenantId, items));
});
app.MapPost("/entrypoints", async Task<IResult> (
[FromServices] IEntrypointRepository repo,
[FromBody] EntrypointUpsertRequest request,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(request.Tenant))
{
return Results.BadRequest(new { error = "tenant is required" });
}
if (string.IsNullOrWhiteSpace(request.Artifact) || string.IsNullOrWhiteSpace(request.Service) || string.IsNullOrWhiteSpace(request.Path))
{
return Results.BadRequest(new { error = "artifact, service, and path are required" });
}
var entrypoint = new Entrypoint(
request.Artifact.Trim(),
request.Service.Trim(),
request.Path.Trim(),
string.IsNullOrWhiteSpace(request.Scope) ? "runtime" : request.Scope.Trim(),
request.RuntimeFlag);
var tenantId = request.Tenant.Trim();
using var activity = SbomTracing.Source.StartActivity("entrypoints.upsert", ActivityKind.Server);
activity?.SetTag("tenant", tenantId);
activity?.SetTag("artifact", entrypoint.Artifact);
activity?.SetTag("service", entrypoint.Service);
await repo.UpsertAsync(tenantId, entrypoint, cancellationToken);
var items = await repo.ListAsync(tenantId, cancellationToken);
return Results.Ok(new EntrypointListResponse(tenantId, items));
});
var app = builder.Build();
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
app.MapGet("/readyz", () => Results.Ok(new { status = "warming" }));
app.MapGet("/console/sboms", async Task<IResult> (
[FromServices] ISbomQueryService service,
[FromQuery] string? artifact,
[FromQuery] string? license,
app.MapGet("/console/sboms", async Task<IResult> (
[FromServices] ISbomQueryService service,
[FromQuery] string? artifact,
[FromQuery] string? license,
[FromQuery] string? scope,
[FromQuery(Name = "assetTag")] string? assetTag,
[FromQuery] string? cursor,
@@ -80,15 +151,17 @@ app.MapGet("/console/sboms", async Task<IResult> (
return Results.BadRequest(new { error = "cursor must be an integer offset" });
}
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
var pageSize = limit ?? 50;
var start = Stopwatch.GetTimestamp();
var result = await service.GetConsoleCatalogAsync(
new SbomCatalogQuery(artifact?.Trim(), license?.Trim(), scope?.Trim(), assetTag?.Trim(), pageSize, offset),
cancellationToken);
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
var pageSize = limit ?? 50;
using var activity = SbomTracing.Source.StartActivity("console.sboms", ActivityKind.Server);
activity?.SetTag("artifact", artifact);
var start = Stopwatch.GetTimestamp();
var result = await service.GetConsoleCatalogAsync(
new SbomCatalogQuery(artifact?.Trim(), license?.Trim(), scope?.Trim(), assetTag?.Trim(), pageSize, offset),
cancellationToken);
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new TagList
{
{ "scope", scope ?? string.Empty },
@@ -103,10 +176,10 @@ app.MapGet("/console/sboms", async Task<IResult> (
return Results.Ok(result.Result);
});
app.MapGet("/components/lookup", async Task<IResult> (
[FromServices] ISbomQueryService service,
[FromQuery] string? purl,
[FromQuery] string? artifact,
app.MapGet("/components/lookup", async Task<IResult> (
[FromServices] ISbomQueryService service,
[FromQuery] string? purl,
[FromQuery] string? artifact,
[FromQuery] string? cursor,
[FromQuery] int? limit,
CancellationToken cancellationToken) =>
@@ -126,13 +199,16 @@ app.MapGet("/components/lookup", async Task<IResult> (
return Results.BadRequest(new { error = "cursor must be an integer offset" });
}
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
var pageSize = limit ?? 50;
var start = Stopwatch.GetTimestamp();
var result = await service.GetComponentLookupAsync(
new ComponentLookupQuery(purl.Trim(), artifact?.Trim(), pageSize, offset),
cancellationToken);
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
var pageSize = limit ?? 50;
using var activity = SbomTracing.Source.StartActivity("components.lookup", ActivityKind.Server);
activity?.SetTag("purl", purl);
activity?.SetTag("artifact", artifact);
var start = Stopwatch.GetTimestamp();
var result = await service.GetComponentLookupAsync(
new ComponentLookupQuery(purl.Trim(), artifact?.Trim(), pageSize, offset),
cancellationToken);
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new TagList
@@ -149,13 +225,13 @@ app.MapGet("/components/lookup", async Task<IResult> (
return Results.Ok(result.Result);
});
app.MapGet("/sbom/paths", async Task<IResult> (
[FromServices] ISbomQueryService service,
[FromQuery] string? purl,
[FromQuery] string? artifact,
[FromQuery] string? scope,
[FromQuery(Name = "env")] string? environment,
[FromQuery] string? cursor,
app.MapGet("/sbom/paths", async Task<IResult> (
[FromServices] IServiceProvider services,
[FromQuery] string? purl,
[FromQuery] string? artifact,
[FromQuery] string? scope,
[FromQuery(Name = "env")] string? environment,
[FromQuery] string? cursor,
[FromQuery] int? limit,
CancellationToken cancellationToken) =>
{
@@ -172,15 +248,16 @@ app.MapGet("/sbom/paths", async Task<IResult> (
if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
{
return Results.BadRequest(new { error = "cursor must be an integer offset" });
}
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
var pageSize = limit ?? 50;
var start = Stopwatch.GetTimestamp();
var result = await service.GetPathsAsync(
new SbomPathQuery(purl.Trim(), artifact?.Trim(), scope?.Trim(), environment?.Trim(), pageSize, offset),
cancellationToken);
}
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
var pageSize = limit ?? 50;
var service = services.GetRequiredService<ISbomQueryService>();
var start = Stopwatch.GetTimestamp();
var result = await service.GetPathsAsync(
new SbomPathQuery(purl.Trim(), artifact?.Trim(), scope?.Trim(), environment?.Trim(), pageSize, offset),
cancellationToken);
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new TagList
@@ -250,20 +327,37 @@ app.MapGet("/sboms/{snapshotId}/projection", async Task<IResult> (
return Results.BadRequest(new { error = "tenant is required" });
}
var start = Stopwatch.GetTimestamp();
var projection = await service.GetProjectionAsync(snapshotId.Trim(), tenantId.Trim(), cancellationToken);
if (projection is null)
{
return Results.NotFound(new { error = "projection not found" });
}
return Results.Ok(new
using var activity = SbomTracing.Source.StartActivity("sbom.projection", ActivityKind.Server);
activity?.SetTag("tenant", projection.TenantId);
activity?.SetTag("snapshotId", projection.SnapshotId);
activity?.SetTag("schema", projection.SchemaVersion);
var payload = new
{
snapshotId = projection.SnapshotId,
tenantId = projection.TenantId,
schemaVersion = projection.SchemaVersion,
hash = projection.ProjectionHash,
projection = projection.Projection
});
};
var json = JsonSerializer.Serialize(payload);
var sizeBytes = System.Text.Encoding.UTF8.GetByteCount(json);
SbomMetrics.ProjectionSizeBytes.Record(sizeBytes, new TagList { { "tenant", projection.TenantId } });
SbomMetrics.ProjectionLatencySeconds.Record(Stopwatch.GetElapsedTime(start).TotalSeconds,
new TagList { { "tenant", projection.TenantId } });
SbomMetrics.ProjectionQueryTotal.Add(1, new TagList { { "tenant", projection.TenantId } });
app.Logger.LogInformation("projection_returned tenant={Tenant} snapshot={Snapshot} size={SizeBytes}", projection.TenantId, projection.SnapshotId, sizeBytes);
return Results.Ok(payload);
});
app.MapGet("/internal/sbom/events", async Task<IResult> (

View File

@@ -4,5 +4,9 @@ namespace StellaOps.SbomService.Repositories;
public interface IComponentLookupRepository
{
Task<IReadOnlyList<ComponentLookupRecord>> QueryAsync(ComponentLookupQuery query, CancellationToken cancellationToken);
/// <summary>
/// Returns a page of component neighbors along with the total count that match the query filters.
/// The total is required for deterministic pagination cursors.
/// </summary>
Task<(IReadOnlyList<ComponentLookupRecord> Items, int Total)> QueryAsync(ComponentLookupQuery query, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,9 @@
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Repositories;
public interface IEntrypointRepository
{
Task<IReadOnlyList<Entrypoint>> ListAsync(string tenantId, CancellationToken cancellationToken);
Task UpsertAsync(string tenantId, Entrypoint entrypoint, CancellationToken cancellationToken);
}

View File

@@ -6,7 +6,7 @@ public sealed class InMemoryComponentLookupRepository : IComponentLookupReposito
{
private static readonly IReadOnlyList<ComponentLookupRecord> Components = Seed();
public Task<IReadOnlyList<ComponentLookupRecord>> QueryAsync(ComponentLookupQuery query, CancellationToken cancellationToken)
public Task<(IReadOnlyList<ComponentLookupRecord> Items, int Total)> QueryAsync(ComponentLookupQuery query, CancellationToken cancellationToken)
{
var filtered = Components
.Where(c => c.Purl.Equals(query.Purl, StringComparison.OrdinalIgnoreCase))
@@ -20,7 +20,7 @@ public sealed class InMemoryComponentLookupRepository : IComponentLookupReposito
.Take(query.Limit)
.ToList();
return Task.FromResult<IReadOnlyList<ComponentLookupRecord>>(page);
return Task.FromResult<(IReadOnlyList<ComponentLookupRecord>, int)>((page, filtered.Count));
}
private static IReadOnlyList<ComponentLookupRecord> Seed()

View File

@@ -0,0 +1,56 @@
using System.Collections.Concurrent;
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Repositories;
public sealed class InMemoryEntrypointRepository : IEntrypointRepository
{
// tenant -> list of entrypoints
private readonly ConcurrentDictionary<string, List<Entrypoint>> _store = new(StringComparer.OrdinalIgnoreCase);
public InMemoryEntrypointRepository()
{
_store["tenant-a"] = new List<Entrypoint>
{
new("ghcr.io/stellaops/sample-api", "web", "/api", "runtime", true),
new("ghcr.io/stellaops/sample-worker", "worker", "queue:jobs", "runtime", true)
};
_store["tenant-b"] = new List<Entrypoint>
{
new("ghcr.io/stellaops/console", "ui", "/", "runtime", true)
};
}
public Task<IReadOnlyList<Entrypoint>> ListAsync(string tenantId, CancellationToken cancellationToken)
{
var items = _store.TryGetValue(tenantId, out var list)
? list.OrderBy(e => e.Artifact, StringComparer.OrdinalIgnoreCase)
.ThenBy(e => e.Service, StringComparer.OrdinalIgnoreCase)
.ThenBy(e => e.Path, StringComparer.Ordinal)
.ToList()
: new List<Entrypoint>();
return Task.FromResult<IReadOnlyList<Entrypoint>>(items);
}
public Task UpsertAsync(string tenantId, Entrypoint entrypoint, CancellationToken cancellationToken)
{
var list = _store.GetOrAdd(tenantId, _ => new List<Entrypoint>());
var existingIndex = list.FindIndex(e =>
e.Artifact.Equals(entrypoint.Artifact, StringComparison.OrdinalIgnoreCase) &&
e.Service.Equals(entrypoint.Service, StringComparison.OrdinalIgnoreCase));
if (existingIndex >= 0)
{
list[existingIndex] = entrypoint;
}
else
{
list.Add(entrypoint);
}
return Task.CompletedTask;
}
}

View File

@@ -1,33 +0,0 @@
using MongoDB.Driver;
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Repositories;
internal sealed class MongoComponentLookupRepository : IComponentLookupRepository
{
private readonly IMongoCollection<ComponentLookupRecord> _collection;
public MongoComponentLookupRepository(IMongoDatabase database)
{
_collection = database.GetCollection<ComponentLookupRecord>("sbom_components");
}
public async Task<IReadOnlyList<ComponentLookupRecord>> QueryAsync(ComponentLookupQuery query, CancellationToken cancellationToken)
{
var filter = Builders<ComponentLookupRecord>.Filter.Eq(c => c.Purl, query.Purl);
if (!string.IsNullOrWhiteSpace(query.Artifact))
{
filter &= Builders<ComponentLookupRecord>.Filter.Eq(c => c.Artifact, query.Artifact);
}
var results = await _collection
.Find(filter)
.Skip(query.Offset)
.Limit(query.Limit)
.Sort(Builders<ComponentLookupRecord>.Sort.Ascending(c => c.Artifact).Ascending(c => c.NeighborPurl))
.ToListAsync(cancellationToken);
return results;
}
}

View File

@@ -152,15 +152,15 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
return new QueryResult<ComponentLookupResult>(cachedResult, true);
}
var page = await _componentLookupRepository.QueryAsync(query, cancellationToken);
string? nextCursor = query.Offset + query.Limit < page.Count
? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture)
: null;
var neighbors = page
.Select(c => new ComponentNeighbor(c.NeighborPurl, c.Relationship, c.License, c.Scope, c.RuntimeFlag))
.ToList();
var (items, total) = await _componentLookupRepository.QueryAsync(query, cancellationToken);
string? nextCursor = query.Offset + query.Limit < total
? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture)
: null;
var neighbors = items
.Select(c => new ComponentNeighbor(c.NeighborPurl, c.Relationship, c.License, c.Scope, c.RuntimeFlag))
.ToList();
var result = new ComponentLookupResult(query.Purl, query.Artifact, neighbors, nextCursor, CacheHint: "seeded");
_cache[cacheKey] = result;

View File

@@ -15,6 +15,5 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="2.24.0" />
</ItemGroup>
</Project>

View File

@@ -3,3 +3,5 @@
| Task ID | Status | Notes | Updated (UTC) |
| --- | --- | --- | --- |
| PREP-SBOM-CONSOLE-23-001-BUILD-TEST-FAILING-D | DONE | Offline feed cache + script added; see `docs/modules/sbomservice/offline-feed-plan.md`. | 2025-11-20 |
| SBOM-SERVICE-21-002 | DONE | `sbom.version.created` events emitted via in-memory publisher; `/internal/sbom/events` + backfill wired; component lookup pagination cursor fixed; tests pass. | 2025-11-23 |
| SBOM-SERVICE-21-003 | DONE | Entrypoint/service node API (`GET/POST /entrypoints`) with tenant guard, deterministic ordering, seeded data; tests added. | 2025-11-23 |