Frontend gaps fill work. Testing fixes work. Auditing in progress.

This commit is contained in:
StellaOps Bot
2025-12-30 01:22:58 +02:00
parent 1dc4bcbf10
commit 7a5210e2aa
928 changed files with 183942 additions and 3941 deletions

View File

@@ -0,0 +1,401 @@
// <copyright file="AdminModels.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace StellaOps.Registry.TokenService.Admin;
/// <summary>
/// Plan rule definition for admin API.
/// </summary>
public sealed record PlanRuleDto
{
/// <summary>
/// Unique identifier for the plan.
/// </summary>
[Required]
[JsonPropertyName("id")]
public required string Id { get; init; }
/// <summary>
/// Display name for the plan.
/// </summary>
[Required]
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Human-readable description.
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; init; }
/// <summary>
/// Whether the plan is currently active.
/// </summary>
[JsonPropertyName("enabled")]
public bool Enabled { get; init; } = true;
/// <summary>
/// Repository scope rules.
/// </summary>
[JsonPropertyName("repositories")]
public IReadOnlyList<RepositoryRuleDto> Repositories { get; init; } = [];
/// <summary>
/// Explicit allowlist of client identifiers.
/// </summary>
[JsonPropertyName("allowlist")]
public IReadOnlyList<string> Allowlist { get; init; } = [];
/// <summary>
/// Optional rate limit configuration.
/// </summary>
[JsonPropertyName("rateLimit")]
public RateLimitDto? RateLimit { get; init; }
/// <summary>
/// UTC timestamp when this plan was created.
/// </summary>
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// UTC timestamp when this plan was last modified.
/// </summary>
[JsonPropertyName("modifiedAt")]
public DateTimeOffset ModifiedAt { get; init; }
/// <summary>
/// Version for optimistic concurrency.
/// </summary>
[JsonPropertyName("version")]
public int Version { get; init; }
}
/// <summary>
/// Repository rule definition.
/// </summary>
public sealed record RepositoryRuleDto
{
/// <summary>
/// Repository pattern (supports wildcards: * matches any).
/// </summary>
[Required]
[JsonPropertyName("pattern")]
public required string Pattern { get; init; }
/// <summary>
/// Allowed actions for matching repositories.
/// </summary>
[JsonPropertyName("actions")]
public IReadOnlyList<string> Actions { get; init; } = ["pull"];
}
/// <summary>
/// Rate limit configuration.
/// </summary>
public sealed record RateLimitDto
{
/// <summary>
/// Maximum requests per window.
/// </summary>
[JsonPropertyName("maxRequests")]
public int MaxRequests { get; init; }
/// <summary>
/// Window duration in seconds.
/// </summary>
[JsonPropertyName("windowSeconds")]
public int WindowSeconds { get; init; }
}
/// <summary>
/// Request to create a new plan.
/// </summary>
public sealed record CreatePlanRequest
{
/// <summary>
/// Display name for the plan.
/// </summary>
[Required]
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Human-readable description.
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; init; }
/// <summary>
/// Repository scope rules.
/// </summary>
[JsonPropertyName("repositories")]
public IReadOnlyList<RepositoryRuleDto> Repositories { get; init; } = [];
/// <summary>
/// Explicit allowlist of client identifiers.
/// </summary>
[JsonPropertyName("allowlist")]
public IReadOnlyList<string> Allowlist { get; init; } = [];
/// <summary>
/// Optional rate limit configuration.
/// </summary>
[JsonPropertyName("rateLimit")]
public RateLimitDto? RateLimit { get; init; }
}
/// <summary>
/// Request to update an existing plan.
/// </summary>
public sealed record UpdatePlanRequest
{
/// <summary>
/// Display name for the plan.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
/// <summary>
/// Human-readable description.
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; init; }
/// <summary>
/// Whether the plan is currently active.
/// </summary>
[JsonPropertyName("enabled")]
public bool? Enabled { get; init; }
/// <summary>
/// Repository scope rules.
/// </summary>
[JsonPropertyName("repositories")]
public IReadOnlyList<RepositoryRuleDto>? Repositories { get; init; }
/// <summary>
/// Explicit allowlist of client identifiers.
/// </summary>
[JsonPropertyName("allowlist")]
public IReadOnlyList<string>? Allowlist { get; init; }
/// <summary>
/// Optional rate limit configuration.
/// </summary>
[JsonPropertyName("rateLimit")]
public RateLimitDto? RateLimit { get; init; }
/// <summary>
/// Version for optimistic concurrency.
/// </summary>
[Required]
[JsonPropertyName("version")]
public required int Version { get; init; }
}
/// <summary>
/// Request for dry-run validation.
/// </summary>
public sealed record ValidatePlanRequest
{
/// <summary>
/// Plan to validate.
/// </summary>
[Required]
[JsonPropertyName("plan")]
public required CreatePlanRequest Plan { get; init; }
/// <summary>
/// Optional test scopes to evaluate.
/// </summary>
[JsonPropertyName("testScopes")]
public IReadOnlyList<TestScopeRequest>? TestScopes { get; init; }
}
/// <summary>
/// Test scope for dry-run validation.
/// </summary>
public sealed record TestScopeRequest
{
/// <summary>
/// Repository name to test.
/// </summary>
[Required]
[JsonPropertyName("repository")]
public required string Repository { get; init; }
/// <summary>
/// Actions to test.
/// </summary>
[JsonPropertyName("actions")]
public IReadOnlyList<string> Actions { get; init; } = ["pull"];
}
/// <summary>
/// Validation result from dry-run.
/// </summary>
public sealed record ValidationResult
{
/// <summary>
/// Whether the plan is valid.
/// </summary>
[JsonPropertyName("valid")]
public bool Valid { get; init; }
/// <summary>
/// Validation errors if any.
/// </summary>
[JsonPropertyName("errors")]
public IReadOnlyList<ValidationError> Errors { get; init; } = [];
/// <summary>
/// Warnings that don't prevent saving.
/// </summary>
[JsonPropertyName("warnings")]
public IReadOnlyList<string> Warnings { get; init; } = [];
/// <summary>
/// Test scope results if provided.
/// </summary>
[JsonPropertyName("testResults")]
public IReadOnlyList<TestScopeResult>? TestResults { get; init; }
}
/// <summary>
/// Validation error detail.
/// </summary>
public sealed record ValidationError
{
/// <summary>
/// Field path that has the error.
/// </summary>
[JsonPropertyName("field")]
public required string Field { get; init; }
/// <summary>
/// Error message.
/// </summary>
[JsonPropertyName("message")]
public required string Message { get; init; }
}
/// <summary>
/// Test scope evaluation result.
/// </summary>
public sealed record TestScopeResult
{
/// <summary>
/// Repository that was tested.
/// </summary>
[JsonPropertyName("repository")]
public required string Repository { get; init; }
/// <summary>
/// Actions that were tested.
/// </summary>
[JsonPropertyName("actions")]
public IReadOnlyList<string> Actions { get; init; } = [];
/// <summary>
/// Whether access would be allowed.
/// </summary>
[JsonPropertyName("allowed")]
public bool Allowed { get; init; }
/// <summary>
/// Matching rule pattern if allowed.
/// </summary>
[JsonPropertyName("matchedPattern")]
public string? MatchedPattern { get; init; }
}
/// <summary>
/// Audit log entry for plan changes.
/// </summary>
public sealed record PlanAuditEntry
{
/// <summary>
/// Unique audit entry ID.
/// </summary>
[JsonPropertyName("id")]
public required string Id { get; init; }
/// <summary>
/// Plan ID that was changed.
/// </summary>
[JsonPropertyName("planId")]
public required string PlanId { get; init; }
/// <summary>
/// Type of change: Created, Updated, Deleted, Enabled, Disabled.
/// </summary>
[JsonPropertyName("action")]
public required string Action { get; init; }
/// <summary>
/// User or service that made the change.
/// </summary>
[JsonPropertyName("actor")]
public required string Actor { get; init; }
/// <summary>
/// UTC timestamp of the change.
/// </summary>
[JsonPropertyName("timestamp")]
public DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Summary of what changed.
/// </summary>
[JsonPropertyName("summary")]
public string? Summary { get; init; }
/// <summary>
/// Previous version if applicable.
/// </summary>
[JsonPropertyName("previousVersion")]
public int? PreviousVersion { get; init; }
/// <summary>
/// New version if applicable.
/// </summary>
[JsonPropertyName("newVersion")]
public int? NewVersion { get; init; }
}
/// <summary>
/// Paginated list response.
/// </summary>
public sealed record PaginatedResponse<T>
{
/// <summary>
/// Items in this page.
/// </summary>
[JsonPropertyName("items")]
public IReadOnlyList<T> Items { get; init; } = [];
/// <summary>
/// Total count of items across all pages.
/// </summary>
[JsonPropertyName("totalCount")]
public int TotalCount { get; init; }
/// <summary>
/// Current page number (1-based).
/// </summary>
[JsonPropertyName("page")]
public int Page { get; init; }
/// <summary>
/// Page size.
/// </summary>
[JsonPropertyName("pageSize")]
public int PageSize { get; init; }
}

View File

@@ -0,0 +1,100 @@
// <copyright file="IPlanRuleStore.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Registry.TokenService.Admin;
/// <summary>
/// Abstraction for persistent plan rule storage.
/// </summary>
public interface IPlanRuleStore
{
/// <summary>
/// Gets all plan rules ordered by name.
/// </summary>
Task<IReadOnlyList<PlanRuleDto>> GetAllAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets a plan rule by ID.
/// </summary>
Task<PlanRuleDto?> GetByIdAsync(string id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a plan rule by name.
/// </summary>
Task<PlanRuleDto?> GetByNameAsync(string name, CancellationToken cancellationToken = default);
/// <summary>
/// Creates a new plan rule.
/// </summary>
Task<PlanRuleDto> CreateAsync(CreatePlanRequest request, string actor, CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing plan rule.
/// </summary>
Task<PlanRuleDto> UpdateAsync(string id, UpdatePlanRequest request, string actor, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a plan rule.
/// </summary>
Task<bool> DeleteAsync(string id, string actor, CancellationToken cancellationToken = default);
/// <summary>
/// Gets audit history for a plan.
/// </summary>
Task<IReadOnlyList<PlanAuditEntry>> GetAuditHistoryAsync(
string? planId = null,
int page = 1,
int pageSize = 50,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Exception thrown when a plan rule version conflict occurs.
/// </summary>
public sealed class PlanVersionConflictException : System.Exception
{
public PlanVersionConflictException(string planId, int expectedVersion, int actualVersion)
: base($"Plan '{planId}' version conflict: expected {expectedVersion}, actual {actualVersion}")
{
PlanId = planId;
ExpectedVersion = expectedVersion;
ActualVersion = actualVersion;
}
public string PlanId { get; }
public int ExpectedVersion { get; }
public int ActualVersion { get; }
}
/// <summary>
/// Exception thrown when a plan rule is not found.
/// </summary>
public sealed class PlanNotFoundException : System.Exception
{
public PlanNotFoundException(string planId)
: base($"Plan '{planId}' not found")
{
PlanId = planId;
}
public string PlanId { get; }
}
/// <summary>
/// Exception thrown when a plan name already exists.
/// </summary>
public sealed class PlanNameConflictException : System.Exception
{
public PlanNameConflictException(string name)
: base($"Plan with name '{name}' already exists")
{
Name = name;
}
public string Name { get; }
}

View File

@@ -0,0 +1,226 @@
// <copyright file="InMemoryPlanRuleStore.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Registry.TokenService.Admin;
/// <summary>
/// In-memory implementation of plan rule storage for development and testing.
/// Production deployments should use a persistent store (PostgreSQL).
/// </summary>
public sealed class InMemoryPlanRuleStore : IPlanRuleStore
{
private readonly ConcurrentDictionary<string, PlanRuleDto> _plans = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentBag<PlanAuditEntry> _auditLog = [];
private readonly TimeProvider _timeProvider;
private int _auditIdCounter;
public InMemoryPlanRuleStore(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public Task<IReadOnlyList<PlanRuleDto>> GetAllAsync(CancellationToken cancellationToken = default)
{
var plans = _plans.Values
.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
return Task.FromResult<IReadOnlyList<PlanRuleDto>>(plans);
}
public Task<PlanRuleDto?> GetByIdAsync(string id, CancellationToken cancellationToken = default)
{
_plans.TryGetValue(id, out var plan);
return Task.FromResult(plan);
}
public Task<PlanRuleDto?> GetByNameAsync(string name, CancellationToken cancellationToken = default)
{
var plan = _plans.Values.FirstOrDefault(p =>
string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
return Task.FromResult(plan);
}
public Task<PlanRuleDto> CreateAsync(CreatePlanRequest request, string actor, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
// Check for name conflict
var existing = _plans.Values.FirstOrDefault(p =>
string.Equals(p.Name, request.Name, StringComparison.OrdinalIgnoreCase));
if (existing is not null)
{
throw new PlanNameConflictException(request.Name);
}
var now = _timeProvider.GetUtcNow();
var id = GenerateId();
var plan = new PlanRuleDto
{
Id = id,
Name = request.Name.Trim(),
Description = request.Description?.Trim(),
Enabled = true,
Repositories = request.Repositories,
Allowlist = request.Allowlist,
RateLimit = request.RateLimit,
CreatedAt = now,
ModifiedAt = now,
Version = 1,
};
if (!_plans.TryAdd(id, plan))
{
throw new InvalidOperationException("Failed to create plan due to ID collision.");
}
AddAuditEntry(id, "Created", actor, $"Created plan '{plan.Name}'", null, 1);
return Task.FromResult(plan);
}
public Task<PlanRuleDto> UpdateAsync(string id, UpdatePlanRequest request, string actor, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
if (!_plans.TryGetValue(id, out var existing))
{
throw new PlanNotFoundException(id);
}
if (existing.Version != request.Version)
{
throw new PlanVersionConflictException(id, request.Version, existing.Version);
}
// Check for name conflict if name is changing
if (request.Name is not null &&
!string.Equals(request.Name, existing.Name, StringComparison.OrdinalIgnoreCase))
{
var nameConflict = _plans.Values.FirstOrDefault(p =>
!string.Equals(p.Id, id, StringComparison.OrdinalIgnoreCase) &&
string.Equals(p.Name, request.Name, StringComparison.OrdinalIgnoreCase));
if (nameConflict is not null)
{
throw new PlanNameConflictException(request.Name);
}
}
var now = _timeProvider.GetUtcNow();
var changes = new List<string>();
var updated = existing with
{
Name = request.Name?.Trim() ?? existing.Name,
Description = request.Description?.Trim() ?? existing.Description,
Enabled = request.Enabled ?? existing.Enabled,
Repositories = request.Repositories ?? existing.Repositories,
Allowlist = request.Allowlist ?? existing.Allowlist,
RateLimit = request.RateLimit ?? existing.RateLimit,
ModifiedAt = now,
Version = existing.Version + 1,
};
if (request.Name is not null && request.Name != existing.Name)
{
changes.Add($"name: '{existing.Name}' → '{updated.Name}'");
}
if (request.Enabled.HasValue && request.Enabled.Value != existing.Enabled)
{
changes.Add($"enabled: {existing.Enabled} → {updated.Enabled}");
}
if (request.Repositories is not null)
{
changes.Add("repositories updated");
}
if (request.Allowlist is not null)
{
changes.Add("allowlist updated");
}
if (!_plans.TryUpdate(id, updated, existing))
{
throw new PlanVersionConflictException(id, request.Version, existing.Version);
}
var summary = changes.Count > 0
? $"Updated: {string.Join(", ", changes)}"
: "No changes";
AddAuditEntry(id, "Updated", actor, summary, existing.Version, updated.Version);
return Task.FromResult(updated);
}
public Task<bool> DeleteAsync(string id, string actor, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
if (_plans.TryRemove(id, out var removed))
{
AddAuditEntry(id, "Deleted", actor, $"Deleted plan '{removed.Name}'", removed.Version, null);
return Task.FromResult(true);
}
return Task.FromResult(false);
}
public Task<IReadOnlyList<PlanAuditEntry>> GetAuditHistoryAsync(
string? planId = null,
int page = 1,
int pageSize = 50,
CancellationToken cancellationToken = default)
{
var query = _auditLog.AsEnumerable();
if (!string.IsNullOrWhiteSpace(planId))
{
query = query.Where(e => string.Equals(e.PlanId, planId, StringComparison.OrdinalIgnoreCase));
}
var result = query
.OrderByDescending(e => e.Timestamp)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToList();
return Task.FromResult<IReadOnlyList<PlanAuditEntry>>(result);
}
private void AddAuditEntry(string planId, string action, string actor, string summary, int? previousVersion, int? newVersion)
{
var auditId = Interlocked.Increment(ref _auditIdCounter);
var entry = new PlanAuditEntry
{
Id = $"audit-{auditId:D8}",
PlanId = planId,
Action = action,
Actor = actor,
Timestamp = _timeProvider.GetUtcNow(),
Summary = summary,
PreviousVersion = previousVersion,
NewVersion = newVersion,
};
_auditLog.Add(entry);
}
private static string GenerateId()
{
return $"plan-{Guid.NewGuid():N}"[..16];
}
}

View File

@@ -0,0 +1,292 @@
// <copyright file="PlanAdminEndpoints.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Security.Claims;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
namespace StellaOps.Registry.TokenService.Admin;
/// <summary>
/// Minimal API endpoint mappings for Plan Rule administration.
/// </summary>
public static class PlanAdminEndpoints
{
/// <summary>
/// Maps the plan admin endpoints to the application.
/// </summary>
public static void MapPlanAdminEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/admin/plans")
.WithTags("Plan Administration")
.RequireAuthorization("registry.admin");
group.MapGet("/", ListPlans)
.WithName("ListPlans")
.WithDescription("Lists all plan rules ordered by name.")
.Produces<IReadOnlyList<PlanRuleDto>>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status403Forbidden);
group.MapGet("/{planId}", GetPlan)
.WithName("GetPlan")
.WithDescription("Gets a plan rule by ID.")
.Produces<PlanRuleDto>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status403Forbidden);
group.MapPost("/", CreatePlan)
.WithName("CreatePlan")
.WithDescription("Creates a new plan rule.")
.Produces<PlanRuleDto>(StatusCodes.Status201Created)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status409Conflict)
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status403Forbidden);
group.MapPut("/{planId}", UpdatePlan)
.WithName("UpdatePlan")
.WithDescription("Updates an existing plan rule. Requires version for optimistic concurrency.")
.Produces<PlanRuleDto>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status409Conflict)
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status403Forbidden);
group.MapDelete("/{planId}", DeletePlan)
.WithName("DeletePlan")
.WithDescription("Deletes a plan rule.")
.Produces(StatusCodes.Status204NoContent)
.ProducesProblem(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status403Forbidden);
group.MapPost("/validate", ValidatePlan)
.WithName("ValidatePlan")
.WithDescription("Validates a plan rule without saving. Optionally evaluates test scopes.")
.Produces<ValidationResult>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status403Forbidden);
group.MapGet("/audit", GetAuditHistory)
.WithName("GetPlanAuditHistory")
.WithDescription("Gets audit history for plan changes. Optionally filter by plan ID.")
.Produces<PaginatedResponse<PlanAuditEntry>>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status403Forbidden);
}
private static async Task<Ok<IReadOnlyList<PlanRuleDto>>> ListPlans(
[FromServices] IPlanRuleStore store,
CancellationToken cancellationToken)
{
var plans = await store.GetAllAsync(cancellationToken);
return TypedResults.Ok(plans);
}
private static async Task<Results<Ok<PlanRuleDto>, NotFound<ProblemDetails>>> GetPlan(
string planId,
[FromServices] IPlanRuleStore store,
CancellationToken cancellationToken)
{
var plan = await store.GetByIdAsync(planId, cancellationToken);
if (plan is null)
{
return TypedResults.NotFound(CreateProblemDetails(
StatusCodes.Status404NotFound,
"Plan Not Found",
$"Plan with ID '{planId}' was not found."));
}
return TypedResults.Ok(plan);
}
private static async Task<Results<Created<PlanRuleDto>, BadRequest<ProblemDetails>, Conflict<ProblemDetails>>> CreatePlan(
[FromBody] CreatePlanRequest request,
[FromServices] IPlanRuleStore store,
[FromServices] PlanValidator validator,
HttpContext context,
CancellationToken cancellationToken)
{
// Validate the request
var validationRequest = new ValidatePlanRequest { Plan = request };
var validationResult = validator.Validate(validationRequest);
if (!validationResult.Valid)
{
return TypedResults.BadRequest(CreateProblemDetails(
StatusCodes.Status400BadRequest,
"Validation Failed",
"The plan request is invalid.",
validationResult.Errors));
}
var actor = GetActorFromContext(context);
try
{
var created = await store.CreateAsync(request, actor, cancellationToken);
return TypedResults.Created($"/api/admin/plans/{created.Id}", created);
}
catch (PlanNameConflictException ex)
{
return TypedResults.Conflict(CreateProblemDetails(
StatusCodes.Status409Conflict,
"Name Conflict",
ex.Message));
}
}
private static async Task<Results<Ok<PlanRuleDto>, BadRequest<ProblemDetails>, NotFound<ProblemDetails>, Conflict<ProblemDetails>>> UpdatePlan(
string planId,
[FromBody] UpdatePlanRequest request,
[FromServices] IPlanRuleStore store,
[FromServices] PlanValidator validator,
HttpContext context,
CancellationToken cancellationToken)
{
// Validate the request if it includes full plan fields
if (request.Repositories is not null || request.Name is not null)
{
var tempPlan = new CreatePlanRequest
{
Name = request.Name ?? "temp",
Description = request.Description,
Repositories = request.Repositories ?? [],
Allowlist = request.Allowlist ?? [],
RateLimit = request.RateLimit,
};
var validationRequest = new ValidatePlanRequest { Plan = tempPlan };
var validationResult = validator.Validate(validationRequest);
// Filter out errors for fields not being updated
var relevantErrors = validationResult.Errors
.Where(e => request.Name is not null || !e.Field.StartsWith("plan.name", StringComparison.Ordinal))
.Where(e => request.Repositories is not null || !e.Field.StartsWith("plan.repositories", StringComparison.Ordinal))
.ToList();
if (relevantErrors.Count > 0)
{
return TypedResults.BadRequest(CreateProblemDetails(
StatusCodes.Status400BadRequest,
"Validation Failed",
"The update request is invalid.",
relevantErrors));
}
}
var actor = GetActorFromContext(context);
try
{
var updated = await store.UpdateAsync(planId, request, actor, cancellationToken);
return TypedResults.Ok(updated);
}
catch (PlanNotFoundException)
{
return TypedResults.NotFound(CreateProblemDetails(
StatusCodes.Status404NotFound,
"Plan Not Found",
$"Plan with ID '{planId}' was not found."));
}
catch (PlanVersionConflictException ex)
{
return TypedResults.Conflict(CreateProblemDetails(
StatusCodes.Status409Conflict,
"Version Conflict",
ex.Message));
}
catch (PlanNameConflictException ex)
{
return TypedResults.Conflict(CreateProblemDetails(
StatusCodes.Status409Conflict,
"Name Conflict",
ex.Message));
}
}
private static async Task<Results<NoContent, NotFound<ProblemDetails>>> DeletePlan(
string planId,
[FromServices] IPlanRuleStore store,
HttpContext context,
CancellationToken cancellationToken)
{
var actor = GetActorFromContext(context);
var deleted = await store.DeleteAsync(planId, actor, cancellationToken);
if (!deleted)
{
return TypedResults.NotFound(CreateProblemDetails(
StatusCodes.Status404NotFound,
"Plan Not Found",
$"Plan with ID '{planId}' was not found."));
}
return TypedResults.NoContent();
}
private static Ok<ValidationResult> ValidatePlan(
[FromBody] ValidatePlanRequest request,
[FromServices] PlanValidator validator)
{
var result = validator.Validate(request);
return TypedResults.Ok(result);
}
private static async Task<Ok<PaginatedResponse<PlanAuditEntry>>> GetAuditHistory(
[FromServices] IPlanRuleStore store,
[FromQuery] string? planId = null,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
CancellationToken cancellationToken = default)
{
// Clamp page size
pageSize = Math.Clamp(pageSize, 1, 100);
page = Math.Max(1, page);
var entries = await store.GetAuditHistoryAsync(planId, page, pageSize, cancellationToken);
var response = new PaginatedResponse<PlanAuditEntry>
{
Items = entries,
TotalCount = entries.Count, // Note: in-memory store doesn't track total; real store should
Page = page,
PageSize = pageSize,
};
return TypedResults.Ok(response);
}
private static string GetActorFromContext(HttpContext context)
{
var user = context.User;
var sub = user.FindFirstValue(ClaimTypes.NameIdentifier) ?? user.FindFirstValue("sub");
var name = user.FindFirstValue(ClaimTypes.Name) ?? user.FindFirstValue("name");
return sub ?? name ?? "anonymous";
}
private static ProblemDetails CreateProblemDetails(
int statusCode,
string title,
string detail,
IReadOnlyList<ValidationError>? errors = null)
{
var problem = new ProblemDetails
{
Status = statusCode,
Title = title,
Detail = detail,
};
if (errors is not null && errors.Count > 0)
{
problem.Extensions["errors"] = errors;
}
return problem;
}
}

View File

@@ -0,0 +1,237 @@
// <copyright file="PlanValidator.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
namespace StellaOps.Registry.TokenService.Admin;
/// <summary>
/// Validates plan rules and performs dry-run scope evaluation.
/// </summary>
public sealed class PlanValidator
{
private static readonly HashSet<string> ValidActions = new(StringComparer.OrdinalIgnoreCase)
{
"pull", "push", "delete", "*"
};
/// <summary>
/// Validates a plan request and optionally evaluates test scopes.
/// </summary>
public ValidationResult Validate(ValidatePlanRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var errors = new List<ValidationError>();
var warnings = new List<string>();
// Validate name
if (string.IsNullOrWhiteSpace(request.Plan.Name))
{
errors.Add(new ValidationError { Field = "plan.name", Message = "Plan name is required." });
}
else if (request.Plan.Name.Length > 128)
{
errors.Add(new ValidationError { Field = "plan.name", Message = "Plan name must be 128 characters or less." });
}
// Validate repositories
if (request.Plan.Repositories.Count == 0)
{
warnings.Add("Plan has no repository rules; all repository access will be denied.");
}
for (int i = 0; i < request.Plan.Repositories.Count; i++)
{
var repo = request.Plan.Repositories[i];
var fieldPrefix = $"plan.repositories[{i}]";
if (string.IsNullOrWhiteSpace(repo.Pattern))
{
errors.Add(new ValidationError { Field = $"{fieldPrefix}.pattern", Message = "Repository pattern is required." });
}
else
{
// Validate pattern syntax
var patternError = ValidatePattern(repo.Pattern);
if (patternError is not null)
{
errors.Add(new ValidationError { Field = $"{fieldPrefix}.pattern", Message = patternError });
}
}
if (repo.Actions.Count == 0)
{
errors.Add(new ValidationError { Field = $"{fieldPrefix}.actions", Message = "At least one action is required." });
}
else
{
foreach (var action in repo.Actions)
{
if (!ValidActions.Contains(action))
{
errors.Add(new ValidationError
{
Field = $"{fieldPrefix}.actions",
Message = $"Invalid action '{action}'. Valid actions are: pull, push, delete, *."
});
}
}
}
}
// Validate rate limit
if (request.Plan.RateLimit is not null)
{
if (request.Plan.RateLimit.MaxRequests <= 0)
{
errors.Add(new ValidationError { Field = "plan.rateLimit.maxRequests", Message = "Max requests must be positive." });
}
if (request.Plan.RateLimit.WindowSeconds <= 0)
{
errors.Add(new ValidationError { Field = "plan.rateLimit.windowSeconds", Message = "Window seconds must be positive." });
}
}
// Validate allowlist
for (int i = 0; i < request.Plan.Allowlist.Count; i++)
{
if (string.IsNullOrWhiteSpace(request.Plan.Allowlist[i]))
{
errors.Add(new ValidationError { Field = $"plan.allowlist[{i}]", Message = "Allowlist entry cannot be empty." });
}
}
// Check for overlapping patterns (warning only)
var overlaps = FindOverlappingPatterns(request.Plan.Repositories);
foreach (var overlap in overlaps)
{
warnings.Add($"Repository patterns may overlap: '{overlap.Item1}' and '{overlap.Item2}'");
}
// Evaluate test scopes if provided
List<TestScopeResult>? testResults = null;
if (request.TestScopes is not null && request.TestScopes.Count > 0 && errors.Count == 0)
{
testResults = EvaluateTestScopes(request.Plan.Repositories, request.TestScopes);
}
return new ValidationResult
{
Valid = errors.Count == 0,
Errors = errors,
Warnings = warnings,
TestResults = testResults,
};
}
private static string? ValidatePattern(string pattern)
{
if (pattern.Contains("**", StringComparison.Ordinal))
{
return "Double wildcards (**) are not supported; use single wildcard (*).";
}
// Check for invalid characters
if (pattern.Any(c => char.IsControl(c)))
{
return "Pattern contains invalid control characters.";
}
// Try to compile the regex
try
{
var escaped = Regex.Escape(pattern);
escaped = escaped.Replace(@"\*", ".*", StringComparison.Ordinal);
_ = new Regex($"^{escaped}$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
return null;
}
catch (ArgumentException ex)
{
return $"Invalid pattern: {ex.Message}";
}
}
private static List<(string, string)> FindOverlappingPatterns(IReadOnlyList<RepositoryRuleDto> repositories)
{
var overlaps = new List<(string, string)>();
for (int i = 0; i < repositories.Count; i++)
{
for (int j = i + 1; j < repositories.Count; j++)
{
var p1 = repositories[i].Pattern;
var p2 = repositories[j].Pattern;
// Simple overlap detection: if one pattern is a prefix of another
if (p1.StartsWith(p2.Replace("*", "", StringComparison.Ordinal), StringComparison.OrdinalIgnoreCase) ||
p2.StartsWith(p1.Replace("*", "", StringComparison.Ordinal), StringComparison.OrdinalIgnoreCase))
{
overlaps.Add((p1, p2));
}
}
}
return overlaps;
}
private static List<TestScopeResult> EvaluateTestScopes(
IReadOnlyList<RepositoryRuleDto> repositories,
IReadOnlyList<TestScopeRequest> testScopes)
{
var results = new List<TestScopeResult>();
foreach (var test in testScopes)
{
var (allowed, matchedPattern) = EvaluateScope(repositories, test.Repository, test.Actions);
results.Add(new TestScopeResult
{
Repository = test.Repository,
Actions = test.Actions,
Allowed = allowed,
MatchedPattern = matchedPattern,
});
}
return results;
}
private static (bool Allowed, string? MatchedPattern) EvaluateScope(
IReadOnlyList<RepositoryRuleDto> repositories,
string repository,
IReadOnlyList<string> actions)
{
foreach (var rule in repositories)
{
var pattern = CompilePattern(rule.Pattern);
if (!pattern.IsMatch(repository))
{
continue;
}
// Check if all actions are allowed
var ruleActions = new HashSet<string>(rule.Actions, StringComparer.OrdinalIgnoreCase);
var allAllowed = ruleActions.Contains("*") ||
actions.All(a => ruleActions.Contains(a));
if (allAllowed)
{
return (true, rule.Pattern);
}
}
return (false, null);
}
private static Regex CompilePattern(string pattern)
{
var escaped = Regex.Escape(pattern);
escaped = escaped.Replace(@"\*", ".*", StringComparison.Ordinal);
return new Regex($"^{escaped}$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
}
}

View File

@@ -16,6 +16,7 @@ using StellaOps.Auth.ServerIntegration;
using StellaOps.Configuration;
using StellaOps.Telemetry.Core;
using StellaOps.Registry.TokenService;
using StellaOps.Registry.TokenService.Admin;
using StellaOps.Registry.TokenService.Observability;
var builder = WebApplication.CreateBuilder(args);
@@ -57,6 +58,10 @@ builder.Services.AddSingleton<PlanRegistry>(sp =>
});
builder.Services.AddSingleton<RegistryTokenIssuer>();
// Plan Admin API dependencies
builder.Services.AddSingleton<IPlanRuleStore, InMemoryPlanRuleStore>();
builder.Services.AddSingleton<PlanValidator>();
builder.Services.AddHealthChecks().AddCheck("self", () => Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckResult.Healthy());
builder.Services.AddAirGapEgressPolicy(builder.Configuration);
@@ -102,6 +107,14 @@ builder.Services.AddAuthorization(options =>
policy.Requirements.Add(new StellaOpsScopeRequirement(scopes));
policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme);
});
// Admin policy for plan management
options.AddPolicy("registry.admin", policy =>
{
policy.RequireAuthenticatedUser();
policy.Requirements.Add(new StellaOpsScopeRequirement(["registry.admin"]));
policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme);
});
});
var app = builder.Build();
@@ -112,6 +125,9 @@ app.UseAuthorization();
app.MapHealthChecks("/healthz");
// Plan Admin API endpoints
app.MapPlanAdminEndpoints();
app.MapGet("/token", (
HttpContext context,
[FromServices] IOptions<RegistryTokenServiceOptions> options,

View File

@@ -0,0 +1,351 @@
// <copyright file="InMemoryPlanRuleStoreTests.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using Microsoft.Extensions.Time.Testing;
using StellaOps.Registry.TokenService.Admin;
using StellaOps.TestKit;
namespace StellaOps.Registry.TokenService.Tests.Admin;
public sealed class InMemoryPlanRuleStoreTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly InMemoryPlanRuleStore _store;
public InMemoryPlanRuleStoreTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero));
_store = new InMemoryPlanRuleStore(_timeProvider);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CreateAsync_CreatesNewPlan()
{
var request = new CreatePlanRequest
{
Name = "test-plan",
Description = "A test plan",
Repositories =
[
new RepositoryRuleDto
{
Pattern = "org/*",
Actions = ["pull"]
}
],
};
var result = await _store.CreateAsync(request, "test-user");
Assert.NotNull(result);
Assert.NotEmpty(result.Id);
Assert.Equal("test-plan", result.Name);
Assert.Equal("A test plan", result.Description);
Assert.True(result.Enabled);
Assert.Single(result.Repositories);
Assert.Equal(1, result.Version);
Assert.Equal(_timeProvider.GetUtcNow(), result.CreatedAt);
Assert.Equal(_timeProvider.GetUtcNow(), result.ModifiedAt);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CreateAsync_DuplicateName_ThrowsConflict()
{
var request = new CreatePlanRequest
{
Name = "duplicate-plan",
Repositories = [],
};
await _store.CreateAsync(request, "test-user");
await Assert.ThrowsAsync<PlanNameConflictException>(
() => _store.CreateAsync(request, "test-user"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetAllAsync_ReturnsPlansOrderedByName()
{
await _store.CreateAsync(new CreatePlanRequest { Name = "zebra" }, "user");
await _store.CreateAsync(new CreatePlanRequest { Name = "alpha" }, "user");
await _store.CreateAsync(new CreatePlanRequest { Name = "beta" }, "user");
var plans = await _store.GetAllAsync();
Assert.Equal(3, plans.Count);
Assert.Equal("alpha", plans[0].Name);
Assert.Equal("beta", plans[1].Name);
Assert.Equal("zebra", plans[2].Name);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetByIdAsync_ExistingId_ReturnsPlan()
{
var created = await _store.CreateAsync(new CreatePlanRequest { Name = "test" }, "user");
var result = await _store.GetByIdAsync(created.Id);
Assert.NotNull(result);
Assert.Equal(created.Id, result.Id);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetByIdAsync_NonExistingId_ReturnsNull()
{
var result = await _store.GetByIdAsync("non-existing-id");
Assert.Null(result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetByNameAsync_ExistingName_ReturnsPlan()
{
await _store.CreateAsync(new CreatePlanRequest { Name = "test-plan" }, "user");
var result = await _store.GetByNameAsync("test-plan");
Assert.NotNull(result);
Assert.Equal("test-plan", result.Name);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetByNameAsync_CaseInsensitive()
{
await _store.CreateAsync(new CreatePlanRequest { Name = "Test-Plan" }, "user");
var result = await _store.GetByNameAsync("TEST-PLAN");
Assert.NotNull(result);
Assert.Equal("Test-Plan", result.Name);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task UpdateAsync_UpdatesFields()
{
var created = await _store.CreateAsync(
new CreatePlanRequest { Name = "original", Description = "Original desc" },
"user");
_timeProvider.Advance(TimeSpan.FromMinutes(5));
var updateRequest = new UpdatePlanRequest
{
Name = "updated",
Description = "Updated desc",
Version = 1,
};
var updated = await _store.UpdateAsync(created.Id, updateRequest, "user");
Assert.Equal("updated", updated.Name);
Assert.Equal("Updated desc", updated.Description);
Assert.Equal(2, updated.Version);
Assert.Equal(created.CreatedAt, updated.CreatedAt);
Assert.True(updated.ModifiedAt > created.ModifiedAt);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task UpdateAsync_VersionMismatch_ThrowsConflict()
{
var created = await _store.CreateAsync(new CreatePlanRequest { Name = "test" }, "user");
var updateRequest = new UpdatePlanRequest
{
Name = "updated",
Version = 999, // Wrong version
};
await Assert.ThrowsAsync<PlanVersionConflictException>(
() => _store.UpdateAsync(created.Id, updateRequest, "user"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task UpdateAsync_NonExistingId_ThrowsNotFound()
{
var updateRequest = new UpdatePlanRequest
{
Name = "updated",
Version = 1,
};
await Assert.ThrowsAsync<PlanNotFoundException>(
() => _store.UpdateAsync("non-existing", updateRequest, "user"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task UpdateAsync_DuplicateName_ThrowsConflict()
{
await _store.CreateAsync(new CreatePlanRequest { Name = "plan-a" }, "user");
var planB = await _store.CreateAsync(new CreatePlanRequest { Name = "plan-b" }, "user");
var updateRequest = new UpdatePlanRequest
{
Name = "plan-a", // Trying to rename to existing name
Version = 1,
};
await Assert.ThrowsAsync<PlanNameConflictException>(
() => _store.UpdateAsync(planB.Id, updateRequest, "user"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task UpdateAsync_SameName_CaseInsensitive_DoesNotConflict()
{
var created = await _store.CreateAsync(new CreatePlanRequest { Name = "Test-Plan" }, "user");
var updateRequest = new UpdatePlanRequest
{
Name = "TEST-PLAN", // Same name, different case
Version = 1,
};
var updated = await _store.UpdateAsync(created.Id, updateRequest, "user");
Assert.Equal("TEST-PLAN", updated.Name);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task UpdateAsync_PartialUpdate_PreservesUnchangedFields()
{
var created = await _store.CreateAsync(
new CreatePlanRequest
{
Name = "test",
Description = "Original",
Repositories =
[
new RepositoryRuleDto { Pattern = "org/*", Actions = ["pull"] }
],
},
"user");
var updateRequest = new UpdatePlanRequest
{
Description = "Updated",
Version = 1,
};
var updated = await _store.UpdateAsync(created.Id, updateRequest, "user");
Assert.Equal("test", updated.Name);
Assert.Equal("Updated", updated.Description);
Assert.Single(updated.Repositories);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DeleteAsync_ExistingId_ReturnsTrue()
{
var created = await _store.CreateAsync(new CreatePlanRequest { Name = "test" }, "user");
var result = await _store.DeleteAsync(created.Id, "user");
Assert.True(result);
var fetched = await _store.GetByIdAsync(created.Id);
Assert.Null(fetched);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DeleteAsync_NonExistingId_ReturnsFalse()
{
var result = await _store.DeleteAsync("non-existing", "user");
Assert.False(result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetAuditHistoryAsync_ReturnsEntriesOrderedByTimestampDesc()
{
var plan1 = await _store.CreateAsync(new CreatePlanRequest { Name = "plan1" }, "user1");
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await _store.UpdateAsync(plan1.Id, new UpdatePlanRequest { Description = "Updated", Version = 1 }, "user2");
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await _store.DeleteAsync(plan1.Id, "user3");
var history = await _store.GetAuditHistoryAsync();
Assert.Equal(3, history.Count);
Assert.Equal("Deleted", history[0].Action);
Assert.Equal("user3", history[0].Actor);
Assert.Equal("Updated", history[1].Action);
Assert.Equal("user2", history[1].Actor);
Assert.Equal("Created", history[2].Action);
Assert.Equal("user1", history[2].Actor);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetAuditHistoryAsync_FilterByPlanId()
{
var plan1 = await _store.CreateAsync(new CreatePlanRequest { Name = "plan1" }, "user");
var plan2 = await _store.CreateAsync(new CreatePlanRequest { Name = "plan2" }, "user");
await _store.UpdateAsync(plan1.Id, new UpdatePlanRequest { Description = "Updated", Version = 1 }, "user");
var history = await _store.GetAuditHistoryAsync(planId: plan1.Id);
Assert.Equal(2, history.Count);
Assert.All(history, e => Assert.Equal(plan1.Id, e.PlanId));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetAuditHistoryAsync_Pagination()
{
for (int i = 0; i < 10; i++)
{
await _store.CreateAsync(new CreatePlanRequest { Name = $"plan{i}" }, "user");
_timeProvider.Advance(TimeSpan.FromSeconds(1));
}
var page1 = await _store.GetAuditHistoryAsync(page: 1, pageSize: 3);
var page2 = await _store.GetAuditHistoryAsync(page: 2, pageSize: 3);
Assert.Equal(3, page1.Count);
Assert.Equal(3, page2.Count);
// Verify different entries
Assert.DoesNotContain(page1, e => page2.Any(p2 => p2.Id == e.Id));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task AuditEntry_IncludesVersionInfo()
{
var plan = await _store.CreateAsync(new CreatePlanRequest { Name = "test" }, "user");
await _store.UpdateAsync(plan.Id, new UpdatePlanRequest { Description = "v2", Version = 1 }, "user");
await _store.UpdateAsync(plan.Id, new UpdatePlanRequest { Description = "v3", Version = 2 }, "user");
var history = await _store.GetAuditHistoryAsync(planId: plan.Id);
var createEntry = history.Single(e => e.Action == "Created");
Assert.Null(createEntry.PreviousVersion);
Assert.Equal(1, createEntry.NewVersion);
var updateEntries = history.Where(e => e.Action == "Updated").OrderBy(e => e.Timestamp).ToList();
Assert.Equal(1, updateEntries[0].PreviousVersion);
Assert.Equal(2, updateEntries[0].NewVersion);
Assert.Equal(2, updateEntries[1].PreviousVersion);
Assert.Equal(3, updateEntries[1].NewVersion);
}
}

View File

@@ -0,0 +1,390 @@
// <copyright file="PlanAdminEndpointsTests.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Registry.TokenService.Admin;
using StellaOps.TestKit;
namespace StellaOps.Registry.TokenService.Tests.Admin;
public sealed class PlanAdminEndpointsTests : IClassFixture<PlanAdminEndpointsTests.TestWebApplicationFactory>
{
private readonly TestWebApplicationFactory _factory;
public PlanAdminEndpointsTests(TestWebApplicationFactory factory)
{
_factory = factory;
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task ListPlans_ReturnsEmptyList_WhenNoPlans()
{
var client = _factory.CreateAuthenticatedClient();
var response = await client.GetAsync("/api/admin/plans/");
response.EnsureSuccessStatusCode();
var plans = await response.Content.ReadFromJsonAsync<List<PlanRuleDto>>();
Assert.NotNull(plans);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task CreatePlan_ReturnsCreated()
{
var client = _factory.CreateAuthenticatedClient();
var request = new CreatePlanRequest
{
Name = $"test-plan-{Guid.NewGuid():N}",
Description = "A test plan",
Repositories =
[
new RepositoryRuleDto
{
Pattern = "org/*",
Actions = ["pull"]
}
],
};
var response = await client.PostAsJsonAsync("/api/admin/plans/", request);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.NotNull(response.Headers.Location);
var created = await response.Content.ReadFromJsonAsync<PlanRuleDto>();
Assert.NotNull(created);
Assert.Equal(request.Name, created.Name);
Assert.Equal(1, created.Version);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task CreatePlan_DuplicateName_ReturnsConflict()
{
var client = _factory.CreateAuthenticatedClient();
var planName = $"duplicate-{Guid.NewGuid():N}";
var request = new CreatePlanRequest
{
Name = planName,
Repositories = [],
};
var response1 = await client.PostAsJsonAsync("/api/admin/plans/", request);
Assert.Equal(HttpStatusCode.Created, response1.StatusCode);
var response2 = await client.PostAsJsonAsync("/api/admin/plans/", request);
Assert.Equal(HttpStatusCode.Conflict, response2.StatusCode);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task CreatePlan_InvalidRequest_ReturnsBadRequest()
{
var client = _factory.CreateAuthenticatedClient();
var request = new CreatePlanRequest
{
Name = "", // Empty name
Repositories = [],
};
var response = await client.PostAsJsonAsync("/api/admin/plans/", request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task GetPlan_ExistingPlan_ReturnsPlan()
{
var client = _factory.CreateAuthenticatedClient();
var createRequest = new CreatePlanRequest
{
Name = $"get-test-{Guid.NewGuid():N}",
Repositories = [],
};
var createResponse = await client.PostAsJsonAsync("/api/admin/plans/", createRequest);
var created = await createResponse.Content.ReadFromJsonAsync<PlanRuleDto>();
var getResponse = await client.GetAsync($"/api/admin/plans/{created!.Id}");
getResponse.EnsureSuccessStatusCode();
var fetched = await getResponse.Content.ReadFromJsonAsync<PlanRuleDto>();
Assert.NotNull(fetched);
Assert.Equal(created.Id, fetched.Id);
Assert.Equal(created.Name, fetched.Name);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task GetPlan_NonExisting_ReturnsNotFound()
{
var client = _factory.CreateAuthenticatedClient();
var response = await client.GetAsync("/api/admin/plans/non-existing-id");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task UpdatePlan_UpdatesFields()
{
var client = _factory.CreateAuthenticatedClient();
var createRequest = new CreatePlanRequest
{
Name = $"update-test-{Guid.NewGuid():N}",
Description = "Original",
Repositories = [],
};
var createResponse = await client.PostAsJsonAsync("/api/admin/plans/", createRequest);
var created = await createResponse.Content.ReadFromJsonAsync<PlanRuleDto>();
var updateRequest = new UpdatePlanRequest
{
Description = "Updated",
Version = 1,
};
var updateResponse = await client.PutAsJsonAsync($"/api/admin/plans/{created!.Id}", updateRequest);
updateResponse.EnsureSuccessStatusCode();
var updated = await updateResponse.Content.ReadFromJsonAsync<PlanRuleDto>();
Assert.NotNull(updated);
Assert.Equal("Updated", updated.Description);
Assert.Equal(2, updated.Version);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task UpdatePlan_VersionMismatch_ReturnsConflict()
{
var client = _factory.CreateAuthenticatedClient();
var createRequest = new CreatePlanRequest
{
Name = $"version-test-{Guid.NewGuid():N}",
Repositories = [],
};
var createResponse = await client.PostAsJsonAsync("/api/admin/plans/", createRequest);
var created = await createResponse.Content.ReadFromJsonAsync<PlanRuleDto>();
var updateRequest = new UpdatePlanRequest
{
Description = "Updated",
Version = 999, // Wrong version
};
var updateResponse = await client.PutAsJsonAsync($"/api/admin/plans/{created!.Id}", updateRequest);
Assert.Equal(HttpStatusCode.Conflict, updateResponse.StatusCode);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task DeletePlan_ExistingPlan_ReturnsNoContent()
{
var client = _factory.CreateAuthenticatedClient();
var createRequest = new CreatePlanRequest
{
Name = $"delete-test-{Guid.NewGuid():N}",
Repositories = [],
};
var createResponse = await client.PostAsJsonAsync("/api/admin/plans/", createRequest);
var created = await createResponse.Content.ReadFromJsonAsync<PlanRuleDto>();
var deleteResponse = await client.DeleteAsync($"/api/admin/plans/{created!.Id}");
Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
var getResponse = await client.GetAsync($"/api/admin/plans/{created.Id}");
Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task DeletePlan_NonExisting_ReturnsNotFound()
{
var client = _factory.CreateAuthenticatedClient();
var response = await client.DeleteAsync("/api/admin/plans/non-existing-id");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task ValidatePlan_ValidPlan_ReturnsValid()
{
var client = _factory.CreateAuthenticatedClient();
var request = new ValidatePlanRequest
{
Plan = new CreatePlanRequest
{
Name = "test-plan",
Repositories =
[
new RepositoryRuleDto
{
Pattern = "org/*",
Actions = ["pull"]
}
],
}
};
var response = await client.PostAsJsonAsync("/api/admin/plans/validate", request);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ValidationResult>();
Assert.NotNull(result);
Assert.True(result.Valid);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task ValidatePlan_InvalidPlan_ReturnsInvalid()
{
var client = _factory.CreateAuthenticatedClient();
var request = new ValidatePlanRequest
{
Plan = new CreatePlanRequest
{
Name = "",
Repositories = [],
}
};
var response = await client.PostAsJsonAsync("/api/admin/plans/validate", request);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ValidationResult>();
Assert.NotNull(result);
Assert.False(result.Valid);
Assert.NotEmpty(result.Errors);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task GetAuditHistory_ReturnsEntries()
{
var client = _factory.CreateAuthenticatedClient();
// Create a plan to generate audit entries
var createRequest = new CreatePlanRequest
{
Name = $"audit-test-{Guid.NewGuid():N}",
Repositories = [],
};
await client.PostAsJsonAsync("/api/admin/plans/", createRequest);
var response = await client.GetAsync("/api/admin/plans/audit");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<PaginatedResponse<PlanAuditEntry>>();
Assert.NotNull(result);
Assert.NotEmpty(result.Items);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Endpoints_Unauthenticated_ReturnsUnauthorized()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/admin/plans/");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
public sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");
builder.ConfigureTestServices(services =>
{
// Replace TimeProvider with fake for deterministic tests
services.AddSingleton<TimeProvider>(new FakeTimeProvider(
new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero)));
// Add test authentication
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", _ => { });
// Configure authorization to use test scheme
services.PostConfigure<Microsoft.AspNetCore.Authorization.AuthorizationOptions>(options =>
{
foreach (var policyName in new[] { "registry.admin", "registry.token.issue" })
{
var existingPolicy = options.GetPolicy(policyName);
if (existingPolicy != null)
{
var newPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.AddAuthenticationSchemes("Test")
.Build();
options.AddPolicy(policyName, newPolicy);
}
}
});
});
}
public HttpClient CreateAuthenticatedClient()
{
var client = CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Test");
return client;
}
}
private sealed class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder) : base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.Authorization.Any())
{
return Task.FromResult(AuthenticateResult.NoResult());
}
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, "test-user"),
new Claim(ClaimTypes.Name, "Test User"),
new Claim("scope", "registry.admin"),
};
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, "Test");
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
}

View File

@@ -0,0 +1,342 @@
// <copyright file="PlanValidatorTests.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using StellaOps.Registry.TokenService.Admin;
using StellaOps.TestKit;
namespace StellaOps.Registry.TokenService.Tests.Admin;
public sealed class PlanValidatorTests
{
private readonly PlanValidator _validator = new();
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_ValidPlan_ReturnsValid()
{
var request = new ValidatePlanRequest
{
Plan = new CreatePlanRequest
{
Name = "test-plan",
Description = "A test plan",
Repositories =
[
new RepositoryRuleDto
{
Pattern = "org/*",
Actions = ["pull", "push"]
}
],
}
};
var result = _validator.Validate(request);
Assert.True(result.Valid);
Assert.Empty(result.Errors);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_EmptyName_ReturnsError()
{
var request = new ValidatePlanRequest
{
Plan = new CreatePlanRequest
{
Name = "",
Repositories =
[
new RepositoryRuleDto
{
Pattern = "org/*",
Actions = ["pull"]
}
],
}
};
var result = _validator.Validate(request);
Assert.False(result.Valid);
Assert.Contains(result.Errors, e => e.Field == "plan.name");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_NameTooLong_ReturnsError()
{
var request = new ValidatePlanRequest
{
Plan = new CreatePlanRequest
{
Name = new string('x', 200),
Repositories =
[
new RepositoryRuleDto
{
Pattern = "org/*",
Actions = ["pull"]
}
],
}
};
var result = _validator.Validate(request);
Assert.False(result.Valid);
Assert.Contains(result.Errors, e => e.Field == "plan.name" && e.Message.Contains("128"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_NoRepositories_ReturnsWarning()
{
var request = new ValidatePlanRequest
{
Plan = new CreatePlanRequest
{
Name = "empty-plan",
Repositories = [],
}
};
var result = _validator.Validate(request);
Assert.True(result.Valid);
Assert.Contains(result.Warnings, w => w.Contains("no repository rules"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_EmptyPattern_ReturnsError()
{
var request = new ValidatePlanRequest
{
Plan = new CreatePlanRequest
{
Name = "test-plan",
Repositories =
[
new RepositoryRuleDto
{
Pattern = "",
Actions = ["pull"]
}
],
}
};
var result = _validator.Validate(request);
Assert.False(result.Valid);
Assert.Contains(result.Errors, e => e.Field == "plan.repositories[0].pattern");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_NoActions_ReturnsError()
{
var request = new ValidatePlanRequest
{
Plan = new CreatePlanRequest
{
Name = "test-plan",
Repositories =
[
new RepositoryRuleDto
{
Pattern = "org/*",
Actions = []
}
],
}
};
var result = _validator.Validate(request);
Assert.False(result.Valid);
Assert.Contains(result.Errors, e => e.Field == "plan.repositories[0].actions");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_InvalidAction_ReturnsError()
{
var request = new ValidatePlanRequest
{
Plan = new CreatePlanRequest
{
Name = "test-plan",
Repositories =
[
new RepositoryRuleDto
{
Pattern = "org/*",
Actions = ["pull", "invalid-action"]
}
],
}
};
var result = _validator.Validate(request);
Assert.False(result.Valid);
Assert.Contains(result.Errors, e => e.Message.Contains("invalid-action"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_DoubleWildcard_ReturnsError()
{
var request = new ValidatePlanRequest
{
Plan = new CreatePlanRequest
{
Name = "test-plan",
Repositories =
[
new RepositoryRuleDto
{
Pattern = "org/**",
Actions = ["pull"]
}
],
}
};
var result = _validator.Validate(request);
Assert.False(result.Valid);
Assert.Contains(result.Errors, e => e.Message.Contains("Double wildcards"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_InvalidRateLimit_ReturnsError()
{
var request = new ValidatePlanRequest
{
Plan = new CreatePlanRequest
{
Name = "test-plan",
Repositories =
[
new RepositoryRuleDto
{
Pattern = "org/*",
Actions = ["pull"]
}
],
RateLimit = new RateLimitDto
{
MaxRequests = 0,
WindowSeconds = -1
}
}
};
var result = _validator.Validate(request);
Assert.False(result.Valid);
Assert.Contains(result.Errors, e => e.Field == "plan.rateLimit.maxRequests");
Assert.Contains(result.Errors, e => e.Field == "plan.rateLimit.windowSeconds");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_WithTestScopes_EvaluatesCorrectly()
{
var request = new ValidatePlanRequest
{
Plan = new CreatePlanRequest
{
Name = "test-plan",
Repositories =
[
new RepositoryRuleDto
{
Pattern = "org/public/*",
Actions = ["pull"]
},
new RepositoryRuleDto
{
Pattern = "org/private/*",
Actions = ["pull", "push"]
}
],
},
TestScopes =
[
new TestScopeRequest
{
Repository = "org/public/app",
Actions = ["pull"]
},
new TestScopeRequest
{
Repository = "org/private/app",
Actions = ["push"]
},
new TestScopeRequest
{
Repository = "other/repo",
Actions = ["pull"]
}
]
};
var result = _validator.Validate(request);
Assert.True(result.Valid);
Assert.NotNull(result.TestResults);
Assert.Equal(3, result.TestResults.Count);
Assert.True(result.TestResults[0].Allowed);
Assert.Equal("org/public/*", result.TestResults[0].MatchedPattern);
Assert.True(result.TestResults[1].Allowed);
Assert.Equal("org/private/*", result.TestResults[1].MatchedPattern);
Assert.False(result.TestResults[2].Allowed);
Assert.Null(result.TestResults[2].MatchedPattern);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_WildcardAction_AllowsAllActions()
{
var request = new ValidatePlanRequest
{
Plan = new CreatePlanRequest
{
Name = "test-plan",
Repositories =
[
new RepositoryRuleDto
{
Pattern = "org/*",
Actions = ["*"]
}
],
},
TestScopes =
[
new TestScopeRequest
{
Repository = "org/app",
Actions = ["pull", "push", "delete"]
}
]
};
var result = _validator.Validate(request);
Assert.True(result.Valid);
Assert.NotNull(result.TestResults);
Assert.Single(result.TestResults);
Assert.True(result.TestResults[0].Allowed);
}
}