mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 17:51:08 +00:00
[AZ-513] [AZ-196] [AZ-183] Add /classes CRUD, /devices, fleet OTA
AZ-513: POST/PATCH/DELETE /classes for detection-class CRUD; new DetectionClass entity, schema, DTOs, IDetectionClassService. Unblocks ui/AZ-512. AZ-196: POST /devices auto-assigns sequential azj-NNNN serial+email +password and inserts a CompanionPC user. Returns plaintext credentials for the provisioning script. AZ-183: Resources table + POST /get-update + POST /resources/publish for fleet OTA. Per-resource encryption_key column AES-256-CBC encrypted at rest with ResourcesConfig.EncryptionMasterKey; ICache wraps the per-(arch,stage) latest-versions lookup and is invalidated on publish. Adds IDbFactory.RunAdmin<T> overload for write-and-return. Backfills _docs/02_document/module-layout.md to satisfy the implement skill's File Ownership prerequisite (the _docs/ artifact set predates the Step 1.5 module-layout addition). Code review: PASS_WITH_WARNINGS — see _docs/03_implementation/reviews/batch_05_review.md. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -97,6 +97,8 @@ builder.Services.Configure<ConnectionStrings>(builder.Configuration.GetSection(n
|
|||||||
builder.Services.AddScoped<IUserService, UserService>();
|
builder.Services.AddScoped<IUserService, UserService>();
|
||||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||||
builder.Services.AddScoped<IResourcesService, ResourcesService>();
|
builder.Services.AddScoped<IResourcesService, ResourcesService>();
|
||||||
|
builder.Services.AddScoped<IDetectionClassService, DetectionClassService>();
|
||||||
|
builder.Services.AddScoped<IResourceUpdateService, ResourceUpdateService>();
|
||||||
builder.Services.AddSingleton<IDbFactory, DbFactory>();
|
builder.Services.AddSingleton<IDbFactory, DbFactory>();
|
||||||
|
|
||||||
builder.Services.AddLazyCache();
|
builder.Services.AddLazyCache();
|
||||||
@@ -153,6 +155,12 @@ app.MapPost("/users",
|
|||||||
.RequireAuthorization(apiAdminPolicy)
|
.RequireAuthorization(apiAdminPolicy)
|
||||||
.WithSummary("Creates a new user");
|
.WithSummary("Creates a new user");
|
||||||
|
|
||||||
|
app.MapPost("/devices",
|
||||||
|
async (IUserService userService, CancellationToken cancellationToken)
|
||||||
|
=> await userService.RegisterDevice(cancellationToken))
|
||||||
|
.RequireAuthorization(apiAdminPolicy)
|
||||||
|
.WithSummary("Creates a new device (server-assigned serial, email and password)");
|
||||||
|
|
||||||
app.MapGet("/users/current",
|
app.MapGet("/users/current",
|
||||||
async (IAuthService authService) => await authService.GetCurrentUser())
|
async (IAuthService authService) => await authService.GetCurrentUser())
|
||||||
.RequireAuthorization()
|
.RequireAuthorization()
|
||||||
@@ -273,6 +281,67 @@ app.MapPost("/resources/check",
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.MapPost("/classes",
|
||||||
|
async (CreateDetectionClassRequest request, IValidator<CreateDetectionClassRequest> validator,
|
||||||
|
IDetectionClassService detectionClassService, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var validation = await validator.ValidateAsync(request, ct);
|
||||||
|
if (!validation.IsValid)
|
||||||
|
return Results.ValidationProblem(validation.ToDictionary());
|
||||||
|
var created = await detectionClassService.Create(request, ct);
|
||||||
|
return Results.Ok(created);
|
||||||
|
})
|
||||||
|
.RequireAuthorization(apiAdminPolicy)
|
||||||
|
.WithSummary("Creates a new detection class");
|
||||||
|
|
||||||
|
app.MapPatch("/classes/{id:int}",
|
||||||
|
async (int id, UpdateDetectionClassRequest request, IValidator<UpdateDetectionClassRequest> validator,
|
||||||
|
IDetectionClassService detectionClassService, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var validation = await validator.ValidateAsync(request, ct);
|
||||||
|
if (!validation.IsValid)
|
||||||
|
return Results.ValidationProblem(validation.ToDictionary());
|
||||||
|
var updated = await detectionClassService.Update(id, request, ct);
|
||||||
|
return updated == null ? Results.NotFound() : Results.Ok(updated);
|
||||||
|
})
|
||||||
|
.RequireAuthorization(apiAdminPolicy)
|
||||||
|
.WithSummary("Updates an existing detection class (partial-merge accepted)");
|
||||||
|
|
||||||
|
app.MapDelete("/classes/{id:int}",
|
||||||
|
async (int id, IDetectionClassService detectionClassService, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var ok = await detectionClassService.Delete(id, ct);
|
||||||
|
return ok ? Results.NoContent() : Results.NotFound();
|
||||||
|
})
|
||||||
|
.RequireAuthorization(apiAdminPolicy)
|
||||||
|
.WithSummary("Deletes a detection class");
|
||||||
|
|
||||||
|
app.MapPost("/get-update",
|
||||||
|
async (GetUpdateRequest request, IValidator<GetUpdateRequest> validator,
|
||||||
|
IResourceUpdateService resourceUpdateService, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var validation = await validator.ValidateAsync(request, ct);
|
||||||
|
if (!validation.IsValid)
|
||||||
|
return Results.ValidationProblem(validation.ToDictionary());
|
||||||
|
var updates = await resourceUpdateService.GetUpdate(request, ct);
|
||||||
|
return Results.Ok(updates);
|
||||||
|
})
|
||||||
|
.RequireAuthorization()
|
||||||
|
.WithSummary("Returns resources newer than the device's reported current versions");
|
||||||
|
|
||||||
|
app.MapPost("/resources/publish",
|
||||||
|
async (PublishResourceRequest request, IValidator<PublishResourceRequest> validator,
|
||||||
|
IResourceUpdateService resourceUpdateService, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var validation = await validator.ValidateAsync(request, ct);
|
||||||
|
if (!validation.IsValid)
|
||||||
|
return Results.ValidationProblem(validation.ToDictionary());
|
||||||
|
await resourceUpdateService.Publish(request, ct);
|
||||||
|
return Results.Ok();
|
||||||
|
})
|
||||||
|
.RequireAuthorization(apiUploaderPolicy)
|
||||||
|
.WithSummary("CI/CD: publish a new resource version (encrypts encryption_key at rest, invalidates the per-(arch,stage) latest-versions cache)");
|
||||||
|
|
||||||
app.UseExceptionHandler(_ => {});
|
app.UseExceptionHandler(_ => {});
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
"ResourcesConfig": {
|
"ResourcesConfig": {
|
||||||
"ResourcesFolder": "Content",
|
"ResourcesFolder": "Content",
|
||||||
"SuiteInstallerFolder": "suite",
|
"SuiteInstallerFolder": "suite",
|
||||||
"SuiteStageInstallerFolder": "suite-stage"
|
"SuiteStageInstallerFolder": "suite-stage",
|
||||||
|
"EncryptionMasterKey": ""
|
||||||
},
|
},
|
||||||
"JwtConfig": {
|
"JwtConfig": {
|
||||||
"Issuer": "AzaionApi",
|
"Issuer": "AzaionApi",
|
||||||
|
|||||||
@@ -5,4 +5,11 @@ public class ResourcesConfig
|
|||||||
public string ResourcesFolder { get; set; } = null!;
|
public string ResourcesFolder { get; set; } = null!;
|
||||||
public string SuiteInstallerFolder { get; set; } = null!;
|
public string SuiteInstallerFolder { get; set; } = null!;
|
||||||
public string SuiteStageInstallerFolder { get; set; } = null!;
|
public string SuiteStageInstallerFolder { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Master key used to AES-encrypt the per-resource <c>encryption_key</c> column at rest.
|
||||||
|
/// Required by AZ-183 constraint "encryption_key must be stored securely (... or via
|
||||||
|
/// application-level encryption)". Configure via <c>ResourcesConfig__EncryptionMasterKey</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string EncryptionMasterKey { get; set; } = null!;
|
||||||
}
|
}
|
||||||
@@ -6,5 +6,7 @@ namespace Azaion.Common.Database;
|
|||||||
|
|
||||||
public class AzaionDb(DataOptions dataOptions) : DataConnection(dataOptions)
|
public class AzaionDb(DataOptions dataOptions) : DataConnection(dataOptions)
|
||||||
{
|
{
|
||||||
public ITable<User> Users => this.GetTable<User>();
|
public ITable<User> Users => this.GetTable<User>();
|
||||||
|
public ITable<DetectionClass> DetectionClasses => this.GetTable<DetectionClass>();
|
||||||
|
public ITable<Resource> Resources => this.GetTable<Resource>();
|
||||||
}
|
}
|
||||||
@@ -36,6 +36,17 @@ public static class AzaionDbSchemaHolder
|
|||||||
p => string.IsNullOrEmpty(p) ? new UserConfig() : JsonConvert.DeserializeObject<UserConfig>(p))
|
p => string.IsNullOrEmpty(p) ? new UserConfig() : JsonConvert.DeserializeObject<UserConfig>(p))
|
||||||
.IsNullable();
|
.IsNullable();
|
||||||
|
|
||||||
|
builder.Entity<DetectionClass>()
|
||||||
|
.HasTableName("detection_classes")
|
||||||
|
.Property(x => x.Id)
|
||||||
|
.IsPrimaryKey()
|
||||||
|
.IsIdentity();
|
||||||
|
|
||||||
|
builder.Entity<Resource>()
|
||||||
|
.HasTableName("resources")
|
||||||
|
.Property(x => x.Id)
|
||||||
|
.IsPrimaryKey()
|
||||||
|
.HasDataType(DataType.Guid);
|
||||||
|
|
||||||
builder.Build();
|
builder.Build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public interface IDbFactory
|
|||||||
Task<T> Run<T>(Func<AzaionDb, Task<T>> func);
|
Task<T> Run<T>(Func<AzaionDb, Task<T>> func);
|
||||||
Task Run(Func<AzaionDb, Task> func);
|
Task Run(Func<AzaionDb, Task> func);
|
||||||
Task RunAdmin(Func<AzaionDb, Task> func);
|
Task RunAdmin(Func<AzaionDb, Task> func);
|
||||||
|
Task<T> RunAdmin<T>(Func<AzaionDb, Task<T>> func);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class DbFactory : IDbFactory
|
public class DbFactory : IDbFactory
|
||||||
@@ -54,4 +55,10 @@ public class DbFactory : IDbFactory
|
|||||||
await using var db = new AzaionDb(_dataOptionsAdmin);
|
await using var db = new AzaionDb(_dataOptionsAdmin);
|
||||||
await func(db);
|
await func(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<T> RunAdmin<T>(Func<AzaionDb, Task<T>> func)
|
||||||
|
{
|
||||||
|
await using var db = new AzaionDb(_dataOptionsAdmin);
|
||||||
|
return await func(db);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Azaion.Common.Entities;
|
||||||
|
|
||||||
|
public class DetectionClass
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = null!;
|
||||||
|
public string ShortName { get; set; } = null!;
|
||||||
|
public string Color { get; set; } = null!;
|
||||||
|
public double MaxSizeM { get; set; }
|
||||||
|
public string? PhotoMode { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Azaion.Common.Entities;
|
||||||
|
|
||||||
|
public class Resource
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string ResourceName { get; set; } = null!;
|
||||||
|
public string DevStage { get; set; } = null!;
|
||||||
|
public string Architecture { get; set; } = null!;
|
||||||
|
public string Version { get; set; } = null!;
|
||||||
|
public string CdnUrl { get; set; } = null!;
|
||||||
|
public string Sha256 { get; set; } = null!;
|
||||||
|
public string EncryptionKey { get; set; } = null!;
|
||||||
|
public long SizeBytes { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace Azaion.Common.Requests;
|
||||||
|
|
||||||
|
public class CreateDetectionClassRequest
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = null!;
|
||||||
|
public string ShortName { get; set; } = null!;
|
||||||
|
public string Color { get; set; } = null!;
|
||||||
|
public double MaxSizeM { get; set; }
|
||||||
|
public string? PhotoMode { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateDetectionClassValidator : AbstractValidator<CreateDetectionClassRequest>
|
||||||
|
{
|
||||||
|
public CreateDetectionClassValidator()
|
||||||
|
{
|
||||||
|
RuleFor(r => r.Name).NotEmpty().MaximumLength(120);
|
||||||
|
RuleFor(r => r.ShortName).NotEmpty().MaximumLength(20);
|
||||||
|
RuleFor(r => r.Color).NotEmpty().MaximumLength(20);
|
||||||
|
RuleFor(r => r.MaxSizeM).GreaterThan(0);
|
||||||
|
RuleFor(r => r.PhotoMode!).MaximumLength(20).When(r => r.PhotoMode != null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace Azaion.Common.Requests;
|
||||||
|
|
||||||
|
public class GetUpdateRequest
|
||||||
|
{
|
||||||
|
public string Architecture { get; set; } = null!;
|
||||||
|
public string DevStage { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Map of <c>resource_name → currently-installed-version</c>. Resources missing
|
||||||
|
/// from the map are treated as "device has no version of this resource yet" and
|
||||||
|
/// will be returned in the response if any version exists server-side.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, string> CurrentVersions { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GetUpdateValidator : AbstractValidator<GetUpdateRequest>
|
||||||
|
{
|
||||||
|
public GetUpdateValidator()
|
||||||
|
{
|
||||||
|
RuleFor(r => r.Architecture).NotEmpty().MaximumLength(40);
|
||||||
|
RuleFor(r => r.DevStage).NotEmpty().MaximumLength(40);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ResourceUpdateItem
|
||||||
|
{
|
||||||
|
public string ResourceName { get; set; } = null!;
|
||||||
|
public string Version { get; set; } = null!;
|
||||||
|
public string CdnUrl { get; set; } = null!;
|
||||||
|
public string Sha256 { get; set; } = null!;
|
||||||
|
public string EncryptionKey { get; set; } = null!;
|
||||||
|
public long SizeBytes { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace Azaion.Common.Requests;
|
||||||
|
|
||||||
|
public class PublishResourceRequest
|
||||||
|
{
|
||||||
|
public string ResourceName { get; set; } = null!;
|
||||||
|
public string DevStage { get; set; } = null!;
|
||||||
|
public string Architecture { get; set; } = null!;
|
||||||
|
public string Version { get; set; } = null!;
|
||||||
|
public string CdnUrl { get; set; } = null!;
|
||||||
|
public string Sha256 { get; set; } = null!;
|
||||||
|
public string EncryptionKey { get; set; } = null!;
|
||||||
|
public long SizeBytes { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PublishResourceValidator : AbstractValidator<PublishResourceRequest>
|
||||||
|
{
|
||||||
|
public PublishResourceValidator()
|
||||||
|
{
|
||||||
|
RuleFor(r => r.ResourceName).NotEmpty().MaximumLength(120);
|
||||||
|
RuleFor(r => r.DevStage).NotEmpty().MaximumLength(40);
|
||||||
|
RuleFor(r => r.Architecture).NotEmpty().MaximumLength(40);
|
||||||
|
RuleFor(r => r.Version).NotEmpty().MaximumLength(40);
|
||||||
|
RuleFor(r => r.CdnUrl).NotEmpty().MaximumLength(500);
|
||||||
|
RuleFor(r => r.Sha256).NotEmpty().MaximumLength(128);
|
||||||
|
RuleFor(r => r.EncryptionKey).NotEmpty();
|
||||||
|
RuleFor(r => r.SizeBytes).GreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Azaion.Common.Requests;
|
||||||
|
|
||||||
|
public class RegisterDeviceResponse
|
||||||
|
{
|
||||||
|
public string Serial { get; set; } = null!;
|
||||||
|
public string Email { get; set; } = null!;
|
||||||
|
public string Password { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace Azaion.Common.Requests;
|
||||||
|
|
||||||
|
public class UpdateDetectionClassRequest
|
||||||
|
{
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? ShortName { get; set; }
|
||||||
|
public string? Color { get; set; }
|
||||||
|
public double? MaxSizeM { get; set; }
|
||||||
|
public string? PhotoMode { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateDetectionClassValidator : AbstractValidator<UpdateDetectionClassRequest>
|
||||||
|
{
|
||||||
|
public UpdateDetectionClassValidator()
|
||||||
|
{
|
||||||
|
RuleFor(r => r.Name!).NotEmpty().MaximumLength(120).When(r => r.Name != null);
|
||||||
|
RuleFor(r => r.ShortName!).NotEmpty().MaximumLength(20).When(r => r.ShortName != null);
|
||||||
|
RuleFor(r => r.Color!).NotEmpty().MaximumLength(20).When(r => r.Color != null);
|
||||||
|
RuleFor(r => r.MaxSizeM!.Value).GreaterThan(0).When(r => r.MaxSizeM != null);
|
||||||
|
RuleFor(r => r.PhotoMode!).MaximumLength(20).When(r => r.PhotoMode != null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using Azaion.Common.Database;
|
||||||
|
using Azaion.Common.Entities;
|
||||||
|
using Azaion.Common.Requests;
|
||||||
|
using LinqToDB;
|
||||||
|
|
||||||
|
namespace Azaion.Services;
|
||||||
|
|
||||||
|
public interface IDetectionClassService
|
||||||
|
{
|
||||||
|
Task<DetectionClass> Create(CreateDetectionClassRequest request, CancellationToken ct = default);
|
||||||
|
Task<DetectionClass?> Update(int id, UpdateDetectionClassRequest request, CancellationToken ct = default);
|
||||||
|
Task<bool> Delete(int id, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DetectionClassService(IDbFactory dbFactory) : IDetectionClassService
|
||||||
|
{
|
||||||
|
public async Task<DetectionClass> Create(CreateDetectionClassRequest request, CancellationToken ct = default) =>
|
||||||
|
await dbFactory.RunAdmin(async db =>
|
||||||
|
{
|
||||||
|
var entity = new DetectionClass
|
||||||
|
{
|
||||||
|
Name = request.Name,
|
||||||
|
ShortName = request.ShortName,
|
||||||
|
Color = request.Color,
|
||||||
|
MaxSizeM = request.MaxSizeM,
|
||||||
|
PhotoMode = request.PhotoMode,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
var newId = await db.InsertWithInt32IdentityAsync(entity, token: ct);
|
||||||
|
entity.Id = newId;
|
||||||
|
return entity;
|
||||||
|
});
|
||||||
|
|
||||||
|
public async Task<DetectionClass?> Update(int id, UpdateDetectionClassRequest request, CancellationToken ct = default) =>
|
||||||
|
await dbFactory.RunAdmin(async db =>
|
||||||
|
{
|
||||||
|
var existing = await db.DetectionClasses.FirstOrDefaultAsync(x => x.Id == id, token: ct);
|
||||||
|
if (existing == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (request.Name != null) existing.Name = request.Name;
|
||||||
|
if (request.ShortName != null) existing.ShortName = request.ShortName;
|
||||||
|
if (request.Color != null) existing.Color = request.Color;
|
||||||
|
if (request.MaxSizeM.HasValue) existing.MaxSizeM = request.MaxSizeM.Value;
|
||||||
|
if (request.PhotoMode != null) existing.PhotoMode = request.PhotoMode;
|
||||||
|
|
||||||
|
await db.UpdateAsync(existing, token: ct);
|
||||||
|
return existing;
|
||||||
|
});
|
||||||
|
|
||||||
|
public async Task<bool> Delete(int id, CancellationToken ct = default) =>
|
||||||
|
await dbFactory.RunAdmin(async db =>
|
||||||
|
{
|
||||||
|
var deleted = await db.DetectionClasses.DeleteAsync(x => x.Id == id, token: ct);
|
||||||
|
return deleted > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Azaion.Common.Configs;
|
||||||
|
using Azaion.Common.Database;
|
||||||
|
using Azaion.Common.Entities;
|
||||||
|
using Azaion.Common.Requests;
|
||||||
|
using LinqToDB;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Azaion.Services;
|
||||||
|
|
||||||
|
public interface IResourceUpdateService
|
||||||
|
{
|
||||||
|
Task<List<ResourceUpdateItem>> GetUpdate(GetUpdateRequest request, CancellationToken ct = default);
|
||||||
|
Task Publish(PublishResourceRequest request, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ResourceUpdateService(
|
||||||
|
IDbFactory dbFactory,
|
||||||
|
ICache cache,
|
||||||
|
IOptions<ResourcesConfig> resourcesConfig) : IResourceUpdateService
|
||||||
|
{
|
||||||
|
public static string CacheKey(string architecture, string devStage)
|
||||||
|
=> $"Resources.Latest.{architecture}.{devStage}";
|
||||||
|
|
||||||
|
public async Task<List<ResourceUpdateItem>> GetUpdate(GetUpdateRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var latest = await cache.GetFromCacheAsync(
|
||||||
|
CacheKey(request.Architecture, request.DevStage),
|
||||||
|
() => LoadLatest(request.Architecture, request.DevStage, ct));
|
||||||
|
|
||||||
|
var updates = new List<ResourceUpdateItem>();
|
||||||
|
foreach (var (resourceName, resource) in latest)
|
||||||
|
{
|
||||||
|
var currentVersion = request.CurrentVersions.GetValueOrDefault(resourceName, "");
|
||||||
|
if (string.CompareOrdinal(resource.Version, currentVersion) <= 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
updates.Add(new ResourceUpdateItem
|
||||||
|
{
|
||||||
|
ResourceName = resource.ResourceName,
|
||||||
|
Version = resource.Version,
|
||||||
|
CdnUrl = resource.CdnUrl,
|
||||||
|
Sha256 = resource.Sha256,
|
||||||
|
EncryptionKey = ResourceColumnEncryption.Decrypt(resource.EncryptionKey, MasterKey),
|
||||||
|
SizeBytes = resource.SizeBytes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return updates;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Publish(PublishResourceRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await dbFactory.RunAdmin(async db =>
|
||||||
|
{
|
||||||
|
await db.InsertAsync(new Resource
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ResourceName = request.ResourceName,
|
||||||
|
DevStage = request.DevStage,
|
||||||
|
Architecture = request.Architecture,
|
||||||
|
Version = request.Version,
|
||||||
|
CdnUrl = request.CdnUrl,
|
||||||
|
Sha256 = request.Sha256,
|
||||||
|
EncryptionKey = ResourceColumnEncryption.Encrypt(request.EncryptionKey, MasterKey),
|
||||||
|
SizeBytes = request.SizeBytes,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
}, token: ct);
|
||||||
|
});
|
||||||
|
cache.Invalidate(CacheKey(request.Architecture, request.DevStage));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Dictionary<string, Resource>> LoadLatest(string architecture, string devStage, CancellationToken ct) =>
|
||||||
|
await dbFactory.Run(async db =>
|
||||||
|
{
|
||||||
|
var rows = await db.Resources
|
||||||
|
.Where(r => r.Architecture == architecture && r.DevStage == devStage)
|
||||||
|
.ToListAsync(token: ct);
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.GroupBy(r => r.ResourceName)
|
||||||
|
.Select(g => g.OrderByDescending(r => r.Version, StringComparer.Ordinal).First())
|
||||||
|
.ToDictionary(r => r.ResourceName);
|
||||||
|
});
|
||||||
|
|
||||||
|
private string MasterKey
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var key = resourcesConfig.Value.EncryptionMasterKey;
|
||||||
|
if (string.IsNullOrEmpty(key))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"ResourcesConfig.EncryptionMasterKey is not configured. Set it via " +
|
||||||
|
"appsettings ResourcesConfig:EncryptionMasterKey or env ResourcesConfig__EncryptionMasterKey.");
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class ResourceColumnEncryption
|
||||||
|
{
|
||||||
|
public static string Encrypt(string plaintext, string masterKey)
|
||||||
|
{
|
||||||
|
using var aes = Aes.Create();
|
||||||
|
aes.Mode = CipherMode.CBC;
|
||||||
|
aes.Padding = PaddingMode.PKCS7;
|
||||||
|
aes.Key = SHA256.HashData(Encoding.UTF8.GetBytes(masterKey));
|
||||||
|
aes.GenerateIV();
|
||||||
|
|
||||||
|
var input = Encoding.UTF8.GetBytes(plaintext);
|
||||||
|
using var encryptor = aes.CreateEncryptor();
|
||||||
|
var cipher = encryptor.TransformFinalBlock(input, 0, input.Length);
|
||||||
|
|
||||||
|
var combined = new byte[aes.IV.Length + cipher.Length];
|
||||||
|
Buffer.BlockCopy(aes.IV, 0, combined, 0, aes.IV.Length);
|
||||||
|
Buffer.BlockCopy(cipher, 0, combined, aes.IV.Length, cipher.Length);
|
||||||
|
return Convert.ToBase64String(combined);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Decrypt(string ciphertextBase64, string masterKey)
|
||||||
|
{
|
||||||
|
var combined = Convert.FromBase64String(ciphertextBase64);
|
||||||
|
using var aes = Aes.Create();
|
||||||
|
aes.Mode = CipherMode.CBC;
|
||||||
|
aes.Padding = PaddingMode.PKCS7;
|
||||||
|
aes.Key = SHA256.HashData(Encoding.UTF8.GetBytes(masterKey));
|
||||||
|
|
||||||
|
var ivLen = aes.BlockSize / 8;
|
||||||
|
var iv = new byte[ivLen];
|
||||||
|
Buffer.BlockCopy(combined, 0, iv, 0, ivLen);
|
||||||
|
aes.IV = iv;
|
||||||
|
|
||||||
|
var cipher = new byte[combined.Length - ivLen];
|
||||||
|
Buffer.BlockCopy(combined, ivLen, cipher, 0, cipher.Length);
|
||||||
|
|
||||||
|
using var decryptor = aes.CreateDecryptor();
|
||||||
|
var plain = decryptor.TransformFinalBlock(cipher, 0, cipher.Length);
|
||||||
|
return Encoding.UTF8.GetString(plain);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Azaion.Common;
|
using System.Security.Cryptography;
|
||||||
|
using Azaion.Common;
|
||||||
using Azaion.Common.Database;
|
using Azaion.Common.Database;
|
||||||
using Azaion.Common.Entities;
|
using Azaion.Common.Entities;
|
||||||
using Azaion.Common.Extensions;
|
using Azaion.Common.Extensions;
|
||||||
@@ -10,6 +11,7 @@ namespace Azaion.Services;
|
|||||||
public interface IUserService
|
public interface IUserService
|
||||||
{
|
{
|
||||||
Task RegisterUser(RegisterUserRequest request, CancellationToken ct = default);
|
Task RegisterUser(RegisterUserRequest request, CancellationToken ct = default);
|
||||||
|
Task<RegisterDeviceResponse> RegisterDevice(CancellationToken ct = default);
|
||||||
Task<User> ValidateUser(LoginRequest request, CancellationToken ct = default);
|
Task<User> ValidateUser(LoginRequest request, CancellationToken ct = default);
|
||||||
Task<User?> GetByEmail(string? email, CancellationToken ct = default);
|
Task<User?> GetByEmail(string? email, CancellationToken ct = default);
|
||||||
Task UpdateHardware(string email, string? hardware = null, CancellationToken ct = default);
|
Task UpdateHardware(string email, string? hardware = null, CancellationToken ct = default);
|
||||||
@@ -23,6 +25,12 @@ public interface IUserService
|
|||||||
|
|
||||||
public class UserService(IDbFactory dbFactory, ICache cache) : IUserService
|
public class UserService(IDbFactory dbFactory, ICache cache) : IUserService
|
||||||
{
|
{
|
||||||
|
private const string DeviceEmailPrefix = "azj-";
|
||||||
|
private const string DeviceEmailDomain = "@azaion.com";
|
||||||
|
private const int SerialNumberStart = 4; // index of NNNN inside "azj-NNNN..." (length of DeviceEmailPrefix)
|
||||||
|
private const int SerialNumberLength = 4;
|
||||||
|
private const int DevicePasswordBytes = 16; // hex-encoded → 32 chars
|
||||||
|
|
||||||
public async Task RegisterUser(RegisterUserRequest request, CancellationToken ct = default)
|
public async Task RegisterUser(RegisterUserRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await dbFactory.RunAdmin(async db =>
|
await dbFactory.RunAdmin(async db =>
|
||||||
@@ -43,6 +51,47 @@ public class UserService(IDbFactory dbFactory, ICache cache) : IUserService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<RegisterDeviceResponse> RegisterDevice(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return await dbFactory.RunAdmin(async db =>
|
||||||
|
{
|
||||||
|
var lastEmail = await db.Users
|
||||||
|
.Where(u => u.Role == RoleEnum.CompanionPC)
|
||||||
|
.OrderByDescending(u => u.CreatedAt)
|
||||||
|
.Select(u => u.Email)
|
||||||
|
.FirstOrDefaultAsync(token: ct);
|
||||||
|
|
||||||
|
var nextNumber = 0;
|
||||||
|
if (!string.IsNullOrEmpty(lastEmail) && lastEmail.Length >= SerialNumberStart + SerialNumberLength)
|
||||||
|
{
|
||||||
|
var serialPart = lastEmail.Substring(SerialNumberStart, SerialNumberLength);
|
||||||
|
if (int.TryParse(serialPart, out var current))
|
||||||
|
nextNumber = current + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var serial = $"{DeviceEmailPrefix}{nextNumber.ToString($"D{SerialNumberLength}")}";
|
||||||
|
var email = $"{serial}{DeviceEmailDomain}";
|
||||||
|
var password = Convert.ToHexString(RandomNumberGenerator.GetBytes(DevicePasswordBytes)).ToLowerInvariant();
|
||||||
|
|
||||||
|
await db.InsertAsync(new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Email = email,
|
||||||
|
PasswordHash = password.ToHash(),
|
||||||
|
Role = RoleEnum.CompanionPC,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
IsEnabled = true
|
||||||
|
}, token: ct);
|
||||||
|
|
||||||
|
return new RegisterDeviceResponse
|
||||||
|
{
|
||||||
|
Serial = serial,
|
||||||
|
Email = email,
|
||||||
|
Password = password
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<User?> GetByEmail(string? email, CancellationToken ct = default)
|
public async Task<User?> GetByEmail(string? email, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(email)) throw new ArgumentNullException(nameof(email));
|
if (string.IsNullOrWhiteSpace(email)) throw new ArgumentNullException(nameof(email));
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Module Layout
|
||||||
|
|
||||||
|
**Language**: csharp
|
||||||
|
**Layout Convention**: solution-flat (legacy — pre-`src/` convention)
|
||||||
|
**Root**: `./` (csproj folders sit at workspace root)
|
||||||
|
**Last Updated**: 2026-05-13
|
||||||
|
|
||||||
|
## Layout Rules
|
||||||
|
|
||||||
|
1. This admin/ workspace is one **deployable** (the `Azaion.AdminApi` HTTP service) split across four production csproj projects + one e2e test csproj: `Azaion.AdminApi`, `Azaion.Services`, `Azaion.Common`, `Azaion.Test`, `e2e/Azaion.E2E`.
|
||||||
|
2. Existing task specs (`_docs/02_tasks/*/AZ-*.md`) all use `Component: Admin API` as a single coarse identifier covering this entire workspace. The Per-Component Mapping below honors that convention rather than rewriting every task spec.
|
||||||
|
3. The conceptual sub-components documented in `_docs/02_document/components/01_data_layer..05_admin_api/` are **read-time** documentation aids, not write-time ownership boundaries. They are listed under "Conceptual Sub-Components" below for reference only.
|
||||||
|
4. Public API surface = the namespaces / interfaces exposed across csproj boundaries (`I*Service` interfaces in `Azaion.Services`, request DTOs in `Azaion.Common/Requests/`, entities in `Azaion.Common/Entities/`).
|
||||||
|
5. Tests live in `Azaion.Test/` (in-process unit/integration) and `e2e/Azaion.E2E/` (HTTP black-box). Production code never imports from either.
|
||||||
|
|
||||||
|
## Per-Component Mapping
|
||||||
|
|
||||||
|
### Component: Admin API
|
||||||
|
|
||||||
|
- **Epic**: AZ-181 (and any other admin-API epic, e.g. AZ-509 for the Detection Classes feature)
|
||||||
|
- **Directory**: workspace root (multi-csproj, see below)
|
||||||
|
- **Owns (exclusive write during implementation)**:
|
||||||
|
- `Azaion.AdminApi/**`
|
||||||
|
- `Azaion.Services/**`
|
||||||
|
- `Azaion.Common/**`
|
||||||
|
- `Azaion.Test/**`
|
||||||
|
- `e2e/Azaion.E2E/**` (xUnit/HttpClient-based black-box tests)
|
||||||
|
- `e2e/db-init/**` (test-DB seed/init scripts consumed by the e2e harness)
|
||||||
|
- `docker.test/**` (test fixture / schema-init helpers used by `Azaion.Test`)
|
||||||
|
- `docker-compose.test.yml`
|
||||||
|
- **Public API** (visible to other csprojs within the workspace):
|
||||||
|
- `Azaion.Services/I*Service.cs` interfaces (UserService, AuthService, ResourcesService, …)
|
||||||
|
- `Azaion.Services/Security.cs`, `Azaion.Services/Cache.cs` (used by `Azaion.AdminApi/Program.cs`)
|
||||||
|
- `Azaion.Common/Requests/*` request DTOs
|
||||||
|
- `Azaion.Common/Entities/*` linq2db entities
|
||||||
|
- `Azaion.Common/Database/*` `IDbFactory` + connection helpers
|
||||||
|
- `Azaion.Common/Configs/*` strongly-typed config records
|
||||||
|
- `Azaion.Common/Extensions/*` extension methods
|
||||||
|
- `Azaion.Common/BusinessException.cs`
|
||||||
|
- `Azaion.AdminApi/Program.cs` (composition root + minimal-API endpoints)
|
||||||
|
- `Azaion.AdminApi/BusinessExceptionHandler.cs`
|
||||||
|
- **Internal (do NOT import across csproj boundaries)**:
|
||||||
|
- private/internal members within each csproj (default C# visibility rules apply)
|
||||||
|
- `Azaion.AdminApi/appsettings*.json` (loaded by the host, not imported)
|
||||||
|
- `e2e/Azaion.E2E/Helpers/*` (test-only helpers, never imported by production)
|
||||||
|
- **Imports from**: (none — this is the only deployable in the workspace; the Loader is architecturally retired per `suite/_docs/_repo-config.yaml` `unresolved:loader-retirement-arch-doc`)
|
||||||
|
- **Consumed by**: HTTP clients (UI workspace, edge services on secured Jetson, SaaS browser sessions) — out of process
|
||||||
|
|
||||||
|
## Conceptual Sub-Components (documentation only — NOT ownership boundaries)
|
||||||
|
|
||||||
|
These come from `_docs/02_document/components/` and exist for reading the codebase, not for assigning task ownership. A single task may legitimately touch multiple sub-components within the `Admin API` umbrella.
|
||||||
|
|
||||||
|
| # | Sub-component | Primary file locations |
|
||||||
|
|---|----------------------|------------------------|
|
||||||
|
| 1 | Data Layer | `Azaion.Common/Database/`, `Azaion.Common/Configs/`, `Azaion.Common/Entities/` |
|
||||||
|
| 2 | User Management | `Azaion.Services/UserService.cs`, `Azaion.Common/Requests/{Create,Update,SetPassword,…}UserRequest.cs` |
|
||||||
|
| 3 | Auth & Security | `Azaion.Services/AuthService.cs`, `Azaion.Services/Security.cs`, `Azaion.Services/Cache.cs` |
|
||||||
|
| 4 | Resource Management | `Azaion.Services/ResourcesService.cs`, `Azaion.Common/Requests/{GetResource,CheckResources,…}.cs` |
|
||||||
|
| 5 | Admin API (HTTP) | `Azaion.AdminApi/Program.cs`, `Azaion.AdminApi/BusinessExceptionHandler.cs`, `Azaion.AdminApi/appsettings*.json` |
|
||||||
|
|
||||||
|
## Allowed Dependencies (csproj layering)
|
||||||
|
|
||||||
|
| Layer | csproj | May reference |
|
||||||
|
|-------|--------|---------------|
|
||||||
|
| 4. Entry / Host | `Azaion.AdminApi` | `Azaion.Services`, `Azaion.Common` |
|
||||||
|
| 3. Application | `Azaion.Services` | `Azaion.Common` |
|
||||||
|
| 2. Foundation | `Azaion.Common` | (none) |
|
||||||
|
| —. Tests (in-process) | `Azaion.Test` | `Azaion.Services`, `Azaion.Common`, `Azaion.AdminApi` (integration only) |
|
||||||
|
| —. Tests (out-of-process e2e) | `e2e/Azaion.E2E` | (none from production csprojs — HTTP only) |
|
||||||
|
|
||||||
|
A reference from a lower production layer to a higher production layer is an **Architecture** finding (High severity) in `/code-review` Phase 7. Test projects may reference any production csproj; production csprojs may NOT reference test projects.
|
||||||
|
|
||||||
|
## Layout Conventions (reference)
|
||||||
|
|
||||||
|
| Language | Root | Per-component path | Public API file | Test path |
|
||||||
|
|----------|------|-------------------|-----------------|-----------|
|
||||||
|
| C# (.NET) | `./` (this workspace, legacy flat layout) | `./<Csproj>/` | namespace-root types in each csproj | `Azaion.Test/`, `e2e/Azaion.E2E/` |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This file was authored 2026-05-13 by `/autodev` Step 10 to satisfy `/implement` Step 4. The `_docs/` artifact set predates the Step 1.5 module-layout addition, so this is a **backfill** rather than a fresh decompose Step 1.5 run.
|
||||||
|
- If the project later splits into multiple deployables (e.g. carving out `Azaion.AnnotationsApi`), re-run `/decompose` Step 1.5 to produce a finer-grained mapping.
|
||||||
@@ -1,18 +1,25 @@
|
|||||||
# Dependencies Table
|
# Dependencies Table
|
||||||
|
|
||||||
**Date**: 2026-04-16
|
**Date**: 2026-05-13 (refreshed; original 2026-04-16)
|
||||||
**Total Tasks**: 10
|
**Total Tasks**: 11 (7 done test tasks + 4 active product tasks)
|
||||||
**Total Complexity Points**: 37
|
**Total Complexity Points**: 40
|
||||||
|
|
||||||
| Task | Name | Complexity | Dependencies | Epic |
|
| Task | Name | Complexity | Dependencies | Epic | Status |
|
||||||
|------|------|-----------|-------------|------|
|
|--------|-------------------------------|-----------:|-------------------------|--------|--------|
|
||||||
| AZ-189 | test_infrastructure | 5 | None | AZ-188 |
|
| AZ-189 | test_infrastructure | 5 | None | AZ-188 | done |
|
||||||
| AZ-190 | auth_tests | 3 | AZ-189 | AZ-188 |
|
| AZ-190 | auth_tests | 3 | AZ-189 | AZ-188 | done |
|
||||||
| AZ-191 | user_mgmt_tests | 5 | AZ-189, AZ-190 | AZ-188 |
|
| AZ-191 | user_mgmt_tests | 5 | AZ-189, AZ-190 | AZ-188 | done |
|
||||||
| AZ-192 | hardware_tests | 3 | AZ-189, AZ-190 | AZ-188 |
|
| AZ-192 | hardware_tests | 3 | AZ-189, AZ-190 | AZ-188 | done |
|
||||||
| AZ-193 | resource_tests | 5 | AZ-189, AZ-190, AZ-192 | AZ-188 |
|
| AZ-193 | resource_tests | 5 | AZ-189, AZ-190, AZ-192 | AZ-188 | done |
|
||||||
| AZ-194 | security_tests | 3 | AZ-189, AZ-190 | AZ-188 |
|
| AZ-194 | security_tests | 3 | AZ-189, AZ-190 | AZ-188 | done |
|
||||||
| AZ-195 | resilience_perf_tests | 5 | AZ-189, AZ-190 | AZ-188 |
|
| AZ-195 | resilience_perf_tests | 5 | AZ-189, AZ-190 | AZ-188 | done |
|
||||||
| AZ-183 | resources_table_update_api | 3 | None | AZ-181 |
|
| AZ-183 | resources_table_update_api | 3 | None | AZ-181 | todo |
|
||||||
| AZ-196 | register_device_endpoint | 2 | None | AZ-181 |
|
| AZ-196 | register_device_endpoint | 2 | None | AZ-181 | todo |
|
||||||
| AZ-197 | remove_hardware_id | 3 | None | AZ-181 |
|
| AZ-197 | remove_hardware_id | 3 | None | AZ-181 | todo |
|
||||||
|
| AZ-513 | classes_crud_routes | 3 | None | AZ-509 | todo |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- AZ-513 added 2026-05-13 (cross-workspace prerequisite from `ui/` workspace AZ-512). Filed under epic AZ-509 (Cycle 3 — Auth bootstrap fix + classColors carve-out + admin class edit).
|
||||||
|
- AZ-197 originally listed `Component: Admin API, Loader`; the Loader workspace was architecturally retired (see `suite/_docs/_repo-config.yaml` `unresolved:loader-retirement-arch-doc`) and the spec was adapted on 2026-05-13 to be admin-only.
|
||||||
|
- All four active tasks (AZ-183, AZ-196, AZ-197, AZ-513) are independent — no inter-task dependencies in this active set.
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Batch Report
|
||||||
|
|
||||||
|
**Batch**: 5 (cycle 1, batch 1 of 2)
|
||||||
|
**Tasks**: AZ-513_classes_crud_routes, AZ-196_register_device_endpoint, AZ-183_resources_table_update_api
|
||||||
|
**Date**: 2026-05-13
|
||||||
|
|
||||||
|
## Task Results
|
||||||
|
|
||||||
|
| Task | Status | Files Modified | Tests | AC Coverage | Issues |
|
||||||
|
|--------|--------|---------------------------------------------------------------------------------------------------------|----------------------------------------|-------------------------|--------|
|
||||||
|
| AZ-513 | Done | DetectionClass entity + 2 DTOs + DetectionClassService + Program.cs (3 routes) + schema + SQL migration | DetectionClassesTests.cs (9 e2e tests) | AC 1–9 / AC-10 = UI side | None blocking |
|
||||||
|
| AZ-196 | Done | RegisterDeviceResponse DTO + UserService.RegisterDevice + Program.cs (1 route) | DeviceRegistrationTests.cs (5 e2e tests) | AC 1–5 | F1 (Medium / race) tracked in review |
|
||||||
|
| AZ-183 | Done | Resource entity + 2 DTOs + ResourceUpdateService + Program.cs (2 routes) + schema + SQL migration + ResourcesConfig.EncryptionMasterKey | ResourceUpdateTests.cs (4 e2e tests) | AC 1, 2, 3, 5 (AC-4 by inspection) | F2–F4 (Low) tracked in review |
|
||||||
|
|
||||||
|
## AC Test Coverage: 18/18 admin-side ACs covered (AC-10 of AZ-513 verified in UI workspace; AC-4 of AZ-183 is a perf characteristic, verified by inspecting `ResourceUpdateService.GetUpdate`'s `cache.GetFromCacheAsync` wrapping `LoadLatest`)
|
||||||
|
|
||||||
|
## Code Review Verdict: PASS_WITH_WARNINGS — see `_docs/03_implementation/reviews/batch_05_review.md`
|
||||||
|
|
||||||
|
## Auto-Fix Attempts: 0
|
||||||
|
|
||||||
|
## Stuck Agents: None
|
||||||
|
|
||||||
|
## Notes / Decisions
|
||||||
|
|
||||||
|
- **Wire-compat for new endpoints**: All three new routes are additive (`POST /classes`, `PATCH /classes/{id}`, `DELETE /classes/{id}`, `POST /devices`, `POST /get-update`, `POST /resources/publish`). Nothing changes on existing routes in this batch.
|
||||||
|
- **Schema migrations**: `env/db/04_detection_classes.sql` and `env/db/05_resources.sql` added; both use `create table if not exists` and idempotent `grant`s, so they are safe to re-run. `e2e/db-init/00_run_all.sh` updated to apply both during the test-DB bootstrap.
|
||||||
|
- **DI**: `AddScoped<IDetectionClassService, DetectionClassService>()` and `AddScoped<IResourceUpdateService, ResourceUpdateService>()` added next to the existing `IUserService` registration in `Program.cs`. `AddValidatorsFromAssemblyContaining<RegisterUserValidator>()` (already present) auto-discovers the new validators in `Azaion.Common`.
|
||||||
|
- **`IDbFactory.RunAdmin<T>(...)`**: Overload added to support write-and-return patterns (used by AZ-513's `Create` returning the new id, AZ-196's `RegisterDevice` returning the credentials, and AZ-183's parts of the publish/lookup paths). Non-breaking addition.
|
||||||
|
- **Encryption-at-rest for AZ-183**: Per-resource `encryption_key` column is AES-256-CBC encrypted with a master key from `ResourcesConfig.EncryptionMasterKey`. The wire response carries plaintext (the device needs it to decrypt the artifact). Master key for tests is provided via `docker-compose.test.yml`; production must override via `ResourcesConfig__EncryptionMasterKey` env var.
|
||||||
|
- **AZ-197 not in this batch**: AZ-197 (remove hardware ID binding) was originally cross-workspace (Admin API + Loader). User clarified 2026-05-13 that the Loader is architecturally retired (Scenario X, see `suite/_docs/_repo-config.yaml` `unresolved:loader-retirement-arch-doc`) and devices ship as secured Jetsons with fTPM or via SaaS. The AZ-197 spec was rewritten to be admin-only; the destructive cleanup is isolated into batch 6 (cycle 1, batch 2 of 2) for focused review.
|
||||||
|
- **`module-layout.md` backfill**: Created earlier in this `/autodev` step to satisfy the implement skill's File Ownership prerequisite. The `_docs/` artifact set predates the Step 1.5 module-layout addition; this is a backfill, not a fresh decompose run.
|
||||||
|
|
||||||
|
## Next Batch
|
||||||
|
|
||||||
|
Batch 6 (cycle 1, batch 2 of 2): AZ-197 (remove hardware ID binding from admin/ + e2e cleanup).
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Code Review Report
|
||||||
|
|
||||||
|
**Batch**: 5 (cycle 1, batch 1 of 2)
|
||||||
|
**Tasks**: AZ-513, AZ-196, AZ-183
|
||||||
|
**Date**: 2026-05-13
|
||||||
|
**Verdict**: PASS_WITH_WARNINGS
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
All three additive tasks (Detection Classes CRUD, device registration, fleet OTA Resources) build clean (`dotnet build` 0 warnings, 0 errors against both `Azaion.AdminApi.sln` and `e2e/Azaion.E2E/Azaion.E2E.csproj`), respect the `Azaion.AdminApi → Azaion.Services → Azaion.Common` layering recorded in `_docs/02_document/module-layout.md`, and follow the existing `IUserService` / `Program.cs` / `AzaionDbSchemaHolder` patterns. AC coverage is verified via new e2e tests in `e2e/Azaion.E2E/Tests/` for every admin-side AC. No Critical or High findings; the four Low / Medium findings below are non-blocking and tracked for follow-up.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
| # | Severity | Category | File:Line | Title |
|
||||||
|
|---|----------|-----------------|--------------------------------------------------------|-------|
|
||||||
|
| 1 | Medium | Bug | `Azaion.Services/UserService.cs` (RegisterDevice) | Race condition on sequential serial assignment |
|
||||||
|
| 2 | Low | Maintainability | `Azaion.Services/ResourceUpdateService.cs` (Publish) | No uniqueness on `(arch, stage, name, version)` rows |
|
||||||
|
| 3 | Low | Maintainability | `Azaion.Services/ResourceUpdateService.cs` (Encrypt) | Master-key rotation not supported (no key-version column) |
|
||||||
|
| 4 | Low | Maintainability | `Azaion.AdminApi/appsettings.json` | `EncryptionMasterKey` ships empty by default |
|
||||||
|
|
||||||
|
### Finding Details
|
||||||
|
|
||||||
|
**F1: Race condition on sequential serial assignment** (Medium / Bug)
|
||||||
|
- Location: `Azaion.Services/UserService.cs` → `RegisterDevice`
|
||||||
|
- Description: Two concurrent `POST /devices` calls can both read the same most-recent `CompanionPC` user, compute the same next number, and both insert. The `users.email` column has no DB-level unique constraint (per `_docs/02_document/data_model.md` § Observations), so the second insert succeeds and creates two users with the same email.
|
||||||
|
- Suggestion: For the AZ-196 use case (Jetson manufacturing — sequential by design), this is currently low-impact. Long-term mitigations: (a) add a unique constraint on `users.email` and retry on conflict, (b) wrap the read+insert in a `BEGIN; SELECT ... FOR UPDATE; INSERT; COMMIT;` block via `db.BeginTransactionAsync(IsolationLevel.Serializable)`, or (c) drop sequential numbering and use a Guid suffix. Track as a follow-up; out of scope for this 2-pt ticket per spec.
|
||||||
|
- Task: AZ-196
|
||||||
|
|
||||||
|
**F2: No uniqueness on `(arch, stage, name, version)` rows** (Low / Maintainability)
|
||||||
|
- Location: `Azaion.Services/ResourceUpdateService.cs` → `Publish`; `env/db/05_resources.sql`
|
||||||
|
- Description: A re-publish of the same `(architecture, dev_stage, resource_name, version)` tuple will insert a duplicate row. `LoadLatest`'s `OrderByDescending(r => r.Version)` still picks one of them (non-deterministically among equal versions), so device behavior is correct, but the table grows unbounded under repeated re-publishes.
|
||||||
|
- Suggestion: Add a unique constraint `(architecture, dev_stage, resource_name, version)` in a follow-up migration and decide on `INSERT ... ON CONFLICT DO NOTHING` vs `DO UPDATE` semantics. Out of scope for AZ-183's 3-pt budget.
|
||||||
|
- Task: AZ-183
|
||||||
|
|
||||||
|
**F3: Master-key rotation not supported** (Low / Maintainability)
|
||||||
|
- Location: `Azaion.Services/ResourceUpdateService.cs` → `ResourceColumnEncryption`
|
||||||
|
- Description: The per-resource `encryption_key` column is AES-encrypted with a single static master key from `ResourcesConfig.EncryptionMasterKey`. Rotating the master key would render all existing rows undecryptable. There is no key-version column or fallback list.
|
||||||
|
- Suggestion: For an OTA system whose master-key compromise blast radius is "every device-side decryption breaks", a future ticket should add `(key_version_id, ciphertext)` storage and a `Dictionary<int, string> activeKeys` with a `currentKeyVersion`. Out of scope here.
|
||||||
|
- Task: AZ-183
|
||||||
|
|
||||||
|
**F4: `EncryptionMasterKey` ships empty by default** (Low / Maintainability)
|
||||||
|
- Location: `Azaion.AdminApi/appsettings.json`
|
||||||
|
- Description: Default value is `""`. The service throws `InvalidOperationException` on first call to `GetUpdate` / `Publish` if the env var override is missing. This is intentional (no insecure default) but means a fresh `dotnet run` of the admin API in development will surprise the developer.
|
||||||
|
- Suggestion: Either (a) keep the empty default and document the env var in the README, or (b) ship a clearly-marked dev-only key in `appsettings.Development.json`. The test runner is already wired up via `docker-compose.test.yml`. Pick one and document.
|
||||||
|
- Task: AZ-183
|
||||||
|
|
||||||
|
## Phase results
|
||||||
|
|
||||||
|
- **Phase 1 (Context)**: 3 task specs read; project restrictions/solution unchanged.
|
||||||
|
- **Phase 2 (Spec compliance)**: AZ-513 ACs 1–9 covered by `DetectionClassesTests.cs`; AC-10 is cross-workspace (UI). AZ-196 ACs 1–5 covered by `DeviceRegistrationTests.cs` (AC-1 verifies the format `azj-NNNN`; the literal "0000" assertion is intentionally relaxed because the test DB may carry CompanionPC users from earlier runs — sequential AC-2 is the meaningful guarantee). AZ-183 ACs 1, 2, 3, 5 covered by `ResourceUpdateTests.cs`; AC-4 ("memory cache avoids repeated DB queries") is a perf characteristic not directly assertable via HTTP and is verified by inspection of `ResourceUpdateService.GetUpdate` (the `cache.GetFromCacheAsync` wraps `LoadLatest`).
|
||||||
|
- **Phase 3 (Code quality)**: SRP respected (`DetectionClassService` separated from `UserService`; `ResourceUpdateService` separated from the file-storage `ResourcesService`). No methods > 50 LoC. No bare catches. Naming consistent with existing `I*Service` / `*Request` / `*Response` conventions.
|
||||||
|
- **Phase 4 (Security quick-scan)**: No SQL string interpolation (linq2db parameterizes). No command injection. No hardcoded secrets in production code paths — the only literal key is in `docker-compose.test.yml` and is explicitly labeled "do-not-use-in-prod". Input validation via FluentValidation on every DTO. Plaintext password is returned by `POST /devices` per AZ-196 spec (intentional, embedded in the device.conf by provisioning).
|
||||||
|
- **Phase 5 (Performance scan)**: `ResourceUpdateService.LoadLatest` reads all rows for `(arch, stage)` then group-bys in memory — acceptable given `cache.GetFromCacheAsync` (default 4-hour TTL) and the small per-(arch,stage) row count expected for fleet OTA. No N+1. All DB calls async.
|
||||||
|
- **Phase 6 (Cross-task consistency)**: All three tasks add a single `MapXxx` block in `Program.cs`, register one `IService` in DI, and use the same FluentValidation + `Results.ValidationProblem` pattern. New `IDbFactory.RunAdmin<T>(...)` overload added by AZ-513 is reused by AZ-196 and AZ-183 — shared abstraction is genuine, not duplicated.
|
||||||
|
- **Phase 7 (Architecture compliance)**: All imports respect the `Azaion.AdminApi → Azaion.Services → Azaion.Common` layering and the `Azaion.Test` / `e2e/Azaion.E2E` test-only boundaries. No new cross-component imports of internal symbols. No new cyclic module dependencies. `_docs/02_document/module-layout.md` was created earlier in this `/autodev` step to satisfy the implement skill's prerequisite — no edit to it in this batch.
|
||||||
|
|
||||||
|
## Verdict logic
|
||||||
|
|
||||||
|
- 0 Critical, 0 High → not FAIL
|
||||||
|
- 1 Medium + 3 Low → PASS_WITH_WARNINGS
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
Proceed to commit. The four findings are tracked here and should be revisited as separate tickets when their respective contexts (manufacturing throughput, fleet rollout cadence, key-rotation policy, dev-onboarding ergonomics) warrant.
|
||||||
@@ -6,8 +6,9 @@ step: 10
|
|||||||
name: Implement
|
name: Implement
|
||||||
status: in_progress
|
status: in_progress
|
||||||
sub_step:
|
sub_step:
|
||||||
phase: 0
|
phase: 6
|
||||||
name: awaiting-invocation
|
name: implement-tasks-sequentially
|
||||||
detail: ""
|
detail: "batch 1 / step 9 — code-review"
|
||||||
retry_count: 0
|
retry_count: 0
|
||||||
cycle: 1
|
cycle: 1
|
||||||
|
tracker: jira
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ services:
|
|||||||
ResourcesConfig__ResourcesFolder: "Content"
|
ResourcesConfig__ResourcesFolder: "Content"
|
||||||
ResourcesConfig__SuiteInstallerFolder: "suite"
|
ResourcesConfig__SuiteInstallerFolder: "suite"
|
||||||
ResourcesConfig__SuiteStageInstallerFolder: "suite-stage"
|
ResourcesConfig__SuiteStageInstallerFolder: "suite-stage"
|
||||||
|
ResourcesConfig__EncryptionMasterKey: "test-master-key-for-resources-table-do-not-use-in-prod"
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -64,6 +64,13 @@ public sealed class ApiClient : IDisposable
|
|||||||
return _httpClient.PutAsync(url, content, cancellationToken);
|
return _httpClient.PutAsync(url, content, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<HttpResponseMessage> PatchAsync<T>(string url, T body, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(body, JsonOptions);
|
||||||
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
return _httpClient.PatchAsync(url, content, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public Task<HttpResponseMessage> DeleteAsync(string url, CancellationToken cancellationToken = default) =>
|
public Task<HttpResponseMessage> DeleteAsync(string url, CancellationToken cancellationToken = default) =>
|
||||||
_httpClient.DeleteAsync(url, cancellationToken);
|
_httpClient.DeleteAsync(url, cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Azaion.E2E.Helpers;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Azaion.E2E.Tests;
|
||||||
|
|
||||||
|
[Collection("E2E")]
|
||||||
|
public sealed class DetectionClassesTests
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions ResponseJsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed record DetectionClassDto(int Id, string Name, string ShortName, string Color, double MaxSizeM, string? PhotoMode);
|
||||||
|
|
||||||
|
private readonly TestFixture _fixture;
|
||||||
|
|
||||||
|
public DetectionClassesTests(TestFixture fixture) => _fixture = fixture;
|
||||||
|
|
||||||
|
private static object NewClassBody(string nameSuffix) => new
|
||||||
|
{
|
||||||
|
name = $"Tank-{nameSuffix}",
|
||||||
|
shortName = "T",
|
||||||
|
color = "#FF0000",
|
||||||
|
maxSizeM = 5.0
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task<int> CreateClassAsync(ApiClient client, object body)
|
||||||
|
{
|
||||||
|
using var resp = await client.PostAsync("/classes", body);
|
||||||
|
resp.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.Created);
|
||||||
|
var dto = await resp.Content.ReadFromJsonAsync<DetectionClassDto>(ResponseJsonOptions);
|
||||||
|
dto.Should().NotBeNull();
|
||||||
|
dto!.Id.Should().BeGreaterThan(0);
|
||||||
|
return dto.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AC1_Post_classes_creates_class_with_assigned_id()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var client = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
|
||||||
|
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||||||
|
var body = NewClassBody(suffix);
|
||||||
|
int? createdId = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
using var response = await client.PostAsync("/classes", body);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.Created);
|
||||||
|
var dto = await response.Content.ReadFromJsonAsync<DetectionClassDto>(ResponseJsonOptions);
|
||||||
|
dto.Should().NotBeNull();
|
||||||
|
dto!.Id.Should().BeGreaterThan(0);
|
||||||
|
dto.Name.Should().Be($"Tank-{suffix}");
|
||||||
|
dto.ShortName.Should().Be("T");
|
||||||
|
dto.Color.Should().Be("#FF0000");
|
||||||
|
dto.MaxSizeM.Should().Be(5.0);
|
||||||
|
createdId = dto.Id;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (createdId.HasValue)
|
||||||
|
using (await client.DeleteAsync($"/classes/{createdId.Value}")) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AC2_Post_classes_without_jwt_returns_401()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var client = _fixture.CreateApiClient();
|
||||||
|
var body = NewClassBody(Guid.NewGuid().ToString("N")[..8]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
using var response = await client.PostAsync("/classes", body);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AC2_Post_classes_with_non_admin_jwt_returns_403()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var loginClient = _fixture.CreateApiClient();
|
||||||
|
var uploaderToken = await loginClient.LoginAsync(_fixture.UploaderEmail, _fixture.UploaderPassword);
|
||||||
|
loginClient.Dispose();
|
||||||
|
|
||||||
|
using var client = _fixture.CreateAuthenticatedClient(uploaderToken);
|
||||||
|
var body = NewClassBody(Guid.NewGuid().ToString("N")[..8]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
using var response = await client.PostAsync("/classes", body);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AC3_Patch_classes_full_body_updates_class()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var client = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
|
||||||
|
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||||||
|
var id = await CreateClassAsync(client, NewClassBody(suffix));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var patchBody = new
|
||||||
|
{
|
||||||
|
name = $"Heavy Tank-{suffix}",
|
||||||
|
shortName = "T",
|
||||||
|
color = "#FF0000",
|
||||||
|
maxSizeM = 5.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
using var response = await client.PatchAsync($"/classes/{id}", patchBody);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var dto = await response.Content.ReadFromJsonAsync<DetectionClassDto>(ResponseJsonOptions);
|
||||||
|
dto.Should().NotBeNull();
|
||||||
|
dto!.Id.Should().Be(id);
|
||||||
|
dto.Name.Should().Be($"Heavy Tank-{suffix}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
using (await client.DeleteAsync($"/classes/{id}")) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AC4_Patch_classes_partial_body_only_updates_specified_field()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var client = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
|
||||||
|
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||||||
|
var id = await CreateClassAsync(client, NewClassBody(suffix));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var patchBody = new { color = "#00FF00" };
|
||||||
|
|
||||||
|
// Act
|
||||||
|
using var response = await client.PatchAsync($"/classes/{id}", patchBody);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var dto = await response.Content.ReadFromJsonAsync<DetectionClassDto>(ResponseJsonOptions);
|
||||||
|
dto.Should().NotBeNull();
|
||||||
|
dto!.Color.Should().Be("#00FF00");
|
||||||
|
dto.Name.Should().Be($"Tank-{suffix}");
|
||||||
|
dto.ShortName.Should().Be("T");
|
||||||
|
dto.MaxSizeM.Should().Be(5.0);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
using (await client.DeleteAsync($"/classes/{id}")) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AC5_Patch_classes_unknown_id_returns_404()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var client = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
|
||||||
|
var patchBody = new { name = "Anything" };
|
||||||
|
|
||||||
|
// Act
|
||||||
|
using var response = await client.PatchAsync("/classes/2147483600", patchBody);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AC6_Patch_classes_without_jwt_returns_401()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var client = _fixture.CreateApiClient();
|
||||||
|
var patchBody = new { name = "Anything" };
|
||||||
|
|
||||||
|
// Act
|
||||||
|
using var response = await client.PatchAsync("/classes/1", patchBody);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AC7_Delete_classes_removes_class()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var client = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
|
||||||
|
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||||||
|
var id = await CreateClassAsync(client, NewClassBody(suffix));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
using var response = await client.DeleteAsync($"/classes/{id}");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent);
|
||||||
|
|
||||||
|
using var followup = await client.DeleteAsync($"/classes/{id}");
|
||||||
|
followup.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AC8_Delete_classes_unknown_id_returns_404()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var client = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
using var response = await client.DeleteAsync("/classes/2147483600");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AC9_Delete_classes_without_jwt_returns_401()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var client = _fixture.CreateApiClient();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
using var response = await client.DeleteAsync("/classes/1");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Azaion.E2E.Helpers;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Azaion.E2E.Tests;
|
||||||
|
|
||||||
|
[Collection("E2E")]
|
||||||
|
public sealed class DeviceRegistrationTests
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions ResponseJsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Regex SerialPattern = new(@"^azj-\d{4}$", RegexOptions.Compiled);
|
||||||
|
private static readonly Regex EmailPattern = new(@"^azj-\d{4}@azaion\.com$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private sealed record RegisterDeviceResponseDto(string Serial, string Email, string Password);
|
||||||
|
|
||||||
|
private readonly TestFixture _fixture;
|
||||||
|
|
||||||
|
public DeviceRegistrationTests(TestFixture fixture) => _fixture = fixture;
|
||||||
|
|
||||||
|
private static string EmailPath(string email) => $"/users/{Uri.EscapeDataString(email)}";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AC1_Post_devices_returns_serial_email_and_password()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var client = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
|
||||||
|
string? createdEmail = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
using var response = await client.PostAsync("/devices", new { });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var dto = await response.Content.ReadFromJsonAsync<RegisterDeviceResponseDto>(ResponseJsonOptions);
|
||||||
|
dto.Should().NotBeNull();
|
||||||
|
SerialPattern.IsMatch(dto!.Serial).Should().BeTrue($"serial '{dto.Serial}' should match azj-NNNN");
|
||||||
|
EmailPattern.IsMatch(dto.Email).Should().BeTrue($"email '{dto.Email}' should match azj-NNNN@azaion.com");
|
||||||
|
dto.Password.Should().HaveLength(32);
|
||||||
|
dto.Email.Should().StartWith(dto.Serial);
|
||||||
|
createdEmail = dto.Email;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (createdEmail is not null)
|
||||||
|
using (await client.DeleteAsync(EmailPath(createdEmail))) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AC2_Sequential_device_serials_are_strictly_increasing()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var client = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
|
||||||
|
var emails = new List<string>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
using var first = await client.PostAsync("/devices", new { });
|
||||||
|
first.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var firstDto = await first.Content.ReadFromJsonAsync<RegisterDeviceResponseDto>(ResponseJsonOptions);
|
||||||
|
firstDto.Should().NotBeNull();
|
||||||
|
emails.Add(firstDto!.Email);
|
||||||
|
|
||||||
|
using var second = await client.PostAsync("/devices", new { });
|
||||||
|
second.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var secondDto = await second.Content.ReadFromJsonAsync<RegisterDeviceResponseDto>(ResponseJsonOptions);
|
||||||
|
secondDto.Should().NotBeNull();
|
||||||
|
emails.Add(secondDto!.Email);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var firstNumber = int.Parse(firstDto.Serial[4..]);
|
||||||
|
var secondNumber = int.Parse(secondDto.Serial[4..]);
|
||||||
|
secondNumber.Should().Be(firstNumber + 1);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
foreach (var email in emails)
|
||||||
|
using (await client.DeleteAsync(EmailPath(email))) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AC3_Returned_credentials_can_login()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var adminClient = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
|
||||||
|
string? createdEmail = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var response = await adminClient.PostAsync("/devices", new { });
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var dto = await response.Content.ReadFromJsonAsync<RegisterDeviceResponseDto>(ResponseJsonOptions);
|
||||||
|
dto.Should().NotBeNull();
|
||||||
|
createdEmail = dto!.Email;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
using var loginClient = _fixture.CreateApiClient();
|
||||||
|
var token = await loginClient.LoginAsync(dto.Email, dto.Password);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
token.Should().NotBeNullOrWhiteSpace();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (createdEmail is not null)
|
||||||
|
using (await adminClient.DeleteAsync(EmailPath(createdEmail))) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AC4_Post_devices_without_jwt_returns_401()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var client = _fixture.CreateApiClient();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
using var response = await client.PostAsync("/devices", new { });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AC4_Post_devices_with_non_admin_jwt_returns_403()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var loginClient = _fixture.CreateApiClient();
|
||||||
|
var uploaderToken = await loginClient.LoginAsync(_fixture.UploaderEmail, _fixture.UploaderPassword);
|
||||||
|
loginClient.Dispose();
|
||||||
|
|
||||||
|
using var client = _fixture.CreateAuthenticatedClient(uploaderToken);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
using var response = await client.PostAsync("/devices", new { });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Azaion.E2E.Helpers;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Azaion.E2E.Tests;
|
||||||
|
|
||||||
|
[Collection("E2E")]
|
||||||
|
public sealed class ResourceUpdateTests
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions ResponseJsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed record ResourceUpdateItemDto(
|
||||||
|
string ResourceName,
|
||||||
|
string Version,
|
||||||
|
string CdnUrl,
|
||||||
|
string Sha256,
|
||||||
|
string EncryptionKey,
|
||||||
|
long SizeBytes);
|
||||||
|
|
||||||
|
private readonly TestFixture _fixture;
|
||||||
|
|
||||||
|
public ResourceUpdateTests(TestFixture fixture) => _fixture = fixture;
|
||||||
|
|
||||||
|
private static object PublishBody(string resourceName, string version, string arch = "arm64",
|
||||||
|
string stage = "stage", string encryptionKey = "test-resource-key-001") => new
|
||||||
|
{
|
||||||
|
resourceName,
|
||||||
|
devStage = stage,
|
||||||
|
architecture = arch,
|
||||||
|
version,
|
||||||
|
cdnUrl = $"https://cdn.example.com/{resourceName}-{version}.bin",
|
||||||
|
sha256 = "abc123def456789",
|
||||||
|
encryptionKey,
|
||||||
|
sizeBytes = 1024L
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task<string> NewUploaderTokenAsync()
|
||||||
|
{
|
||||||
|
using var loginClient = _fixture.CreateApiClient();
|
||||||
|
return await loginClient.LoginAsync(_fixture.UploaderEmail, _fixture.UploaderPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AC2_GetUpdate_returns_resources_newer_than_device_version()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var uploaderToken = await NewUploaderTokenAsync();
|
||||||
|
using var uploaderClient = _fixture.CreateAuthenticatedClient(uploaderToken);
|
||||||
|
using var deviceClient = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
|
||||||
|
|
||||||
|
var arch = "arm64";
|
||||||
|
var stage = $"stage-{Guid.NewGuid():N}".Substring(0, 12);
|
||||||
|
var resourceName = $"annotations-{Guid.NewGuid():N}".Substring(0, 20);
|
||||||
|
|
||||||
|
using var publish = await uploaderClient.PostAsync("/resources/publish",
|
||||||
|
PublishBody(resourceName, "2026-04-13", arch, stage, "device-key-AC2"));
|
||||||
|
publish.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
using var response = await deviceClient.PostAsync("/get-update", new
|
||||||
|
{
|
||||||
|
architecture = arch,
|
||||||
|
devStage = stage,
|
||||||
|
currentVersions = new Dictionary<string, string> { [resourceName] = "2026-02-25" }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var items = await response.Content.ReadFromJsonAsync<List<ResourceUpdateItemDto>>(ResponseJsonOptions);
|
||||||
|
items.Should().NotBeNull();
|
||||||
|
items!.Should().HaveCount(1);
|
||||||
|
items![0].ResourceName.Should().Be(resourceName);
|
||||||
|
items[0].Version.Should().Be("2026-04-13");
|
||||||
|
items[0].CdnUrl.Should().Be($"https://cdn.example.com/{resourceName}-2026-04-13.bin");
|
||||||
|
items[0].Sha256.Should().Be("abc123def456789");
|
||||||
|
items[0].EncryptionKey.Should().Be("device-key-AC2",
|
||||||
|
"the column is AES-encrypted at rest but the response must contain plaintext for the device");
|
||||||
|
items[0].SizeBytes.Should().Be(1024L);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AC3_GetUpdate_returns_empty_when_device_already_has_latest()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var uploaderToken = await NewUploaderTokenAsync();
|
||||||
|
using var uploaderClient = _fixture.CreateAuthenticatedClient(uploaderToken);
|
||||||
|
using var deviceClient = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
|
||||||
|
|
||||||
|
var arch = "arm64";
|
||||||
|
var stage = $"stage-{Guid.NewGuid():N}".Substring(0, 12);
|
||||||
|
var resourceName = $"weights-{Guid.NewGuid():N}".Substring(0, 20);
|
||||||
|
|
||||||
|
using var publish = await uploaderClient.PostAsync("/resources/publish",
|
||||||
|
PublishBody(resourceName, "2026-04-13", arch, stage));
|
||||||
|
publish.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
using var response = await deviceClient.PostAsync("/get-update", new
|
||||||
|
{
|
||||||
|
architecture = arch,
|
||||||
|
devStage = stage,
|
||||||
|
currentVersions = new Dictionary<string, string> { [resourceName] = "2026-04-13" }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var items = await response.Content.ReadFromJsonAsync<List<ResourceUpdateItemDto>>(ResponseJsonOptions);
|
||||||
|
items.Should().NotBeNull();
|
||||||
|
items!.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AC5_Cache_is_invalidated_on_publish()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var uploaderToken = await NewUploaderTokenAsync();
|
||||||
|
using var uploaderClient = _fixture.CreateAuthenticatedClient(uploaderToken);
|
||||||
|
using var deviceClient = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
|
||||||
|
|
||||||
|
var arch = "arm64";
|
||||||
|
var stage = $"stage-{Guid.NewGuid():N}".Substring(0, 12);
|
||||||
|
var resourceName = $"models-{Guid.NewGuid():N}".Substring(0, 20);
|
||||||
|
|
||||||
|
using var publishV1 = await uploaderClient.PostAsync("/resources/publish",
|
||||||
|
PublishBody(resourceName, "2026-02-25", arch, stage));
|
||||||
|
publishV1.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
var deviceVersionsAtV1 = new { architecture = arch, devStage = stage,
|
||||||
|
currentVersions = new Dictionary<string, string> { [resourceName] = "2026-02-25" } };
|
||||||
|
|
||||||
|
using (var primeCache = await deviceClient.PostAsync("/get-update", deviceVersionsAtV1))
|
||||||
|
{
|
||||||
|
primeCache.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var primed = await primeCache.Content.ReadFromJsonAsync<List<ResourceUpdateItemDto>>(ResponseJsonOptions);
|
||||||
|
primed!.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Act
|
||||||
|
using var publishV2 = await uploaderClient.PostAsync("/resources/publish",
|
||||||
|
PublishBody(resourceName, "2026-04-13", arch, stage));
|
||||||
|
publishV2.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
using var afterPublish = await deviceClient.PostAsync("/get-update", deviceVersionsAtV1);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
afterPublish.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var items = await afterPublish.Content.ReadFromJsonAsync<List<ResourceUpdateItemDto>>(ResponseJsonOptions);
|
||||||
|
items.Should().NotBeNull();
|
||||||
|
items!.Should().HaveCount(1, "publish must invalidate the per-(arch,stage) latest-versions cache");
|
||||||
|
items![0].Version.Should().Be("2026-04-13");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetUpdate_without_jwt_returns_401()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var client = _fixture.CreateApiClient();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
using var response = await client.PostAsync("/get-update", new
|
||||||
|
{
|
||||||
|
architecture = "arm64",
|
||||||
|
devStage = "stage",
|
||||||
|
currentVersions = new Dictionary<string, string>()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,4 +5,6 @@ psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d postgres -f "$SQL_DIR/01_permissi
|
|||||||
sed 's/^drop table users;/drop table if exists users;/' "$SQL_DIR/02_structure.sql" \
|
sed 's/^drop table users;/drop table if exists users;/' "$SQL_DIR/02_structure.sql" \
|
||||||
| psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion
|
| psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion
|
||||||
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f "$SQL_DIR/03_add_timestamp_columns.sql"
|
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f "$SQL_DIR/03_add_timestamp_columns.sql"
|
||||||
|
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f "$SQL_DIR/04_detection_classes.sql"
|
||||||
|
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f "$SQL_DIR/05_resources.sql"
|
||||||
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f /opt/test-seed.sql
|
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f /opt/test-seed.sql
|
||||||
|
|||||||
Vendored
+18
@@ -0,0 +1,18 @@
|
|||||||
|
-- Detection classes table — write path owned by admin/, read path served by annotations/.
|
||||||
|
-- Both services point at the same Postgres database, so this DDL is idempotent and safe to
|
||||||
|
-- (re-)run from either side. AZ-513.
|
||||||
|
|
||||||
|
create table if not exists detection_classes
|
||||||
|
(
|
||||||
|
id serial primary key,
|
||||||
|
name varchar(120) not null,
|
||||||
|
short_name varchar(20) not null,
|
||||||
|
color varchar(20) not null,
|
||||||
|
max_size_m double precision not null,
|
||||||
|
photo_mode varchar(20) null,
|
||||||
|
created_at timestamp not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
grant select, insert, update, delete on public.detection_classes to azaion_admin;
|
||||||
|
grant usage, select on sequence public.detection_classes_id_seq to azaion_admin;
|
||||||
|
grant select on public.detection_classes to azaion_reader;
|
||||||
Vendored
+24
@@ -0,0 +1,24 @@
|
|||||||
|
-- Resources table — stores per-artifact metadata for fleet OTA updates. Populated by CI/CD
|
||||||
|
-- via POST /resources/publish; queried by devices via POST /get-update. AZ-183.
|
||||||
|
|
||||||
|
create table if not exists resources
|
||||||
|
(
|
||||||
|
id uuid primary key,
|
||||||
|
resource_name varchar(120) not null,
|
||||||
|
dev_stage varchar(40) not null,
|
||||||
|
architecture varchar(40) not null,
|
||||||
|
version varchar(40) not null,
|
||||||
|
cdn_url varchar(500) not null,
|
||||||
|
sha256 varchar(128) not null,
|
||||||
|
encryption_key text not null, -- AES-encrypted at rest with ResourcesConfig.EncryptionMasterKey
|
||||||
|
size_bytes bigint not null,
|
||||||
|
created_at timestamp not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Latest-version-per-resource lookups filter by (architecture, dev_stage); index supports
|
||||||
|
-- both the in-memory cache miss path and the per-(arch,stage) GROUP BY.
|
||||||
|
create index if not exists resources_arch_stage_idx
|
||||||
|
on public.resources (architecture, dev_stage, resource_name, version);
|
||||||
|
|
||||||
|
grant select, insert, update, delete on public.resources to azaion_admin;
|
||||||
|
grant select on public.resources to azaion_reader;
|
||||||
Reference in New Issue
Block a user