diff --git a/Azaion.AdminApi/Program.cs b/Azaion.AdminApi/Program.cs index 232b5e7..d51f4b9 100644 --- a/Azaion.AdminApi/Program.cs +++ b/Azaion.AdminApi/Program.cs @@ -97,6 +97,8 @@ builder.Services.Configure(builder.Configuration.GetSection(n builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); 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 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 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 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 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(); diff --git a/Azaion.AdminApi/appsettings.json b/Azaion.AdminApi/appsettings.json index 95d9ed7..0ce3fbf 100644 --- a/Azaion.AdminApi/appsettings.json +++ b/Azaion.AdminApi/appsettings.json @@ -9,7 +9,8 @@ "ResourcesConfig": { "ResourcesFolder": "Content", "SuiteInstallerFolder": "suite", - "SuiteStageInstallerFolder": "suite-stage" + "SuiteStageInstallerFolder": "suite-stage", + "EncryptionMasterKey": "" }, "JwtConfig": { "Issuer": "AzaionApi", diff --git a/Azaion.Common/Configs/ResourcesConfig.cs b/Azaion.Common/Configs/ResourcesConfig.cs index ecad0f2..02b94c0 100644 --- a/Azaion.Common/Configs/ResourcesConfig.cs +++ b/Azaion.Common/Configs/ResourcesConfig.cs @@ -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!; + + /// + /// Master key used to AES-encrypt the per-resource encryption_key column at rest. + /// Required by AZ-183 constraint "encryption_key must be stored securely (... or via + /// application-level encryption)". Configure via ResourcesConfig__EncryptionMasterKey. + /// + public string EncryptionMasterKey { get; set; } = null!; } \ No newline at end of file diff --git a/Azaion.Common/Database/AzaionDb.cs b/Azaion.Common/Database/AzaionDb.cs index 0ff794a..4b7aff8 100644 --- a/Azaion.Common/Database/AzaionDb.cs +++ b/Azaion.Common/Database/AzaionDb.cs @@ -6,5 +6,7 @@ namespace Azaion.Common.Database; public class AzaionDb(DataOptions dataOptions) : DataConnection(dataOptions) { - public ITable Users => this.GetTable(); + public ITable Users => this.GetTable(); + public ITable DetectionClasses => this.GetTable(); + public ITable Resources => this.GetTable(); } \ No newline at end of file diff --git a/Azaion.Common/Database/AzaionDbShemaHolder.cs b/Azaion.Common/Database/AzaionDbShemaHolder.cs index 24fc31e..a8ce367 100644 --- a/Azaion.Common/Database/AzaionDbShemaHolder.cs +++ b/Azaion.Common/Database/AzaionDbShemaHolder.cs @@ -36,6 +36,17 @@ public static class AzaionDbSchemaHolder p => string.IsNullOrEmpty(p) ? new UserConfig() : JsonConvert.DeserializeObject(p)) .IsNullable(); + builder.Entity() + .HasTableName("detection_classes") + .Property(x => x.Id) + .IsPrimaryKey() + .IsIdentity(); + + builder.Entity() + .HasTableName("resources") + .Property(x => x.Id) + .IsPrimaryKey() + .HasDataType(DataType.Guid); builder.Build(); } diff --git a/Azaion.Common/Database/DbFactory.cs b/Azaion.Common/Database/DbFactory.cs index 20eccf7..aec7629 100644 --- a/Azaion.Common/Database/DbFactory.cs +++ b/Azaion.Common/Database/DbFactory.cs @@ -10,6 +10,7 @@ public interface IDbFactory Task Run(Func> func); Task Run(Func func); Task RunAdmin(Func func); + Task RunAdmin(Func> 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 RunAdmin(Func> func) + { + await using var db = new AzaionDb(_dataOptionsAdmin); + return await func(db); + } } diff --git a/Azaion.Common/Entities/DetectionClass.cs b/Azaion.Common/Entities/DetectionClass.cs new file mode 100644 index 0000000..32bc67e --- /dev/null +++ b/Azaion.Common/Entities/DetectionClass.cs @@ -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; } +} diff --git a/Azaion.Common/Entities/Resource.cs b/Azaion.Common/Entities/Resource.cs new file mode 100644 index 0000000..0f77176 --- /dev/null +++ b/Azaion.Common/Entities/Resource.cs @@ -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; } +} diff --git a/Azaion.Common/Requests/CreateDetectionClassRequest.cs b/Azaion.Common/Requests/CreateDetectionClassRequest.cs new file mode 100644 index 0000000..58f938d --- /dev/null +++ b/Azaion.Common/Requests/CreateDetectionClassRequest.cs @@ -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 +{ + 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); + } +} diff --git a/Azaion.Common/Requests/GetUpdateRequest.cs b/Azaion.Common/Requests/GetUpdateRequest.cs new file mode 100644 index 0000000..5b5d151 --- /dev/null +++ b/Azaion.Common/Requests/GetUpdateRequest.cs @@ -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!; + + /// + /// Map of resource_name → currently-installed-version. 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. + /// + public Dictionary CurrentVersions { get; set; } = new(); +} + +public class GetUpdateValidator : AbstractValidator +{ + 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; } +} diff --git a/Azaion.Common/Requests/PublishResourceRequest.cs b/Azaion.Common/Requests/PublishResourceRequest.cs new file mode 100644 index 0000000..2e022a4 --- /dev/null +++ b/Azaion.Common/Requests/PublishResourceRequest.cs @@ -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 +{ + 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); + } +} diff --git a/Azaion.Common/Requests/RegisterDeviceResponse.cs b/Azaion.Common/Requests/RegisterDeviceResponse.cs new file mode 100644 index 0000000..5113f94 --- /dev/null +++ b/Azaion.Common/Requests/RegisterDeviceResponse.cs @@ -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!; +} diff --git a/Azaion.Common/Requests/UpdateDetectionClassRequest.cs b/Azaion.Common/Requests/UpdateDetectionClassRequest.cs new file mode 100644 index 0000000..59091c7 --- /dev/null +++ b/Azaion.Common/Requests/UpdateDetectionClassRequest.cs @@ -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 +{ + 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); + } +} diff --git a/Azaion.Services/DetectionClassService.cs b/Azaion.Services/DetectionClassService.cs new file mode 100644 index 0000000..f1111a5 --- /dev/null +++ b/Azaion.Services/DetectionClassService.cs @@ -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 Create(CreateDetectionClassRequest request, CancellationToken ct = default); + Task Update(int id, UpdateDetectionClassRequest request, CancellationToken ct = default); + Task Delete(int id, CancellationToken ct = default); +} + +public class DetectionClassService(IDbFactory dbFactory) : IDetectionClassService +{ + public async Task 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 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 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; + }); +} diff --git a/Azaion.Services/ResourceUpdateService.cs b/Azaion.Services/ResourceUpdateService.cs new file mode 100644 index 0000000..6fecaba --- /dev/null +++ b/Azaion.Services/ResourceUpdateService.cs @@ -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> GetUpdate(GetUpdateRequest request, CancellationToken ct = default); + Task Publish(PublishResourceRequest request, CancellationToken ct = default); +} + +public class ResourceUpdateService( + IDbFactory dbFactory, + ICache cache, + IOptions resourcesConfig) : IResourceUpdateService +{ + public static string CacheKey(string architecture, string devStage) + => $"Resources.Latest.{architecture}.{devStage}"; + + public async Task> 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(); + 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> 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); + } +} diff --git a/Azaion.Services/UserService.cs b/Azaion.Services/UserService.cs index 59f5f81..a8a6c52 100644 --- a/Azaion.Services/UserService.cs +++ b/Azaion.Services/UserService.cs @@ -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 RegisterDevice(CancellationToken ct = default); Task ValidateUser(LoginRequest request, CancellationToken ct = default); Task 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 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 GetByEmail(string? email, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(email)) throw new ArgumentNullException(nameof(email)); diff --git a/_docs/02_document/module-layout.md b/_docs/02_document/module-layout.md new file mode 100644 index 0000000..a1fdd67 --- /dev/null +++ b/_docs/02_document/module-layout.md @@ -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) | `.//` | 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. diff --git a/_docs/02_tasks/_dependencies_table.md b/_docs/02_tasks/_dependencies_table.md index 67847dd..321c495 100644 --- a/_docs/02_tasks/_dependencies_table.md +++ b/_docs/02_tasks/_dependencies_table.md @@ -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. diff --git a/_docs/03_implementation/batch_05_report.md b/_docs/03_implementation/batch_05_report.md new file mode 100644 index 0000000..1ed7c53 --- /dev/null +++ b/_docs/03_implementation/batch_05_report.md @@ -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()` and `AddScoped()` added next to the existing `IUserService` registration in `Program.cs`. `AddValidatorsFromAssemblyContaining()` (already present) auto-discovers the new validators in `Azaion.Common`. +- **`IDbFactory.RunAdmin(...)`**: 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). diff --git a/_docs/03_implementation/reviews/batch_05_review.md b/_docs/03_implementation/reviews/batch_05_review.md new file mode 100644 index 0000000..1b50b3a --- /dev/null +++ b/_docs/03_implementation/reviews/batch_05_review.md @@ -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 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(...)` 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. diff --git a/_docs/_autodev_state.md b/_docs/_autodev_state.md index d497489..dc78adc 100644 --- a/_docs/_autodev_state.md +++ b/_docs/_autodev_state.md @@ -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 diff --git a/docker-compose.test.yml b/docker-compose.test.yml index a2d1d2d..5cf1113 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -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: diff --git a/e2e/Azaion.E2E/Helpers/ApiClient.cs b/e2e/Azaion.E2E/Helpers/ApiClient.cs index ca115dd..67fe411 100644 --- a/e2e/Azaion.E2E/Helpers/ApiClient.cs +++ b/e2e/Azaion.E2E/Helpers/ApiClient.cs @@ -64,6 +64,13 @@ public sealed class ApiClient : IDisposable return _httpClient.PutAsync(url, content, cancellationToken); } + public Task PatchAsync(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 DeleteAsync(string url, CancellationToken cancellationToken = default) => _httpClient.DeleteAsync(url, cancellationToken); diff --git a/e2e/Azaion.E2E/Tests/DetectionClassesTests.cs b/e2e/Azaion.E2E/Tests/DetectionClassesTests.cs new file mode 100644 index 0000000..127a806 --- /dev/null +++ b/e2e/Azaion.E2E/Tests/DetectionClassesTests.cs @@ -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 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(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(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(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(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); + } +} diff --git a/e2e/Azaion.E2E/Tests/DeviceRegistrationTests.cs b/e2e/Azaion.E2E/Tests/DeviceRegistrationTests.cs new file mode 100644 index 0000000..dd7956d --- /dev/null +++ b/e2e/Azaion.E2E/Tests/DeviceRegistrationTests.cs @@ -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(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(); + + try + { + // Act + using var first = await client.PostAsync("/devices", new { }); + first.StatusCode.Should().Be(HttpStatusCode.OK); + var firstDto = await first.Content.ReadFromJsonAsync(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(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(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); + } +} diff --git a/e2e/Azaion.E2E/Tests/ResourceUpdateTests.cs b/e2e/Azaion.E2E/Tests/ResourceUpdateTests.cs new file mode 100644 index 0000000..932ca53 --- /dev/null +++ b/e2e/Azaion.E2E/Tests/ResourceUpdateTests.cs @@ -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 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 { [resourceName] = "2026-02-25" } + }); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var items = await response.Content.ReadFromJsonAsync>(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 { [resourceName] = "2026-04-13" } + }); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var items = await response.Content.ReadFromJsonAsync>(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 { [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>(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>(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() + }); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } +} diff --git a/e2e/db-init/00_run_all.sh b/e2e/db-init/00_run_all.sh index 4c3b1c9..04ad5c4 100755 --- a/e2e/db-init/00_run_all.sh +++ b/e2e/db-init/00_run_all.sh @@ -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 diff --git a/env/db/04_detection_classes.sql b/env/db/04_detection_classes.sql new file mode 100644 index 0000000..2e87e00 --- /dev/null +++ b/env/db/04_detection_classes.sql @@ -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; diff --git a/env/db/05_resources.sql b/env/db/05_resources.sql new file mode 100644 index 0000000..70fcb1e --- /dev/null +++ b/env/db/05_resources.sql @@ -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;