[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:
Oleksandr Bezdieniezhnykh
2026-05-13 04:34:42 +03:00
parent f13c57b314
commit 5ca9ccab2c
29 changed files with 1319 additions and 21 deletions
+69
View File
@@ -97,6 +97,8 @@ builder.Services.Configure<ConnectionStrings>(builder.Configuration.GetSection(n
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IResourcesService, ResourcesService>();
builder.Services.AddScoped<IDetectionClassService, DetectionClassService>();
builder.Services.AddScoped<IResourceUpdateService, ResourceUpdateService>();
builder.Services.AddSingleton<IDbFactory, DbFactory>();
builder.Services.AddLazyCache();
@@ -153,6 +155,12 @@ app.MapPost("/users",
.RequireAuthorization(apiAdminPolicy)
.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",
async (IAuthService authService) => await authService.GetCurrentUser())
.RequireAuthorization()
@@ -273,6 +281,67 @@ app.MapPost("/resources/check",
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.Run();
+2 -1
View File
@@ -9,7 +9,8 @@
"ResourcesConfig": {
"ResourcesFolder": "Content",
"SuiteInstallerFolder": "suite",
"SuiteStageInstallerFolder": "suite-stage"
"SuiteStageInstallerFolder": "suite-stage",
"EncryptionMasterKey": ""
},
"JwtConfig": {
"Issuer": "AzaionApi",
+7
View File
@@ -5,4 +5,11 @@ public class ResourcesConfig
public string ResourcesFolder { get; set; } = null!;
public string SuiteInstallerFolder { 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!;
}
+3 -1
View File
@@ -6,5 +6,7 @@ namespace Azaion.Common.Database;
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))
.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();
}
+7
View File
@@ -10,6 +10,7 @@ public interface IDbFactory
Task<T> Run<T>(Func<AzaionDb, Task<T>> func);
Task Run(Func<AzaionDb, Task> func);
Task RunAdmin(Func<AzaionDb, Task> func);
Task<T> RunAdmin<T>(Func<AzaionDb, Task<T>> func);
}
public class DbFactory : IDbFactory
@@ -54,4 +55,10 @@ public class DbFactory : IDbFactory
await using var db = new AzaionDb(_dataOptionsAdmin);
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);
}
}
+12
View File
@@ -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; }
}
+15
View File
@@ -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);
}
}
+57
View File
@@ -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;
});
}
+140
View File
@@ -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);
}
}
+50 -1
View File
@@ -1,4 +1,5 @@
using Azaion.Common;
using System.Security.Cryptography;
using Azaion.Common;
using Azaion.Common.Database;
using Azaion.Common.Entities;
using Azaion.Common.Extensions;
@@ -10,6 +11,7 @@ namespace Azaion.Services;
public interface IUserService
{
Task RegisterUser(RegisterUserRequest request, CancellationToken ct = default);
Task<RegisterDeviceResponse> RegisterDevice(CancellationToken ct = default);
Task<User> ValidateUser(LoginRequest request, CancellationToken ct = default);
Task<User?> GetByEmail(string? email, 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
{
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)
{
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)
{
if (string.IsNullOrWhiteSpace(email)) throw new ArgumentNullException(nameof(email));
+82
View File
@@ -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.
+22 -15
View File
@@ -1,18 +1,25 @@
# Dependencies Table
**Date**: 2026-04-16
**Total Tasks**: 10
**Total Complexity Points**: 37
**Date**: 2026-05-13 (refreshed; original 2026-04-16)
**Total Tasks**: 11 (7 done test tasks + 4 active product tasks)
**Total Complexity Points**: 40
| Task | Name | Complexity | Dependencies | Epic |
|------|------|-----------|-------------|------|
| AZ-189 | test_infrastructure | 5 | None | AZ-188 |
| AZ-190 | auth_tests | 3 | AZ-189 | AZ-188 |
| AZ-191 | user_mgmt_tests | 5 | AZ-189, AZ-190 | AZ-188 |
| AZ-192 | hardware_tests | 3 | AZ-189, AZ-190 | AZ-188 |
| AZ-193 | resource_tests | 5 | AZ-189, AZ-190, AZ-192 | AZ-188 |
| AZ-194 | security_tests | 3 | AZ-189, AZ-190 | AZ-188 |
| AZ-195 | resilience_perf_tests | 5 | AZ-189, AZ-190 | AZ-188 |
| AZ-183 | resources_table_update_api | 3 | None | AZ-181 |
| AZ-196 | register_device_endpoint | 2 | None | AZ-181 |
| AZ-197 | remove_hardware_id | 3 | None | AZ-181 |
| Task | Name | Complexity | Dependencies | Epic | Status |
|--------|-------------------------------|-----------:|-------------------------|--------|--------|
| AZ-189 | test_infrastructure | 5 | None | AZ-188 | done |
| AZ-190 | auth_tests | 3 | AZ-189 | AZ-188 | done |
| AZ-191 | user_mgmt_tests | 5 | AZ-189, AZ-190 | AZ-188 | done |
| 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 | done |
| AZ-194 | security_tests | 3 | AZ-189, AZ-190 | AZ-188 | done |
| AZ-195 | resilience_perf_tests | 5 | AZ-189, AZ-190 | AZ-188 | done |
| AZ-183 | resources_table_update_api | 3 | None | AZ-181 | todo |
| AZ-196 | register_device_endpoint | 2 | None | AZ-181 | todo |
| 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 19 / AC-10 = UI side | None blocking |
| AZ-196 | Done | RegisterDeviceResponse DTO + UserService.RegisterDevice + Program.cs (1 route) | DeviceRegistrationTests.cs (5 e2e tests) | AC 15 | 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) | F2F4 (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 19 covered by `DetectionClassesTests.cs`; AC-10 is cross-workspace (UI). AZ-196 ACs 15 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.
+4 -3
View File
@@ -6,8 +6,9 @@ step: 10
name: Implement
status: in_progress
sub_step:
phase: 0
name: awaiting-invocation
detail: ""
phase: 6
name: implement-tasks-sequentially
detail: "batch 1 / step 9 — code-review"
retry_count: 0
cycle: 1
tracker: jira
+1
View File
@@ -34,6 +34,7 @@ services:
ResourcesConfig__ResourcesFolder: "Content"
ResourcesConfig__SuiteInstallerFolder: "suite"
ResourcesConfig__SuiteStageInstallerFolder: "suite-stage"
ResourcesConfig__EncryptionMasterKey: "test-master-key-for-resources-table-do-not-use-in-prod"
ports:
- "8080:8080"
volumes:
+7
View File
@@ -64,6 +64,13 @@ public sealed class ApiClient : IDisposable
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) =>
_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);
}
}
+176
View File
@@ -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);
}
}
+2
View File
@@ -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" \
| 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/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
+18
View File
@@ -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;
+24
View File
@@ -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;