mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 13:31:08 +00:00
5ca9ccab2c
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>
177 lines
6.8 KiB
C#
177 lines
6.8 KiB
C#
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);
|
|
}
|
|
}
|