151 lines
6.7 KiB
C#
151 lines
6.7 KiB
C#
using System.Text.Json.Nodes;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace MoShiro.Backend.Tests;
|
|
|
|
/// <summary>
|
|
/// 对照 docs/api/api-guidelines.md 检查运行时生成的 OpenAPI 文档:operationId、路径风格、错误响应格式、字段命名。
|
|
/// 新增端点违反规范时,这里会直接失败(等价于文档里的 <c>eng/check-openapi</c>)。
|
|
/// </summary>
|
|
[Collection("backend")]
|
|
public sealed partial class OpenApiConformanceTests
|
|
{
|
|
private readonly BackendFixture _backend;
|
|
|
|
public OpenApiConformanceTests(BackendFixture backend)
|
|
{
|
|
_backend = backend;
|
|
}
|
|
|
|
private async Task<JsonObject> DocumentAsync() =>
|
|
(await _backend.GetJsonAsync(_backend.Anonymous, "/openapi/v1.json")).AsObject();
|
|
|
|
private static IEnumerable<(string Path, string Method, JsonObject Operation)> Operations(JsonObject doc)
|
|
{
|
|
foreach ((string path, JsonNode? item) in doc["paths"]!.AsObject())
|
|
{
|
|
foreach ((string method, JsonNode? operation) in item!.AsObject())
|
|
{
|
|
yield return (path, method, operation!.AsObject());
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EveryOperation_HasAUniqueSnakeCaseOperationId_ASummaryAndATag()
|
|
{
|
|
JsonObject doc = await DocumentAsync();
|
|
var ids = new HashSet<string>();
|
|
foreach ((string path, string method, JsonObject operation) in Operations(doc))
|
|
{
|
|
string id = operation["operationId"]?.GetValue<string>() ?? "";
|
|
Assert.True(OperationIdPattern().IsMatch(id), $"{method} {path}: operationId '{id}' must be snake_case (resource_action)");
|
|
Assert.True(ids.Add(id), $"duplicate operationId {id}");
|
|
Assert.False(string.IsNullOrWhiteSpace(operation["summary"]?.GetValue<string>()), $"{method} {path}: summary");
|
|
Assert.NotEmpty(operation["tags"]!.AsArray());
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Paths_AreLowercaseKebabCase_UnderTheDocumentedPrefixes()
|
|
{
|
|
JsonObject doc = await DocumentAsync();
|
|
foreach ((string path, _) in doc["paths"]!.AsObject())
|
|
{
|
|
string[] prefixes = ["/api/public/", "/api/admin/", "/api/agent/v1/"];
|
|
string[] roots = ["/healthz", "/metrics", "/install", "/install.ps1", "/install/manifest", "/install/manifest.sig", "/install/compose/{file}", "/install/quadlet/{file}", "/api/release/offer"];
|
|
Assert.True(prefixes.Any(p => path.StartsWith(p, StringComparison.Ordinal)) || roots.Contains(path), $"{path} is outside the documented layers");
|
|
foreach (string segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries))
|
|
{
|
|
Assert.True(segment.StartsWith('{') || PathSegmentPattern().IsMatch(segment), $"{path}: '{segment}' must be lowercase kebab-case");
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ErrorResponses_AreProblemJsonWithTheSharedSchema()
|
|
{
|
|
JsonObject doc = await DocumentAsync();
|
|
foreach ((string path, string method, JsonObject operation) in Operations(doc))
|
|
{
|
|
foreach ((string status, JsonNode? response) in operation["responses"]!.AsObject())
|
|
{
|
|
if (!status.StartsWith('4') && !status.StartsWith('5'))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
JsonObject? content = response?["content"]?.AsObject();
|
|
if (content is null)
|
|
{
|
|
continue; // 没有声明正文的框架错误
|
|
}
|
|
|
|
Assert.True(content.ContainsKey("application/problem+json"), $"{method} {path} {status} must use application/problem+json");
|
|
Assert.Equal("#/components/schemas/ProblemDto", content["application/problem+json"]!["schema"]!["$ref"]!.GetValue<string>());
|
|
}
|
|
}
|
|
|
|
JsonObject problem = doc["components"]!["schemas"]!["ProblemDto"]!.AsObject();
|
|
foreach (string required in new[] { "type", "title", "status", "code", "trace_id" })
|
|
{
|
|
Assert.NotNull(problem["properties"]![required]);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SchemaProperties_AreSnakeCase_AndResourcesAreStronglyTyped()
|
|
{
|
|
JsonObject doc = await DocumentAsync();
|
|
foreach ((string name, JsonNode? schema) in doc["components"]!["schemas"]!.AsObject())
|
|
{
|
|
JsonObject? properties = schema?["properties"]?.AsObject();
|
|
if (properties is null || name is "ProblemDetails" or "HttpValidationProblemDetails")
|
|
{
|
|
continue;
|
|
}
|
|
|
|
foreach ((string property, _) in properties)
|
|
{
|
|
Assert.True(PropertyPattern().IsMatch(property), $"{name}.{property} must be snake_case");
|
|
}
|
|
}
|
|
|
|
// 指标值是字典(metrics: { id: number }),新增指标不需要改 OpenAPI 和前端类型
|
|
JsonObject summary = doc["components"]!["schemas"]!["SummaryDto"]!["properties"]!["metrics"]!.AsObject();
|
|
Assert.True(summary.ContainsKey("additionalProperties"));
|
|
JsonObject series = doc["components"]!["schemas"]!["SeriesDto"]!["properties"]!["values"]!.AsObject();
|
|
Assert.True(series.ContainsKey("additionalProperties"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EveryAdminOperation_DocumentsItsAuthenticationFailures()
|
|
{
|
|
JsonObject doc = await DocumentAsync();
|
|
foreach ((string path, string method, JsonObject operation) in Operations(doc).Where(o => o.Path.StartsWith("/api/admin/", StringComparison.Ordinal) && o.Path is not ("/api/admin/auth/login" or "/api/admin/auth/passkey/login" or "/api/admin/auth/passkey/options" or "/api/admin/auth/setup")))
|
|
{
|
|
JsonObject responses = operation["responses"]!.AsObject();
|
|
Assert.True(responses.ContainsKey("401"), $"{method} {path} must document 401");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreatedResources_DeclareA201_AndAcceptedOnesA202()
|
|
{
|
|
JsonObject doc = await DocumentAsync();
|
|
Assert.True(doc["paths"]!["/api/admin/tasks"]!["post"]!["responses"]!.AsObject().ContainsKey("201"));
|
|
Assert.True(doc["paths"]!["/api/admin/join-tokens"]!["post"]!["responses"]!.AsObject().ContainsKey("201"));
|
|
Assert.True(doc["paths"]!["/api/admin/lines/{line_id}/tests"]!["post"]!["responses"]!.AsObject().ContainsKey("202"));
|
|
Assert.True(doc["paths"]!["/api/admin/tasks/{id}"]!["delete"]!["responses"]!.AsObject().ContainsKey("204"));
|
|
}
|
|
|
|
[GeneratedRegex("^[a-z0-9]+(_[a-z0-9]+)+$")]
|
|
private static partial Regex OperationIdPattern();
|
|
|
|
[GeneratedRegex("^[a-z0-9]+(-[a-z0-9]+)*(\\.[a-z0-9]+)?$")]
|
|
private static partial Regex PathSegmentPattern();
|
|
|
|
[GeneratedRegex("^[a-z0-9_]+$")]
|
|
private static partial Regex PropertyPattern();
|
|
}
|