diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml
index ed420d6..e3c8ce4 100644
--- a/.github/workflows/ci-build.yml
+++ b/.github/workflows/ci-build.yml
@@ -24,8 +24,8 @@ jobs:
fail-fast: false
matrix:
include:
- - name: desktop-repository-server-tunit
- project: DesktopRepositoryServer.Tests/DesktopRepositoryServer.Tests.csproj
+ - name: repository-server-tunit
+ project: RepositoryServer.Tests/RepositoryServer.Tests.csproj
steps:
- name: Checkout
@@ -47,8 +47,8 @@ jobs:
fail-fast: false
matrix:
include:
- - dockerfile: docker/DesktopRepositoryServer.Dockerfile
- image: desktop-repository-server
+ - dockerfile: docker/RepositoryServer.Dockerfile
+ image: repository-server
steps:
- name: Checkout
@@ -82,7 +82,7 @@ jobs:
fail-fast: false
matrix:
include:
- - image: desktop-repository-server
+ - image: repository-server
steps:
- name: Checkout
@@ -112,7 +112,7 @@ jobs:
uses: OpenShock/actions/repository-dispatch@21ca2d511bca92cf24fb502fa302f82700822584 # v1.1.1
with:
repo: openshock/kubernetes-cluster-gitops
- event-type: update-desktop-repository-server-prod
+ event-type: update-repository-server-prod
client-payload: |
{"tag": "${{ inputs.tag-prefix || 'rn' }}-${{ github.run_number }}-a${{ github.run_attempt }}"}
token: ${{ secrets.GITOPS_PAT }}
\ No newline at end of file
diff --git a/.github/workflows/ci-tag.yml b/.github/workflows/ci-tag.yml
index b9f7de6..98e6463 100644
--- a/.github/workflows/ci-tag.yml
+++ b/.github/workflows/ci-tag.yml
@@ -8,7 +8,7 @@ name: ci-tag
env:
REGISTRY: ghcr.io
- IMAGE_NAME: ${{ github.repository_owner }}/api
+ IMAGE_NAME: ${{ github.repository_owner }}/repository-server
jobs:
@@ -40,7 +40,7 @@ jobs:
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
- images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/desktop-repository-server
+ images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/repository-server
flavor: |
latest=false
tags: |
@@ -53,7 +53,7 @@ jobs:
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
- file: Dockerfile
+ file: docker/RepositoryServer.Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
diff --git a/.gitignore b/.gitignore
index 7b145da..f12a155 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,4 +2,5 @@
.idea
obj/
bin/
-*.user
\ No newline at end of file
+*.user
+.claude/settings.local.json
\ No newline at end of file
diff --git a/DesktopRepositoryServer.Tests/DesktopRepositoryServer.Tests.csproj b/DesktopRepositoryServer.Tests/DesktopRepositoryServer.Tests.csproj
deleted file mode 100644
index 6b40f31..0000000
--- a/DesktopRepositoryServer.Tests/DesktopRepositoryServer.Tests.csproj
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
- net10.0
- enable
- enable
- false
- true
- 13
- OpenShock.Desktop.RepositoryServer.Tests
- OpenShock.Desktop.RepositoryServer.Tests
- OpenShock.Desktop.RepositoryServer.Tests
- OpenShock.Desktop.RepositoryServer.Tests
-
-
-
-
-
\ No newline at end of file
diff --git a/DesktopRepositoryServer.Tests/UnitTest1.cs b/DesktopRepositoryServer.Tests/UnitTest1.cs
deleted file mode 100644
index 0361efd..0000000
--- a/DesktopRepositoryServer.Tests/UnitTest1.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace OpenShock.Desktop.RepositoryServer.Tests;
-
-public class UnitTest1
-{
- [Test]
- public async Task Test()
- {
- await Assert.That(true).IsTrue();
- }
-}
\ No newline at end of file
diff --git a/DesktopRepositoryServer/AuthSchemas.cs b/DesktopRepositoryServer/AuthSchemas.cs
deleted file mode 100644
index 52da677..0000000
--- a/DesktopRepositoryServer/AuthSchemas.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace OpenShock.Desktop.RepositoryServer;
-
-public static class AuthSchemas
-{
- public const string AdminToken = "AdminToken";
-}
\ No newline at end of file
diff --git a/DesktopRepositoryServer/Config/ApiConfig.cs b/DesktopRepositoryServer/Config/ApiConfig.cs
deleted file mode 100644
index b778351..0000000
--- a/DesktopRepositoryServer/Config/ApiConfig.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using System.ComponentModel.DataAnnotations;
-
-namespace OpenShock.Desktop.RepositoryServer.Config;
-
-public class ApiConfig
-{
- [Required] public required DbConfig Db { get; init; }
- [Required] public required string AdminToken { get; init; }
- [Required] public required RepoConfig Repo { get; init; }
- public MetricsConfig Metrics { get; init; } = new();
-}
\ No newline at end of file
diff --git a/DesktopRepositoryServer/Migrations/MigrationOpenShockContextModelSnapshot.cs b/DesktopRepositoryServer/Migrations/MigrationOpenShockContextModelSnapshot.cs
deleted file mode 100644
index 000bd3e..0000000
--- a/DesktopRepositoryServer/Migrations/MigrationOpenShockContextModelSnapshot.cs
+++ /dev/null
@@ -1,118 +0,0 @@
-//
-using Microsoft.EntityFrameworkCore;
-using Microsoft.EntityFrameworkCore.Infrastructure;
-using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
-using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
-using OpenShock.Desktop.RepositoryServer.RepoServerDb;
-
-#nullable disable
-
-namespace OpenShock.Desktop.RepositoryServer.Migrations
-{
- [DbContext(typeof(MigrationOpenShockContext))]
- partial class MigrationOpenShockContextModelSnapshot : ModelSnapshot
- {
- protected override void BuildModel(ModelBuilder modelBuilder)
- {
-#pragma warning disable 612, 618
- modelBuilder
- .HasAnnotation("ProductVersion", "9.0.3")
- .HasAnnotation("Relational:MaxIdentifierLength", 63);
-
- NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
-
- modelBuilder.Entity("OpenShock.Desktop.RepositoryServer.RepoServerDb.Module", b =>
- {
- b.Property("Id")
- .HasMaxLength(128)
- .HasColumnType("character varying(128)")
- .HasColumnName("id");
-
- b.Property("Description")
- .IsRequired()
- .HasColumnType("text")
- .HasColumnName("description");
-
- b.Property("IconUrl")
- .HasMaxLength(256)
- .HasColumnType("character varying(256)")
- .HasColumnName("icon_url");
-
- b.Property("Name")
- .IsRequired()
- .HasMaxLength(128)
- .HasColumnType("character varying(128)")
- .HasColumnName("name");
-
- b.Property("SourceUrl")
- .HasMaxLength(256)
- .HasColumnType("character varying(256)")
- .HasColumnName("source_url");
-
- b.HasKey("Id")
- .HasName("modules_pkey");
-
- b.ToTable("modules", (string)null);
- });
-
- modelBuilder.Entity("OpenShock.Desktop.RepositoryServer.RepoServerDb.Version", b =>
- {
- b.Property("VersionName")
- .HasMaxLength(64)
- .HasColumnType("character varying(64)")
- .HasColumnName("version");
-
- b.Property("Module")
- .HasMaxLength(128)
- .HasColumnType("character varying(128)")
- .HasColumnName("module");
-
- b.Property("ChangelogUrl")
- .HasMaxLength(256)
- .HasColumnType("character varying(256)")
- .HasColumnName("changelog_url");
-
- b.Property("HashSha256")
- .IsRequired()
- .HasColumnType("bytea")
- .HasColumnName("hash_sha256");
-
- b.Property("ReleaseUrl")
- .HasMaxLength(256)
- .HasColumnType("character varying(256)")
- .HasColumnName("release_url");
-
- b.Property("ZipUrl")
- .IsRequired()
- .HasMaxLength(256)
- .HasColumnType("character varying(256)")
- .HasColumnName("zip_url");
-
- b.HasKey("VersionName", "Module")
- .HasName("versions_pkey");
-
- b.HasIndex("Module");
-
- b.ToTable("versions", (string)null);
- });
-
- modelBuilder.Entity("OpenShock.Desktop.RepositoryServer.RepoServerDb.Version", b =>
- {
- b.HasOne("OpenShock.Desktop.RepositoryServer.RepoServerDb.Module", "ModuleNavigation")
- .WithMany("Versions")
- .HasForeignKey("Module")
- .OnDelete(DeleteBehavior.Cascade)
- .IsRequired()
- .HasConstraintName("fk_versions_module");
-
- b.Navigation("ModuleNavigation");
- });
-
- modelBuilder.Entity("OpenShock.Desktop.RepositoryServer.RepoServerDb.Module", b =>
- {
- b.Navigation("Versions");
- });
-#pragma warning restore 612, 618
- }
- }
-}
diff --git a/DesktopRepositoryServer/Models/CreateModuleRequest.cs b/DesktopRepositoryServer/Models/CreateModuleRequest.cs
deleted file mode 100644
index 3ad6327..0000000
--- a/DesktopRepositoryServer/Models/CreateModuleRequest.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-namespace OpenShock.Desktop.RepositoryServer.Models;
-
-public class CreateModuleRequest
-{
- public required string Name { get; init; }
- public required string Description { get; init; }
- public Uri? SourceUrl { get; init; } = null;
- public Uri? IconUrl { get; init; } = null;
-}
\ No newline at end of file
diff --git a/DesktopRepositoryServer/Problems/ModuleError.cs b/DesktopRepositoryServer/Problems/ModuleError.cs
deleted file mode 100644
index 9f9496d..0000000
--- a/DesktopRepositoryServer/Problems/ModuleError.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-using System.Net;
-
-namespace OpenShock.Desktop.RepositoryServer.Problems;
-
-public static class ModuleError
-{
- public static OpenShockProblem ModuleNotFound => new OpenShockProblem("Module.NotFound", "The referenced module was not found", HttpStatusCode.NotFound);
-}
\ No newline at end of file
diff --git a/DesktopRepositoryServer/RepoServerDb/Module.cs b/DesktopRepositoryServer/RepoServerDb/Module.cs
deleted file mode 100644
index 78e0925..0000000
--- a/DesktopRepositoryServer/RepoServerDb/Module.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using System;
-using System.Collections.Generic;
-
-namespace OpenShock.Desktop.RepositoryServer.RepoServerDb;
-
-public partial class Module
-{
- public string Id { get; set; } = null!;
-
- public string Name { get; set; } = null!;
-
- public string Description { get; set; } = null!;
-
- public Uri? SourceUrl { get; set; } = null!;
-
- public Uri? IconUrl { get; set; }
-
- public virtual ICollection Versions { get; set; } = new List();
-}
diff --git a/DesktopRepositoryServer/RepoServerDb/RepoServerContext.cs b/DesktopRepositoryServer/RepoServerDb/RepoServerContext.cs
deleted file mode 100644
index de70d09..0000000
--- a/DesktopRepositoryServer/RepoServerDb/RepoServerContext.cs
+++ /dev/null
@@ -1,130 +0,0 @@
-using System;
-using System.Collections.Generic;
-using EntityFramework.Exceptions.PostgreSQL;
-using Microsoft.EntityFrameworkCore;
-
-namespace OpenShock.Desktop.RepositoryServer.RepoServerDb;
-
-///
-/// This is meant for use in migrations only.
-///
-public sealed class MigrationOpenShockContext : RepoServerContext
-{
- private readonly string? _connectionString = null;
- private readonly bool _debug;
- private readonly bool _migrationTool;
- private readonly ILoggerFactory? _loggerFactory = null;
-
- public MigrationOpenShockContext()
- {
- _migrationTool = true;
- }
-
- public MigrationOpenShockContext(string connectionString, bool debug, ILoggerFactory loggerFactory)
- {
- _connectionString = connectionString;
- _debug = debug;
- _loggerFactory = loggerFactory;
- }
-
- protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
- {
- if (_migrationTool)
- {
- ConfigureOptionsBuilder(optionsBuilder, "Host=localhost;Database=desktop-repo-server;Username=openshock;Password=openshock", true);
- return;
- }
- if(string.IsNullOrWhiteSpace(_connectionString))
- throw new InvalidOperationException("Connection string is not set.");
- ConfigureOptionsBuilder(optionsBuilder, _connectionString, _debug);
-
- if (_loggerFactory != null)
- optionsBuilder.UseLoggerFactory(_loggerFactory);
- }
-}
-
-public partial class RepoServerContext : DbContext
-{
- public RepoServerContext()
- {
- }
-
- public RepoServerContext(DbContextOptions options)
- : base(options)
- {
- }
-
- public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilder, string connectionString,
- bool debug)
- {
- optionsBuilder.UseNpgsql(connectionString, npgsqlBuilder =>
- {
- // Map Enums to their string values
- });
-
- optionsBuilder.UseExceptionProcessor();
-
- if (debug)
- {
- optionsBuilder.EnableSensitiveDataLogging();
- optionsBuilder.EnableDetailedErrors();
- }
- }
-
- public virtual DbSet Modules { get; set; }
-
- public virtual DbSet Versions { get; set; }
-
-
- protected override void OnModelCreating(ModelBuilder modelBuilder)
- {
- modelBuilder.Entity(entity =>
- {
- entity.HasKey(e => e.Id).HasName("modules_pkey");
-
- entity.ToTable("modules");
-
- entity.Property(e => e.Id)
- .HasMaxLength(128)
- .HasColumnName("id");
- entity.Property(e => e.Description).HasColumnName("description");
- entity.Property(e => e.IconUrl)
- .HasMaxLength(256)
- .HasColumnName("icon_url");
- entity.Property(e => e.Name)
- .HasMaxLength(128)
- .HasColumnName("name");
- entity.Property(e => e.SourceUrl)
- .HasMaxLength(256)
- .HasColumnName("source_url");
- });
-
- modelBuilder.Entity(entity =>
- {
- entity.HasKey(e => new { Version1 = e.VersionName, e.Module }).HasName("versions_pkey");
-
- entity.ToTable("versions");
-
- entity.Property(e => e.VersionName)
- .HasMaxLength(64)
- .HasColumnName("version");
- entity.Property(e => e.Module)
- .HasMaxLength(128)
- .HasColumnName("module");
- entity.Property(e => e.ChangelogUrl)
- .HasMaxLength(256)
- .HasColumnName("changelog_url");
- entity.Property(e => e.HashSha256).HasColumnName("hash_sha256");
- entity.Property(e => e.ReleaseUrl)
- .HasMaxLength(256)
- .HasColumnName("release_url");
- entity.Property(e => e.ZipUrl)
- .HasMaxLength(256)
- .HasColumnName("zip_url");
-
- entity.HasOne(d => d.ModuleNavigation).WithMany(p => p.Versions)
- .HasForeignKey(d => d.Module)
- .HasConstraintName("fk_versions_module");
- });
- }
-}
diff --git a/Directory.Packages.props b/Directory.Packages.props
index d703c45..b0854b8 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -2,6 +2,8 @@
true
+
+ true
net10.0
enable
enable
@@ -12,15 +14,21 @@
+
+
+
+
+
+
@@ -35,5 +43,7 @@
+
+
\ No newline at end of file
diff --git a/RepositoryServer.Tests/Integration/Docker/InMemoryDatabase.cs b/RepositoryServer.Tests/Integration/Docker/InMemoryDatabase.cs
new file mode 100644
index 0000000..e696f6e
--- /dev/null
+++ b/RepositoryServer.Tests/Integration/Docker/InMemoryDatabase.cs
@@ -0,0 +1,39 @@
+using Testcontainers.PostgreSql;
+using TUnit.Core.Interfaces;
+
+namespace OpenShock.RepositoryServer.Tests.Integration.Docker;
+
+///
+/// Per-test-session Postgres container. Mirrors the InMemoryDatabase pattern from
+/// the OpenShock API integration tests. Lazy-starts on first access, disposed at end of
+/// test session.
+///
+public sealed class InMemoryDatabase : IAsyncInitializer, IAsyncDisposable
+{
+ private PostgreSqlContainer? _container;
+
+ public PostgreSqlContainer Container
+ {
+ get
+ {
+ _container ??= new PostgreSqlBuilder("postgres:17-alpine")
+ .WithName($"repo-server-tests-pg-{Guid.NewGuid():N}")
+ .WithDatabase("repo_server_tests")
+ .WithUsername("repo_server")
+ .WithPassword("repo_server_test_password")
+ .Build();
+
+ return _container;
+ }
+ }
+
+ public Task InitializeAsync() => Container.StartAsync();
+
+ public async ValueTask DisposeAsync()
+ {
+ if (_container is not null)
+ {
+ await _container.DisposeAsync();
+ }
+ }
+}
diff --git a/RepositoryServer.Tests/Integration/TestAdminToken.cs b/RepositoryServer.Tests/Integration/TestAdminToken.cs
new file mode 100644
index 0000000..8c2777c
--- /dev/null
+++ b/RepositoryServer.Tests/Integration/TestAdminToken.cs
@@ -0,0 +1,10 @@
+namespace OpenShock.RepositoryServer.Tests.Integration;
+
+///
+/// Shared constants for the integration test harness.
+///
+public static class TestAdminToken
+{
+ public const string Value = "test-admin-token";
+ public const string HeaderName = "Authorization";
+}
diff --git a/RepositoryServer.Tests/Integration/TestCiCdAuthHandler.cs b/RepositoryServer.Tests/Integration/TestCiCdAuthHandler.cs
new file mode 100644
index 0000000..af36d7c
--- /dev/null
+++ b/RepositoryServer.Tests/Integration/TestCiCdAuthHandler.cs
@@ -0,0 +1,96 @@
+using System.Security.Claims;
+using System.Text.Encodings.Web;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace OpenShock.RepositoryServer.Tests.Integration;
+
+///
+/// Stands in for the GitHub OIDC handler on the scheme.
+///
+///
+/// The real handler validates a GitHub-signed JWT against live JWKS, which a test cannot mint. It
+/// then resolves the token's repository against the repositories allowlist and attaches
+/// . This handler substitutes only the token-validation half:
+/// tests declare which registered repository is calling, and everything downstream — the ownership
+/// checks, the state machine — runs for real against the same claims the production handler emits.
+///
+/// What this deliberately does not cover is the allowlist lookup itself. That path is
+/// verified by construction instead: there is no code anywhere that inserts a repository row outside
+/// the admin endpoint.
+///
+public sealed class TestCiCdAuthHandler : AuthenticationHandler
+{
+ public const string RepositoryIdHeader = "X-Test-Repository-Id";
+ public const string CommitHashHeader = "X-Test-Commit-Hash";
+ public const string RefHeader = "X-Test-Ref";
+ public const string RunIdHeader = "X-Test-Run-Id";
+
+ ///
+ /// Comma-separated scopes. Defaults to both, so tests opt in to restricting a grant. Use
+ /// to model a repository that is registered but granted nothing — an
+ /// empty header value cannot express that, because HttpClient drops empty headers.
+ ///
+ public const string ScopesHeader = "X-Test-Scopes";
+
+ /// Sentinel meaning "registered, but no scopes granted".
+ public const string NoScopes = "none";
+
+ public TestCiCdAuthHandler(
+ IOptionsMonitor options,
+ ILoggerFactory logger,
+ UrlEncoder encoder)
+ : base(options, logger, encoder)
+ {
+ }
+
+ protected override Task HandleAuthenticateAsync()
+ {
+ if (!Request.Headers.TryGetValue(RepositoryIdHeader, out var rawRepositoryId))
+ {
+ return Task.FromResult(AuthenticateResult.NoResult());
+ }
+
+ if (!Guid.TryParse(rawRepositoryId.ToString(), out var repositoryId))
+ {
+ return Task.FromResult(AuthenticateResult.Fail("Malformed test repository id."));
+ }
+
+ var commitHash = Request.Headers.TryGetValue(CommitHashHeader, out var rawCommit)
+ ? rawCommit.ToString()
+ : "abc1234567890abcdef1234567890abcdef12345";
+
+ var claims = new List
+ {
+ new(AuthSchemas.CiCdClaims.RepositoryId, repositoryId.ToString()),
+ new(AuthSchemas.CiCdClaims.CommitHash, commitHash)
+ };
+
+ var rawScopes = Request.Headers.TryGetValue(ScopesHeader, out var scopeHeader)
+ ? scopeHeader.ToString()
+ : "publish_firmware,publish_modules";
+ if (!string.Equals(rawScopes, NoScopes, StringComparison.Ordinal))
+ {
+ foreach (var scope in rawScopes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ claims.Add(new Claim(AuthSchemas.CiCdClaims.Scope, scope));
+ }
+ }
+
+ if (Request.Headers.TryGetValue(RefHeader, out var rawRef) && !string.IsNullOrWhiteSpace(rawRef))
+ {
+ claims.Add(new Claim(AuthSchemas.CiCdClaims.Ref, rawRef.ToString()));
+ }
+
+ if (Request.Headers.TryGetValue(RunIdHeader, out var rawRunId) && !string.IsNullOrWhiteSpace(rawRunId))
+ {
+ claims.Add(new Claim(AuthSchemas.CiCdClaims.RunId, rawRunId.ToString()));
+ }
+
+ var identity = new ClaimsIdentity(claims, Scheme.Name);
+ var principal = new ClaimsPrincipal(identity);
+
+ return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)));
+ }
+}
diff --git a/RepositoryServer.Tests/Integration/Tests/AdvisoriesAdminControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/AdvisoriesAdminControllerTests.cs
new file mode 100644
index 0000000..6ca56b5
--- /dev/null
+++ b/RepositoryServer.Tests/Integration/Tests/AdvisoriesAdminControllerTests.cs
@@ -0,0 +1,198 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.Extensions.DependencyInjection;
+using OpenShock.RepositoryServer.Models.Firmware;
+using OpenShock.RepositoryServer.RepoServerDb;
+
+namespace OpenShock.RepositoryServer.Tests.Integration.Tests;
+
+[NotInParallel("repo-server-integration")]
+public class AdvisoriesAdminControllerTests
+{
+ private const string BasePath = "/2/firmware/admin/advisories";
+
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public required WebApplicationFactory Factory { get; init; }
+
+ [Before(Test)]
+ public Task Setup() => Factory.ResetDatabaseAsync();
+
+ [Test]
+ public async Task Post_ValidRequest_Returns201WithGeneratedId()
+ {
+ using var client = Factory.CreateAdminClient();
+
+ var response = await client.PostAsJsonAsync(BasePath, new UpsertFirmwareAdvisoryRequest
+ {
+ Severity = "critical",
+ Title = "OTA bricking bug",
+ Content = "Versions before 1.4.0 have a bug.",
+ AffectedVersions = "<1.4.0",
+ Url = "https://example.invalid/issues/123"
+ });
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
+
+ var body = await response.Content.ReadFromJsonAsync();
+ await Assert.That(body).IsNotNull();
+ await Assert.That(body!.Id).IsNotEqualTo(Guid.Empty);
+ await Assert.That(body.Severity).IsEqualTo("critical");
+ await Assert.That(body.Title).IsEqualTo("OTA bricking bug");
+ }
+
+ [Test]
+ public async Task Post_InvalidSeverity_Returns400()
+ {
+ using var client = Factory.CreateAdminClient();
+
+ var response = await client.PostAsJsonAsync(BasePath, new UpsertFirmwareAdvisoryRequest
+ {
+ Severity = "catastrophic",
+ Title = "Nope",
+ Content = "Nope",
+ AffectedVersions = "<1.0.0"
+ });
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
+ }
+
+ [Test]
+ public async Task Post_WithoutAuth_Returns401()
+ {
+ using var client = Factory.CreateClient();
+
+ var response = await client.PostAsJsonAsync(BasePath, new UpsertFirmwareAdvisoryRequest
+ {
+ Severity = "info",
+ Title = "Whatever",
+ Content = "Whatever",
+ AffectedVersions = ">=1.0.0"
+ });
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
+ }
+
+ [Test]
+ public async Task Get_ReturnsAllAdvisoriesOrderedBySeverity()
+ {
+ await SeedAdvisoryAsync(AdvisorySeverity: "info", "Minor issue");
+ await SeedAdvisoryAsync(AdvisorySeverity: "critical", "Bricking bug");
+ await SeedAdvisoryAsync(AdvisorySeverity: "warning", "Gotcha");
+
+ using var client = Factory.CreateAdminClient();
+ var response = await client.GetAsync(BasePath);
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+
+ var body = await response.Content.ReadFromJsonAsync>();
+ await Assert.That(body).IsNotNull();
+ await Assert.That(body!).Count().IsEqualTo(3);
+
+ // Ordered by severity rank: Critical (0), Warning (1), Info (2).
+ await Assert.That(body[0].Severity).IsEqualTo("critical");
+ await Assert.That(body[1].Severity).IsEqualTo("warning");
+ await Assert.That(body[2].Severity).IsEqualTo("info");
+ }
+
+ [Test]
+ public async Task Put_ExistingAdvisory_UpdatesFields()
+ {
+ var id = await SeedAdvisoryAsync(AdvisorySeverity: "info", "Original title");
+
+ using var client = Factory.CreateAdminClient();
+ var response = await client.PutAsJsonAsync($"{BasePath}/{id}", new UpsertFirmwareAdvisoryRequest
+ {
+ Severity = "warning",
+ Title = "Updated title",
+ Content = "Updated content",
+ AffectedVersions = ">=1.0.0 <2.0.0",
+ Url = "https://example.invalid/new"
+ });
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+
+ var updated = await response.Content.ReadFromJsonAsync();
+ await Assert.That(updated).IsNotNull();
+ await Assert.That(updated!.Id).IsEqualTo(id);
+ await Assert.That(updated.Severity).IsEqualTo("warning");
+ await Assert.That(updated.Title).IsEqualTo("Updated title");
+ }
+
+ [Test]
+ public async Task Put_UnknownId_Returns404()
+ {
+ using var client = Factory.CreateAdminClient();
+ var response = await client.PutAsJsonAsync($"{BasePath}/{Guid.NewGuid()}",
+ new UpsertFirmwareAdvisoryRequest
+ {
+ Severity = "info",
+ Title = "Nothing to update",
+ Content = "-",
+ AffectedVersions = ">=0.0.0"
+ });
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
+ }
+
+ [Test]
+ public async Task Delete_ExistingAdvisory_Returns204()
+ {
+ var id = await SeedAdvisoryAsync(AdvisorySeverity: "warning", "Will be deleted");
+
+ using var client = Factory.CreateAdminClient();
+ var response = await client.DeleteAsync($"{BasePath}/{id}");
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
+
+ // Confirm gone via GET
+ var list = await client.GetFromJsonAsync>(BasePath);
+ await Assert.That(list).IsNotNull();
+ await Assert.That(list!.Any(a => a.Id == id)).IsFalse();
+ }
+
+ [Test]
+ public async Task Delete_UnknownId_Returns404()
+ {
+ using var client = Factory.CreateAdminClient();
+ var response = await client.DeleteAsync($"{BasePath}/{Guid.NewGuid()}");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
+ }
+
+ [Test]
+ public async Task Post_Advisory_AppearsOnPublicManifest()
+ {
+ using var adminClient = Factory.CreateAdminClient();
+ var createResponse = await adminClient.PostAsJsonAsync(BasePath, new UpsertFirmwareAdvisoryRequest
+ {
+ Severity = "info",
+ Title = "FYI",
+ Content = "Something to know",
+ AffectedVersions = ">=1.0.0"
+ });
+ await Assert.That(createResponse.StatusCode).IsEqualTo(HttpStatusCode.Created);
+
+ using var publicClient = Factory.CreateClient();
+ var manifest = await publicClient.GetFromJsonAsync("/2/firmware/manifest");
+ var advisories = manifest.GetProperty("advisories").EnumerateArray().ToList();
+
+ await Assert.That(advisories).Count().IsEqualTo(1);
+ await Assert.That(advisories[0].GetProperty("title").GetString()).IsEqualTo("FYI");
+ // Manifest DTO does not expose id — check the fields that are public.
+ await Assert.That(advisories[0].TryGetProperty("id", out _)).IsFalse();
+ }
+
+ private async Task SeedAdvisoryAsync(string AdvisorySeverity, string title)
+ {
+ using var client = Factory.CreateAdminClient();
+ var response = await client.PostAsJsonAsync(BasePath, new UpsertFirmwareAdvisoryRequest
+ {
+ Severity = AdvisorySeverity,
+ Title = title,
+ Content = "seed",
+ AffectedVersions = ">=0.0.0"
+ });
+ response.EnsureSuccessStatusCode();
+ var body = await response.Content.ReadFromJsonAsync();
+ return body!.Id;
+ }
+}
diff --git a/RepositoryServer.Tests/Integration/Tests/BoardsAdminControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/BoardsAdminControllerTests.cs
new file mode 100644
index 0000000..58114f5
--- /dev/null
+++ b/RepositoryServer.Tests/Integration/Tests/BoardsAdminControllerTests.cs
@@ -0,0 +1,270 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.Extensions.DependencyInjection;
+using OpenShock.RepositoryServer.Enums;
+using OpenShock.RepositoryServer.Models.Firmware;
+using OpenShock.RepositoryServer.RepoServerDb;
+
+namespace OpenShock.RepositoryServer.Tests.Integration.Tests;
+
+[NotInParallel("repo-server-integration")]
+public class BoardsAdminControllerTests
+{
+ private const string BasePath = "/2/firmware/admin/boards";
+ private const string ChipsPath = "/2/firmware/admin/chips";
+
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public required WebApplicationFactory Factory { get; init; }
+
+ [Before(Test)]
+ public Task Setup() => Factory.ResetDatabaseAsync();
+
+ [Test]
+ public async Task Post_WithValidChip_Returns201()
+ {
+ var chipId = await SeedChipAsync("ESP32-S3");
+ using var client = Factory.CreateAdminClient();
+
+ var response = await client.PostAsJsonAsync(BasePath, new CreateFirmwareBoardRequest
+ {
+ Name = "OpenShock-Core-V1",
+ ChipId = chipId,
+ RequiredArtifactTypes = ["merged"]
+ });
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
+ var body = await response.Content.ReadFromJsonAsync();
+ await Assert.That(body.GetProperty("id").GetGuid()).IsNotEqualTo(Guid.Empty);
+ }
+
+ [Test]
+ public async Task Post_UnknownChip_Returns404()
+ {
+ using var client = Factory.CreateAdminClient();
+ var response = await client.PostAsJsonAsync(BasePath, new CreateFirmwareBoardRequest
+ {
+ Name = "Phantom-Board",
+ ChipId = Guid.NewGuid(),
+ RequiredArtifactTypes = ["merged"]
+ });
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
+ }
+
+ [Test]
+ [Arguments("OpenShock Core V1")] // spaces
+ [Arguments("core/../etc")] // path separator — would reshape the storage key
+ [Arguments("-leading-dash")] // must start alphanumeric
+ [Arguments("emoji-\U0001F600")]
+ public async Task Post_NonUrlSafeName_Returns400(string name)
+ {
+ var chipId = await SeedChipAsync("ESP32");
+ using var client = Factory.CreateAdminClient();
+
+ var response = await client.PostAsJsonAsync(BasePath, new CreateFirmwareBoardRequest
+ {
+ Name = name,
+ ChipId = chipId,
+ RequiredArtifactTypes = ["merged"]
+ });
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
+ }
+
+ [Test]
+ public async Task Post_NameDifferingOnlyByCase_IsRejected()
+ {
+ var chipId = await SeedChipAsync("ESP32");
+ using var client = Factory.CreateAdminClient();
+
+ var first = await client.PostAsJsonAsync(BasePath, new CreateFirmwareBoardRequest
+ {
+ Name = "ESP32-Core",
+ ChipId = chipId,
+ RequiredArtifactTypes = ["merged"]
+ });
+ await Assert.That(first.StatusCode).IsEqualTo(HttpStatusCode.Created);
+
+ // Two boards differing only in case would both resolve to whichever the lookup picked, so the
+ // loser could never receive an upload and a hub compiled with its spelling would be served the
+ // other board's firmware. The unique index on lower(name) makes that unrepresentable.
+ var second = await client.PostAsJsonAsync(BasePath, new CreateFirmwareBoardRequest
+ {
+ Name = "esp32-core",
+ ChipId = chipId,
+ RequiredArtifactTypes = ["merged"]
+ });
+ await Assert.That(second.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
+ }
+
+ [Test]
+ public async Task Post_InvalidArtifactType_Returns400()
+ {
+ var chipId = await SeedChipAsync("ESP32");
+ using var client = Factory.CreateAdminClient();
+ var response = await client.PostAsJsonAsync(BasePath, new CreateFirmwareBoardRequest
+ {
+ Name = "bad-board",
+ ChipId = chipId,
+ RequiredArtifactTypes = ["unknown_type"]
+ });
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
+ }
+
+ [Test]
+ public async Task Patch_Discontinue_FlipsFlag()
+ {
+ var chipId = await SeedChipAsync("ESP32");
+ var boardId = await SeedBoardAsync(chipId, "Pishock-Lite-2021");
+
+ using var client = Factory.CreateAdminClient();
+ var response = await client.PatchAsync($"{BasePath}/{boardId}/discontinue", content: null);
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+
+ await using var scope = Factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var board = await db.FirmwareBoards.FindAsync(boardId);
+ await Assert.That(board!.Discontinued).IsTrue();
+ }
+
+ [Test]
+ public async Task Delete_Unused_Returns204()
+ {
+ var chipId = await SeedChipAsync("ESP32-C3");
+ var boardId = await SeedBoardAsync(chipId, "Generic-C3");
+
+ using var client = Factory.CreateAdminClient();
+ var response = await client.DeleteAsync($"{BasePath}/{boardId}");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
+ }
+
+ [Test]
+ public async Task Delete_ReferencedByArtifact_Returns409InUse()
+ {
+ var chipId = await SeedChipAsync("ESP32");
+ var boardId = await SeedBoardAsync(chipId, "Wemos-D1-Mini-ESP32");
+
+ await using (var scope = Factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ // Set up: a repository, a firmware version, and an artifact referencing the board.
+ var repo = new SourceRepository
+ {
+ Id = Guid.NewGuid(),
+ Provider = RepositoryProvider.Github,
+ Owner = "openshock",
+ Repo = "firmware"
+ };
+ var version = new FirmwareVersion
+ {
+ Version = "1.5.1",
+ Channel = ReleaseChannel.Stable,
+ ReleaseDate = DateTimeOffset.UtcNow,
+ RepositoryId = repo.Id,
+ CommitHash = "abc1234567890abcdef1234567890abcdef12345"
+ };
+ db.Repositories.Add(repo);
+ db.FirmwareVersions.Add(version);
+ db.FirmwareArtifacts.Add(new FirmwareArtifact
+ {
+ Version = version.Version,
+ BoardId = boardId,
+ ArtifactType = FirmwareArtifactType.Merged,
+ HashSha256 = new byte[32],
+ FileSize = 1024
+ });
+ await db.SaveChangesAsync();
+ }
+
+ using var client = Factory.CreateAdminClient();
+ var response = await client.DeleteAsync($"{BasePath}/{boardId}");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
+ }
+
+ [Test]
+ public async Task Put_UpdateBoard_ChangesName()
+ {
+ var chipId = await SeedChipAsync("ESP32");
+ var boardId = await SeedBoardAsync(chipId, "Original-Name");
+
+ using var client = Factory.CreateAdminClient();
+ var response = await client.PutAsJsonAsync($"{BasePath}/{boardId}", new CreateFirmwareBoardRequest
+ {
+ Name = "New-Name",
+ ChipId = chipId,
+ RequiredArtifactTypes = ["merged", "app"]
+ });
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+
+ await using var scope = Factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var board = await db.FirmwareBoards.FindAsync(boardId);
+ await Assert.That(board!.Name).IsEqualTo("New-Name");
+ await Assert.That(board.RequiredArtifactTypes.Length).IsEqualTo(2);
+ }
+
+ [Test]
+ public async Task AttachUsbDevice_Idempotent_WritesSingleRow()
+ {
+ var chipId = await SeedChipAsync("ESP32");
+ var boardId = await SeedBoardAsync(chipId, "Sample-Board");
+
+ Guid deviceId;
+ await using (var scope = Factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var device = new UsbDevice { Id = Guid.NewGuid(), Vid = 0x1A86, Pid = 0x7522, Name = "CH9102" };
+ db.UsbDevices.Add(device);
+ await db.SaveChangesAsync();
+ deviceId = device.Id;
+ }
+
+ using var client = Factory.CreateAdminClient();
+ var first = await client.PutAsync($"{BasePath}/{boardId}/usb-devices/{deviceId}", content: null);
+ var second = await client.PutAsync($"{BasePath}/{boardId}/usb-devices/{deviceId}", content: null);
+
+ await Assert.That(first.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
+ await Assert.That(second.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
+
+ await using var verify = Factory.Services.CreateAsyncScope();
+ var db2 = verify.ServiceProvider.GetRequiredService();
+ var count = db2.FirmwareBoardUsbDevices.Count(j => j.BoardId == boardId && j.UsbDeviceId == deviceId);
+ await Assert.That(count).IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task DetachUsbDevice_IgnoresMissingLink()
+ {
+ using var client = Factory.CreateAdminClient();
+ var response = await client.DeleteAsync(
+ $"{BasePath}/{Guid.NewGuid()}/usb-devices/{Guid.NewGuid()}");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
+ }
+
+ private async Task SeedChipAsync(string name)
+ {
+ using var client = Factory.CreateAdminClient();
+ var response = await client.PostAsJsonAsync(ChipsPath, new CreateFirmwareChipRequest
+ {
+ Name = name
+ });
+ response.EnsureSuccessStatusCode();
+ var body = await response.Content.ReadFromJsonAsync();
+ return body.GetProperty("id").GetGuid();
+ }
+
+ private async Task SeedBoardAsync(Guid chipId, string name)
+ {
+ using var client = Factory.CreateAdminClient();
+ var response = await client.PostAsJsonAsync(BasePath, new CreateFirmwareBoardRequest
+ {
+ Name = name,
+ ChipId = chipId,
+ RequiredArtifactTypes = []
+ });
+ response.EnsureSuccessStatusCode();
+ var body = await response.Content.ReadFromJsonAsync();
+ return body.GetProperty("id").GetGuid();
+ }
+}
diff --git a/RepositoryServer.Tests/Integration/Tests/ChipsAdminControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/ChipsAdminControllerTests.cs
new file mode 100644
index 0000000..4836480
--- /dev/null
+++ b/RepositoryServer.Tests/Integration/Tests/ChipsAdminControllerTests.cs
@@ -0,0 +1,159 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.Extensions.DependencyInjection;
+using OpenShock.RepositoryServer.Enums;
+using OpenShock.RepositoryServer.Models.Firmware;
+using OpenShock.RepositoryServer.RepoServerDb;
+
+namespace OpenShock.RepositoryServer.Tests.Integration.Tests;
+
+[NotInParallel("repo-server-integration")]
+public class ChipsAdminControllerTests
+{
+ private const string BasePath = "/2/firmware/admin/chips";
+
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public required WebApplicationFactory Factory { get; init; }
+
+ [Before(Test)]
+ public Task Setup() => Factory.ResetDatabaseAsync();
+
+ [Test]
+ public async Task Post_ValidRequest_Returns201WithId()
+ {
+ using var client = Factory.CreateAdminClient();
+
+ var response = await client.PostAsJsonAsync(BasePath, new CreateFirmwareChipRequest
+ {
+ Name = "ESP32-S3",
+ Architecture = "xtensa"
+ });
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
+ var body = await response.Content.ReadFromJsonAsync();
+ var id = body.GetProperty("id").GetGuid();
+ await Assert.That(id).IsNotEqualTo(Guid.Empty);
+ }
+
+ [Test]
+ public async Task Post_InvalidArchitecture_Returns400()
+ {
+ using var client = Factory.CreateAdminClient();
+ var response = await client.PostAsJsonAsync(BasePath, new CreateFirmwareChipRequest
+ {
+ Name = "ESP42",
+ Architecture = "quantum"
+ });
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
+ }
+
+ [Test]
+ public async Task Put_ExistingChip_UpdatesName()
+ {
+ var id = await SeedChipAsync(name: "ESP32-S3", arch: FirmwareChipArchitecture.Xtensa);
+ using var client = Factory.CreateAdminClient();
+
+ var response = await client.PutAsJsonAsync($"{BasePath}/{id}", new CreateFirmwareChipRequest
+ {
+ Name = "ESP32-S3 (renamed)",
+ Architecture = "xtensa"
+ });
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+
+ await using var scope = Factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var chip = await db.FirmwareChips.FindAsync(id);
+ await Assert.That(chip!.Name).IsEqualTo("ESP32-S3 (renamed)");
+ }
+
+ [Test]
+ public async Task Put_UnknownId_Returns404()
+ {
+ using var client = Factory.CreateAdminClient();
+ var response = await client.PutAsJsonAsync($"{BasePath}/{Guid.NewGuid()}",
+ new CreateFirmwareChipRequest { Name = "ESP-any" });
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
+ }
+
+ [Test]
+ public async Task Delete_Unused_Returns204()
+ {
+ var id = await SeedChipAsync(name: "ESP32-C6", arch: FirmwareChipArchitecture.RiscV);
+ using var client = Factory.CreateAdminClient();
+
+ var response = await client.DeleteAsync($"{BasePath}/{id}");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
+ }
+
+ [Test]
+ public async Task Delete_ReferencedByBoard_Returns409InUse()
+ {
+ var chipId = await SeedChipAsync(name: "ESP32", arch: FirmwareChipArchitecture.Xtensa);
+ await using (var scope = Factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.FirmwareBoards.Add(new FirmwareBoard
+ {
+ Id = Guid.NewGuid(),
+ Name = "Wemos-D1-Mini-ESP32",
+ ChipId = chipId,
+ RequiredArtifactTypes = []
+ });
+ await db.SaveChangesAsync();
+ }
+
+ using var client = Factory.CreateAdminClient();
+ var response = await client.DeleteAsync($"{BasePath}/{chipId}");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
+ }
+
+ [Test]
+ public async Task AttachUsbDevice_Idempotent()
+ {
+ var chipId = await SeedChipAsync(name: "ESP32-S3", arch: FirmwareChipArchitecture.Xtensa);
+ Guid deviceId;
+ await using (var scope = Factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var device = new UsbDevice { Id = Guid.NewGuid(), Vid = 0x303A, Pid = 0x1001, Name = "ESP32-S3 USB-JTAG" };
+ db.UsbDevices.Add(device);
+ await db.SaveChangesAsync();
+ deviceId = device.Id;
+ }
+
+ using var client = Factory.CreateAdminClient();
+ var first = await client.PutAsync($"{BasePath}/{chipId}/usb-devices/{deviceId}", content: null);
+ var second = await client.PutAsync($"{BasePath}/{chipId}/usb-devices/{deviceId}", content: null);
+
+ await Assert.That(first.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
+ await Assert.That(second.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
+
+ await using var verify = Factory.Services.CreateAsyncScope();
+ var db2 = verify.ServiceProvider.GetRequiredService();
+ var count = db2.FirmwareChipUsbDevices.Count(j => j.ChipId == chipId && j.UsbDeviceId == deviceId);
+ await Assert.That(count).IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task AttachUsbDevice_UnknownChip_Returns404()
+ {
+ using var client = Factory.CreateAdminClient();
+ var response = await client.PutAsync(
+ $"{BasePath}/{Guid.NewGuid()}/usb-devices/{Guid.NewGuid()}", content: null);
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
+ }
+
+ private async Task SeedChipAsync(string name, FirmwareChipArchitecture arch)
+ {
+ using var client = Factory.CreateAdminClient();
+ var response = await client.PostAsJsonAsync(BasePath, new CreateFirmwareChipRequest
+ {
+ Name = name,
+ Architecture = arch.ToString().ToLowerInvariant()
+ });
+ response.EnsureSuccessStatusCode();
+ var body = await response.Content.ReadFromJsonAsync();
+ return body.GetProperty("id").GetGuid();
+ }
+}
diff --git a/RepositoryServer.Tests/Integration/Tests/DesktopCiCdControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/DesktopCiCdControllerTests.cs
new file mode 100644
index 0000000..82b5d99
--- /dev/null
+++ b/RepositoryServer.Tests/Integration/Tests/DesktopCiCdControllerTests.cs
@@ -0,0 +1,147 @@
+using System.IO.Compression;
+using System.Net;
+using System.Security.Cryptography;
+using System.Text;
+using Microsoft.Extensions.DependencyInjection;
+using OpenShock.RepositoryServer.Enums;
+using OpenShock.RepositoryServer.RepoServerDb;
+
+namespace OpenShock.RepositoryServer.Tests.Integration.Tests;
+
+///
+/// Desktop module publishing shares the CI/CD principal with firmware ingestion, so it needs the same
+/// authorization boundary: a module belongs to one repository, and nobody else may write to it.
+///
+[NotInParallel("repo-server-integration")]
+public class DesktopCiCdControllerTests
+{
+ private const string ModuleId = "test-module";
+
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public required WebApplicationFactory Factory { get; init; }
+
+ [Before(Test)]
+ public Task Setup() => Factory.ResetDatabaseAsync();
+
+ [Test]
+ public async Task Publish_ByOwningRepository_Succeeds()
+ {
+ var ownerId = await RegisterRepositoryAsync("openshock", "desktop");
+ await SeedModuleAsync(ownerId);
+
+ using var client = Factory.CreateCiCdClient(ownerId);
+ var response = await PublishAsync(client, "1.0.0");
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
+ }
+
+ [Test]
+ public async Task Publish_FromDifferentRepository_IsForbidden()
+ {
+ var ownerId = await RegisterRepositoryAsync("openshock", "desktop");
+ var intruderId = await RegisterRepositoryAsync("attacker", "evil-repo");
+ await SeedModuleAsync(ownerId);
+
+ using var client = Factory.CreateCiCdClient(intruderId);
+ var response = await PublishAsync(client, "1.0.0");
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
+ }
+
+ [Test]
+ public async Task Publish_ToModuleWithNoOwner_IsForbidden()
+ {
+ var repoId = await RegisterRepositoryAsync("openshock", "desktop");
+ await SeedModuleAsync(owningRepositoryId: null);
+
+ // Unassigned modules are closed to everyone rather than open to anyone.
+ using var client = Factory.CreateCiCdClient(repoId);
+ var response = await PublishAsync(client, "1.0.0");
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
+ }
+
+ [Test]
+ public async Task Publish_ExistingVersion_IsRejectedAsImmutable()
+ {
+ var ownerId = await RegisterRepositoryAsync("openshock", "desktop");
+ await SeedModuleAsync(ownerId);
+
+ using var client = Factory.CreateCiCdClient(ownerId);
+ var first = await PublishAsync(client, "1.0.0");
+ await Assert.That(first.StatusCode).IsEqualTo(HttpStatusCode.Created);
+
+ // The upsert is keyed on (module, version), so without a guard this would silently replace the
+ // published zip URL and hash in place.
+ var second = await PublishAsync(client, "1.0.0");
+ await Assert.That(second.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
+ }
+
+ [Test]
+ public async Task Publish_WithOnlyTheFirmwareScope_IsForbidden()
+ {
+ var ownerId = await RegisterRepositoryAsync("openshock", "desktop");
+ await SeedModuleAsync(ownerId);
+
+ // The mirror of the firmware check: a firmware-only grant must not reach module publishing.
+ using var client = Factory.CreateCiCdClient(ownerId, scopes: "publish_firmware");
+ var response = await PublishAsync(client, "1.0.0");
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
+ }
+
+ // ---- Helpers ----
+
+ private async Task RegisterRepositoryAsync(string owner, string repo)
+ {
+ await using var scope = Factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var row = new SourceRepository
+ {
+ Id = Guid.NewGuid(),
+ Provider = RepositoryProvider.Github,
+ Owner = owner,
+ Repo = repo
+ };
+ db.Repositories.Add(row);
+ await db.SaveChangesAsync();
+ return row.Id;
+ }
+
+ private async Task SeedModuleAsync(Guid? owningRepositoryId)
+ {
+ await using var scope = Factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ db.Modules.Add(new Module
+ {
+ Id = ModuleId,
+ Name = "Test Module",
+ Description = "Fixture",
+ RepositoryId = owningRepositoryId
+ });
+ await db.SaveChangesAsync();
+ }
+
+ private static async Task PublishAsync(HttpClient client, string version)
+ {
+ var content = new MultipartFormDataContent();
+ content.Add(new ByteArrayContent(BuildModuleZip()), "zip", "module.zip");
+
+ return await client.PutAsync($"/1/cicd/modules/{ModuleId}/versions/{version}", content);
+ }
+
+ /// Minimal zip passing the controller's root-entry validation (.dll/.pdb/.json only).
+ private static byte[] BuildModuleZip()
+ {
+ using var memory = new MemoryStream();
+ using (var archive = new ZipArchive(memory, ZipArchiveMode.Create, leaveOpen: true))
+ {
+ var entry = archive.CreateEntry("module.dll");
+ using var stream = entry.Open();
+ stream.Write(Encoding.UTF8.GetBytes("not-a-real-assembly"));
+ }
+ return memory.ToArray();
+ }
+}
diff --git a/RepositoryServer.Tests/Integration/Tests/DiscordWebhooksControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/DiscordWebhooksControllerTests.cs
new file mode 100644
index 0000000..7cd052f
--- /dev/null
+++ b/RepositoryServer.Tests/Integration/Tests/DiscordWebhooksControllerTests.cs
@@ -0,0 +1,149 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.Extensions.DependencyInjection;
+using OpenShock.RepositoryServer.Enums;
+using OpenShock.RepositoryServer.Models.Discord;
+using OpenShock.RepositoryServer.RepoServerDb;
+
+namespace OpenShock.RepositoryServer.Tests.Integration.Tests;
+
+[NotInParallel("repo-server-integration")]
+public class DiscordWebhooksControllerTests
+{
+ private const string BasePath = "/2/admin/discord-webhooks";
+ private const string WebhookUrl = "https://discord.com/api/webhooks/123456789/s3cr3t-token-value";
+
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public required WebApplicationFactory Factory { get; init; }
+
+ [Before(Test)]
+ public Task Setup() => Factory.ResetDatabaseAsync();
+
+ [Test]
+ public async Task Post_CreatesWebhook_AndNeverEchoesTheUrl()
+ {
+ using var client = Factory.CreateAdminClient();
+
+ var response = await client.PostAsJsonAsync(BasePath, new UpsertDiscordWebhookRequest
+ {
+ Name = "releases",
+ Url = WebhookUrl,
+ Events = ["firmware_release_published"]
+ });
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
+
+ // The URL is the credential — possession of it is authorization to post to the channel — so it
+ // must never come back out, in any field.
+ var raw = await response.Content.ReadAsStringAsync();
+ await Assert.That(raw).DoesNotContain("s3cr3t-token-value");
+
+ var body = JsonSerializer.Deserialize(raw);
+ await Assert.That(body.GetProperty("urlMasked").GetString()).IsEqualTo(
+ "https://discord.com/api/webhooks/123456789/***");
+ }
+
+ [Test]
+ public async Task Get_ListsWebhooksWithoutLeakingUrls()
+ {
+ using var client = Factory.CreateAdminClient();
+ await client.PostAsJsonAsync(BasePath, new UpsertDiscordWebhookRequest
+ {
+ Name = "releases", Url = WebhookUrl, Events = ["firmware_release_published"]
+ });
+
+ var response = await client.GetAsync(BasePath);
+ var raw = await response.Content.ReadAsStringAsync();
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ await Assert.That(raw).DoesNotContain("s3cr3t-token-value");
+ await Assert.That(raw).Contains("releases");
+ }
+
+ [Test]
+ public async Task Post_UnknownEvent_Returns400()
+ {
+ using var client = Factory.CreateAdminClient();
+
+ var response = await client.PostAsJsonAsync(BasePath, new UpsertDiscordWebhookRequest
+ {
+ Name = "releases", Url = WebhookUrl, Events = ["everything"]
+ });
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
+ }
+
+ [Test]
+ public async Task Put_UpdatesEventSubscription()
+ {
+ using var client = Factory.CreateAdminClient();
+ var created = await client.PostAsJsonAsync(BasePath, new UpsertDiscordWebhookRequest
+ {
+ Name = "releases", Url = WebhookUrl, Events = ["firmware_release_published"]
+ });
+ var id = (await created.Content.ReadFromJsonAsync()).GetProperty("id").GetGuid();
+
+ var response = await client.PutAsJsonAsync($"{BasePath}/{id}", new UpsertDiscordWebhookRequest
+ {
+ Name = "maintainers",
+ Url = WebhookUrl,
+ Events = ["release_notes_need_editing", "staged_release_expired"]
+ });
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+
+ var body = await response.Content.ReadFromJsonAsync();
+ var events = body.GetProperty("events").EnumerateArray().Select(e => e.GetString()).ToList();
+ await Assert.That(events).Contains("release_notes_need_editing");
+ await Assert.That(events).DoesNotContain("firmware_release_published");
+ }
+
+ [Test]
+ public async Task Delete_RemovesWebhook()
+ {
+ using var client = Factory.CreateAdminClient();
+ var created = await client.PostAsJsonAsync(BasePath, new UpsertDiscordWebhookRequest
+ {
+ Name = "releases", Url = WebhookUrl, Events = []
+ });
+ var id = (await created.Content.ReadFromJsonAsync()).GetProperty("id").GetGuid();
+
+ var response = await client.DeleteAsync($"{BasePath}/{id}");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
+
+ var again = await client.DeleteAsync($"{BasePath}/{id}");
+ await Assert.That(again.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
+ }
+
+ [Test]
+ public async Task Endpoints_RequireTheAdminToken()
+ {
+ using var client = Factory.CreateClient();
+
+ var response = await client.GetAsync(BasePath);
+ await Assert.That(response.IsSuccessStatusCode).IsFalse();
+ }
+
+ [Test]
+ public async Task StoredRows_RoundTripEventsAndEnabledFlag()
+ {
+ using var client = Factory.CreateAdminClient();
+ await client.PostAsJsonAsync(BasePath, new UpsertDiscordWebhookRequest
+ {
+ Name = "maintainers",
+ Url = WebhookUrl,
+ Events = ["release_notes_need_editing"],
+ Enabled = false
+ });
+
+ // Verified against the database rather than the response, since the delivery path reads rows.
+ await using var scope = Factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var row = db.DiscordWebhooks.Single();
+
+ await Assert.That(row.Enabled).IsFalse();
+ await Assert.That(row.Events).Contains(DiscordNotificationEvent.ReleaseNotesNeedEditing);
+ await Assert.That(row.Url).IsEqualTo(WebhookUrl);
+ }
+}
diff --git a/RepositoryServer.Tests/Integration/Tests/LatestControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/LatestControllerTests.cs
new file mode 100644
index 0000000..8177fb4
--- /dev/null
+++ b/RepositoryServer.Tests/Integration/Tests/LatestControllerTests.cs
@@ -0,0 +1,315 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.Extensions.DependencyInjection;
+using OpenShock.RepositoryServer.Enums;
+using OpenShock.RepositoryServer.RepoServerDb;
+
+namespace OpenShock.RepositoryServer.Tests.Integration.Tests;
+
+[NotInParallel("repo-server-integration")]
+public class LatestControllerTests
+{
+ /// Slug-form board name, matching what a hub compiles in as OPENSHOCK_FW_BOARD.
+ private const string BoardName = "Wemos-D1-Mini-ESP32";
+
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public required WebApplicationFactory Factory { get; init; }
+
+ [Before(Test)]
+ public Task Setup() => Factory.ResetDatabaseAsync();
+
+ [Test]
+ public async Task GetLatest_InvalidChannel_Returns400()
+ {
+ using var client = Factory.CreateClient();
+ var response = await client.GetAsync("/2/firmware/latest/nightly");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
+ }
+
+ [Test]
+ public async Task GetLatest_ChannelWithNoReleases_Returns404()
+ {
+ using var client = Factory.CreateClient();
+ var response = await client.GetAsync("/2/firmware/latest/stable");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
+ }
+
+ [Test]
+ public async Task GetLatest_HappyPath_ReturnsReleaseWithSourceAndBoards()
+ {
+ var (boardId, _, _) = await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable);
+
+ using var client = Factory.CreateClient();
+ var response = await client.GetAsync("/2/firmware/latest/stable");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+
+ var body = await response.Content.ReadFromJsonAsync();
+ await Assert.That(body.GetProperty("version").GetString()).IsEqualTo("1.5.1");
+ await Assert.That(body.GetProperty("channel").GetString()).IsEqualTo("stable");
+
+ var source = body.GetProperty("source");
+ await Assert.That(source.GetProperty("repository").GetProperty("provider").GetString())
+ .IsEqualTo("github");
+ await Assert.That(source.GetProperty("commitUrl").GetString())
+ .IsEqualTo("https://github.com/openshock/firmware/commit/abc1234567890abcdef1234567890abcdef12345");
+
+ var boards = body.GetProperty("boards");
+ var boardKeys = boards.EnumerateObject().Select(p => p.Name).ToList();
+ await Assert.That(boardKeys).Contains(BoardName);
+ }
+
+ [Test]
+ public async Task GetLatestForBoard_UnknownChannel_Returns400()
+ {
+ using var client = Factory.CreateClient();
+ var response = await client.GetAsync($"/2/firmware/latest/nightly/{Guid.NewGuid()}");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
+ }
+
+ [Test]
+ public async Task GetLatestForBoard_NoReleases_Returns404()
+ {
+ using var client = Factory.CreateClient();
+ var response = await client.GetAsync($"/2/firmware/latest/stable/{Guid.NewGuid()}");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
+ }
+
+ [Test]
+ public async Task GetLatestForBoard_MatchingVersion_Returns204()
+ {
+ var (boardId, _, _) = await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable);
+
+ using var client = Factory.CreateClient();
+ var response = await client.GetAsync($"/2/firmware/latest/stable/{boardId}?version=1.5.1");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
+ }
+
+ [Test]
+ public async Task GetLatestForBoard_DifferentVersion_Returns200WithArtifacts()
+ {
+ var (boardId, _, _) = await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable);
+
+ using var client = Factory.CreateClient();
+ var response = await client.GetAsync($"/2/firmware/latest/stable/{boardId}?version=1.4.0");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+
+ var body = await response.Content.ReadFromJsonAsync();
+ await Assert.That(body.GetProperty("version").GetString()).IsEqualTo("1.5.1");
+ await Assert.That(body.GetProperty("boardId").GetString()).IsEqualTo(BoardName);
+ await Assert.That(body.GetProperty("artifacts").GetArrayLength()).IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task GetLatestForBoard_NoVersionParam_Returns200()
+ {
+ var (boardId, _, _) = await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable);
+
+ using var client = Factory.CreateClient();
+ var response = await client.GetAsync($"/2/firmware/latest/stable/{boardId}");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ }
+
+ [Test]
+ public async Task GetLatestForBoard_ByName_Returns200WithNameEchoed()
+ {
+ await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable);
+
+ using var client = Factory.CreateClient();
+ var response = await client.GetAsync($"/2/firmware/latest/stable/{BoardName}?version=1.4.0");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+
+ var body = await response.Content.ReadFromJsonAsync();
+ await Assert.That(body.GetProperty("boardId").GetString()).IsEqualTo(BoardName);
+ }
+
+ [Test]
+ public async Task GetLatestForBoard_ByNameDifferentCase_Returns200WithCanonicalName()
+ {
+ await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable);
+
+ using var client = Factory.CreateClient();
+ var response = await client.GetAsync(
+ $"/2/firmware/latest/stable/{BoardName.ToLowerInvariant()}?version=1.4.0");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+
+ // The canonical stored spelling comes back, not what the caller sent.
+ var body = await response.Content.ReadFromJsonAsync();
+ await Assert.That(body.GetProperty("boardId").GetString()).IsEqualTo(BoardName);
+ }
+
+ [Test]
+ public async Task GetLatestForBoard_ByName_MatchingVersion_Returns204()
+ {
+ await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable);
+
+ using var client = Factory.CreateClient();
+ var response = await client.GetAsync($"/2/firmware/latest/stable/{BoardName}?version=1.5.1");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
+ }
+
+ [Test]
+ public async Task GetLatestForBoard_ArtifactUrlUsesImmutableBoardId()
+ {
+ var (boardId, _, _) = await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable);
+
+ using var client = Factory.CreateClient();
+ var body = await client.GetFromJsonAsync(
+ $"/2/firmware/latest/stable/{BoardName}");
+
+ // Storage paths are keyed by the board id so a rename cannot strand published artifacts,
+ // while the response still labels the board by name.
+ await Assert.That(body.GetProperty("boardId").GetString()).IsEqualTo(BoardName);
+
+ var url = body.GetProperty("artifacts")[0].GetProperty("url").GetString();
+ await Assert.That(url).Contains($"/1.5.1/{boardId}/");
+ }
+
+ [Test]
+ public async Task GetLatestForBoard_UnknownName_Returns404NotNoContent()
+ {
+ await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable);
+
+ // An unknown board must not be masked as "already up to date" just because the
+ // version query happens to match the latest release.
+ using var client = Factory.CreateClient();
+ var response = await client.GetAsync("/2/firmware/latest/stable/No-Such-Board?version=1.5.1");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
+ }
+
+ [Test]
+ public async Task GetLatestForBoard_UnknownBoard_Returns404()
+ {
+ await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable);
+
+ using var client = Factory.CreateClient();
+ var response = await client.GetAsync($"/2/firmware/latest/stable/{Guid.NewGuid()}");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
+ }
+
+ [Test]
+ public async Task GetLatest_BetaChannel_IncludesNewerStableRelease()
+ {
+ // CI advances stable, beta and develop pointers together when it ships a stable build, so a
+ // beta subscriber must be offered it. Strict per-channel equality would pin them back to the
+ // last explicit release candidate and offer a downgrade.
+ var (_, chipId, repoId) = await SeedReleaseAsync("1.5.0-beta.1", ReleaseChannel.Beta);
+ await AddVersionAsync("1.5.0", ReleaseChannel.Stable, DateTimeOffset.UtcNow, repoId);
+
+ using var client = Factory.CreateClient();
+ var body = await client.GetFromJsonAsync("/2/firmware/latest/beta");
+ await Assert.That(body.GetProperty("version").GetString()).IsEqualTo("1.5.0");
+ }
+
+ [Test]
+ public async Task GetLatest_StableChannel_ExcludesBetaReleases()
+ {
+ // The cascade only runs one way.
+ var (_, _, repoId) = await SeedReleaseAsync("1.5.0", ReleaseChannel.Stable);
+ await AddVersionAsync("1.6.0-beta.1", ReleaseChannel.Beta, DateTimeOffset.UtcNow.AddDays(1), repoId);
+
+ using var client = Factory.CreateClient();
+ var body = await client.GetFromJsonAsync("/2/firmware/latest/stable");
+ await Assert.That(body.GetProperty("version").GetString()).IsEqualTo("1.5.0");
+ }
+
+ [Test]
+ public async Task GetLatest_TiedReleaseDates_ResolvesDeterministicallyAndAgreesWithManifest()
+ {
+ // release_date is client-supplied and not unique — the spec's own example uses midnight. With
+ // no tiebreaker, ties were broken arbitrarily by the database and endpoints could disagree
+ // with each other and flip between requests, which a hub sees as firmware flapping.
+ var sameInstant = DateTimeOffset.Parse("2026-04-15T00:00:00Z");
+ var (_, _, repoId) = await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable, sameInstant);
+ await AddVersionAsync("1.5.2", ReleaseChannel.Stable, sameInstant, repoId);
+
+ using var client = Factory.CreateClient();
+
+ var first = await client.GetFromJsonAsync("/2/firmware/latest/stable");
+ var picked = first.GetProperty("version").GetString();
+ await Assert.That(picked).IsEqualTo("1.5.2");
+
+ // Stable across repeated calls...
+ for (var i = 0; i < 3; i++)
+ {
+ var again = await client.GetFromJsonAsync("/2/firmware/latest/stable");
+ await Assert.That(again.GetProperty("version").GetString()).IsEqualTo(picked);
+ }
+
+ // ...and consistent with the manifest, which computes latest independently.
+ var manifest = await client.GetFromJsonAsync("/2/firmware/manifest");
+ await Assert.That(manifest.GetProperty("latest").GetProperty("stable").GetString())
+ .IsEqualTo(picked);
+ }
+
+ private async Task AddVersionAsync(
+ string version, ReleaseChannel channel, DateTimeOffset releaseDate, Guid repoId)
+ {
+ await using var scope = Factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ db.FirmwareVersions.Add(new FirmwareVersion
+ {
+ Version = version,
+ Channel = channel,
+ ReleaseDate = releaseDate,
+ RepositoryId = repoId,
+ CommitHash = "abc1234567890abcdef1234567890abcdef12345"
+ });
+ await db.SaveChangesAsync();
+ }
+
+ private async Task<(Guid boardId, Guid chipId, Guid repoId)> SeedReleaseAsync(
+ string version, ReleaseChannel channel, DateTimeOffset? releaseDate = null)
+ {
+ await using var scope = Factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var chip = new FirmwareChip
+ {
+ Id = Guid.NewGuid(),
+ Name = "ESP32-S3",
+ Architecture = FirmwareChipArchitecture.Xtensa
+ };
+ var board = new FirmwareBoard
+ {
+ Id = Guid.NewGuid(),
+ Name = BoardName,
+ ChipId = chip.Id,
+ RequiredArtifactTypes = [FirmwareArtifactType.Merged]
+ };
+ var repo = new SourceRepository
+ {
+ Id = Guid.NewGuid(),
+ Provider = RepositoryProvider.Github,
+ Owner = "openshock",
+ Repo = "firmware"
+ };
+ var fwVersion = new FirmwareVersion
+ {
+ Version = version,
+ Channel = channel,
+ ReleaseDate = releaseDate ?? DateTimeOffset.UtcNow,
+ RepositoryId = repo.Id,
+ CommitHash = "abc1234567890abcdef1234567890abcdef12345",
+ Ref = "refs/tags/v" + version
+ };
+ var artifact = new FirmwareArtifact
+ {
+ Version = version,
+ BoardId = board.Id,
+ ArtifactType = FirmwareArtifactType.Merged,
+ HashSha256 = new byte[32],
+ FileSize = 1_572_864
+ };
+
+ db.FirmwareChips.Add(chip);
+ db.FirmwareBoards.Add(board);
+ db.Repositories.Add(repo);
+ db.FirmwareVersions.Add(fwVersion);
+ db.FirmwareArtifacts.Add(artifact);
+ await db.SaveChangesAsync();
+
+ return (board.Id, chip.Id, repo.Id);
+ }
+}
diff --git a/RepositoryServer.Tests/Integration/Tests/ManifestControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/ManifestControllerTests.cs
new file mode 100644
index 0000000..fc1f0cf
--- /dev/null
+++ b/RepositoryServer.Tests/Integration/Tests/ManifestControllerTests.cs
@@ -0,0 +1,166 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.Extensions.DependencyInjection;
+using OpenShock.RepositoryServer.Enums;
+using OpenShock.RepositoryServer.RepoServerDb;
+
+namespace OpenShock.RepositoryServer.Tests.Integration.Tests;
+
+[NotInParallel("repo-server-integration")]
+public class ManifestControllerTests
+{
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public required WebApplicationFactory Factory { get; init; }
+
+ [Before(Test)]
+ public Task Setup() => Factory.ResetDatabaseAsync();
+
+ [Test]
+ public async Task GetManifest_EmptyDatabase_ReturnsShellWithFixedChannels()
+ {
+ using var client = Factory.CreateClient();
+
+ var response = await client.GetAsync("/2/firmware/manifest");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+
+ var manifest = await response.Content.ReadFromJsonAsync();
+
+ var channels = manifest.GetProperty("channels").EnumerateArray()
+ .Select(e => e.GetString()).ToList();
+ await Assert.That(channels).Contains("stable");
+ await Assert.That(channels).Contains("beta");
+ await Assert.That(channels).Contains("develop");
+
+ var latest = manifest.GetProperty("latest");
+ await Assert.That(latest.EnumerateObject().Any()).IsFalse();
+
+ await Assert.That(manifest.GetProperty("boards").GetArrayLength()).IsEqualTo(0);
+ await Assert.That(manifest.GetProperty("chips").GetArrayLength()).IsEqualTo(0);
+ await Assert.That(manifest.GetProperty("usbSerialFilters").GetArrayLength()).IsEqualTo(0);
+ await Assert.That(manifest.GetProperty("usbDevices").GetArrayLength()).IsEqualTo(0);
+ await Assert.That(manifest.GetProperty("advisories").GetArrayLength()).IsEqualTo(0);
+ }
+
+ [Test]
+ public async Task GetManifest_WithSeededData_ReturnsFullShape()
+ {
+ // Seed directly through EF so we don't rely on admin endpoints being under test.
+ Guid chipId;
+ Guid boardId;
+ Guid usbDeviceId;
+
+ await using (var scope = Factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var chip = new FirmwareChip
+ {
+ Id = Guid.NewGuid(),
+ Name = "ESP32-S3",
+ Architecture = FirmwareChipArchitecture.Xtensa
+ };
+ var board = new FirmwareBoard
+ {
+ Id = Guid.NewGuid(),
+ Name = "OpenShock-Core-V1",
+ ChipId = chip.Id,
+ Discontinued = false,
+ RequiredArtifactTypes = [FirmwareArtifactType.Merged]
+ };
+ var usbDevice = new UsbDevice
+ {
+ Id = Guid.NewGuid(),
+ Vid = 0x1A86,
+ Pid = 0x7522,
+ Name = "CH9102"
+ };
+ var filter = new UsbSerialFilter
+ {
+ Id = Guid.NewGuid(),
+ Vid = 0x1A86,
+ Pid = null,
+ Description = "WCH vendor-wide"
+ };
+ var advisory = new FirmwareAdvisory
+ {
+ Id = Guid.NewGuid(),
+ Severity = AdvisorySeverity.Critical,
+ Title = "OTA bricking bug",
+ Content = "Versions before 1.4.0 have a bug that can brick the device during OTA.",
+ AffectedVersions = "<1.4.0",
+ Url = "https://example.invalid/issues/123"
+ };
+
+ db.FirmwareChips.Add(chip);
+ db.FirmwareBoards.Add(board);
+ db.UsbDevices.Add(usbDevice);
+ db.UsbSerialFilters.Add(filter);
+ db.FirmwareAdvisories.Add(advisory);
+ db.FirmwareBoardUsbDevices.Add(new FirmwareBoardUsbDevice
+ {
+ BoardId = board.Id,
+ UsbDeviceId = usbDevice.Id
+ });
+
+ await db.SaveChangesAsync();
+
+ chipId = chip.Id;
+ boardId = board.Id;
+ usbDeviceId = usbDevice.Id;
+ }
+
+ using var client = Factory.CreateClient();
+ var response = await client.GetAsync("/2/firmware/manifest");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+
+ var manifest = await response.Content.ReadFromJsonAsync();
+
+ var boards = manifest.GetProperty("boards").EnumerateArray().ToList();
+ await Assert.That(boards).Count().IsEqualTo(1);
+ // Boards and chips are identified by name on the public surface; UUIDs stay internal.
+ await Assert.That(boards[0].GetProperty("name").GetString()).IsEqualTo("OpenShock-Core-V1");
+ await Assert.That(boards[0].GetProperty("chipName").GetString()).IsEqualTo("ESP32-S3");
+ await Assert.That(boards[0].GetProperty("discontinued").GetBoolean()).IsFalse();
+
+ var boardUsb = boards[0].GetProperty("usbDevices").EnumerateArray().ToList();
+ await Assert.That(boardUsb).Count().IsEqualTo(1);
+ await Assert.That(boardUsb[0].GetProperty("vid").GetInt32()).IsEqualTo(0x1A86);
+ await Assert.That(boardUsb[0].GetProperty("pid").GetInt32()).IsEqualTo(0x7522);
+ await Assert.That(boardUsb[0].GetProperty("name").GetString()).IsEqualTo("CH9102");
+
+ var chips = manifest.GetProperty("chips").EnumerateArray().ToList();
+ await Assert.That(chips).Count().IsEqualTo(1);
+ await Assert.That(chips[0].GetProperty("name").GetString()).IsEqualTo("ESP32-S3");
+ await Assert.That(chips[0].GetProperty("architecture").GetString()).IsEqualTo("xtensa");
+
+ var filters = manifest.GetProperty("usbSerialFilters").EnumerateArray().ToList();
+ await Assert.That(filters).Count().IsEqualTo(1);
+ await Assert.That(filters[0].GetProperty("vid").GetInt32()).IsEqualTo(0x1A86);
+ var filterHasPid = filters[0].TryGetProperty("pid", out var pidProp) &&
+ pidProp.ValueKind != JsonValueKind.Null;
+ await Assert.That(filterHasPid).IsFalse();
+
+ var devices = manifest.GetProperty("usbDevices").EnumerateArray().ToList();
+ await Assert.That(devices).Count().IsEqualTo(1);
+ await Assert.That(Guid.Parse(devices[0].GetProperty("id").GetString()!))
+ .IsEqualTo(usbDeviceId);
+
+ var advisories = manifest.GetProperty("advisories").EnumerateArray().ToList();
+ await Assert.That(advisories).Count().IsEqualTo(1);
+ await Assert.That(advisories[0].GetProperty("severity").GetString()).IsEqualTo("critical");
+ await Assert.That(advisories[0].GetProperty("affectedVersions").GetString()).IsEqualTo("<1.4.0");
+ }
+
+ [Test]
+ public async Task GetManifest_SendsCacheControlHeader()
+ {
+ using var client = Factory.CreateClient();
+ var response = await client.GetAsync("/2/firmware/manifest");
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ await Assert.That(response.Headers.CacheControl).IsNotNull();
+ await Assert.That(response.Headers.CacheControl!.Public).IsTrue();
+ await Assert.That(response.Headers.CacheControl.MaxAge).IsEqualTo(TimeSpan.FromSeconds(300));
+ }
+}
diff --git a/RepositoryServer.Tests/Integration/Tests/PublicBoardsAndChipsControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/PublicBoardsAndChipsControllerTests.cs
new file mode 100644
index 0000000..c0d2dd4
--- /dev/null
+++ b/RepositoryServer.Tests/Integration/Tests/PublicBoardsAndChipsControllerTests.cs
@@ -0,0 +1,110 @@
+using System.Net.Http.Json;
+using Microsoft.Extensions.DependencyInjection;
+using OpenShock.RepositoryServer.Enums;
+using OpenShock.RepositoryServer.Models.Firmware;
+using OpenShock.RepositoryServer.RepoServerDb;
+
+namespace OpenShock.RepositoryServer.Tests.Integration.Tests;
+
+[NotInParallel("repo-server-integration")]
+public class PublicBoardsAndChipsControllerTests
+{
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public required WebApplicationFactory Factory { get; init; }
+
+ [Before(Test)]
+ public Task Setup() => Factory.ResetDatabaseAsync();
+
+ [Test]
+ public async Task ListBoards_FilterByChipId_ReturnsOnlyMatching()
+ {
+ Guid chipAId;
+ Guid chipBId;
+ await using (var scope = Factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var chipA = new FirmwareChip { Id = Guid.NewGuid(), Name = "ESP32-S3" };
+ var chipB = new FirmwareChip { Id = Guid.NewGuid(), Name = "ESP32-C3" };
+ db.FirmwareChips.Add(chipA);
+ db.FirmwareChips.Add(chipB);
+ db.FirmwareBoards.Add(new FirmwareBoard
+ {
+ Id = Guid.NewGuid(), Name = "Board-S3", ChipId = chipA.Id, RequiredArtifactTypes = []
+ });
+ db.FirmwareBoards.Add(new FirmwareBoard
+ {
+ Id = Guid.NewGuid(), Name = "Board-C3", ChipId = chipB.Id, RequiredArtifactTypes = []
+ });
+ await db.SaveChangesAsync();
+ chipAId = chipA.Id;
+ chipBId = chipB.Id;
+ }
+
+ using var client = Factory.CreateClient();
+ var boardsForChipA = await client.GetFromJsonAsync>(
+ "/2/firmware/boards?chip=ESP32-S3");
+ await Assert.That(boardsForChipA).IsNotNull();
+ await Assert.That(boardsForChipA!).Count().IsEqualTo(1);
+ await Assert.That(boardsForChipA[0].ChipName).IsEqualTo("ESP32-S3");
+
+ // Chip names are matched case-insensitively, same as board names.
+ var lowerCased = await client.GetFromJsonAsync>(
+ "/2/firmware/boards?chip=esp32-s3");
+ await Assert.That(lowerCased!).Count().IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task ListBoards_ExcludeDiscontinued_HidesEOLBoards()
+ {
+ await using (var scope = Factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var chip = new FirmwareChip { Id = Guid.NewGuid(), Name = "ESP32" };
+ db.FirmwareChips.Add(chip);
+ db.FirmwareBoards.Add(new FirmwareBoard
+ {
+ Id = Guid.NewGuid(), Name = "Active", ChipId = chip.Id,
+ Discontinued = false, RequiredArtifactTypes = []
+ });
+ db.FirmwareBoards.Add(new FirmwareBoard
+ {
+ Id = Guid.NewGuid(), Name = "EOL", ChipId = chip.Id,
+ Discontinued = true, RequiredArtifactTypes = []
+ });
+ await db.SaveChangesAsync();
+ }
+
+ using var client = Factory.CreateClient();
+ var withDiscontinued = await client.GetFromJsonAsync>(
+ "/2/firmware/boards?includeDiscontinued=true");
+ var withoutDiscontinued = await client.GetFromJsonAsync>(
+ "/2/firmware/boards?includeDiscontinued=false");
+
+ await Assert.That(withDiscontinued!).Count().IsEqualTo(2);
+ await Assert.That(withoutDiscontinued!).Count().IsEqualTo(1);
+ await Assert.That(withoutDiscontinued![0].Name).IsEqualTo("Active");
+ }
+
+ [Test]
+ public async Task ListChips_ReturnsArchitectureLowercase()
+ {
+ await using (var scope = Factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.FirmwareChips.Add(new FirmwareChip
+ {
+ Id = Guid.NewGuid(),
+ Name = "ESP32-C6",
+ Architecture = FirmwareChipArchitecture.RiscV
+ });
+ await db.SaveChangesAsync();
+ }
+
+ using var client = Factory.CreateClient();
+ var chips = await client.GetFromJsonAsync>("/2/firmware/chips");
+ await Assert.That(chips).IsNotNull();
+ await Assert.That(chips!).Count().IsEqualTo(1);
+ await Assert.That(chips[0].Name).IsEqualTo("ESP32-C6");
+ await Assert.That(chips[0].Architecture).IsEqualTo("risc_v");
+ }
+}
diff --git a/RepositoryServer.Tests/Integration/Tests/ReleasesControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/ReleasesControllerTests.cs
new file mode 100644
index 0000000..857ea53
--- /dev/null
+++ b/RepositoryServer.Tests/Integration/Tests/ReleasesControllerTests.cs
@@ -0,0 +1,563 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.DependencyInjection;
+using OpenShock.RepositoryServer.Enums;
+using OpenShock.RepositoryServer.Models.Firmware;
+using OpenShock.RepositoryServer.RepoServerDb;
+
+namespace OpenShock.RepositoryServer.Tests.Integration.Tests;
+
+///
+/// Covers the CI/CD ingestion state machine: init, artifact upload, publish, abort — and in
+/// particular the authorization boundary between two registered repositories.
+///
+[NotInParallel("repo-server-integration")]
+public class ReleasesControllerTests
+{
+ private const string BoardName = "Wemos-D1-Mini-ESP32";
+ private const string Changelog = "### Info\n- Something changed";
+
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public required WebApplicationFactory Factory { get; init; }
+
+ [Before(Test)]
+ public Task Setup() => Factory.ResetDatabaseAsync();
+
+ // ---- Happy path ----
+
+ [Test]
+ public async Task FullReleaseLifecycle_InitUploadPublish_MakesVersionPublic()
+ {
+ var seed = await SeedAsync();
+ using var client = Factory.CreateCiCdClient(seed.RepositoryId);
+
+ var releaseId = await InitReleaseAsync(client, "1.5.1");
+
+ var upload = await UploadArtifactsAsync(client, releaseId, BoardName);
+ await Assert.That(upload.StatusCode).IsEqualTo(HttpStatusCode.OK);
+
+ var publish = await client.PostAsync($"/2/firmware/releases/{releaseId}/publish", null);
+ await Assert.That(publish.IsSuccessStatusCode).IsTrue();
+
+ // Once published it must be visible on the public read path.
+ using var anon = Factory.CreateClient();
+ var latest = await anon.GetFromJsonAsync("/2/firmware/latest/stable");
+ await Assert.That(latest.GetProperty("version").GetString()).IsEqualTo("1.5.1");
+ }
+
+ // ---- Ownership (B2) ----
+
+ [Test]
+ public async Task Upload_FromDifferentRepository_IsForbidden()
+ {
+ var seed = await SeedAsync();
+ var intruderId = await RegisterRepositoryAsync("attacker", "evil-repo");
+
+ using var owner = Factory.CreateCiCdClient(seed.RepositoryId);
+ var releaseId = await InitReleaseAsync(owner, "1.5.1");
+
+ // A second registered repository is fully authenticated, but must not be able to inject
+ // binaries into someone else's in-flight release.
+ using var intruder = Factory.CreateCiCdClient(intruderId);
+ var response = await UploadArtifactsAsync(intruder, releaseId, BoardName);
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
+ }
+
+ [Test]
+ public async Task Publish_FromDifferentRepository_IsForbidden()
+ {
+ var seed = await SeedAsync();
+ var intruderId = await RegisterRepositoryAsync("attacker", "evil-repo");
+
+ using var owner = Factory.CreateCiCdClient(seed.RepositoryId);
+ var releaseId = await InitReleaseAsync(owner, "1.5.1");
+ await UploadArtifactsAsync(owner, releaseId, BoardName);
+
+ // Publishing someone else's release would attribute it to their repository and commit hash.
+ using var intruder = Factory.CreateCiCdClient(intruderId);
+ var response = await intruder.PostAsync($"/2/firmware/releases/{releaseId}/publish", null);
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
+ }
+
+ [Test]
+ public async Task Abort_FromDifferentRepository_IsForbidden()
+ {
+ var seed = await SeedAsync();
+ var intruderId = await RegisterRepositoryAsync("attacker", "evil-repo");
+
+ using var owner = Factory.CreateCiCdClient(seed.RepositoryId);
+ var releaseId = await InitReleaseAsync(owner, "1.5.1");
+
+ using var intruder = Factory.CreateCiCdClient(intruderId);
+ var response = await intruder.DeleteAsync($"/2/firmware/releases/{releaseId}");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
+ }
+
+ [Test]
+ public async Task Releases_WithoutCiCdPrincipal_AreRejected()
+ {
+ await SeedAsync();
+
+ using var anon = Factory.CreateClient();
+ var response = await anon.PostAsJsonAsync("/2/firmware/releases", new InitReleaseRequest
+ {
+ Version = "1.5.1",
+ Channel = "stable",
+ ReleaseDate = DateTimeOffset.UtcNow,
+ Boards = [BoardName],
+ Changelog = Changelog
+ });
+
+ await Assert.That(response.IsSuccessStatusCode).IsFalse();
+ }
+
+ [Test]
+ public async Task Releases_WithOnlyTheModulesScope_AreForbidden()
+ {
+ var seed = await SeedAsync();
+
+ // Firmware and desktop ingestion share the CI/CD scheme, so a repository onboarded purely to
+ // publish desktop modules must not be able to start a firmware release.
+ using var client = Factory.CreateCiCdClient(seed.RepositoryId, scopes: "publish_modules");
+ var response = await client.PostAsJsonAsync("/2/firmware/releases", new InitReleaseRequest
+ {
+ Version = "1.5.1",
+ Channel = "stable",
+ ReleaseDate = DateTimeOffset.UtcNow,
+ Boards = [BoardName],
+ Changelog = Changelog
+ });
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
+ }
+
+ [Test]
+ public async Task Releases_WithNoScopes_AreForbidden()
+ {
+ var seed = await SeedAsync();
+
+ using var client = Factory.CreateCiCdClient(seed.RepositoryId, scopes: TestCiCdAuthHandler.NoScopes);
+ var response = await client.PostAsJsonAsync("/2/firmware/releases", new InitReleaseRequest
+ {
+ Version = "1.5.1",
+ Channel = "stable",
+ ReleaseDate = DateTimeOffset.UtcNow,
+ Boards = [BoardName],
+ Changelog = Changelog
+ });
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
+ }
+
+ // ---- Immutability (B3) ----
+
+ [Test]
+ public async Task Init_ForAlreadyPublishedVersion_IsRejected()
+ {
+ var seed = await SeedAsync();
+ using var client = Factory.CreateCiCdClient(seed.RepositoryId);
+
+ var releaseId = await InitReleaseAsync(client, "1.5.1");
+ await UploadArtifactsAsync(client, releaseId, BoardName);
+ await client.PostAsync($"/2/firmware/releases/{releaseId}/publish", null);
+
+ // Re-initialising a published version would let a second release overwrite live artifacts at
+ // the same storage keys, and if abandoned, have them deleted by the TTL job.
+ var response = await client.PostAsJsonAsync("/2/firmware/releases", new InitReleaseRequest
+ {
+ Version = "1.5.1",
+ Channel = "stable",
+ ReleaseDate = DateTimeOffset.UtcNow,
+ Boards = [BoardName],
+ Changelog = Changelog
+ });
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
+ }
+
+ [Test]
+ public async Task Init_WhileAnotherReleaseIsStaging_IsRejected()
+ {
+ var seed = await SeedAsync();
+ using var client = Factory.CreateCiCdClient(seed.RepositoryId);
+
+ await InitReleaseAsync(client, "1.5.1");
+
+ var response = await client.PostAsJsonAsync("/2/firmware/releases", new InitReleaseRequest
+ {
+ Version = "1.5.1",
+ Channel = "stable",
+ ReleaseDate = DateTimeOffset.UtcNow,
+ Boards = [BoardName],
+ Changelog = Changelog
+ });
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
+ }
+
+ // ---- Validation ----
+
+ [Test]
+ public async Task Init_UnknownBoard_NamesTheOffendingBoard()
+ {
+ var seed = await SeedAsync();
+ using var client = Factory.CreateCiCdClient(seed.RepositoryId);
+
+ var response = await client.PostAsJsonAsync("/2/firmware/releases", new InitReleaseRequest
+ {
+ Version = "1.5.1",
+ Channel = "stable",
+ ReleaseDate = DateTimeOffset.UtcNow,
+ Boards = [BoardName, "No-Such-Board"],
+ Changelog = Changelog
+ });
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
+
+ // A CI operator reading this in a log needs the board name, not a UUID.
+ var body = await response.Content.ReadAsStringAsync();
+ await Assert.That(body).Contains("No-Such-Board");
+ }
+
+ [Test]
+ public async Task Init_InvalidChangelogWithoutNofail_Fails()
+ {
+ var seed = await SeedAsync();
+ using var client = Factory.CreateCiCdClient(seed.RepositoryId);
+
+ var response = await client.PostAsJsonAsync("/2/firmware/releases", new InitReleaseRequest
+ {
+ Version = "1.5.1",
+ Channel = "stable",
+ ReleaseDate = DateTimeOffset.UtcNow,
+ Boards = [BoardName],
+ Changelog = "no headings here at all"
+ });
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
+ }
+
+ [Test]
+ public async Task Init_InvalidChangelogWithNofail_LandsInEditing()
+ {
+ var seed = await SeedAsync();
+ using var client = Factory.CreateCiCdClient(seed.RepositoryId);
+
+ var response = await client.PostAsJsonAsync("/2/firmware/releases?nofail", new InitReleaseRequest
+ {
+ Version = "1.5.1",
+ Channel = "stable",
+ ReleaseDate = DateTimeOffset.UtcNow,
+ Boards = [BoardName],
+ Changelog = "no headings here at all"
+ });
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
+ var body = await response.Content.ReadFromJsonAsync();
+ await Assert.That(body.GetProperty("status").GetString()).IsEqualTo("editing");
+ }
+
+ [Test]
+ public async Task Publish_WhileEditing_IsRejected()
+ {
+ var seed = await SeedAsync();
+ using var client = Factory.CreateCiCdClient(seed.RepositoryId);
+
+ var init = await client.PostAsJsonAsync("/2/firmware/releases?nofail", new InitReleaseRequest
+ {
+ Version = "1.5.1",
+ Channel = "stable",
+ ReleaseDate = DateTimeOffset.UtcNow,
+ Boards = [BoardName],
+ Changelog = "no headings here at all"
+ });
+ var releaseId = (await init.Content.ReadFromJsonAsync()).GetProperty("id").GetGuid();
+
+ await UploadArtifactsAsync(client, releaseId, BoardName);
+
+ var publish = await client.PostAsync($"/2/firmware/releases/{releaseId}/publish", null);
+ await Assert.That(publish.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
+ }
+
+ [Test]
+ public async Task Publish_WithoutAllDeclaredBoards_NamesTheMissingBoard()
+ {
+ var seed = await SeedAsync(extraBoardName: "Wemos-Lolin-S3");
+ using var client = Factory.CreateCiCdClient(seed.RepositoryId);
+
+ var init = await client.PostAsJsonAsync("/2/firmware/releases", new InitReleaseRequest
+ {
+ Version = "1.5.1",
+ Channel = "stable",
+ ReleaseDate = DateTimeOffset.UtcNow,
+ Boards = [BoardName, "Wemos-Lolin-S3"],
+ Changelog = Changelog
+ });
+ var releaseId = (await init.Content.ReadFromJsonAsync()).GetProperty("id").GetGuid();
+
+ await UploadArtifactsAsync(client, releaseId, BoardName);
+
+ var publish = await client.PostAsync($"/2/firmware/releases/{releaseId}/publish", null);
+ await Assert.That(publish.IsSuccessStatusCode).IsFalse();
+
+ var body = await publish.Content.ReadAsStringAsync();
+ await Assert.That(body).Contains("Wemos-Lolin-S3");
+ }
+
+ [Test]
+ public async Task Upload_WithMismatchedSha256_IsRejected()
+ {
+ var seed = await SeedAsync();
+ using var client = Factory.CreateCiCdClient(seed.RepositoryId);
+ var releaseId = await InitReleaseAsync(client, "1.5.1");
+
+ var content = new MultipartFormDataContent();
+ var bytes = Encoding.UTF8.GetBytes("merged-artifact-bytes");
+ content.Add(new ByteArrayContent(bytes), "merged", "firmware.bin");
+ // A hash the payload does not have.
+ content.Add(new StringContent(
+ JsonSerializer.Serialize(new Dictionary { ["merged"] = new string('a', 64) })),
+ "sha256");
+
+ var response = await client.PutAsync(
+ $"/2/firmware/releases/{releaseId}/boards/{BoardName}", content);
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
+ }
+
+ [Test]
+ public async Task Upload_ForUndeclaredBoard_IsRejected()
+ {
+ var seed = await SeedAsync(extraBoardName: "Wemos-Lolin-S3");
+ using var client = Factory.CreateCiCdClient(seed.RepositoryId);
+ var releaseId = await InitReleaseAsync(client, "1.5.1");
+
+ var response = await UploadArtifactsAsync(client, releaseId, "Wemos-Lolin-S3");
+ await Assert.That(response.IsSuccessStatusCode).IsFalse();
+ }
+
+ [Test]
+ public async Task Abort_ByOwner_MakesReleaseUnpublishable()
+ {
+ var seed = await SeedAsync();
+ using var client = Factory.CreateCiCdClient(seed.RepositoryId);
+ var releaseId = await InitReleaseAsync(client, "1.5.1");
+
+ var abort = await client.DeleteAsync($"/2/firmware/releases/{releaseId}");
+ await Assert.That(abort.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
+
+ var publish = await client.PostAsync($"/2/firmware/releases/{releaseId}/publish", null);
+ await Assert.That(publish.IsSuccessStatusCode).IsFalse();
+ }
+
+ // ---- Atomicity and coercion ----
+
+ [Test]
+ public async Task Upload_PartialFailure_LeavesNoOrphanedBlobsOrLostState()
+ {
+ var seed = await SeedAsync();
+ using var client = Factory.CreateCiCdClient(seed.RepositoryId);
+ var releaseId = await InitReleaseAsync(client, "1.5.1");
+
+ // A valid first upload establishes staged state we must not lose.
+ await UploadArtifactsAsync(client, releaseId, BoardName);
+
+ // Now send two files where only one hashes correctly.
+ var goodBytes = Encoding.UTF8.GetBytes("good-app-bytes");
+ var badBytes = Encoding.UTF8.GetBytes("bad-merged-bytes");
+ var content = new MultipartFormDataContent();
+ content.Add(new ByteArrayContent(goodBytes), "app", "app.bin");
+ content.Add(new ByteArrayContent(badBytes), "merged", "firmware.bin");
+ content.Add(new StringContent(JsonSerializer.Serialize(new Dictionary
+ {
+ ["app"] = Convert.ToHexString(SHA256.HashData(goodBytes)),
+ ["merged"] = new string('a', 64)
+ })), "sha256");
+
+ var response = await client.PutAsync(
+ $"/2/firmware/releases/{releaseId}/boards/{BoardName}", content);
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
+
+ // The prior staged artifact must survive: nothing is deleted or written until every file in
+ // the request has been verified.
+ await using var scope = Factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var staged = db.FirmwareStagedArtifacts.Where(a => a.ReleaseId == releaseId).ToList();
+ await Assert.That(staged).Count().IsEqualTo(1);
+ await Assert.That(staged[0].ArtifactType).IsEqualTo(FirmwareArtifactType.Merged);
+ }
+
+ [Test]
+ public async Task Init_NonUtcReleaseDate_IsAcceptedAndNormalized()
+ {
+ var seed = await SeedAsync();
+ using var client = Factory.CreateCiCdClient(seed.RepositoryId);
+
+ // Npgsql rejects a non-zero offset for `timestamp with time zone`, which used to surface as a
+ // generic 500 for any CI runner outside UTC.
+ var response = await client.PostAsJsonAsync("/2/firmware/releases", new InitReleaseRequest
+ {
+ Version = "1.5.1",
+ Channel = "stable",
+ ReleaseDate = new DateTimeOffset(2026, 4, 15, 12, 0, 0, TimeSpan.FromHours(2)),
+ Boards = [BoardName],
+ Changelog = Changelog
+ });
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
+ }
+
+ // ---- Staging isolation ----
+
+ [Test]
+ public async Task Upload_DoesNotWriteThePublishedKey()
+ {
+ var seed = await SeedAsync();
+ using var client = Factory.CreateCiCdClient(seed.RepositoryId);
+ var releaseId = await InitReleaseAsync(client, "1.5.1");
+
+ await UploadArtifactsAsync(client, releaseId, BoardName);
+
+ // Nothing is readable at its published path until publish runs.
+ await Assert.That(Factory.StoredFileExists($"1.5.1/{seed.BoardId}/firmware.bin")).IsFalse();
+ await Assert.That(Factory.StoredFileExists($"_staging/{releaseId}/{seed.BoardId}/firmware.bin")).IsTrue();
+ }
+
+ [Test]
+ public async Task Publish_PromotesStagedArtifactsToPublishedKeys()
+ {
+ var seed = await SeedAsync();
+ using var client = Factory.CreateCiCdClient(seed.RepositoryId);
+ var releaseId = await InitReleaseAsync(client, "1.5.1");
+ await UploadArtifactsAsync(client, releaseId, BoardName);
+
+ var publish = await client.PostAsync($"/2/firmware/releases/{releaseId}/publish", null);
+ await Assert.That(publish.IsSuccessStatusCode).IsTrue();
+
+ await Assert.That(Factory.StoredFileExists($"1.5.1/{seed.BoardId}/firmware.bin")).IsTrue();
+
+ // And the staging copies are cleared once they are dead weight.
+ await Assert.That(Factory.StoredFileExists($"_staging/{releaseId}/{seed.BoardId}/firmware.bin")).IsFalse();
+ }
+
+ [Test]
+ public async Task Abort_RemovesStagedArtifactsButNeverPublishedOnes()
+ {
+ var seed = await SeedAsync();
+ using var client = Factory.CreateCiCdClient(seed.RepositoryId);
+
+ // Publish 1.5.1 so there are live artifacts on disk to protect.
+ var publishedId = await InitReleaseAsync(client, "1.5.1");
+ await UploadArtifactsAsync(client, publishedId, BoardName);
+ await client.PostAsync($"/2/firmware/releases/{publishedId}/publish", null);
+
+ // A second, unrelated release is staged and then aborted.
+ var abortedId = await InitReleaseAsync(client, "1.6.0");
+ await UploadArtifactsAsync(client, abortedId, BoardName);
+ var abort = await client.DeleteAsync($"/2/firmware/releases/{abortedId}");
+ await Assert.That(abort.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
+
+ await Assert.That(Factory.StoredFileExists($"_staging/{abortedId}/{seed.BoardId}/firmware.bin")).IsFalse();
+ await Assert.That(Factory.StoredFileExists($"1.5.1/{seed.BoardId}/firmware.bin")).IsTrue();
+ }
+
+ // ---- Helpers ----
+
+ private sealed record Seed(Guid RepositoryId, Guid BoardId, Guid ChipId);
+
+ private async Task SeedAsync(string? extraBoardName = null)
+ {
+ await using var scope = Factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var repo = new SourceRepository
+ {
+ Id = Guid.NewGuid(),
+ Provider = RepositoryProvider.Github,
+ Owner = "openshock",
+ Repo = "firmware"
+ };
+ var chip = new FirmwareChip
+ {
+ Id = Guid.NewGuid(),
+ Name = "ESP32",
+ Architecture = FirmwareChipArchitecture.Xtensa
+ };
+ var board = new FirmwareBoard
+ {
+ Id = Guid.NewGuid(),
+ Name = BoardName,
+ ChipId = chip.Id,
+ RequiredArtifactTypes = []
+ };
+
+ db.Repositories.Add(repo);
+ db.FirmwareChips.Add(chip);
+ db.FirmwareBoards.Add(board);
+
+ if (extraBoardName is not null)
+ {
+ db.FirmwareBoards.Add(new FirmwareBoard
+ {
+ Id = Guid.NewGuid(),
+ Name = extraBoardName,
+ ChipId = chip.Id,
+ RequiredArtifactTypes = []
+ });
+ }
+
+ await db.SaveChangesAsync();
+ return new Seed(repo.Id, board.Id, chip.Id);
+ }
+
+ private async Task RegisterRepositoryAsync(string owner, string repo)
+ {
+ await using var scope = Factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var row = new SourceRepository
+ {
+ Id = Guid.NewGuid(),
+ Provider = RepositoryProvider.Github,
+ Owner = owner,
+ Repo = repo
+ };
+ db.Repositories.Add(row);
+ await db.SaveChangesAsync();
+ return row.Id;
+ }
+
+ private static async Task InitReleaseAsync(HttpClient client, string version)
+ {
+ var response = await client.PostAsJsonAsync("/2/firmware/releases", new InitReleaseRequest
+ {
+ Version = version,
+ Channel = "stable",
+ ReleaseDate = DateTimeOffset.UtcNow,
+ Boards = [BoardName],
+ Changelog = Changelog
+ });
+
+ response.EnsureSuccessStatusCode();
+ var body = await response.Content.ReadFromJsonAsync();
+ return body.GetProperty("id").GetGuid();
+ }
+
+ private static async Task UploadArtifactsAsync(
+ HttpClient client, Guid releaseId, string board)
+ {
+ var bytes = Encoding.UTF8.GetBytes($"merged-artifact-for-{board}");
+ var hash = Convert.ToHexString(SHA256.HashData(bytes));
+
+ var content = new MultipartFormDataContent();
+ content.Add(new ByteArrayContent(bytes), "merged", "firmware.bin");
+ content.Add(new StringContent(
+ JsonSerializer.Serialize(new Dictionary { ["merged"] = hash })),
+ "sha256");
+
+ return await client.PutAsync($"/2/firmware/releases/{releaseId}/boards/{board}", content);
+ }
+}
diff --git a/RepositoryServer.Tests/Integration/Tests/RepositoriesAdminControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/RepositoriesAdminControllerTests.cs
new file mode 100644
index 0000000..79869e4
--- /dev/null
+++ b/RepositoryServer.Tests/Integration/Tests/RepositoriesAdminControllerTests.cs
@@ -0,0 +1,237 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.Extensions.DependencyInjection;
+using OpenShock.RepositoryServer.Enums;
+using OpenShock.RepositoryServer.Models.Firmware;
+using OpenShock.RepositoryServer.RepoServerDb;
+
+namespace OpenShock.RepositoryServer.Tests.Integration.Tests;
+
+[NotInParallel("repo-server-integration")]
+public class RepositoriesAdminControllerTests
+{
+ private const string BasePath = "/2/firmware/admin/repositories";
+
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public required WebApplicationFactory Factory { get; init; }
+
+ [Before(Test)]
+ public Task Setup() => Factory.ResetDatabaseAsync();
+
+ [Test]
+ public async Task Get_EmptyDatabase_ReturnsEmptyArray()
+ {
+ using var client = Factory.CreateAdminClient();
+ var repos = await client.GetFromJsonAsync>(BasePath);
+ await Assert.That(repos).IsNotNull();
+ await Assert.That(repos!).Count().IsEqualTo(0);
+ }
+
+ [Test]
+ public async Task Get_WithSeededData_ReturnsRepositories()
+ {
+ Guid repoId;
+ await using (var scope = Factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var repo = new SourceRepository
+ {
+ Id = Guid.NewGuid(),
+ Provider = RepositoryProvider.Github,
+ Owner = "openshock",
+ Repo = "firmware"
+ };
+ db.Repositories.Add(repo);
+ await db.SaveChangesAsync();
+ repoId = repo.Id;
+ }
+
+ using var client = Factory.CreateAdminClient();
+ var repos = await client.GetFromJsonAsync>(BasePath);
+ await Assert.That(repos).IsNotNull();
+ await Assert.That(repos!).Count().IsEqualTo(1);
+ await Assert.That(repos[0].Id).IsEqualTo(repoId);
+ await Assert.That(repos[0].Provider).IsEqualTo("github");
+ await Assert.That(repos[0].Owner).IsEqualTo("openshock");
+ await Assert.That(repos[0].Repo).IsEqualTo("firmware");
+ }
+
+ [Test]
+ public async Task Delete_Unreferenced_Returns204()
+ {
+ Guid repoId;
+ await using (var scope = Factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var repo = new SourceRepository
+ {
+ Id = Guid.NewGuid(),
+ Provider = RepositoryProvider.Github,
+ Owner = "openshock",
+ Repo = "desktop"
+ };
+ db.Repositories.Add(repo);
+ await db.SaveChangesAsync();
+ repoId = repo.Id;
+ }
+
+ using var client = Factory.CreateAdminClient();
+ var response = await client.DeleteAsync($"{BasePath}/{repoId}");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
+ }
+
+ [Test]
+ public async Task Delete_UnknownId_Returns404()
+ {
+ using var client = Factory.CreateAdminClient();
+ var response = await client.DeleteAsync($"{BasePath}/{Guid.NewGuid()}");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
+ }
+
+ [Test]
+ public async Task Delete_ReferencedByFirmwareVersion_Returns409()
+ {
+ Guid repoId;
+ await using (var scope = Factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var repo = new SourceRepository
+ {
+ Id = Guid.NewGuid(),
+ Provider = RepositoryProvider.Github,
+ Owner = "openshock",
+ Repo = "firmware"
+ };
+ var version = new FirmwareVersion
+ {
+ Version = "1.5.1",
+ Channel = ReleaseChannel.Stable,
+ ReleaseDate = DateTimeOffset.UtcNow,
+ RepositoryId = repo.Id,
+ CommitHash = "abc1234567890abcdef1234567890abcdef12345"
+ };
+ db.Repositories.Add(repo);
+ db.FirmwareVersions.Add(version);
+ await db.SaveChangesAsync();
+ repoId = repo.Id;
+ }
+
+ using var client = Factory.CreateAdminClient();
+ var response = await client.DeleteAsync($"{BasePath}/{repoId}");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
+ }
+
+ [Test]
+ public async Task Put_RegistersRepository_AsThePublishAllowlistEntry()
+ {
+ using var client = Factory.CreateAdminClient();
+
+ var response = await client.PutAsJsonAsync(BasePath, new UpsertRepositoryRequest
+ {
+ Provider = "github",
+ Owner = "openshock",
+ Repo = "firmware"
+ });
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
+
+ var body = await response.Content.ReadFromJsonAsync();
+ await Assert.That(body.GetProperty("owner").GetString()).IsEqualTo("openshock");
+ await Assert.That(body.GetProperty("repo").GetString()).IsEqualTo("firmware");
+ await Assert.That(Guid.Parse(body.GetProperty("id").GetString()!)).IsNotEqualTo(Guid.Empty);
+ }
+
+ [Test]
+ public async Task Put_IsIdempotent_SoRerunningOnboardingIsHarmless()
+ {
+ using var client = Factory.CreateAdminClient();
+
+ var first = await client.PutAsJsonAsync(BasePath, new UpsertRepositoryRequest
+ {
+ Provider = "github", Owner = "openshock", Repo = "firmware"
+ });
+ var firstId = (await first.Content.ReadFromJsonAsync()).GetProperty("id").GetString();
+
+ var second = await client.PutAsJsonAsync(BasePath, new UpsertRepositoryRequest
+ {
+ Provider = "github", Owner = "openshock", Repo = "firmware"
+ });
+
+ await Assert.That(second.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ var secondId = (await second.Content.ReadFromJsonAsync()).GetProperty("id").GetString();
+ await Assert.That(secondId).IsEqualTo(firstId);
+ }
+
+ [Test]
+ public async Task Put_DifferentCasing_UpdatesTheSameRegistration()
+ {
+ using var client = Factory.CreateAdminClient();
+
+ var first = await client.PutAsJsonAsync(BasePath, new UpsertRepositoryRequest
+ {
+ Provider = "github", Owner = "openshock", Repo = "firmware",
+ Scopes = ["publish_firmware"]
+ });
+ var firstId = (await first.Content.ReadFromJsonAsync()).GetProperty("id").GetString();
+
+ // GitHub treats owner/repo case-insensitively, so this must land on the same grant rather
+ // than creating a second row the OIDC lookup would never reach.
+ var second = await client.PutAsJsonAsync(BasePath, new UpsertRepositoryRequest
+ {
+ Provider = "GitHub", Owner = "OpenShock", Repo = "Firmware",
+ Scopes = ["publish_firmware", "publish_modules"]
+ });
+
+ await Assert.That(second.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ var body = await second.Content.ReadFromJsonAsync();
+ await Assert.That(body.GetProperty("id").GetString()).IsEqualTo(firstId);
+
+ // And scopes are authoritative on re-registration, so a grant can be widened or narrowed.
+ var scopes = body.GetProperty("scopes").EnumerateArray().Select(e => e.GetString()).ToList();
+ await Assert.That(scopes).Contains("publish_modules");
+
+ var all = await client.GetFromJsonAsync>(BasePath);
+ await Assert.That(all!).Count().IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task Put_UnknownScope_Returns400()
+ {
+ using var client = Factory.CreateAdminClient();
+ var response = await client.PutAsJsonAsync(BasePath, new UpsertRepositoryRequest
+ {
+ Provider = "github", Owner = "openshock", Repo = "firmware",
+ Scopes = ["publish_everything"]
+ });
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
+ }
+
+ [Test]
+ public async Task Put_UnknownProvider_Returns400()
+ {
+ using var client = Factory.CreateAdminClient();
+
+ var response = await client.PutAsJsonAsync(BasePath, new UpsertRepositoryRequest
+ {
+ Provider = "bitbucket", Owner = "openshock", Repo = "firmware"
+ });
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
+ }
+
+ [Test]
+ public async Task Put_WithoutAdminToken_IsRejected()
+ {
+ // Onboarding is the authorization decision for publishing, so it must not be reachable
+ // without the admin token.
+ using var client = Factory.CreateClient();
+
+ var response = await client.PutAsJsonAsync(BasePath, new UpsertRepositoryRequest
+ {
+ Provider = "github", Owner = "attacker", Repo = "evil"
+ });
+
+ await Assert.That(response.IsSuccessStatusCode).IsFalse();
+ }
+}
diff --git a/RepositoryServer.Tests/Integration/Tests/StagedReleaseCleanupServiceTests.cs b/RepositoryServer.Tests/Integration/Tests/StagedReleaseCleanupServiceTests.cs
new file mode 100644
index 0000000..5bfd900
--- /dev/null
+++ b/RepositoryServer.Tests/Integration/Tests/StagedReleaseCleanupServiceTests.cs
@@ -0,0 +1,156 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Time.Testing;
+using OpenShock.RepositoryServer.Config;
+using OpenShock.RepositoryServer.Enums;
+using OpenShock.RepositoryServer.RepoServerDb;
+using OpenShock.RepositoryServer.Services;
+
+namespace OpenShock.RepositoryServer.Tests.Integration.Tests;
+
+///
+/// Exercises the TTL cleanup job directly. The hosted service is suppressed in the test harness, so
+/// this constructs it against the same real Postgres and drives it with a
+/// rather than waiting on wall-clock time.
+///
+[NotInParallel("repo-server-integration")]
+public class StagedReleaseCleanupServiceTests
+{
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public required WebApplicationFactory Factory { get; init; }
+
+ [Before(Test)]
+ public Task Setup() => Factory.ResetDatabaseAsync();
+
+ [Test]
+ public async Task Tick_AbortsStagingReleasePastItsTtl()
+ {
+ var now = DateTimeOffset.Parse("2026-01-01T00:00:00Z");
+ var releaseId = await SeedReleaseAsync(ReleaseStatus.Staging, createdAt: now.AddHours(-2));
+
+ // StagedReleaseTtl is 1h in the test harness, so a 2h-old staging release is expired.
+ await RunTickAsync(now);
+
+ await Assert.That(await GetStatusAsync(releaseId)).IsEqualTo(ReleaseStatus.Aborted);
+ }
+
+ [Test]
+ public async Task Tick_LeavesStagingReleaseInsideItsTtl()
+ {
+ var now = DateTimeOffset.Parse("2026-01-01T00:00:00Z");
+ var releaseId = await SeedReleaseAsync(ReleaseStatus.Staging, createdAt: now.AddMinutes(-30));
+
+ await RunTickAsync(now);
+
+ await Assert.That(await GetStatusAsync(releaseId)).IsEqualTo(ReleaseStatus.Staging);
+ }
+
+ [Test]
+ public async Task Tick_UsesLongerTtlForEditingReleases()
+ {
+ var now = DateTimeOffset.Parse("2026-01-01T00:00:00Z");
+
+ // Two hours old: past the 1h staging TTL, well inside the 7d editing TTL. An editing release
+ // is waiting on a human, so it must not be swept up on the CI timescale.
+ var editingId = await SeedReleaseAsync(ReleaseStatus.Editing, createdAt: now.AddHours(-2));
+ var stagingId = await SeedReleaseAsync(ReleaseStatus.Staging, createdAt: now.AddHours(-2), version: "1.6.0");
+
+ await RunTickAsync(now);
+
+ await Assert.That(await GetStatusAsync(editingId)).IsEqualTo(ReleaseStatus.Editing);
+ await Assert.That(await GetStatusAsync(stagingId)).IsEqualTo(ReleaseStatus.Aborted);
+ }
+
+ [Test]
+ public async Task Tick_AbortsEditingReleasePastTheEditingTtl()
+ {
+ var now = DateTimeOffset.Parse("2026-01-01T00:00:00Z");
+ var releaseId = await SeedReleaseAsync(ReleaseStatus.Editing, createdAt: now.AddDays(-8));
+
+ await RunTickAsync(now);
+
+ await Assert.That(await GetStatusAsync(releaseId)).IsEqualTo(ReleaseStatus.Aborted);
+ }
+
+ [Test]
+ public async Task Tick_IgnoresPublishedReleases()
+ {
+ var now = DateTimeOffset.Parse("2026-01-01T00:00:00Z");
+ var releaseId = await SeedReleaseAsync(ReleaseStatus.Published, createdAt: now.AddDays(-365));
+
+ await RunTickAsync(now);
+
+ await Assert.That(await GetStatusAsync(releaseId)).IsEqualTo(ReleaseStatus.Published);
+ }
+
+ // ---- Helpers ----
+
+ ///
+ /// Runs exactly one cleanup pass with the clock pinned to .
+ ///
+ private async Task RunTickAsync(DateTimeOffset now)
+ {
+ var timeProvider = new FakeTimeProvider(now);
+
+ await using var scope = Factory.Services.CreateAsyncScope();
+ var sp = scope.ServiceProvider;
+
+ var service = new StagedReleaseCleanupService(
+ sp.GetRequiredService>(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ timeProvider,
+ sp.GetRequiredService>());
+
+ await service.TickAsync(CancellationToken.None);
+ }
+
+ private async Task SeedReleaseAsync(
+ ReleaseStatus status, DateTimeOffset createdAt, string version = "1.5.1")
+ {
+ await using var scope = Factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var repo = await db.Repositories.FirstOrDefaultAsync();
+ if (repo is null)
+ {
+ repo = new SourceRepository
+ {
+ Id = Guid.NewGuid(),
+ Provider = RepositoryProvider.Github,
+ Owner = "openshock",
+ Repo = "firmware"
+ };
+ db.Repositories.Add(repo);
+ }
+
+ var release = new FirmwareRelease
+ {
+ Id = Guid.NewGuid(),
+ Version = version,
+ Channel = ReleaseChannel.Stable,
+ RepositoryId = repo.Id,
+ CommitHash = "abc1234567890abcdef1234567890abcdef12345",
+ ReleaseDate = createdAt,
+ Status = status,
+ DeclaredBoards = [],
+ CreatedAt = createdAt
+ };
+
+ db.FirmwareReleases.Add(release);
+ await db.SaveChangesAsync();
+ return release.Id;
+ }
+
+ private async Task GetStatusAsync(Guid releaseId)
+ {
+ await using var scope = Factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ return await db.FirmwareReleases
+ .Where(r => r.Id == releaseId)
+ .Select(r => r.Status)
+ .FirstAsync();
+ }
+}
diff --git a/RepositoryServer.Tests/Integration/Tests/UsbDevicesAdminControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/UsbDevicesAdminControllerTests.cs
new file mode 100644
index 0000000..1c7064b
--- /dev/null
+++ b/RepositoryServer.Tests/Integration/Tests/UsbDevicesAdminControllerTests.cs
@@ -0,0 +1,126 @@
+using System.Net;
+using System.Net.Http.Json;
+using Microsoft.Extensions.DependencyInjection;
+using OpenShock.RepositoryServer.Models.Firmware;
+using OpenShock.RepositoryServer.RepoServerDb;
+
+namespace OpenShock.RepositoryServer.Tests.Integration.Tests;
+
+[NotInParallel("repo-server-integration")]
+public class UsbDevicesAdminControllerTests
+{
+ private const string BasePath = "/2/firmware/admin/usb-devices";
+
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public required WebApplicationFactory Factory { get; init; }
+
+ [Before(Test)]
+ public Task Setup() => Factory.ResetDatabaseAsync();
+
+ [Test]
+ public async Task Put_NewDevice_Returns201()
+ {
+ using var client = Factory.CreateAdminClient();
+ var response = await client.PutAsJsonAsync(BasePath, new UpsertUsbDeviceRequest
+ {
+ Vid = 0x1A86,
+ Pid = 0x7522,
+ Name = "CH9102"
+ });
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
+ var body = await response.Content.ReadFromJsonAsync();
+ await Assert.That(body).IsNotNull();
+ await Assert.That(body!.Vid).IsEqualTo(0x1A86);
+ await Assert.That(body.Pid).IsEqualTo(0x7522);
+ await Assert.That(body.Name).IsEqualTo("CH9102");
+ }
+
+ [Test]
+ public async Task Put_DuplicateVidPid_UpdatesNameInPlace()
+ {
+ using var client = Factory.CreateAdminClient();
+
+ var first = await client.PutAsJsonAsync(BasePath, new UpsertUsbDeviceRequest
+ {
+ Vid = 0x10C4, Pid = 0xEA60, Name = "CP2102"
+ });
+ await Assert.That(first.StatusCode).IsEqualTo(HttpStatusCode.Created);
+ var original = await first.Content.ReadFromJsonAsync();
+
+ // Same (vid, pid), new name — should update, reuse id, return 200
+ var second = await client.PutAsJsonAsync(BasePath, new UpsertUsbDeviceRequest
+ {
+ Vid = 0x10C4, Pid = 0xEA60, Name = "CP2102 (Silabs USB-UART)"
+ });
+ await Assert.That(second.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ var updated = await second.Content.ReadFromJsonAsync();
+
+ await Assert.That(updated).IsNotNull();
+ await Assert.That(updated!.Id).IsEqualTo(original!.Id);
+ await Assert.That(updated.Name).IsEqualTo("CP2102 (Silabs USB-UART)");
+ }
+
+ [Test]
+ public async Task Delete_Unreferenced_Returns204()
+ {
+ using var client = Factory.CreateAdminClient();
+ var created = await client.PutAsJsonAsync(BasePath, new UpsertUsbDeviceRequest
+ {
+ Vid = 0x0403, Pid = 0x6001, Name = "FT232"
+ });
+ var body = await created.Content.ReadFromJsonAsync();
+
+ var deleteResponse = await client.DeleteAsync($"{BasePath}/{body!.Id}");
+ await Assert.That(deleteResponse.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
+ }
+
+ [Test]
+ public async Task Delete_UnknownId_Returns404()
+ {
+ using var client = Factory.CreateAdminClient();
+ var response = await client.DeleteAsync($"{BasePath}/{Guid.NewGuid()}");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
+ }
+
+ [Test]
+ public async Task Delete_AttachedToChip_Returns409InUse()
+ {
+ Guid deviceId;
+ await using (var scope = Factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var chip = new FirmwareChip { Id = Guid.NewGuid(), Name = "ESP32-S3" };
+ var device = new UsbDevice { Id = Guid.NewGuid(), Vid = 0x303A, Pid = 0x1001, Name = "ESP32-S3 USB-JTAG" };
+ db.FirmwareChips.Add(chip);
+ db.UsbDevices.Add(device);
+ db.FirmwareChipUsbDevices.Add(new FirmwareChipUsbDevice
+ {
+ ChipId = chip.Id,
+ UsbDeviceId = device.Id
+ });
+ await db.SaveChangesAsync();
+ deviceId = device.Id;
+ }
+
+ using var client = Factory.CreateAdminClient();
+ var response = await client.DeleteAsync($"{BasePath}/{deviceId}");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
+ }
+
+ [Test]
+ public async Task Get_ListsDevicesOrderedByVidPid()
+ {
+ using var client = Factory.CreateAdminClient();
+ await client.PutAsJsonAsync(BasePath, new UpsertUsbDeviceRequest { Vid = 0x1A86, Pid = 0x7522, Name = "CH9102" });
+ await client.PutAsJsonAsync(BasePath, new UpsertUsbDeviceRequest { Vid = 0x10C4, Pid = 0xEA60, Name = "CP2102" });
+ await client.PutAsJsonAsync(BasePath, new UpsertUsbDeviceRequest { Vid = 0x0403, Pid = 0x6001, Name = "FT232" });
+
+ var devices = await client.GetFromJsonAsync>(BasePath);
+ await Assert.That(devices).IsNotNull();
+ await Assert.That(devices!).Count().IsEqualTo(3);
+ await Assert.That(devices[0].Vid).IsEqualTo(0x0403);
+ await Assert.That(devices[1].Vid).IsEqualTo(0x10C4);
+ await Assert.That(devices[2].Vid).IsEqualTo(0x1A86);
+ }
+}
diff --git a/RepositoryServer.Tests/Integration/Tests/UsbSerialFiltersAdminControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/UsbSerialFiltersAdminControllerTests.cs
new file mode 100644
index 0000000..f99343b
--- /dev/null
+++ b/RepositoryServer.Tests/Integration/Tests/UsbSerialFiltersAdminControllerTests.cs
@@ -0,0 +1,128 @@
+using System.Net;
+using System.Net.Http.Json;
+using OpenShock.RepositoryServer.Models.Firmware;
+
+namespace OpenShock.RepositoryServer.Tests.Integration.Tests;
+
+[NotInParallel("repo-server-integration")]
+public class UsbSerialFiltersAdminControllerTests
+{
+ private const string BasePath = "/2/firmware/admin/usb-serial-filters";
+
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public required WebApplicationFactory Factory { get; init; }
+
+ [Before(Test)]
+ public Task Setup() => Factory.ResetDatabaseAsync();
+
+ [Test]
+ public async Task Put_VendorWide_Returns201()
+ {
+ using var client = Factory.CreateAdminClient();
+ var response = await client.PutAsJsonAsync(BasePath, new UpsertUsbSerialFilterRequest
+ {
+ Vid = 0x1A86,
+ Description = "WCH vendor-wide"
+ });
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
+ var body = await response.Content.ReadFromJsonAsync();
+ await Assert.That(body).IsNotNull();
+ await Assert.That(body!.Vid).IsEqualTo(0x1A86);
+ await Assert.That(body.Pid).IsNull();
+ await Assert.That(body.Description).IsEqualTo("WCH vendor-wide");
+ }
+
+ [Test]
+ public async Task Put_SpecificVidPid_Returns201WithBothFieldsSet()
+ {
+ using var client = Factory.CreateAdminClient();
+ var response = await client.PutAsJsonAsync(BasePath, new UpsertUsbSerialFilterRequest
+ {
+ Vid = 0x303A,
+ Pid = 0x1001,
+ Description = "ESP32-S3 native USB-JTAG"
+ });
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
+ var body = await response.Content.ReadFromJsonAsync();
+ await Assert.That(body!.Vid).IsEqualTo(0x303A);
+ await Assert.That(body.Pid).IsEqualTo(0x1001);
+ }
+
+ [Test]
+ public async Task Put_SameVendorWide_UpdatesDescriptionInPlace()
+ {
+ using var client = Factory.CreateAdminClient();
+ var first = await client.PutAsJsonAsync(BasePath, new UpsertUsbSerialFilterRequest
+ {
+ Vid = 0x0403,
+ Description = "FTDI"
+ });
+ var original = await first.Content.ReadFromJsonAsync();
+
+ var second = await client.PutAsJsonAsync(BasePath, new UpsertUsbSerialFilterRequest
+ {
+ Vid = 0x0403,
+ Description = "FTDI (all products)"
+ });
+
+ await Assert.That(second.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ var updated = await second.Content.ReadFromJsonAsync();
+ await Assert.That(updated).IsNotNull();
+ await Assert.That(updated!.Id).IsEqualTo(original!.Id);
+ await Assert.That(updated.Description).IsEqualTo("FTDI (all products)");
+ }
+
+ [Test]
+ public async Task Get_ReturnsAllFilters()
+ {
+ using var client = Factory.CreateAdminClient();
+ await client.PutAsJsonAsync(BasePath, new UpsertUsbSerialFilterRequest { Vid = 0x1A86 });
+ await client.PutAsJsonAsync(BasePath, new UpsertUsbSerialFilterRequest { Vid = 0x10C4 });
+ await client.PutAsJsonAsync(BasePath, new UpsertUsbSerialFilterRequest { Vid = 0x303A, Pid = 0x1001 });
+
+ var body = await client.GetFromJsonAsync>(BasePath);
+ await Assert.That(body).IsNotNull();
+ await Assert.That(body!).Count().IsEqualTo(3);
+ }
+
+ [Test]
+ public async Task Delete_ExistingFilter_Returns204()
+ {
+ using var client = Factory.CreateAdminClient();
+ var created = await client.PutAsJsonAsync(BasePath, new UpsertUsbSerialFilterRequest
+ {
+ Vid = 0x239A
+ });
+ var body = await created.Content.ReadFromJsonAsync();
+
+ var response = await client.DeleteAsync($"{BasePath}/{body!.Id}");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
+ }
+
+ [Test]
+ public async Task Delete_UnknownId_Returns404()
+ {
+ using var client = Factory.CreateAdminClient();
+ var response = await client.DeleteAsync($"{BasePath}/{Guid.NewGuid()}");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
+ }
+
+ [Test]
+ public async Task ManifestShape_OmitsPidForVendorWide()
+ {
+ using var admin = Factory.CreateAdminClient();
+ await admin.PutAsJsonAsync(BasePath, new UpsertUsbSerialFilterRequest
+ {
+ Vid = 0x1A86
+ });
+
+ using var client = Factory.CreateClient();
+ var response = await client.GetAsync("/2/firmware/manifest");
+ var json = await response.Content.ReadAsStringAsync();
+
+ // Vendor-wide entries must have no `pid` key on the public shape.
+ await Assert.That(json.Contains("\"vid\":6790")).IsTrue();
+ }
+}
diff --git a/RepositoryServer.Tests/Integration/Tests/VersionsControllerTests.cs b/RepositoryServer.Tests/Integration/Tests/VersionsControllerTests.cs
new file mode 100644
index 0000000..45a61c5
--- /dev/null
+++ b/RepositoryServer.Tests/Integration/Tests/VersionsControllerTests.cs
@@ -0,0 +1,280 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.Extensions.DependencyInjection;
+using OpenShock.RepositoryServer.Enums;
+using OpenShock.RepositoryServer.RepoServerDb;
+
+namespace OpenShock.RepositoryServer.Tests.Integration.Tests;
+
+[NotInParallel("repo-server-integration")]
+public class VersionsControllerTests
+{
+ private const string BoardName = "Sample-Board";
+
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public required WebApplicationFactory Factory { get; init; }
+
+ [Before(Test)]
+ public Task Setup() => Factory.ResetDatabaseAsync();
+
+ [Test]
+ public async Task List_EmptyDatabase_ReturnsZeroTotal()
+ {
+ using var client = Factory.CreateClient();
+ var response = await client.GetAsync("/2/firmware/versions");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+
+ var body = await response.Content.ReadFromJsonAsync();
+ await Assert.That(body.GetProperty("total").GetInt32()).IsEqualTo(0);
+ await Assert.That(body.GetProperty("versions").GetArrayLength()).IsEqualTo(0);
+ }
+
+ [Test]
+ public async Task List_WithVersions_OrdersByReleaseDateDescending()
+ {
+ await SeedVersionsAsync(("1.5.0", ReleaseChannel.Stable, DateTimeOffset.UtcNow.AddDays(-5)),
+ ("1.5.1", ReleaseChannel.Stable, DateTimeOffset.UtcNow.AddDays(-1)),
+ ("1.5.2-beta.1", ReleaseChannel.Beta, DateTimeOffset.UtcNow));
+
+ using var client = Factory.CreateClient();
+ var body = await client.GetFromJsonAsync("/2/firmware/versions");
+
+ await Assert.That(body.GetProperty("total").GetInt32()).IsEqualTo(3);
+ var versions = body.GetProperty("versions").EnumerateArray()
+ .Select(v => v.GetProperty("version").GetString()).ToList();
+ await Assert.That(versions[0]).IsEqualTo("1.5.2-beta.1");
+ await Assert.That(versions[1]).IsEqualTo("1.5.1");
+ await Assert.That(versions[2]).IsEqualTo("1.5.0");
+ }
+
+ [Test]
+ public async Task List_FilterByChannel_ReturnsOnlyMatching()
+ {
+ await SeedVersionsAsync(
+ ("1.5.0", ReleaseChannel.Stable, DateTimeOffset.UtcNow.AddDays(-2)),
+ ("1.6.0-beta.1", ReleaseChannel.Beta, DateTimeOffset.UtcNow.AddDays(-1)));
+
+ using var client = Factory.CreateClient();
+ var body = await client.GetFromJsonAsync("/2/firmware/versions?channel=stable");
+
+ await Assert.That(body.GetProperty("total").GetInt32()).IsEqualTo(1);
+ var versions = body.GetProperty("versions").EnumerateArray()
+ .Select(v => v.GetProperty("version").GetString()).ToList();
+ await Assert.That(versions).Count().IsEqualTo(1);
+ await Assert.That(versions[0]).IsEqualTo("1.5.0");
+ }
+
+ [Test]
+ public async Task List_InvalidChannel_Returns400()
+ {
+ using var client = Factory.CreateClient();
+ var response = await client.GetAsync("/2/firmware/versions?channel=nightly");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
+ }
+
+ [Test]
+ public async Task List_LimitAndOffset_Paginate()
+ {
+ await SeedVersionsAsync(
+ ("1.0.0", ReleaseChannel.Stable, DateTimeOffset.UtcNow.AddDays(-5)),
+ ("1.1.0", ReleaseChannel.Stable, DateTimeOffset.UtcNow.AddDays(-4)),
+ ("1.2.0", ReleaseChannel.Stable, DateTimeOffset.UtcNow.AddDays(-3)),
+ ("1.3.0", ReleaseChannel.Stable, DateTimeOffset.UtcNow.AddDays(-2)),
+ ("1.4.0", ReleaseChannel.Stable, DateTimeOffset.UtcNow.AddDays(-1)));
+
+ using var client = Factory.CreateClient();
+ var page1 = await client.GetFromJsonAsync("/2/firmware/versions?limit=2&offset=0");
+ await Assert.That(page1.GetProperty("total").GetInt32()).IsEqualTo(5);
+ await Assert.That(page1.GetProperty("versions").GetArrayLength()).IsEqualTo(2);
+
+ var page2 = await client.GetFromJsonAsync("/2/firmware/versions?limit=2&offset=2");
+ await Assert.That(page2.GetProperty("versions").GetArrayLength()).IsEqualTo(2);
+
+ var page3 = await client.GetFromJsonAsync("/2/firmware/versions?limit=2&offset=4");
+ await Assert.That(page3.GetProperty("versions").GetArrayLength()).IsEqualTo(1);
+
+ var firstPageVersions = page1.GetProperty("versions").EnumerateArray()
+ .Select(v => v.GetProperty("version").GetString()).ToList();
+ var secondPageVersions = page2.GetProperty("versions").EnumerateArray()
+ .Select(v => v.GetProperty("version").GetString()).ToList();
+ await Assert.That(firstPageVersions.Intersect(secondPageVersions).Any()).IsFalse();
+ }
+
+ [Test]
+ public async Task GetVersion_Unknown_Returns404()
+ {
+ using var client = Factory.CreateClient();
+ var response = await client.GetAsync("/2/firmware/versions/9.9.9");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
+ }
+
+ [Test]
+ public async Task GetVersion_WithReleaseNotes_ReturnsFullDto()
+ {
+ var boardId = await SeedVersionWithNotesAsync("1.5.1");
+
+ using var client = Factory.CreateClient();
+ var body = await client.GetFromJsonAsync("/2/firmware/versions/1.5.1");
+ await Assert.That(body.GetProperty("version").GetString()).IsEqualTo("1.5.1");
+ await Assert.That(body.GetProperty("releaseNotes").GetArrayLength()).IsEqualTo(2);
+
+ var boards = body.GetProperty("boards").EnumerateObject()
+ .Select(p => p.Name).ToList();
+ await Assert.That(boards).Contains(BoardName);
+ }
+
+ [Test]
+ public async Task GetVersion_ImmutableCacheHeader()
+ {
+ await SeedVersionWithNotesAsync("1.5.1");
+
+ using var client = Factory.CreateClient();
+ var response = await client.GetAsync("/2/firmware/versions/1.5.1");
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ await Assert.That(response.Headers.CacheControl).IsNotNull();
+ await Assert.That(response.Headers.CacheControl!.Public).IsTrue();
+ await Assert.That(response.Headers.CacheControl.MaxAge).IsEqualTo(TimeSpan.FromSeconds(86400));
+ }
+
+ [Test]
+ public async Task GetVersionForBoard_UnknownVersion_Returns404()
+ {
+ using var client = Factory.CreateClient();
+ var response = await client.GetAsync($"/2/firmware/versions/9.9.9/{Guid.NewGuid()}");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
+ }
+
+ [Test]
+ public async Task GetVersionForBoard_HappyPath_ReturnsArtifacts()
+ {
+ var boardId = await SeedVersionWithNotesAsync("1.5.1");
+
+ using var client = Factory.CreateClient();
+ var body = await client.GetFromJsonAsync(
+ $"/2/firmware/versions/1.5.1/{boardId}");
+
+ await Assert.That(body.GetProperty("version").GetString()).IsEqualTo("1.5.1");
+ await Assert.That(body.GetProperty("boardId").GetString()).IsEqualTo(BoardName);
+ await Assert.That(body.GetProperty("artifacts").GetArrayLength()).IsGreaterThanOrEqualTo(1);
+ }
+
+ [Test]
+ public async Task GetVersionForBoard_ByName_LabelsByNameAndAddressesById()
+ {
+ var boardId = await SeedVersionWithNotesAsync("1.5.1");
+
+ using var client = Factory.CreateClient();
+ var body = await client.GetFromJsonAsync(
+ $"/2/firmware/versions/1.5.1/{BoardName}");
+
+ await Assert.That(body.GetProperty("boardId").GetString()).IsEqualTo(BoardName);
+
+ var url = body.GetProperty("artifacts")[0].GetProperty("url").GetString();
+ await Assert.That(url).Contains($"/1.5.1/{boardId}/");
+ }
+
+ [Test]
+ public async Task GetVersionForBoard_UnknownName_Returns404()
+ {
+ await SeedVersionWithNotesAsync("1.5.1");
+
+ using var client = Factory.CreateClient();
+ var response = await client.GetAsync("/2/firmware/versions/1.5.1/No-Such-Board");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
+ }
+
+ private async Task SeedVersionsAsync(params (string Version, ReleaseChannel Channel, DateTimeOffset ReleaseDate)[] rows)
+ {
+ await using var scope = Factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var repo = new SourceRepository
+ {
+ Id = Guid.NewGuid(),
+ Provider = RepositoryProvider.Github,
+ Owner = "openshock",
+ Repo = "firmware"
+ };
+ db.Repositories.Add(repo);
+
+ foreach (var (version, channel, releaseDate) in rows)
+ {
+ db.FirmwareVersions.Add(new FirmwareVersion
+ {
+ Version = version,
+ Channel = channel,
+ ReleaseDate = releaseDate,
+ RepositoryId = repo.Id,
+ CommitHash = "abc1234567890abcdef1234567890abcdef12345"
+ });
+ }
+
+ await db.SaveChangesAsync();
+ }
+
+ private async Task SeedVersionWithNotesAsync(string version)
+ {
+ await using var scope = Factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var chip = new FirmwareChip { Id = Guid.NewGuid(), Name = "ESP32" };
+ var board = new FirmwareBoard
+ {
+ Id = Guid.NewGuid(),
+ Name = BoardName,
+ ChipId = chip.Id,
+ RequiredArtifactTypes = [FirmwareArtifactType.Merged]
+ };
+ var repo = new SourceRepository
+ {
+ Id = Guid.NewGuid(),
+ Provider = RepositoryProvider.Github,
+ Owner = "openshock",
+ Repo = "firmware"
+ };
+ var fw = new FirmwareVersion
+ {
+ Version = version,
+ Channel = ReleaseChannel.Stable,
+ ReleaseDate = DateTimeOffset.UtcNow,
+ RepositoryId = repo.Id,
+ CommitHash = "abc1234567890abcdef1234567890abcdef12345"
+ };
+
+ db.FirmwareChips.Add(chip);
+ db.FirmwareBoards.Add(board);
+ db.Repositories.Add(repo);
+ db.FirmwareVersions.Add(fw);
+
+ db.FirmwareArtifacts.Add(new FirmwareArtifact
+ {
+ Version = version,
+ BoardId = board.Id,
+ ArtifactType = FirmwareArtifactType.Merged,
+ HashSha256 = new byte[32],
+ FileSize = 1024
+ });
+
+ db.FirmwareReleaseNotes.Add(new FirmwareReleaseNote
+ {
+ Version = version,
+ Index = 0,
+ SectionType = ReleaseNoteSectionType.Breaking,
+ Title = "Config format",
+ Content = "Changed config format to TOML"
+ });
+ db.FirmwareReleaseNotes.Add(new FirmwareReleaseNote
+ {
+ Version = version,
+ Index = 1,
+ SectionType = ReleaseNoteSectionType.Info,
+ Title = null,
+ Content = "Fixed WiFi reconnection"
+ });
+
+ await db.SaveChangesAsync();
+ return board.Id;
+ }
+}
diff --git a/RepositoryServer.Tests/Integration/WebApplicationFactory.cs b/RepositoryServer.Tests/Integration/WebApplicationFactory.cs
new file mode 100644
index 0000000..bfd83be
--- /dev/null
+++ b/RepositoryServer.Tests/Integration/WebApplicationFactory.cs
@@ -0,0 +1,199 @@
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using OpenShock.RepositoryServer.RepoServerDb;
+using OpenShock.RepositoryServer.Services;
+using OpenShock.RepositoryServer.Tests.Integration.Docker;
+using TUnit.Core.Interfaces;
+
+namespace OpenShock.RepositoryServer.Tests.Integration;
+
+///
+/// Custom
+/// wired up to a Testcontainers Postgres instance. The harness:
+/// * overlays in-memory configuration on top of whatever Program.cs loads from disk
+/// (overrides db connection, admin token, local storage path, and forces skip-migration)
+/// * removes the hosted service so it doesn't
+/// race the schema creation inside
+/// * applies the real EF migrations (via , the
+/// context the migrations are attributed to) so the suite validates them
+///
+public sealed class WebApplicationFactory
+ : Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory, IAsyncInitializer
+{
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public required InMemoryDatabase PostgreSql { get; init; }
+
+ private readonly string _cdnStoragePath = Path.Combine(
+ Path.GetTempPath(), $"repo-server-tests-cdn-{Guid.NewGuid():N}");
+
+ public async Task InitializeAsync()
+ {
+ _ = Server; // force the host to build
+
+ await using var scope = Services.CreateAsyncScope();
+ var loggerFactory = scope.ServiceProvider.GetRequiredService();
+ await using var migrationContext = new MigrationOpenShockContext(
+ PostgreSql.Container.GetConnectionString(), debug: false, loggerFactory);
+ await migrationContext.Database.MigrateAsync();
+ }
+
+ ///
+ /// Returns an with a pre-populated Authorization
+ /// header matching the admin token baked into the test configuration.
+ ///
+ public HttpClient CreateAdminClient()
+ {
+ var client = CreateClient();
+ // AdminTokenAuthentication compares the raw Authorization header to the configured
+ // admin token — TryAddWithoutValidation bypasses HttpHeaders' scheme+token parser.
+ client.DefaultRequestHeaders.TryAddWithoutValidation(
+ TestAdminToken.HeaderName, TestAdminToken.Value);
+ return client;
+ }
+
+ ///
+ /// Returns an authenticated as the CI/CD principal of
+ /// , which must be a registered repository.
+ ///
+ public HttpClient CreateCiCdClient(Guid repositoryId, string? commitHash = null, string? scopes = null)
+ {
+ var client = CreateClient();
+ client.DefaultRequestHeaders.TryAddWithoutValidation(
+ TestCiCdAuthHandler.RepositoryIdHeader, repositoryId.ToString());
+ if (scopes is not null)
+ {
+ client.DefaultRequestHeaders.TryAddWithoutValidation(
+ TestCiCdAuthHandler.ScopesHeader, scopes);
+ }
+ if (commitHash is not null)
+ {
+ client.DefaultRequestHeaders.TryAddWithoutValidation(
+ TestCiCdAuthHandler.CommitHashHeader, commitHash);
+ }
+ return client;
+ }
+
+ ///
+ /// True when a path exists in the local CDN storage backing this factory. Lets tests assert on
+ /// what actually reached storage, rather than inferring it from response bodies.
+ ///
+ public bool StoredFileExists(string relativePath) =>
+ File.Exists(Path.Combine(_cdnStoragePath, relativePath.Replace('/', Path.DirectorySeparatorChar)));
+
+ ///
+ /// Wipes all mutable test data between tests without tearing down the container.
+ /// Firmware tables first (FK order), then repositories, then catalog.
+ ///
+ public async Task ResetDatabaseAsync(CancellationToken ct = default)
+ {
+ await using var scope = Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ await db.Database.ExecuteSqlRawAsync(
+ """
+ TRUNCATE TABLE
+ firmware_staged_release_notes,
+ firmware_staged_artifacts,
+ firmware_releases,
+ firmware_release_notes,
+ firmware_artifacts,
+ firmware_versions,
+ firmware_board_usb_devices,
+ firmware_chip_usb_devices,
+ firmware_boards,
+ firmware_chips,
+ firmware_advisories,
+ usb_serial_filters,
+ usb_devices,
+ discord_webhooks,
+ versions,
+ modules,
+ repositories
+ RESTART IDENTITY CASCADE;
+ """, ct);
+ }
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ builder.UseEnvironment("Testing");
+
+ // Program.cs reads and validates ApiConfig BEFORE builder.Build(). For minimal-hosting
+ // apps, ConfigureAppConfiguration overlays are only applied at Build() time — too late.
+ // UseSetting goes through host configuration, which the deferred host builder flattens
+ // into command-line args for Program.Main, so Program's `.AddCommandLine(args)` picks
+ // these up before the config is validated.
+ var settings = new Dictionary
+ {
+ ["Db:Conn"] = PostgreSql.Container.GetConnectionString(),
+ ["Db:SkipMigration"] = "true",
+ ["Db:Debug"] = "false",
+
+ ["AdminToken"] = TestAdminToken.Value,
+
+ ["CiCd:Audience"] = "openshock-repository-server-test",
+
+ ["Repo:CdnBaseUrl"] = "https://cdn-test.openshock.example/repo",
+
+ ["Firmware:CdnBaseUrl"] = "https://cdn-test.openshock.example/firmware",
+ ["Firmware:Storage:Type"] = "Local",
+ ["Firmware:Storage:Local:BasePath"] = _cdnStoragePath,
+ ["Firmware:StagedReleaseTtl"] = "01:00:00",
+ ["Firmware:EditingReleaseTtl"] = "7.00:00:00",
+ };
+ foreach (var (key, value) in settings)
+ {
+ builder.UseSetting(key, value);
+ }
+
+ builder.ConfigureTestServices(services =>
+ {
+ // Repoint the CI/CD scheme at the test handler — see TestCiCdAuthHandler for what this
+ // does and does not substitute. The scheme is already registered as JwtBearer by
+ // Program.cs, and registering the same name twice fails host startup, so swap the handler
+ // type on the existing registration rather than adding a second one.
+ services.PostConfigure(options =>
+ {
+ if (options.SchemeMap.TryGetValue(AuthSchemas.CiCdToken, out var scheme))
+ {
+ scheme.HandlerType = typeof(TestCiCdAuthHandler);
+ }
+ });
+
+ // The cleanup hosted service fires on startup and queries the DB. In tests
+ // we build schema AFTER the host starts, so suppress it to avoid noisy
+ // failure logs. Individual tests exercise its logic directly if needed.
+ var hostedDescriptor = services.FirstOrDefault(
+ d => d.ImplementationType == typeof(StagedReleaseCleanupService));
+ if (hostedDescriptor is not null)
+ {
+ services.Remove(hostedDescriptor);
+ }
+ });
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+
+ if (disposing)
+ {
+ try
+ {
+ if (Directory.Exists(_cdnStoragePath))
+ {
+ Directory.Delete(_cdnStoragePath, recursive: true);
+ }
+ }
+ catch
+ {
+ // best effort — tests shouldn't fail on cleanup
+ }
+ }
+ }
+}
diff --git a/RepositoryServer.Tests/RepositoryServer.Tests.csproj b/RepositoryServer.Tests/RepositoryServer.Tests.csproj
new file mode 100644
index 0000000..17a8852
--- /dev/null
+++ b/RepositoryServer.Tests/RepositoryServer.Tests.csproj
@@ -0,0 +1,23 @@
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ default
+ OpenShock.RepositoryServer.Tests
+ OpenShock.RepositoryServer.Tests
+ OpenShock.RepositoryServer.Tests
+ OpenShock.RepositoryServer.Tests
+
+
+
+
+
+
+
+
+
+
+
diff --git a/RepositoryServer.Tests/Utils/ChangelogParserTests.cs b/RepositoryServer.Tests/Utils/ChangelogParserTests.cs
new file mode 100644
index 0000000..e91949b
--- /dev/null
+++ b/RepositoryServer.Tests/Utils/ChangelogParserTests.cs
@@ -0,0 +1,263 @@
+using OpenShock.RepositoryServer.Utils;
+
+namespace OpenShock.RepositoryServer.Tests.Utils;
+
+public class ChangelogParserTests
+{
+ [Test]
+ public async Task Parse_Empty_ReturnsEmptyError()
+ {
+ var result = ChangelogParser.Parse("");
+ await Assert.That(result.IsT1).IsTrue();
+ await Assert.That(result.AsT1).IsEqualTo(ChangelogParseError.Empty);
+ }
+
+ [Test]
+ public async Task Parse_Whitespace_ReturnsEmptyError()
+ {
+ var result = ChangelogParser.Parse(" \n\n\n ");
+ await Assert.That(result.IsT1).IsTrue();
+ await Assert.That(result.AsT1).IsEqualTo(ChangelogParseError.Empty);
+ }
+
+ [Test]
+ public async Task Parse_NoHeadings_ReturnsNoHeadingsError()
+ {
+ var result = ChangelogParser.Parse("Just some text\nwithout headings.");
+ await Assert.That(result.IsT1).IsTrue();
+ await Assert.That(result.AsT1).IsEqualTo(ChangelogParseError.NoHeadings);
+ }
+
+ [Test]
+ public async Task Parse_AllSectionsEmpty_ReturnsAllSectionsEmptyError()
+ {
+ var result = ChangelogParser.Parse("### Info\n\n### Warning\n\n### Breaking\n");
+ await Assert.That(result.IsT1).IsTrue();
+ await Assert.That(result.AsT1).IsEqualTo(ChangelogParseError.AllSectionsEmpty);
+ }
+
+ [Test]
+ public async Task Parse_MapsBreakingWarningInfo_ToLowercaseEnumNames()
+ {
+ var input = "### Breaking\nLorem\n### Warning\nIpsum\n### Info\nDolor";
+ var result = ChangelogParser.Parse(input);
+
+ await Assert.That(result.IsT0).IsTrue();
+ var notes = result.AsT0;
+ await Assert.That(notes).Count().IsEqualTo(3);
+ await Assert.That(notes[0].Type).IsEqualTo("breaking");
+ await Assert.That(notes[1].Type).IsEqualTo("warning");
+ await Assert.That(notes[2].Type).IsEqualTo("info");
+ }
+
+ [Test]
+ public async Task Parse_CustomHeading_BecomesSectionWithTitle()
+ {
+ var input = "### Features\n- New dashboard\n- Bluetooth pairing";
+ var result = ChangelogParser.Parse(input);
+
+ await Assert.That(result.IsT0).IsTrue();
+ var notes = result.AsT0;
+ await Assert.That(notes).Count().IsEqualTo(2);
+ await Assert.That(notes[0].Type).IsEqualTo("section");
+ await Assert.That(notes[0].Title).IsEqualTo("Features");
+ await Assert.That(notes[0].Content).IsEqualTo("New dashboard");
+ await Assert.That(notes[1].Title).IsEqualTo("Features");
+ await Assert.That(notes[1].Content).IsEqualTo("Bluetooth pairing");
+ }
+
+ [Test]
+ public async Task Parse_ExtractsBoldTitlePrefix_WithEmDashSeparator()
+ {
+ var input = "### Breaking\n**Config format** — Changed to TOML";
+ var result = ChangelogParser.Parse(input);
+
+ await Assert.That(result.IsT0).IsTrue();
+ var notes = result.AsT0;
+ await Assert.That(notes).Count().IsEqualTo(1);
+ await Assert.That(notes[0].Type).IsEqualTo("breaking");
+ await Assert.That(notes[0].Title).IsEqualTo("Config format");
+ await Assert.That(notes[0].Content).IsEqualTo("Changed to TOML");
+ }
+
+ [Test]
+ public async Task Parse_MultiLineItemWithoutBullets_IsCombined()
+ {
+ var input = "### Info\nLine one\nLine two";
+ var result = ChangelogParser.Parse(input);
+
+ await Assert.That(result.IsT0).IsTrue();
+ var notes = result.AsT0;
+ await Assert.That(notes).Count().IsEqualTo(1);
+ await Assert.That(notes[0].Content).IsEqualTo("Line one\nLine two");
+ }
+
+ [Test]
+ public async Task Parse_PlainItem_NoTitleExtracted()
+ {
+ var input = "### Info\n- Fixed WiFi reconnection";
+ var result = ChangelogParser.Parse(input);
+
+ await Assert.That(result.IsT0).IsTrue();
+ var notes = result.AsT0;
+ await Assert.That(notes).Count().IsEqualTo(1);
+ await Assert.That(notes[0].Type).IsEqualTo("info");
+ await Assert.That(notes[0].Title).IsNull();
+ await Assert.That(notes[0].Content).IsEqualTo("Fixed WiFi reconnection");
+ }
+
+ [Test]
+ [Arguments("### Info\n**Title** — content", "Title", "content")]
+ [Arguments("### Info\n**Title** – content", "Title", "content")]
+ [Arguments("### Info\n**Title** - content", "Title", "content")]
+ [Arguments("### Info\n**Title** -content", "Title", "content")]
+ public async Task Parse_SupportsMultipleTitleSeparators(string input, string expectedTitle, string expectedContent)
+ {
+ var result = ChangelogParser.Parse(input);
+ await Assert.That(result.IsT0).IsTrue();
+ var notes = result.AsT0;
+ await Assert.That(notes).Count().IsEqualTo(1);
+ await Assert.That(notes[0].Title).IsEqualTo(expectedTitle);
+ await Assert.That(notes[0].Content).IsEqualTo(expectedContent);
+ }
+
+ [Test]
+ public async Task Parse_IgnoresLinesBeforeFirstHeading()
+ {
+ var input = "Intro text we should ignore\n\n### Info\nActual content";
+ var result = ChangelogParser.Parse(input);
+
+ await Assert.That(result.IsT0).IsTrue();
+ var notes = result.AsT0;
+ await Assert.That(notes).Count().IsEqualTo(1);
+ await Assert.That(notes[0].Content).IsEqualTo("Actual content");
+ }
+
+ [Test]
+ public async Task Parse_HandlesCrlfLineEndings()
+ {
+ var input = "### Info\r\n- Fixed WiFi\r\n### Warning\r\nNeeds reset";
+ var result = ChangelogParser.Parse(input);
+
+ await Assert.That(result.IsT0).IsTrue();
+ var notes = result.AsT0;
+ await Assert.That(notes).Count().IsEqualTo(2);
+ await Assert.That(notes[0].Type).IsEqualTo("info");
+ await Assert.That(notes[0].Content).IsEqualTo("Fixed WiFi");
+ await Assert.That(notes[1].Type).IsEqualTo("warning");
+ await Assert.That(notes[1].Content).IsEqualTo("Needs reset");
+ }
+
+ [Test]
+ public async Task Parse_HeadingsAreCaseInsensitive()
+ {
+ var input = "### BREAKING\nFoo\n### warning\nBar\n### Info\nBaz";
+ var result = ChangelogParser.Parse(input);
+
+ await Assert.That(result.IsT0).IsTrue();
+ var notes = result.AsT0;
+ await Assert.That(notes).Count().IsEqualTo(3);
+ await Assert.That(notes[0].Type).IsEqualTo("breaking");
+ await Assert.That(notes[1].Type).IsEqualTo("warning");
+ await Assert.That(notes[2].Type).IsEqualTo("info");
+ }
+
+ [Test]
+ public async Task Parse_SectionProseAndBulletsBothBecomeNotes()
+ {
+ // Prose alongside bullets is content, not noise — it used to be silently discarded.
+ var input = "### Info\nIntroductory prose\n- Actual item 1\n- Actual item 2";
+ var result = ChangelogParser.Parse(input);
+
+ await Assert.That(result.IsT0).IsTrue();
+ var notes = result.AsT0;
+ await Assert.That(notes).Count().IsEqualTo(3);
+ await Assert.That(notes[0].Content).IsEqualTo("Introductory prose");
+ await Assert.That(notes[1].Content).IsEqualTo("Actual item 1");
+ await Assert.That(notes[2].Content).IsEqualTo("Actual item 2");
+ }
+
+ [Test]
+ public async Task Parse_NestedBulletsAreFlattenedNotDropped()
+ {
+ // The DTO has no nesting, so indented items become siblings. Previously they fell into the
+ // prose buffer and were discarded along with it.
+ var input = "### Info\n- Parent item\n - Nested item\n - Deeply nested item";
+ var result = ChangelogParser.Parse(input);
+
+ await Assert.That(result.IsT0).IsTrue();
+ var notes = result.AsT0;
+ await Assert.That(notes).Count().IsEqualTo(3);
+ await Assert.That(notes[0].Content).IsEqualTo("Parent item");
+ await Assert.That(notes[1].Content).IsEqualTo("Nested item");
+ await Assert.That(notes[2].Content).IsEqualTo("Deeply nested item");
+ }
+
+ [Test]
+ public async Task Parse_EmptyBoldTitle_FallsBackToLiteralContent()
+ {
+ var input = "### Info\n**** — still content";
+ var result = ChangelogParser.Parse(input);
+
+ await Assert.That(result.IsT0).IsTrue();
+ var notes = result.AsT0;
+ await Assert.That(notes).Count().IsEqualTo(1);
+ await Assert.That(notes[0].Title).IsNull();
+ await Assert.That(notes[0].Content).IsEqualTo("**** — still content");
+ }
+
+ [Test]
+ public async Task Parse_SpecExample_MatchesGoldenStructure()
+ {
+ // Reproduced verbatim from the §5.3 worked example. Do not trim lines out of this fixture to
+ // make it pass — every line exercises a documented construct, and a shorter fixture is how the
+ // section-prose divergence went unnoticed.
+ var input = string.Join("\n",
+ "### Breaking",
+ "**Config format** — Changed config format to TOML",
+ "Multiple lines of content are concatenated into a single note.",
+ "",
+ "### Warning",
+ "Requires hub reset after update",
+ "",
+ "### Info",
+ "- Fixed WiFi reconnection",
+ "- Improved battery life",
+ "- **OTA** — Added automatic rollback on failed flash",
+ "",
+ "### Features",
+ "Custom sections become type \"section\" with the heading as title.",
+ "- New web dashboard",
+ "- Bluetooth pairing support");
+ var result = ChangelogParser.Parse(input);
+
+ await Assert.That(result.IsT0).IsTrue();
+ var notes = result.AsT0;
+ await Assert.That(notes).Count().IsEqualTo(8);
+
+ await Assert.That(notes[0].Type).IsEqualTo("breaking");
+ await Assert.That(notes[0].Title).IsEqualTo("Config format");
+ await Assert.That(notes[0].Content)
+ .IsEqualTo("Changed config format to TOML\nMultiple lines of content are concatenated into a single note.");
+
+ await Assert.That(notes[1].Type).IsEqualTo("warning");
+ await Assert.That(notes[1].Content).IsEqualTo("Requires hub reset after update");
+
+ await Assert.That(notes[2].Type).IsEqualTo("info");
+ await Assert.That(notes[2].Content).IsEqualTo("Fixed WiFi reconnection");
+ await Assert.That(notes[3].Content).IsEqualTo("Improved battery life");
+ await Assert.That(notes[4].Title).IsEqualTo("OTA");
+ await Assert.That(notes[4].Content).IsEqualTo("Added automatic rollback on failed flash");
+
+ // Section prose is a note in its own right, ahead of the section's bullets.
+ await Assert.That(notes[5].Type).IsEqualTo("section");
+ await Assert.That(notes[5].Title).IsEqualTo("Features");
+ await Assert.That(notes[5].Content)
+ .IsEqualTo("Custom sections become type \"section\" with the heading as title.");
+
+ await Assert.That(notes[6].Type).IsEqualTo("section");
+ await Assert.That(notes[6].Title).IsEqualTo("Features");
+ await Assert.That(notes[6].Content).IsEqualTo("New web dashboard");
+ await Assert.That(notes[7].Content).IsEqualTo("Bluetooth pairing support");
+ }
+}
diff --git a/RepositoryServer.Tests/Utils/FirmwareArtifactFileNamesTests.cs b/RepositoryServer.Tests/Utils/FirmwareArtifactFileNamesTests.cs
new file mode 100644
index 0000000..cb34c6d
--- /dev/null
+++ b/RepositoryServer.Tests/Utils/FirmwareArtifactFileNamesTests.cs
@@ -0,0 +1,30 @@
+using OpenShock.RepositoryServer.Enums;
+using OpenShock.RepositoryServer.Utils;
+
+namespace OpenShock.RepositoryServer.Tests.Utils;
+
+public class FirmwareArtifactFileNamesTests
+{
+ [Test]
+ [Arguments(FirmwareArtifactType.Merged, "firmware.bin")]
+ [Arguments(FirmwareArtifactType.App, "app.bin")]
+ [Arguments(FirmwareArtifactType.Bootloader, "bootloader.bin")]
+ [Arguments(FirmwareArtifactType.Partitions, "partitions.bin")]
+ [Arguments(FirmwareArtifactType.StaticFs, "staticfs.bin")]
+ public async Task GetFileName_KnownType_ReturnsSpecFilename(FirmwareArtifactType type, string expected)
+ {
+ var actual = FirmwareArtifactFileNames.GetFileName(type);
+ await Assert.That(actual).IsEqualTo(expected);
+ }
+
+ [Test]
+ public async Task GetFileName_Covers_AllEnumValues()
+ {
+ foreach (var value in Enum.GetValues())
+ {
+ var result = FirmwareArtifactFileNames.GetFileName(value);
+ await Assert.That(result).IsNotNull();
+ await Assert.That(result.EndsWith(".bin", StringComparison.Ordinal)).IsTrue();
+ }
+ }
+}
diff --git a/RepositoryServer.Tests/Utils/SourceUrlBuilderTests.cs b/RepositoryServer.Tests/Utils/SourceUrlBuilderTests.cs
new file mode 100644
index 0000000..5aba48e
--- /dev/null
+++ b/RepositoryServer.Tests/Utils/SourceUrlBuilderTests.cs
@@ -0,0 +1,63 @@
+using OpenShock.RepositoryServer.Enums;
+using OpenShock.RepositoryServer.Utils;
+
+namespace OpenShock.RepositoryServer.Tests.Utils;
+
+public class SourceUrlBuilderTests
+{
+ [Test]
+ public async Task BuildCommitUrl_Github_ReturnsCanonicalCommitUrl()
+ {
+ var url = SourceUrlBuilder.BuildCommitUrl(RepositoryProvider.Github, "openshock", "firmware", "21e43623abcdef1234567890abcdef1234567890");
+ await Assert.That(url).IsEqualTo("https://github.com/openshock/firmware/commit/21e43623abcdef1234567890abcdef1234567890");
+ }
+
+ [Test]
+ public async Task BuildCommitUrl_EmptyHash_ReturnsNull()
+ {
+ var url = SourceUrlBuilder.BuildCommitUrl(RepositoryProvider.Github, "openshock", "firmware", "");
+ await Assert.That(url).IsNull();
+ }
+
+ [Test]
+ public async Task BuildRefUrl_TagRef_ReturnsReleasesTagUrl()
+ {
+ var url = SourceUrlBuilder.BuildRefUrl(RepositoryProvider.Github, "openshock", "firmware", "refs/tags/v1.5.1");
+ await Assert.That(url).IsEqualTo("https://github.com/openshock/firmware/releases/tag/v1.5.1");
+ }
+
+ [Test]
+ public async Task BuildRefUrl_BranchRef_ReturnsTreeUrl()
+ {
+ var url = SourceUrlBuilder.BuildRefUrl(RepositoryProvider.Github, "openshock", "firmware", "refs/heads/main");
+ await Assert.That(url).IsEqualTo("https://github.com/openshock/firmware/tree/main");
+ }
+
+ [Test]
+ public async Task BuildRefUrl_BareRef_TreatedAsBranch()
+ {
+ var url = SourceUrlBuilder.BuildRefUrl(RepositoryProvider.Github, "openshock", "firmware", "develop");
+ await Assert.That(url).IsEqualTo("https://github.com/openshock/firmware/tree/develop");
+ }
+
+ [Test]
+ public async Task BuildRefUrl_Null_ReturnsNull()
+ {
+ var url = SourceUrlBuilder.BuildRefUrl(RepositoryProvider.Github, "openshock", "firmware", null);
+ await Assert.That(url).IsNull();
+ }
+
+ [Test]
+ public async Task BuildRunUrl_Github_ReturnsActionsRunUrl()
+ {
+ var url = SourceUrlBuilder.BuildRunUrl(RepositoryProvider.Github, "openshock", "firmware", "12345678901");
+ await Assert.That(url).IsEqualTo("https://github.com/openshock/firmware/actions/runs/12345678901");
+ }
+
+ [Test]
+ public async Task BuildRunUrl_Empty_ReturnsNull()
+ {
+ var url = SourceUrlBuilder.BuildRunUrl(RepositoryProvider.Github, "openshock", "firmware", "");
+ await Assert.That(url).IsNull();
+ }
+}
diff --git a/RepositoryServer/AuthSchemas.cs b/RepositoryServer/AuthSchemas.cs
new file mode 100644
index 0000000..b7e7d5b
--- /dev/null
+++ b/RepositoryServer/AuthSchemas.cs
@@ -0,0 +1,31 @@
+namespace OpenShock.RepositoryServer;
+
+public static class AuthSchemas
+{
+ public const string AdminToken = "AdminToken";
+ public const string CiCdToken = "CiCdToken";
+
+ /// Authorization policies layered on top of the CI/CD scheme.
+ public static class Policies
+ {
+ public const string PublishFirmware = "PublishFirmware";
+ public const string PublishModules = "PublishModules";
+ }
+
+ ///
+ /// Claim keys attached to the CI/CD principal after successful GitHub OIDC validation.
+ ///
+ public static class CiCdClaims
+ {
+ public const string RepositoryId = "openshock:repository_id";
+ public const string CommitHash = "openshock:commit_hash";
+ public const string Ref = "openshock:ref";
+ public const string RunId = "openshock:run_id";
+
+ ///
+ /// One claim per granted to the
+ /// calling repository. Authorization policies match on these.
+ ///
+ public const string Scope = "openshock:scope";
+ }
+}
diff --git a/DesktopRepositoryServer/AuthenticationHandlers/AdminTokenAuthentication.cs b/RepositoryServer/AuthenticationHandlers/AdminTokenAuthentication.cs
similarity index 90%
rename from DesktopRepositoryServer/AuthenticationHandlers/AdminTokenAuthentication.cs
rename to RepositoryServer/AuthenticationHandlers/AdminTokenAuthentication.cs
index e51a4d1..9161b56 100644
--- a/DesktopRepositoryServer/AuthenticationHandlers/AdminTokenAuthentication.cs
+++ b/RepositoryServer/AuthenticationHandlers/AdminTokenAuthentication.cs
@@ -5,11 +5,11 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.Extensions.Options;
-using OpenShock.Desktop.RepositoryServer.Config;
-using OpenShock.Desktop.RepositoryServer.Errors;
-using OpenShock.Desktop.RepositoryServer.Problems;
+using OpenShock.RepositoryServer.Config;
+using OpenShock.RepositoryServer.Errors;
+using OpenShock.RepositoryServer.Problems;
-namespace OpenShock.Desktop.RepositoryServer.AuthenticationHandlers;
+namespace OpenShock.RepositoryServer.AuthenticationHandlers;
public sealed class AdminTokenAuthentication : AuthenticationHandler
{
diff --git a/RepositoryServer/AuthenticationHandlers/GitHubOidcAuthentication.cs b/RepositoryServer/AuthenticationHandlers/GitHubOidcAuthentication.cs
new file mode 100644
index 0000000..a294401
--- /dev/null
+++ b/RepositoryServer/AuthenticationHandlers/GitHubOidcAuthentication.cs
@@ -0,0 +1,134 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.IdentityModel.Tokens;
+using OpenShock.RepositoryServer.Enums;
+using OpenShock.RepositoryServer.RepoServerDb;
+using OpenShock.RepositoryServer.Utils;
+
+namespace OpenShock.RepositoryServer.AuthenticationHandlers;
+
+///
+/// Configures the scheme as a GitHub OIDC token
+/// validator. The ASP.NET Core does the JWT + JWKS
+/// cryptography; this class adds an
+/// hook that:
+/// 1. Extracts owner/repo/commit/ref/run_id from the token claims.
+/// 2. Looks up the repositories row for this owner/repo pair, failing if absent.
+/// 3. Attaches to the principal so controllers
+/// can pull the matched repository id, commit SHA, ref, and run id.
+///
+///
+/// A valid GitHub OIDC token proves only that some GitHub Actions workflow requested it. The
+/// issuer is shared by every repository on GitHub, and the audience is a plain string that any workflow
+/// can ask for by name, so neither authenticates which repository is calling. Registration in
+/// the repositories table is therefore the actual authorization decision: an unregistered
+/// repository is rejected here rather than being registered on the spot. Onboarding is an explicit
+/// admin action — see PUT /v2/firmware/admin/repositories.
+///
+public static class GitHubOidcAuthentication
+{
+ public const string Issuer = "https://token.actions.githubusercontent.com";
+
+ public static void Configure(JwtBearerOptions options, string audience)
+ {
+ options.Authority = Issuer;
+ options.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuer = true,
+ ValidIssuer = Issuer,
+ ValidateAudience = true,
+ ValidAudience = audience,
+ ValidateLifetime = true
+ };
+
+ options.Events = new JwtBearerEvents
+ {
+ OnTokenValidated = OnTokenValidatedAsync
+ };
+ }
+
+ private static async Task OnTokenValidatedAsync(TokenValidatedContext context)
+ {
+ var principal = context.Principal;
+ if (principal is null)
+ {
+ context.Fail("Token principal missing.");
+ return;
+ }
+
+ var owner = principal.FindFirstValue("repository_owner");
+ var repoFull = principal.FindFirstValue("repository"); // format: "owner/repo"
+ var commitHash = principal.FindFirstValue("sha");
+ var refValue = principal.FindFirstValue("ref");
+ var runId = principal.FindFirstValue("run_id");
+
+ if (string.IsNullOrWhiteSpace(owner) ||
+ string.IsNullOrWhiteSpace(repoFull) ||
+ string.IsNullOrWhiteSpace(commitHash))
+ {
+ context.Fail("Required GitHub OIDC claims missing.");
+ return;
+ }
+
+ var repo = repoFull;
+ var slashIndex = repoFull.IndexOf('/');
+ if (slashIndex >= 0 && slashIndex < repoFull.Length - 1)
+ {
+ repo = repoFull[(slashIndex + 1)..];
+ }
+
+ var dbFactory = context.HttpContext.RequestServices
+ .GetRequiredService>();
+ await using var db = await dbFactory.CreateDbContextAsync(context.HttpContext.RequestAborted);
+
+ var registration = await FindRepositoryAsync(db, owner, repo, context.HttpContext.RequestAborted);
+ if (registration is null)
+ {
+ // Deliberately does not disclose whether the repository is merely unregistered.
+ context.Fail("Repository is not authorized to publish.");
+ return;
+ }
+
+ var identity = (ClaimsIdentity)principal.Identity!;
+ identity.AddClaim(new Claim(AuthSchemas.CiCdClaims.RepositoryId, registration.Id.ToString()));
+
+ // Scopes decide which ingestion endpoints this grant reaches. A repository with none is
+ // registered but cannot publish anything.
+ foreach (var scope in registration.Scopes)
+ {
+ identity.AddClaim(new Claim(AuthSchemas.CiCdClaims.Scope, scope.ToScopeClaim()));
+ }
+ identity.AddClaim(new Claim(AuthSchemas.CiCdClaims.CommitHash, commitHash));
+ if (!string.IsNullOrWhiteSpace(refValue))
+ identity.AddClaim(new Claim(AuthSchemas.CiCdClaims.Ref, refValue));
+ if (!string.IsNullOrWhiteSpace(runId))
+ identity.AddClaim(new Claim(AuthSchemas.CiCdClaims.RunId, runId));
+ }
+
+ private sealed record Registration(Guid Id, RepositoryScope[] Scopes);
+
+ ///
+ /// Resolves a pre-registered repository. Returns null when the pair is not registered —
+ /// never creates the row, since that would make the allowlist self-populating and authorize
+ /// whoever showed up first.
+ ///
+ ///
+ /// Owner and repo are matched case-insensitively. GitHub treats them that way, and the casing in
+ /// the token's claims follows whatever the repository is currently named, so an exact match would
+ /// silently stop authorizing a repository after a cosmetic rename.
+ ///
+ private static async Task FindRepositoryAsync(RepoServerContext db, string owner, string repo, CancellationToken ct)
+ {
+ const RepositoryProvider provider = RepositoryProvider.Github;
+ var loweredOwner = owner.ToLowerInvariant();
+ var loweredRepo = repo.ToLowerInvariant();
+
+ return await db.Repositories
+ .Where(r => r.Provider == provider
+ && r.Owner.ToLower() == loweredOwner
+ && r.Repo.ToLower() == loweredRepo)
+ .Select(r => new Registration(r.Id, r.Scopes))
+ .FirstOrDefaultAsync(ct);
+ }
+}
diff --git a/RepositoryServer/Config/ApiConfig.cs b/RepositoryServer/Config/ApiConfig.cs
new file mode 100644
index 0000000..3b9c1bf
--- /dev/null
+++ b/RepositoryServer/Config/ApiConfig.cs
@@ -0,0 +1,18 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace OpenShock.RepositoryServer.Config;
+
+public class ApiConfig
+{
+ [Required] public required DbConfig Db { get; init; }
+ [Required] public required string AdminToken { get; init; }
+ [Required] public required RepoConfig Repo { get; init; }
+ [Required] public required FirmwareConfig Firmware { get; init; }
+
+ ///
+ /// Shared CI/CD auth settings. Deliberately not nested under : the scheme it
+ /// configures also guards desktop module publishing.
+ ///
+ [Required] public required CiCdConfig CiCd { get; init; }
+ public MetricsConfig Metrics { get; init; } = new();
+}
\ No newline at end of file
diff --git a/RepositoryServer/Config/CiCdConfig.cs b/RepositoryServer/Config/CiCdConfig.cs
new file mode 100644
index 0000000..bf21289
--- /dev/null
+++ b/RepositoryServer/Config/CiCdConfig.cs
@@ -0,0 +1,26 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace OpenShock.RepositoryServer.Config;
+
+///
+/// Settings for the shared CI/CD authentication scheme, used by both firmware release ingestion and
+/// desktop module publishing.
+///
+public sealed class CiCdConfig
+{
+ ///
+ /// GitHub OIDC audience this server accepts. A workflow chooses its own audience when it
+ /// requests a token, and tokens not addressed here are rejected.
+ ///
+ ///
+ /// This is what makes an OIDC token non-transferable between services. id-token: write is
+ /// granted per job, so any action in a publishing workflow can mint a token; without this check,
+ /// a token obtained for some unrelated vendor would still be a valid firmware-publishing
+ /// credential, because it carries the same repository claim.
+ ///
+ /// It is an identifier, not a secret — anyone can request it by name — so it authenticates nothing
+ /// about who is calling. That is the allowlist's job, and scopes decide what they may do.
+ ///
+ [Required(AllowEmptyStrings = false)]
+ public required string Audience { get; init; }
+}
diff --git a/DesktopRepositoryServer/Config/DbConfig.cs b/RepositoryServer/Config/DbConfig.cs
similarity index 83%
rename from DesktopRepositoryServer/Config/DbConfig.cs
rename to RepositoryServer/Config/DbConfig.cs
index e416a5e..91f9b55 100644
--- a/DesktopRepositoryServer/Config/DbConfig.cs
+++ b/RepositoryServer/Config/DbConfig.cs
@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
-namespace OpenShock.Desktop.RepositoryServer.Config;
+namespace OpenShock.RepositoryServer.Config;
public class DbConfig
{
diff --git a/RepositoryServer/Config/FirmwareConfig.cs b/RepositoryServer/Config/FirmwareConfig.cs
new file mode 100644
index 0000000..afb4542
--- /dev/null
+++ b/RepositoryServer/Config/FirmwareConfig.cs
@@ -0,0 +1,19 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace OpenShock.RepositoryServer.Config;
+
+public sealed class FirmwareConfig
+{
+ [Required(AllowEmptyStrings = false)] public required string CdnBaseUrl { get; init; }
+ [Required] public required StorageConfig Storage { get; init; }
+
+ ///
+ /// How long a release may remain in staging status before the cleanup job aborts it.
+ ///
+ public TimeSpan StagedReleaseTtl { get; init; } = TimeSpan.FromHours(1);
+
+ ///
+ /// How long a release may remain in editing status before the cleanup job aborts it.
+ ///
+ public TimeSpan EditingReleaseTtl { get; init; } = TimeSpan.FromDays(7);
+}
diff --git a/DesktopRepositoryServer/Config/MetricsConfig.cs b/RepositoryServer/Config/MetricsConfig.cs
similarity index 59%
rename from DesktopRepositoryServer/Config/MetricsConfig.cs
rename to RepositoryServer/Config/MetricsConfig.cs
index 600d72a..e596376 100644
--- a/DesktopRepositoryServer/Config/MetricsConfig.cs
+++ b/RepositoryServer/Config/MetricsConfig.cs
@@ -1,6 +1,6 @@
-using OpenShock.Desktop.RepositoryServer.Utils;
+using OpenShock.RepositoryServer.Utils;
-namespace OpenShock.Desktop.RepositoryServer.Config;
+namespace OpenShock.RepositoryServer.Config;
public sealed class MetricsConfig
{
diff --git a/DesktopRepositoryServer/Config/RepoConfig.cs b/RepositoryServer/Config/RepoConfig.cs
similarity index 68%
rename from DesktopRepositoryServer/Config/RepoConfig.cs
rename to RepositoryServer/Config/RepoConfig.cs
index a1f138e..e27ed25 100644
--- a/DesktopRepositoryServer/Config/RepoConfig.cs
+++ b/RepositoryServer/Config/RepoConfig.cs
@@ -1,4 +1,4 @@
-namespace OpenShock.Desktop.RepositoryServer.Config;
+namespace OpenShock.RepositoryServer.Config;
public sealed class RepoConfig
{
@@ -6,4 +6,5 @@ public sealed class RepoConfig
public required string Name { get; init; }
public required string Author { get; init; }
public Uri? Homepage { get; init; } = null;
+ public required string CdnBaseUrl { get; init; }
}
\ No newline at end of file
diff --git a/RepositoryServer/Config/StorageConfig.cs b/RepositoryServer/Config/StorageConfig.cs
new file mode 100644
index 0000000..b74ac33
--- /dev/null
+++ b/RepositoryServer/Config/StorageConfig.cs
@@ -0,0 +1,53 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace OpenShock.RepositoryServer.Config;
+
+public sealed class StorageConfig
+{
+ [Required] public required StorageType Type { get; init; }
+
+ public BunnyCdnStorageConfig? BunnyCdn { get; init; }
+ public LocalStorageConfig? Local { get; init; }
+ public S3StorageConfig? S3 { get; init; }
+}
+
+public enum StorageType
+{
+ BunnyCdn,
+ Local,
+ S3,
+}
+
+public sealed class BunnyCdnStorageConfig
+{
+ [Required(AllowEmptyStrings = false)] public required string StorageUrl { get; init; }
+ [Required(AllowEmptyStrings = false)] public required string ApiKey { get; init; }
+}
+
+public sealed class LocalStorageConfig
+{
+ [Required(AllowEmptyStrings = false)] public required string BasePath { get; init; }
+}
+
+public sealed class S3StorageConfig
+{
+ [Required(AllowEmptyStrings = false)] public required string BucketName { get; init; }
+ [Required(AllowEmptyStrings = false)] public required string AccessKey { get; init; }
+ [Required(AllowEmptyStrings = false)] public required string SecretKey { get; init; }
+
+ ///
+ /// Custom endpoint URL for S3-compatible services (Cloudflare R2, MinIO, etc.).
+ /// Leave null for AWS S3.
+ ///
+ public string? ServiceUrl { get; init; }
+
+ ///
+ /// Optional key prefix prepended to all paths, e.g. "firmware/".
+ ///
+ public string? KeyPrefix { get; init; }
+
+ ///
+ /// AWS region. Required for AWS S3, optional for most S3-compatible services.
+ ///
+ public string? Region { get; init; }
+}
diff --git a/DesktopRepositoryServer/ConfigurationExtensions.cs b/RepositoryServer/ConfigurationExtensions.cs
similarity index 50%
rename from DesktopRepositoryServer/ConfigurationExtensions.cs
rename to RepositoryServer/ConfigurationExtensions.cs
index 4f00d84..6ffdc89 100644
--- a/DesktopRepositoryServer/ConfigurationExtensions.cs
+++ b/RepositoryServer/ConfigurationExtensions.cs
@@ -1,13 +1,21 @@
using System.Text;
using System.Text.Json;
-namespace OpenShock.Desktop.RepositoryServer;
+namespace OpenShock.RepositoryServer;
public static class ConfigurationExtensions
{
public static T GetAndRegisterOpenShockConfig(this WebApplicationBuilder builder) where T : class
{
#if DEBUG
- Console.WriteLine(builder.Configuration.GetDebugView());
+ // GetDebugView() prints every configuration value AND the process environment, which includes
+ // the admin token, the database password, storage credentials and Discord webhook URLs. Only
+ // the resolved keys are printed, which is what is actually useful for diagnosing binding.
+ Console.WriteLine("Configuration keys:");
+ foreach (var (key, value) in builder.Configuration.AsEnumerable().OrderBy(kv => kv.Key))
+ {
+ if (value is null) continue;
+ Console.WriteLine($" {key} = {RedactIfSensitive(key, value)}");
+ }
#endif
var config = builder.Configuration
@@ -37,12 +45,30 @@ public static T GetAndRegisterOpenShockConfig(this WebApplicationBuilder buil
Environment.Exit(-10);
}
-#if DEBUG
- Console.WriteLine(JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true }));
-#endif
builder.Services.AddSingleton(config);
return config;
}
+
+#if DEBUG
+ private static readonly string[] SensitiveKeyFragments =
+ ["token", "password", "secret", "key", "conn", "webhook"];
+
+ ///
+ /// Masks values whose key looks credential-bearing. Name-based rather than attribute-based so a
+ /// newly added secret is redacted by default instead of needing to be remembered.
+ ///
+ private static string RedactIfSensitive(string key, string value)
+ {
+ foreach (var fragment in SensitiveKeyFragments)
+ {
+ if (key.Contains(fragment, StringComparison.OrdinalIgnoreCase))
+ {
+ return $"*** ({value.Length} chars)";
+ }
+ }
+ return value;
+ }
+#endif
}
diff --git a/DesktopRepositoryServer/Controllers/OpenShockControllerBase.cs b/RepositoryServer/Controllers/OpenShockControllerBase.cs
similarity index 71%
rename from DesktopRepositoryServer/Controllers/OpenShockControllerBase.cs
rename to RepositoryServer/Controllers/OpenShockControllerBase.cs
index 6eb3d4a..660b768 100644
--- a/DesktopRepositoryServer/Controllers/OpenShockControllerBase.cs
+++ b/RepositoryServer/Controllers/OpenShockControllerBase.cs
@@ -1,8 +1,8 @@
using System.Net.Mime;
using Microsoft.AspNetCore.Mvc;
-using OpenShock.Desktop.RepositoryServer.Problems;
+using OpenShock.RepositoryServer.Problems;
-namespace OpenShock.Desktop.RepositoryServer.Controllers;
+namespace OpenShock.RepositoryServer.Controllers;
[Consumes(MediaTypeNames.Application.Json)]
public class OpenShockControllerBase : ControllerBase
diff --git a/DesktopRepositoryServer/Controllers/AdminController.cs b/RepositoryServer/Controllers/V1/AdminController.cs
similarity index 77%
rename from DesktopRepositoryServer/Controllers/AdminController.cs
rename to RepositoryServer/Controllers/V1/AdminController.cs
index b504330..e2957a9 100644
--- a/DesktopRepositoryServer/Controllers/AdminController.cs
+++ b/RepositoryServer/Controllers/V1/AdminController.cs
@@ -1,15 +1,17 @@
-using Microsoft.AspNetCore.Authorization;
+using Asp.Versioning;
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
-using OpenShock.Desktop.RepositoryServer.Models;
-using OpenShock.Desktop.RepositoryServer.Problems;
-using OpenShock.Desktop.RepositoryServer.RepoServerDb;
+using OpenShock.RepositoryServer.Models;
+using OpenShock.RepositoryServer.Problems;
+using OpenShock.RepositoryServer.RepoServerDb;
using Semver;
-using Module = OpenShock.Desktop.RepositoryServer.RepoServerDb.Module;
-using Version = OpenShock.Desktop.RepositoryServer.RepoServerDb.Version;
+using Module = OpenShock.RepositoryServer.RepoServerDb.Module;
+using Version = OpenShock.RepositoryServer.RepoServerDb.Version;
-namespace OpenShock.Desktop.RepositoryServer.Controllers;
+namespace OpenShock.RepositoryServer.Controllers.V1;
+[ApiVersion("1.0")]
[ApiController]
[Route("/{version:apiVersion}/admin")]
[Authorize(AuthenticationSchemes = AuthSchemas.AdminToken)]
@@ -25,18 +27,24 @@ public AdminController(RepoServerContext db)
[HttpPut("modules/{moduleId}")]
public async Task CreateModule([FromBody] CreateModuleRequest createModuleRequest, [FromRoute] string moduleId)
{
+ if (createModuleRequest.RepositoryId is { } repositoryId &&
+ !await _db.Repositories.AnyAsync(r => r.Id == repositoryId))
+ {
+ return Problem(FirmwareError.FirmwareRepositoryNotFound);
+ }
+
var module = new Module
{
Id = moduleId.ToLowerInvariant(),
Name = createModuleRequest.Name,
Description = createModuleRequest.Description,
SourceUrl = createModuleRequest.SourceUrl,
- IconUrl = createModuleRequest.IconUrl
+ IconUrl = createModuleRequest.IconUrl,
+ RepositoryId = createModuleRequest.RepositoryId
};
var executed = await _db.Modules.Upsert(module).On(x => x.Id).RunAsync();
-
+
if (executed <= 0) throw new Exception("Failed to upsert module");
-
return Created();
}
@@ -45,29 +53,29 @@ public async Task CreateModule([FromBody] CreateModuleRequest cre
public async Task DeleteModule([FromRoute] string moduleId)
{
var executed = await _db.Modules.Where(x => x.Id == moduleId.ToLowerInvariant()).ExecuteDeleteAsync();
-
+
if(executed <= 0) return Problem(ModuleError.ModuleNotFound);
return Ok();
}
-
+ [Obsolete("Use the CI/CD endpoint at /v1/cicd/modules/{moduleId}/versions/{moduleVersion} instead.")]
[HttpPut("modules/{moduleId}/versions/{moduleVersion}")]
public async Task CreateVersion([FromBody] CreateModuleVersionRequest createModuleVersionRequest, [FromRoute] string moduleId, [FromRoute] string moduleVersion)
{
moduleId = moduleId.ToLowerInvariant();
moduleVersion = moduleVersion.ToLowerInvariant();
-
+
if (!SemVersion.TryParse(moduleVersion, SemVersionStyles.Strict, out _))
{
return Problem(VersionError.VersionInvalidSemver);
}
-
+
if(!await _db.Modules.AnyAsync(x => x.Id == moduleId))
{
return Problem(ModuleError.ModuleNotFound);
}
-
+
var version = new Version
{
Module = moduleId,
@@ -81,10 +89,10 @@ public async Task CreateVersion([FromBody] CreateModuleVersionReq
var executed = await _db.Versions.Upsert(version).On(x => new { x.Module, x.VersionName }).RunAsync();
if (executed <= 0) throw new Exception("Failed to upsert version");
-
+
return Created();
}
-
+
[HttpDelete("modules/{moduleId}/versions/{moduleVersion}")]
public async Task DeleteVersion([FromRoute] string moduleId, [FromRoute] string moduleVersion)
{
diff --git a/RepositoryServer/Controllers/V1/CiCdController.cs b/RepositoryServer/Controllers/V1/CiCdController.cs
new file mode 100644
index 0000000..8b10f99
--- /dev/null
+++ b/RepositoryServer/Controllers/V1/CiCdController.cs
@@ -0,0 +1,158 @@
+using System.IO.Compression;
+using System.Security.Claims;
+using System.Security.Cryptography;
+using Asp.Versioning;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using OpenShock.RepositoryServer.Config;
+using OpenShock.RepositoryServer.Problems;
+using OpenShock.RepositoryServer.RepoServerDb;
+using OpenShock.RepositoryServer.Services;
+using Semver;
+using Version = OpenShock.RepositoryServer.RepoServerDb.Version;
+
+namespace OpenShock.RepositoryServer.Controllers.V1;
+
+[ApiVersion("1.0")]
+[ApiController]
+[Route("/{version:apiVersion}/cicd")]
+[Authorize(AuthenticationSchemes = AuthSchemas.CiCdToken, Policy = AuthSchemas.Policies.PublishModules)]
+public class CiCdController : OpenShockControllerBase
+{
+ private readonly RepoServerContext _db;
+ private readonly IStorageService _storage;
+ private readonly ApiConfig _apiConfig;
+
+ public CiCdController(RepoServerContext db, IStorageService storage, ApiConfig apiConfig)
+ {
+ _db = db;
+ _storage = storage;
+ _apiConfig = apiConfig;
+ }
+
+ ///
+ /// Publishes a desktop module version by uploading a zip file.
+ /// The file is hashed server-side, uploaded to storage, and recorded in the database.
+ ///
+ [HttpPut("modules/{moduleId}/versions/{moduleVersion}")]
+ [Consumes("multipart/form-data")]
+ [RequestSizeLimit(256 * 1024 * 1024)] // 256 MB
+ public async Task PublishVersion(
+ [FromRoute] string moduleId,
+ [FromRoute] string moduleVersion,
+ [FromQuery] string? changelogUrl,
+ [FromQuery] string? releaseUrl)
+ {
+ moduleId = moduleId.ToLowerInvariant();
+ moduleVersion = moduleVersion.ToLowerInvariant();
+
+ if (!SemVersion.TryParse(moduleVersion, SemVersionStyles.Strict, out _))
+ {
+ return Problem(VersionError.VersionInvalidSemver);
+ }
+
+ var module = await _db.Modules.FirstOrDefaultAsync(x => x.Id == moduleId);
+ if (module is null)
+ {
+ return Problem(ModuleError.ModuleNotFound);
+ }
+
+ // Every registered repository presents an equally valid CI/CD principal, so authentication
+ // alone does not say which module the caller may publish to. Unassigned modules are closed to
+ // all publishers rather than open to any.
+ var rawRepoId = User.FindFirstValue(AuthSchemas.CiCdClaims.RepositoryId);
+ if (module.RepositoryId is not { } owningRepositoryId
+ || !Guid.TryParse(rawRepoId, out var callerRepositoryId)
+ || owningRepositoryId != callerRepositoryId)
+ {
+ return Problem(ModuleError.ModuleNotOwned);
+ }
+
+ // Published versions are immutable. The upsert below is keyed on (module, version), so without
+ // this an existing version's zip URL and hash would be silently replaced in place — and
+ // integrity checks would then pass against the replacement.
+ if (await _db.Versions.AnyAsync(v => v.Module == moduleId && v.VersionName == moduleVersion))
+ {
+ return Problem(VersionError.VersionAlreadyExists);
+ }
+
+ var file = Request.Form.Files.GetFile("zip");
+ if (file == null)
+ {
+ return BadRequest(new { error = "No file uploaded. Expected a file field named 'zip'." });
+ }
+
+ // Read file into memory for validation + hashing + storage upload
+ using var memoryStream = new MemoryStream();
+ await file.CopyToAsync(memoryStream);
+ var fileBytes = memoryStream.ToArray();
+
+ // Validate zip contents
+ memoryStream.Position = 0;
+ try
+ {
+ using var zip = new ZipArchive(memoryStream, ZipArchiveMode.Read, leaveOpen: true);
+
+ if (zip.Entries.Count == 0)
+ return Problem(ModuleError.ZipEmpty);
+
+ foreach (var entry in zip.Entries)
+ {
+ if (entry.FullName.Contains("..") ||
+ Path.IsPathRooted(entry.FullName))
+ return Problem(ModuleError.ZipPathTraversal);
+
+ // Determine depth and root segment
+ var normalized = entry.FullName.Replace('\\', '/');
+ var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries);
+
+ if (segments.Length == 0)
+ continue;
+
+ // Directory entries at root: only "wwwroot/" is allowed
+ if (segments.Length >= 2 && !segments[0].Equals("wwwroot", StringComparison.OrdinalIgnoreCase))
+ return Problem(ModuleError.ZipDisallowedDirectory(segments[0]));
+
+ // Files at root: only .dll, .pdb, .json
+ if (segments.Length == 1 && !entry.FullName.EndsWith('/'))
+ {
+ var ext = Path.GetExtension(entry.Name).ToLowerInvariant();
+ if (ext is not (".dll" or ".pdb" or ".json"))
+ return Problem(ModuleError.ZipDisallowedRootFile(entry.Name));
+ }
+ }
+ }
+ catch (InvalidDataException)
+ {
+ return Problem(ModuleError.ZipInvalid);
+ }
+
+ // Compute SHA256 hash server-side
+ var hashBytes = SHA256.HashData(fileBytes);
+
+ // Upload to storage
+ var storagePath = $"modules/{moduleId}/{moduleVersion}/module.zip";
+ using var uploadStream = new MemoryStream(fileBytes);
+ await _storage.UploadFileAsync(storagePath, uploadStream);
+
+ // Construct public download URL
+ var cdnBase = _apiConfig.Repo.CdnBaseUrl.TrimEnd('/');
+ var zipUrl = new Uri($"{cdnBase}/{storagePath}");
+
+ var version = new Version
+ {
+ Module = moduleId,
+ VersionName = moduleVersion,
+ ZipUrl = zipUrl,
+ HashSha256 = hashBytes,
+ ChangelogUrl = changelogUrl != null ? new Uri(changelogUrl) : null,
+ ReleaseUrl = releaseUrl != null ? new Uri(releaseUrl) : null
+ };
+
+ var executed = await _db.Versions.Upsert(version).On(x => new { x.Module, x.VersionName }).RunAsync();
+ if (executed <= 0) throw new Exception("Failed to upsert version");
+
+ return Created();
+ }
+}
diff --git a/DesktopRepositoryServer/Controllers/RepoController.cs b/RepositoryServer/Controllers/V1/RepoController.cs
similarity index 78%
rename from DesktopRepositoryServer/Controllers/RepoController.cs
rename to RepositoryServer/Controllers/V1/RepoController.cs
index 8b70475..0a48de7 100644
--- a/DesktopRepositoryServer/Controllers/RepoController.cs
+++ b/RepositoryServer/Controllers/V1/RepoController.cs
@@ -1,16 +1,18 @@
using System.Collections.Immutable;
+using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
-using OpenShock.Desktop.RepositoryServer.Config;
-using OpenShock.Desktop.RepositoryServer.Models;
-using OpenShock.Desktop.RepositoryServer.RepoServerDb;
-using OpenShock.Desktop.RepositoryServer.Utils;
+using OpenShock.RepositoryServer.Config;
+using OpenShock.RepositoryServer.Models;
+using OpenShock.RepositoryServer.RepoServerDb;
+using OpenShock.RepositoryServer.Utils;
using Semver;
-using Module = OpenShock.Desktop.RepositoryServer.Models.Module;
-using Version = OpenShock.Desktop.RepositoryServer.Models.Version;
+using Module = OpenShock.RepositoryServer.Models.Module;
+using Version = OpenShock.RepositoryServer.Models.Version;
-namespace OpenShock.Desktop.RepositoryServer.Controllers;
+namespace OpenShock.RepositoryServer.Controllers.V1;
+[ApiVersion("1.0")]
[ApiController]
[Route("/{version:apiVersion}/")]
public sealed class RepoController : OpenShockControllerBase
@@ -23,12 +25,12 @@ public RepoController(RepoServerContext db, ApiConfig apiConfig)
_db = db;
_apiConfig = apiConfig;
}
-
+
[HttpGet]
public async Task GetRepo()
{
var moduleRaw = await _db.Modules.Include(x => x.Versions).ToArrayAsync();
-
+
var modules = moduleRaw.ToImmutableDictionary(x => x.Id, x => new Module()
{
Name = x.Name,
@@ -53,7 +55,7 @@ public async Task GetRepo()
Homepage = _apiConfig.Repo.Homepage,
Modules = modules
};
-
+
return Ok(repository);
}
}
\ No newline at end of file
diff --git a/RepositoryServer/Controllers/V2/Admin/DiscordWebhooksController.cs b/RepositoryServer/Controllers/V2/Admin/DiscordWebhooksController.cs
new file mode 100644
index 0000000..077ebbf
--- /dev/null
+++ b/RepositoryServer/Controllers/V2/Admin/DiscordWebhooksController.cs
@@ -0,0 +1,126 @@
+using Asp.Versioning;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using OpenShock.RepositoryServer.Enums;
+using OpenShock.RepositoryServer.Models.Discord;
+using OpenShock.RepositoryServer.Problems;
+using OpenShock.RepositoryServer.RepoServerDb;
+using OpenShock.RepositoryServer.Utils;
+
+namespace OpenShock.RepositoryServer.Controllers.V2.Admin;
+
+///
+/// Manages Discord notification targets.
+///
+///
+/// Not under /firmware/admin because these are not firmware-specific — desktop module
+/// publishing notifies through the same webhooks.
+///
+/// A webhook URL is a credential: anyone holding it can post to that channel. It is therefore
+/// write-only. Responses carry a masked form that keeps the Discord webhook id (useful for matching a
+/// row to a channel) and drops the token.
+///
+[ApiVersion("2.0")]
+[ApiController]
+[Route("/{version:apiVersion}/admin/discord-webhooks")]
+[Authorize(AuthenticationSchemes = AuthSchemas.AdminToken)]
+public class DiscordWebhooksController : OpenShockControllerBase
+{
+ private readonly RepoServerContext _db;
+
+ public DiscordWebhooksController(RepoServerContext db)
+ {
+ _db = db;
+ }
+
+ [HttpGet]
+ public async Task ListWebhooks(CancellationToken ct)
+ {
+ var rows = await _db.DiscordWebhooks.OrderBy(w => w.Name).ToListAsync(ct);
+ return Ok(rows.Select(DiscordWebhookDto.From));
+ }
+
+ [HttpPost]
+ public async Task CreateWebhook(
+ [FromBody] UpsertDiscordWebhookRequest request,
+ CancellationToken ct)
+ {
+ if (!TryParseEvents(request.Events, out var events, out var invalid))
+ {
+ return Problem(DiscordError.DiscordInvalidNotificationEvent(invalid));
+ }
+
+ var webhook = new DiscordWebhook
+ {
+ Id = Guid.NewGuid(),
+ Name = request.Name,
+ Url = request.Url,
+ Events = events,
+ Enabled = request.Enabled
+ };
+
+ _db.DiscordWebhooks.Add(webhook);
+ await _db.SaveChangesAsync(ct);
+
+ return Created((string?)null, DiscordWebhookDto.From(webhook));
+ }
+
+ [HttpPut("{webhookId:guid}")]
+ public async Task UpdateWebhook(
+ [FromRoute] Guid webhookId,
+ [FromBody] UpsertDiscordWebhookRequest request,
+ CancellationToken ct)
+ {
+ if (!TryParseEvents(request.Events, out var events, out var invalid))
+ {
+ return Problem(DiscordError.DiscordInvalidNotificationEvent(invalid));
+ }
+
+ var webhook = await _db.DiscordWebhooks.FirstOrDefaultAsync(w => w.Id == webhookId, ct);
+ if (webhook is null)
+ {
+ return Problem(DiscordError.DiscordWebhookNotFound);
+ }
+
+ webhook.Name = request.Name;
+ webhook.Url = request.Url;
+ webhook.Events = events;
+ webhook.Enabled = request.Enabled;
+
+ await _db.SaveChangesAsync(ct);
+ return Ok(DiscordWebhookDto.From(webhook));
+ }
+
+ [HttpDelete("{webhookId:guid}")]
+ public async Task DeleteWebhook([FromRoute] Guid webhookId, CancellationToken ct)
+ {
+ var deleted = await _db.DiscordWebhooks.Where(w => w.Id == webhookId).ExecuteDeleteAsync(ct);
+ if (deleted <= 0)
+ {
+ return Problem(DiscordError.DiscordWebhookNotFound);
+ }
+
+ return NoContent();
+ }
+
+ private static bool TryParseEvents(
+ List? raw, out DiscordNotificationEvent[] events, out string invalid)
+ {
+ var parsed = new List();
+ foreach (var value in raw ?? [])
+ {
+ if (!DiscordNotificationEventExtensions.TryParseEvent(value, out var parsedEvent))
+ {
+ events = [];
+ invalid = value;
+ return false;
+ }
+ if (!parsed.Contains(parsedEvent)) parsed.Add(parsedEvent);
+ }
+
+ events = parsed.ToArray();
+ invalid = string.Empty;
+ return true;
+ }
+}
diff --git a/RepositoryServer/Controllers/V2/Firmware/Admin/AdvisoriesController.cs b/RepositoryServer/Controllers/V2/Firmware/Admin/AdvisoriesController.cs
new file mode 100644
index 0000000..436a7a6
--- /dev/null
+++ b/RepositoryServer/Controllers/V2/Firmware/Admin/AdvisoriesController.cs
@@ -0,0 +1,112 @@
+using Asp.Versioning;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using OpenShock.RepositoryServer.Enums;
+using OpenShock.RepositoryServer.Models.Firmware;
+using OpenShock.RepositoryServer.Problems;
+using OpenShock.RepositoryServer.RepoServerDb;
+
+namespace OpenShock.RepositoryServer.Controllers.V2.Firmware.Admin;
+
+[ApiVersion("2.0")]
+[ApiController]
+[Route("/{version:apiVersion}/firmware/admin/advisories")]
+[Authorize(AuthenticationSchemes = AuthSchemas.AdminToken)]
+public class AdvisoriesController : OpenShockControllerBase
+{
+ private readonly RepoServerContext _db;
+
+ public AdvisoriesController(RepoServerContext db)
+ {
+ _db = db;
+ }
+
+ [HttpPost]
+ public async Task CreateAdvisory([FromBody] UpsertFirmwareAdvisoryRequest request, CancellationToken ct)
+ {
+ if (!TryParseSeverity(request.Severity, out var severity))
+ {
+ return Problem(FirmwareError.FirmwareInvalidAdvisorySeverity);
+ }
+
+ var advisory = new FirmwareAdvisory
+ {
+ Id = Guid.NewGuid(),
+ Severity = severity,
+ Title = request.Title,
+ Content = request.Content,
+ AffectedVersions = request.AffectedVersions,
+ Url = request.Url
+ };
+ _db.FirmwareAdvisories.Add(advisory);
+ await _db.SaveChangesAsync(ct);
+
+ return Created((string?)null, ToDto(advisory));
+ }
+
+ [HttpGet]
+ public async Task ListAdvisories(CancellationToken ct)
+ {
+ // Postgres sorts enum columns by label creation order (alphabetical with MapEnum),
+ // not severity rank — order on the CLR enum value in memory instead.
+ var rows = await _db.FirmwareAdvisories.ToListAsync(ct);
+
+ return Ok(rows
+ .OrderBy(a => a.Severity)
+ .ThenBy(a => a.Title)
+ .Select(ToDto));
+ }
+
+ [HttpPut("{id:guid}")]
+ public async Task UpdateAdvisory(
+ [FromRoute] Guid id,
+ [FromBody] UpsertFirmwareAdvisoryRequest request,
+ CancellationToken ct)
+ {
+ if (!TryParseSeverity(request.Severity, out var severity))
+ {
+ return Problem(FirmwareError.FirmwareInvalidAdvisorySeverity);
+ }
+
+ var advisory = await _db.FirmwareAdvisories.FirstOrDefaultAsync(a => a.Id == id, ct);
+ if (advisory is null)
+ {
+ return Problem(FirmwareError.FirmwareAdvisoryNotFound);
+ }
+
+ advisory.Severity = severity;
+ advisory.Title = request.Title;
+ advisory.Content = request.Content;
+ advisory.AffectedVersions = request.AffectedVersions;
+ advisory.Url = request.Url;
+ await _db.SaveChangesAsync(ct);
+
+ return Ok(ToDto(advisory));
+ }
+
+ [HttpDelete("{id:guid}")]
+ public async Task DeleteAdvisory([FromRoute] Guid id, CancellationToken ct)
+ {
+ var deleted = await _db.FirmwareAdvisories.Where(a => a.Id == id).ExecuteDeleteAsync(ct);
+ if (deleted <= 0)
+ {
+ return Problem(FirmwareError.FirmwareAdvisoryNotFound);
+ }
+
+ return NoContent();
+ }
+
+ private static bool TryParseSeverity(string value, out AdvisorySeverity severity) =>
+ Enum.TryParse(value, true, out severity);
+
+ private static FirmwareAdvisoryAdminDto ToDto(FirmwareAdvisory a) => new()
+ {
+ Id = a.Id,
+ Severity = a.Severity.ToString().ToLowerInvariant(),
+ Title = a.Title,
+ Content = a.Content,
+ AffectedVersions = a.AffectedVersions,
+ Url = a.Url
+ };
+}
diff --git a/RepositoryServer/Controllers/V2/Firmware/Admin/BoardsAdminController.cs b/RepositoryServer/Controllers/V2/Firmware/Admin/BoardsAdminController.cs
new file mode 100644
index 0000000..aa78c6c
--- /dev/null
+++ b/RepositoryServer/Controllers/V2/Firmware/Admin/BoardsAdminController.cs
@@ -0,0 +1,202 @@
+using Asp.Versioning;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using OpenShock.RepositoryServer.Enums;
+using OpenShock.RepositoryServer.Models.Firmware;
+using OpenShock.RepositoryServer.Problems;
+using OpenShock.RepositoryServer.RepoServerDb;
+using OpenShock.RepositoryServer.Utils;
+
+namespace OpenShock.RepositoryServer.Controllers.V2.Firmware.Admin;
+
+[ApiVersion("2.0")]
+[ApiController]
+[Route("/{version:apiVersion}/firmware/admin/boards")]
+[Authorize(AuthenticationSchemes = AuthSchemas.AdminToken)]
+public class BoardsAdminController : OpenShockControllerBase
+{
+ private readonly RepoServerContext _db;
+
+ public BoardsAdminController(RepoServerContext db)
+ {
+ _db = db;
+ }
+
+ ///
+ /// Create a new board. Name is the unique identifier visible to clients.
+ ///
+ [HttpPost]
+ public async Task CreateBoard(
+ [FromBody] CreateFirmwareBoardRequest request,
+ CancellationToken ct)
+ {
+ if (!await _db.FirmwareChips.AnyAsync(c => c.Id == request.ChipId, ct))
+ {
+ return Problem(FirmwareError.FirmwareChipNotFound);
+ }
+
+ if (!TryParseRequiredArtifactTypes(request.RequiredArtifactTypes, out var requiredArtifactTypes))
+ {
+ return Problem(FirmwareError.FirmwareInvalidArtifactType);
+ }
+
+ var board = new FirmwareBoard
+ {
+ Id = Guid.NewGuid(),
+ Name = request.Name,
+ ChipId = request.ChipId,
+ Discontinued = false,
+ RequiredArtifactTypes = requiredArtifactTypes
+ };
+
+ _db.FirmwareBoards.Add(board);
+
+ try
+ {
+ await _db.SaveChangesAsync(ct);
+ }
+ catch (DbUpdateException ex) when (UniqueViolation.IsOn(ex, UniqueViolation.BoardNameLower))
+ {
+ return Problem(FirmwareError.FirmwareBoardNameConflict);
+ }
+
+ return Created((string?)null, new { id = board.Id });
+ }
+
+ [HttpPut("{boardId:guid}")]
+ public async Task UpdateBoard(
+ [FromRoute] Guid boardId,
+ [FromBody] CreateFirmwareBoardRequest request,
+ CancellationToken ct)
+ {
+ if (!await _db.FirmwareChips.AnyAsync(c => c.Id == request.ChipId, ct))
+ {
+ return Problem(FirmwareError.FirmwareChipNotFound);
+ }
+
+ if (!TryParseRequiredArtifactTypes(request.RequiredArtifactTypes, out var requiredArtifactTypes))
+ {
+ return Problem(FirmwareError.FirmwareInvalidArtifactType);
+ }
+
+ var board = await _db.FirmwareBoards.FirstOrDefaultAsync(b => b.Id == boardId, ct);
+ if (board is null)
+ {
+ return Problem(FirmwareError.FirmwareBoardNotFound);
+ }
+
+ board.Name = request.Name;
+ board.ChipId = request.ChipId;
+ board.RequiredArtifactTypes = requiredArtifactTypes;
+
+ try
+ {
+ await _db.SaveChangesAsync(ct);
+ }
+ catch (DbUpdateException ex) when (UniqueViolation.IsOn(ex, UniqueViolation.BoardNameLower))
+ {
+ return Problem(FirmwareError.FirmwareBoardNameConflict);
+ }
+
+ return Ok();
+ }
+
+ [HttpPatch("{boardId:guid}/discontinue")]
+ public async Task DiscontinueBoard([FromRoute] Guid boardId, CancellationToken ct)
+ {
+ var board = await _db.FirmwareBoards.FirstOrDefaultAsync(b => b.Id == boardId, ct);
+ if (board is null)
+ {
+ return Problem(FirmwareError.FirmwareBoardNotFound);
+ }
+
+ board.Discontinued = true;
+ await _db.SaveChangesAsync(ct);
+ return Ok();
+ }
+
+ [HttpDelete("{boardId:guid}")]
+ public async Task DeleteBoard([FromRoute] Guid boardId, CancellationToken ct)
+ {
+ if (await _db.FirmwareArtifacts.AnyAsync(a => a.BoardId == boardId, ct))
+ {
+ return Problem(FirmwareError.FirmwareBoardInUse);
+ }
+
+ var deleted = await _db.FirmwareBoards.Where(b => b.Id == boardId).ExecuteDeleteAsync(ct);
+ if (deleted <= 0)
+ {
+ return Problem(FirmwareError.FirmwareBoardNotFound);
+ }
+
+ return NoContent();
+ }
+
+ [HttpPut("{boardId:guid}/usb-devices/{usbDeviceId:guid}")]
+ public async Task AttachUsbDevice(
+ [FromRoute] Guid boardId,
+ [FromRoute] Guid usbDeviceId,
+ CancellationToken ct)
+ {
+ if (!await _db.FirmwareBoards.AnyAsync(b => b.Id == boardId, ct))
+ {
+ return Problem(FirmwareError.FirmwareBoardNotFound);
+ }
+
+ if (!await _db.UsbDevices.AnyAsync(d => d.Id == usbDeviceId, ct))
+ {
+ return Problem(FirmwareError.FirmwareUsbDeviceNotFound);
+ }
+
+ var exists = await _db.FirmwareBoardUsbDevices
+ .AnyAsync(j => j.BoardId == boardId && j.UsbDeviceId == usbDeviceId, ct);
+ if (!exists)
+ {
+ _db.FirmwareBoardUsbDevices.Add(new FirmwareBoardUsbDevice
+ {
+ BoardId = boardId,
+ UsbDeviceId = usbDeviceId
+ });
+ await _db.SaveChangesAsync(ct);
+ }
+
+ return NoContent();
+ }
+
+ [HttpDelete("{boardId:guid}/usb-devices/{usbDeviceId:guid}")]
+ public async Task DetachUsbDevice(
+ [FromRoute] Guid boardId,
+ [FromRoute] Guid usbDeviceId,
+ CancellationToken ct)
+ {
+ await _db.FirmwareBoardUsbDevices
+ .Where(j => j.BoardId == boardId && j.UsbDeviceId == usbDeviceId)
+ .ExecuteDeleteAsync(ct);
+
+ return NoContent();
+ }
+
+ private static bool TryParseRequiredArtifactTypes(List? raw, out FirmwareArtifactType[] parsed)
+ {
+ if (raw is null || raw.Count == 0)
+ {
+ parsed = Array.Empty();
+ return true;
+ }
+
+ var list = new List(raw.Count);
+ foreach (var typeStr in raw)
+ {
+ if (!Enum.TryParse(typeStr, true, out var t))
+ {
+ parsed = Array.Empty();
+ return false;
+ }
+ list.Add(t);
+ }
+
+ parsed = list.Distinct().ToArray();
+ return true;
+ }
+}
diff --git a/RepositoryServer/Controllers/V2/Firmware/Admin/ChipsAdminController.cs b/RepositoryServer/Controllers/V2/Firmware/Admin/ChipsAdminController.cs
new file mode 100644
index 0000000..4f799df
--- /dev/null
+++ b/RepositoryServer/Controllers/V2/Firmware/Admin/ChipsAdminController.cs
@@ -0,0 +1,151 @@
+using Asp.Versioning;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using OpenShock.RepositoryServer.Enums;
+using OpenShock.RepositoryServer.Models.Firmware;
+using OpenShock.RepositoryServer.Problems;
+using OpenShock.RepositoryServer.RepoServerDb;
+using OpenShock.RepositoryServer.Utils;
+
+namespace OpenShock.RepositoryServer.Controllers.V2.Firmware.Admin;
+
+[ApiVersion("2.0")]
+[ApiController]
+[Route("/{version:apiVersion}/firmware/admin/chips")]
+[Authorize(AuthenticationSchemes = AuthSchemas.AdminToken)]
+public class ChipsAdminController : OpenShockControllerBase
+{
+ private readonly RepoServerContext _db;
+
+ public ChipsAdminController(RepoServerContext db)
+ {
+ _db = db;
+ }
+
+ [HttpPost]
+ public async Task CreateChip(
+ [FromBody] CreateFirmwareChipRequest request,
+ CancellationToken ct)
+ {
+ FirmwareChipArchitecture? architecture = null;
+ if (request.Architecture is not null)
+ {
+ if (!Enum.TryParse(request.Architecture, true, out var parsed))
+ {
+ return Problem(FirmwareError.FirmwareInvalidArchitecture);
+ }
+ architecture = parsed;
+ }
+
+ var chip = new FirmwareChip
+ {
+ Id = Guid.NewGuid(),
+ Name = request.Name,
+ Architecture = architecture
+ };
+
+ _db.FirmwareChips.Add(chip);
+
+ try
+ {
+ await _db.SaveChangesAsync(ct);
+ }
+ catch (DbUpdateException ex) when (UniqueViolation.IsOn(ex, UniqueViolation.ChipNameLower))
+ {
+ return Problem(FirmwareError.FirmwareChipNameConflict);
+ }
+
+ return Created((string?)null, new { id = chip.Id });
+ }
+
+ [HttpPut("{chipId:guid}")]
+ public async Task UpdateChip(
+ [FromRoute] Guid chipId,
+ [FromBody] CreateFirmwareChipRequest request,
+ CancellationToken ct)
+ {
+ FirmwareChipArchitecture? architecture = null;
+ if (request.Architecture is not null)
+ {
+ if (!Enum.TryParse(request.Architecture, true, out var parsed))
+ {
+ return Problem(FirmwareError.FirmwareInvalidArchitecture);
+ }
+ architecture = parsed;
+ }
+
+ var chip = await _db.FirmwareChips.FirstOrDefaultAsync(c => c.Id == chipId, ct);
+ if (chip is null)
+ {
+ return Problem(FirmwareError.FirmwareChipNotFound);
+ }
+
+ chip.Name = request.Name;
+ chip.Architecture = architecture;
+ await _db.SaveChangesAsync(ct);
+
+ return Ok();
+ }
+
+ [HttpDelete("{chipId:guid}")]
+ public async Task DeleteChip([FromRoute] Guid chipId, CancellationToken ct)
+ {
+ if (await _db.FirmwareBoards.AnyAsync(b => b.ChipId == chipId, ct))
+ {
+ return Problem(FirmwareError.FirmwareChipInUse);
+ }
+
+ var deleted = await _db.FirmwareChips.Where(c => c.Id == chipId).ExecuteDeleteAsync(ct);
+ if (deleted <= 0)
+ {
+ return Problem(FirmwareError.FirmwareChipNotFound);
+ }
+
+ return NoContent();
+ }
+
+ [HttpPut("{chipId:guid}/usb-devices/{usbDeviceId:guid}")]
+ public async Task AttachUsbDevice(
+ [FromRoute] Guid chipId,
+ [FromRoute] Guid usbDeviceId,
+ CancellationToken ct)
+ {
+ if (!await _db.FirmwareChips.AnyAsync(c => c.Id == chipId, ct))
+ {
+ return Problem(FirmwareError.FirmwareChipNotFound);
+ }
+
+ if (!await _db.UsbDevices.AnyAsync(d => d.Id == usbDeviceId, ct))
+ {
+ return Problem(FirmwareError.FirmwareUsbDeviceNotFound);
+ }
+
+ var exists = await _db.FirmwareChipUsbDevices
+ .AnyAsync(j => j.ChipId == chipId && j.UsbDeviceId == usbDeviceId, ct);
+ if (!exists)
+ {
+ _db.FirmwareChipUsbDevices.Add(new FirmwareChipUsbDevice
+ {
+ ChipId = chipId,
+ UsbDeviceId = usbDeviceId
+ });
+ await _db.SaveChangesAsync(ct);
+ }
+
+ return NoContent();
+ }
+
+ [HttpDelete("{chipId:guid}/usb-devices/{usbDeviceId:guid}")]
+ public async Task DetachUsbDevice(
+ [FromRoute] Guid chipId,
+ [FromRoute] Guid usbDeviceId,
+ CancellationToken ct)
+ {
+ await _db.FirmwareChipUsbDevices
+ .Where(j => j.ChipId == chipId && j.UsbDeviceId == usbDeviceId)
+ .ExecuteDeleteAsync(ct);
+
+ return NoContent();
+ }
+}
diff --git a/RepositoryServer/Controllers/V2/Firmware/Admin/ReleasesAdminController.cs b/RepositoryServer/Controllers/V2/Firmware/Admin/ReleasesAdminController.cs
new file mode 100644
index 0000000..e98bb19
--- /dev/null
+++ b/RepositoryServer/Controllers/V2/Firmware/Admin/ReleasesAdminController.cs
@@ -0,0 +1,77 @@
+using Asp.Versioning;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using OpenShock.RepositoryServer.Enums;
+using OpenShock.RepositoryServer.Models.Firmware;
+using OpenShock.RepositoryServer.Problems;
+using OpenShock.RepositoryServer.RepoServerDb;
+using OpenShock.RepositoryServer.Utils;
+
+namespace OpenShock.RepositoryServer.Controllers.V2.Firmware.Admin;
+
+[ApiVersion("2.0")]
+[ApiController]
+[Route("/{version:apiVersion}/firmware/admin/releases")]
+[Authorize(AuthenticationSchemes = AuthSchemas.AdminToken)]
+public class ReleasesAdminController : OpenShockControllerBase
+{
+ private readonly RepoServerContext _db;
+
+ public ReleasesAdminController(RepoServerContext db)
+ {
+ _db = db;
+ }
+
+ [HttpPut("{releaseId:guid}/changelog")]
+ public async Task FixChangelog(
+ [FromRoute] Guid releaseId,
+ [FromBody] FixChangelogRequest request,
+ CancellationToken ct)
+ {
+ var release = await _db.FirmwareReleases.FirstOrDefaultAsync(r => r.Id == releaseId, ct);
+ if (release is null)
+ {
+ return Problem(FirmwareError.FirmwareReleaseNotFound);
+ }
+
+ if (release.Status != ReleaseStatus.Staging && release.Status != ReleaseStatus.Editing)
+ {
+ return Problem(FirmwareError.FirmwareReleaseNotEditable);
+ }
+
+ var parseResult = ChangelogParser.Parse(request.Changelog);
+ if (!parseResult.TryPickT0(out var notes, out var error))
+ {
+ return Problem(FirmwareError.FirmwareInvalidChangelog(error switch
+ {
+ ChangelogParseError.Empty => "Changelog is empty or whitespace-only",
+ ChangelogParseError.NoHeadings => "Changelog contains no '### Heading' sections",
+ ChangelogParseError.AllSectionsEmpty => "All changelog sections are empty",
+ _ => error.ToString()
+ }));
+ }
+
+ await _db.FirmwareStagedReleaseNotes
+ .Where(n => n.ReleaseId == releaseId)
+ .ExecuteDeleteAsync(ct);
+
+ for (var i = 0; i < notes.Count; i++)
+ {
+ var n = notes[i];
+ _db.FirmwareStagedReleaseNotes.Add(new FirmwareStagedReleaseNote
+ {
+ ReleaseId = releaseId,
+ Index = i,
+ SectionType = Enum.Parse(n.Type, true),
+ Title = n.Title,
+ Content = n.Content,
+ });
+ }
+
+ release.Status = ReleaseStatus.Staging;
+ await _db.SaveChangesAsync(ct);
+
+ return Ok();
+ }
+}
diff --git a/RepositoryServer/Controllers/V2/Firmware/Admin/RepositoriesController.cs b/RepositoryServer/Controllers/V2/Firmware/Admin/RepositoriesController.cs
new file mode 100644
index 0000000..00a00e5
--- /dev/null
+++ b/RepositoryServer/Controllers/V2/Firmware/Admin/RepositoriesController.cs
@@ -0,0 +1,125 @@
+using Asp.Versioning;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using OpenShock.RepositoryServer.Enums;
+using OpenShock.RepositoryServer.Models.Firmware;
+using OpenShock.RepositoryServer.Problems;
+using OpenShock.RepositoryServer.RepoServerDb;
+using OpenShock.RepositoryServer.Utils;
+
+namespace OpenShock.RepositoryServer.Controllers.V2.Firmware.Admin;
+
+///
+/// Admin for the shared repositories table, which doubles as the publish allowlist.
+///
+///
+/// Registration here is what authorizes a repository to publish: OIDC token validation proves a
+/// workflow ran somewhere on GitHub, not that it ran in a repository we trust. Onboarding is therefore
+/// deliberately a manual admin action, and deletion is a real revocation.
+///
+[ApiVersion("2.0")]
+[ApiController]
+[Route("/{version:apiVersion}/firmware/admin/repositories")]
+[Authorize(AuthenticationSchemes = AuthSchemas.AdminToken)]
+public class RepositoriesController : OpenShockControllerBase
+{
+ private readonly RepoServerContext _db;
+
+ public RepositoriesController(RepoServerContext db)
+ {
+ _db = db;
+ }
+
+ [HttpGet]
+ public async Task ListRepositories(CancellationToken ct)
+ {
+ var rows = await _db.Repositories
+ .OrderBy(r => r.Provider)
+ .ThenBy(r => r.Owner)
+ .ThenBy(r => r.Repo)
+ .ToListAsync(ct);
+
+ return Ok(rows.Select(RepositoryDto.From));
+ }
+
+ ///
+ /// Registers a repository as authorized to publish, or returns the existing row if already
+ /// registered. Idempotent, so re-running onboarding is harmless.
+ ///
+ [HttpPut]
+ public async Task UpsertRepository(
+ [FromBody] UpsertRepositoryRequest request,
+ CancellationToken ct)
+ {
+ if (!Enum.TryParse(request.Provider, true, out var provider))
+ {
+ return Problem(FirmwareError.FirmwareInvalidRepositoryProvider);
+ }
+
+ var scopes = new List();
+ foreach (var raw in request.Scopes ?? [])
+ {
+ if (!RepositoryScopeExtensions.TryParseScope(raw, out var scope))
+ {
+ return Problem(FirmwareError.FirmwareInvalidRepositoryScope(raw));
+ }
+ if (!scopes.Contains(scope)) scopes.Add(scope);
+ }
+
+ // Matched case-insensitively, consistent with how GitHub treats owner and repo names and with
+ // how the OIDC handler resolves them — otherwise onboarding "OpenShock/Firmware" would create
+ // a second row that the handler's lookup for "openshock/firmware" would never reach.
+ var loweredOwner = request.Owner.ToLowerInvariant();
+ var loweredRepo = request.Repo.ToLowerInvariant();
+
+ var existing = await _db.Repositories.FirstOrDefaultAsync(
+ r => r.Provider == provider
+ && r.Owner.ToLower() == loweredOwner
+ && r.Repo.ToLower() == loweredRepo, ct);
+
+ if (existing is not null)
+ {
+ // Idempotent on identity, but scopes are authoritative: re-running onboarding with a
+ // different set is how a grant is widened or narrowed.
+ existing.Scopes = scopes.ToArray();
+ await _db.SaveChangesAsync(ct);
+ return Ok(RepositoryDto.From(existing));
+ }
+
+ var row = new SourceRepository
+ {
+ Id = Guid.NewGuid(),
+ Provider = provider,
+ Owner = request.Owner,
+ Repo = request.Repo,
+ Scopes = scopes.ToArray()
+ };
+
+ _db.Repositories.Add(row);
+ await _db.SaveChangesAsync(ct);
+
+ return Created((string?)null, RepositoryDto.From(row));
+ }
+
+ [HttpDelete("{repositoryId:guid}")]
+ public async Task DeleteRepository([FromRoute] Guid repositoryId, CancellationToken ct)
+ {
+ var inUse =
+ await _db.FirmwareVersions.AnyAsync(v => v.RepositoryId == repositoryId, ct) ||
+ await _db.FirmwareReleases.AnyAsync(r => r.RepositoryId == repositoryId, ct);
+
+ if (inUse)
+ {
+ return Problem(FirmwareError.FirmwareRepositoryInUse);
+ }
+
+ var deleted = await _db.Repositories.Where(r => r.Id == repositoryId).ExecuteDeleteAsync(ct);
+ if (deleted <= 0)
+ {
+ return Problem(FirmwareError.FirmwareRepositoryNotFound);
+ }
+
+ return NoContent();
+ }
+}
diff --git a/RepositoryServer/Controllers/V2/Firmware/Admin/UsbDevicesController.cs b/RepositoryServer/Controllers/V2/Firmware/Admin/UsbDevicesController.cs
new file mode 100644
index 0000000..d235296
--- /dev/null
+++ b/RepositoryServer/Controllers/V2/Firmware/Admin/UsbDevicesController.cs
@@ -0,0 +1,95 @@
+using Asp.Versioning;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using OpenShock.RepositoryServer.Models.Firmware;
+using OpenShock.RepositoryServer.Problems;
+using OpenShock.RepositoryServer.RepoServerDb;
+
+namespace OpenShock.RepositoryServer.Controllers.V2.Firmware.Admin;
+
+[ApiVersion("2.0")]
+[ApiController]
+[Route("/{version:apiVersion}/firmware/admin/usb-devices")]
+[Authorize(AuthenticationSchemes = AuthSchemas.AdminToken)]
+public class UsbDevicesController : OpenShockControllerBase
+{
+ private readonly RepoServerContext _db;
+
+ public UsbDevicesController(RepoServerContext db)
+ {
+ _db = db;
+ }
+
+ [HttpPut]
+ public async Task UpsertUsbDevice([FromBody] UpsertUsbDeviceRequest request, CancellationToken ct)
+ {
+ var existing = await _db.UsbDevices
+ .FirstOrDefaultAsync(d => d.Vid == request.Vid && d.Pid == request.Pid, ct);
+
+ if (existing is not null)
+ {
+ existing.Name = request.Name;
+ await _db.SaveChangesAsync(ct);
+ return Ok(ToDto(existing));
+ }
+
+ var device = new UsbDevice
+ {
+ Id = Guid.NewGuid(),
+ Vid = request.Vid,
+ Pid = request.Pid,
+ Name = request.Name
+ };
+ _db.UsbDevices.Add(device);
+ await _db.SaveChangesAsync(ct);
+
+ return Created((string?)null, ToDto(device));
+ }
+
+ [HttpGet]
+ public async Task ListUsbDevices(CancellationToken ct)
+ {
+ var rows = await _db.UsbDevices
+ .OrderBy(d => d.Vid).ThenBy(d => d.Pid)
+ .Select(d => new FirmwareUsbDeviceDto
+ {
+ Id = d.Id,
+ Vid = d.Vid,
+ Pid = d.Pid,
+ Name = d.Name
+ })
+ .ToListAsync(ct);
+
+ return Ok(rows);
+ }
+
+ [HttpDelete("{id:guid}")]
+ public async Task DeleteUsbDevice([FromRoute] Guid id, CancellationToken ct)
+ {
+ var inUse =
+ await _db.FirmwareChipUsbDevices.AnyAsync(j => j.UsbDeviceId == id, ct) ||
+ await _db.FirmwareBoardUsbDevices.AnyAsync(j => j.UsbDeviceId == id, ct);
+
+ if (inUse)
+ {
+ return Problem(FirmwareError.FirmwareUsbDeviceInUse);
+ }
+
+ var deleted = await _db.UsbDevices.Where(d => d.Id == id).ExecuteDeleteAsync(ct);
+ if (deleted <= 0)
+ {
+ return Problem(FirmwareError.FirmwareUsbDeviceNotFound);
+ }
+
+ return NoContent();
+ }
+
+ private static FirmwareUsbDeviceDto ToDto(UsbDevice d) => new()
+ {
+ Id = d.Id,
+ Vid = d.Vid,
+ Pid = d.Pid,
+ Name = d.Name
+ };
+}
diff --git a/RepositoryServer/Controllers/V2/Firmware/Admin/UsbSerialFiltersController.cs b/RepositoryServer/Controllers/V2/Firmware/Admin/UsbSerialFiltersController.cs
new file mode 100644
index 0000000..4eef254
--- /dev/null
+++ b/RepositoryServer/Controllers/V2/Firmware/Admin/UsbSerialFiltersController.cs
@@ -0,0 +1,87 @@
+using Asp.Versioning;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using OpenShock.RepositoryServer.Models.Firmware;
+using OpenShock.RepositoryServer.Problems;
+using OpenShock.RepositoryServer.RepoServerDb;
+
+namespace OpenShock.RepositoryServer.Controllers.V2.Firmware.Admin;
+
+[ApiVersion("2.0")]
+[ApiController]
+[Route("/{version:apiVersion}/firmware/admin/usb-serial-filters")]
+[Authorize(AuthenticationSchemes = AuthSchemas.AdminToken)]
+public class UsbSerialFiltersController : OpenShockControllerBase
+{
+ private readonly RepoServerContext _db;
+
+ public UsbSerialFiltersController(RepoServerContext db)
+ {
+ _db = db;
+ }
+
+ [HttpPut]
+ public async Task UpsertFilter([FromBody] UpsertUsbSerialFilterRequest request, CancellationToken ct)
+ {
+ // Unique (vid, pid) with NULLS NOT DISTINCT — at most one vendor-wide row per VID.
+ var existing = await _db.UsbSerialFilters
+ .FirstOrDefaultAsync(f => f.Vid == request.Vid && f.Pid == request.Pid, ct);
+
+ if (existing is not null)
+ {
+ existing.Description = request.Description;
+ await _db.SaveChangesAsync(ct);
+ return Ok(ToDto(existing));
+ }
+
+ var filter = new UsbSerialFilter
+ {
+ Id = Guid.NewGuid(),
+ Vid = request.Vid,
+ Pid = request.Pid,
+ Description = request.Description
+ };
+ _db.UsbSerialFilters.Add(filter);
+ await _db.SaveChangesAsync(ct);
+
+ return Created((string?)null, ToDto(filter));
+ }
+
+ [HttpGet]
+ public async Task ListFilters(CancellationToken ct)
+ {
+ var rows = await _db.UsbSerialFilters
+ .OrderBy(f => f.Vid).ThenBy(f => f.Pid)
+ .Select(f => new FirmwareUsbSerialFilterAdminDto
+ {
+ Id = f.Id,
+ Vid = f.Vid,
+ Pid = f.Pid,
+ Description = f.Description
+ })
+ .ToListAsync(ct);
+
+ return Ok(rows);
+ }
+
+ [HttpDelete("{id:guid}")]
+ public async Task DeleteFilter([FromRoute] Guid id, CancellationToken ct)
+ {
+ var deleted = await _db.UsbSerialFilters.Where(f => f.Id == id).ExecuteDeleteAsync(ct);
+ if (deleted <= 0)
+ {
+ return Problem(FirmwareError.FirmwareUsbSerialFilterNotFound);
+ }
+
+ return NoContent();
+ }
+
+ private static FirmwareUsbSerialFilterAdminDto ToDto(UsbSerialFilter f) => new()
+ {
+ Id = f.Id,
+ Vid = f.Vid,
+ Pid = f.Pid,
+ Description = f.Description
+ };
+}
diff --git a/RepositoryServer/Controllers/V2/Firmware/Admin/VersionsAdminController.cs b/RepositoryServer/Controllers/V2/Firmware/Admin/VersionsAdminController.cs
new file mode 100644
index 0000000..e4b77a5
--- /dev/null
+++ b/RepositoryServer/Controllers/V2/Firmware/Admin/VersionsAdminController.cs
@@ -0,0 +1,37 @@
+using Asp.Versioning;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using OpenShock.RepositoryServer.Problems;
+using OpenShock.RepositoryServer.RepoServerDb;
+
+namespace OpenShock.RepositoryServer.Controllers.V2.Firmware.Admin;
+
+[ApiVersion("2.0")]
+[ApiController]
+[Route("/{version:apiVersion}/firmware/admin/versions")]
+[Authorize(AuthenticationSchemes = AuthSchemas.AdminToken)]
+public class VersionsAdminController : OpenShockControllerBase
+{
+ private readonly RepoServerContext _db;
+
+ public VersionsAdminController(RepoServerContext db)
+ {
+ _db = db;
+ }
+
+ [HttpDelete("{firmwareVersion}")]
+ public async Task DeleteVersion([FromRoute] string firmwareVersion, CancellationToken ct)
+ {
+ var deleted = await _db.FirmwareVersions
+ .Where(v => v.Version == firmwareVersion)
+ .ExecuteDeleteAsync(ct);
+
+ if (deleted <= 0)
+ {
+ return Problem(FirmwareError.FirmwareVersionNotFound);
+ }
+
+ return NoContent();
+ }
+}
diff --git a/RepositoryServer/Controllers/V2/Firmware/BoardsController.cs b/RepositoryServer/Controllers/V2/Firmware/BoardsController.cs
new file mode 100644
index 0000000..8ce0848
--- /dev/null
+++ b/RepositoryServer/Controllers/V2/Firmware/BoardsController.cs
@@ -0,0 +1,62 @@
+using Asp.Versioning;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using OpenShock.RepositoryServer.Models.Firmware;
+using OpenShock.RepositoryServer.RepoServerDb;
+using OpenShock.RepositoryServer.Utils;
+
+namespace OpenShock.RepositoryServer.Controllers.V2.Firmware;
+
+[ApiVersion("2.0")]
+[ApiController]
+[Route("/{version:apiVersion}/firmware/boards")]
+public sealed class BoardsController : OpenShockControllerBase
+{
+ private readonly RepoServerContext _db;
+
+ public BoardsController(RepoServerContext db)
+ {
+ _db = db;
+ }
+
+ [HttpGet]
+ [CacheControl(300)]
+ public async Task ListBoards(
+ [FromQuery] string? chip,
+ [FromQuery] bool includeDiscontinued = true,
+ CancellationToken ct = default)
+ {
+ IQueryable query = _db.FirmwareBoards
+ .Include(b => b.ChipNavigation)
+ .Include(b => b.UsbDevices);
+
+ // Filter by chip name — what a connected-device detection yields, and what the manifest's
+ // chips array is keyed by. Matched case-insensitively against the unique lower(name) index.
+ if (!string.IsNullOrWhiteSpace(chip))
+ {
+ var loweredChip = chip.ToLowerInvariant();
+ query = query.Where(b => b.ChipNavigation.Name.ToLower() == loweredChip);
+ }
+
+ if (!includeDiscontinued)
+ {
+ query = query.Where(b => !b.Discontinued);
+ }
+
+ var rows = await query.OrderBy(b => b.Name).ToListAsync(ct);
+
+ var boards = rows
+ .Select(b => new FirmwareBoardDto
+ {
+ Name = b.Name,
+ ChipName = b.ChipNavigation.Name,
+ Discontinued = b.Discontinued,
+ UsbDevices = b.UsbDevices
+ .Select(d => new FirmwareUsbDeviceDto { Id = d.Id, Vid = d.Vid, Pid = d.Pid, Name = d.Name })
+ .ToList()
+ })
+ .ToList();
+
+ return Ok(boards);
+ }
+}
diff --git a/RepositoryServer/Controllers/V2/Firmware/ChipsController.cs b/RepositoryServer/Controllers/V2/Firmware/ChipsController.cs
new file mode 100644
index 0000000..f4960b6
--- /dev/null
+++ b/RepositoryServer/Controllers/V2/Firmware/ChipsController.cs
@@ -0,0 +1,44 @@
+using Asp.Versioning;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using OpenShock.RepositoryServer.Models.Firmware;
+using OpenShock.RepositoryServer.RepoServerDb;
+using OpenShock.RepositoryServer.Utils;
+
+namespace OpenShock.RepositoryServer.Controllers.V2.Firmware;
+
+[ApiVersion("2.0")]
+[ApiController]
+[Route("/{version:apiVersion}/firmware/chips")]
+public sealed class ChipsController : OpenShockControllerBase
+{
+ private readonly RepoServerContext _db;
+
+ public ChipsController(RepoServerContext db)
+ {
+ _db = db;
+ }
+
+ [HttpGet]
+ [CacheControl(300)]
+ public async Task ListChips(CancellationToken ct)
+ {
+ var rows = await _db.FirmwareChips
+ .Include(c => c.UsbDevices)
+ .OrderBy(c => c.Name)
+ .ToListAsync(ct);
+
+ var chips = rows
+ .Select(c => new FirmwareChipDto
+ {
+ Name = c.Name,
+ Architecture = EnumNaming.FormatArchitecture(c.Architecture),
+ UsbDevices = c.UsbDevices
+ .Select(d => new FirmwareUsbDeviceDto { Id = d.Id, Vid = d.Vid, Pid = d.Pid, Name = d.Name })
+ .ToList()
+ })
+ .ToList();
+
+ return Ok(chips);
+ }
+}
diff --git a/RepositoryServer/Controllers/V2/Firmware/LatestController.cs b/RepositoryServer/Controllers/V2/Firmware/LatestController.cs
new file mode 100644
index 0000000..c424f88
--- /dev/null
+++ b/RepositoryServer/Controllers/V2/Firmware/LatestController.cs
@@ -0,0 +1,121 @@
+using Asp.Versioning;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using OpenShock.RepositoryServer.Config;
+using OpenShock.RepositoryServer.Enums;
+using OpenShock.RepositoryServer.Models.Firmware;
+using OpenShock.RepositoryServer.Problems;
+using OpenShock.RepositoryServer.RepoServerDb;
+using OpenShock.RepositoryServer.Utils;
+
+namespace OpenShock.RepositoryServer.Controllers.V2.Firmware;
+
+[ApiVersion("2.0")]
+[ApiController]
+[Route("/{version:apiVersion}/firmware/latest")]
+public sealed class LatestController : OpenShockControllerBase
+{
+ private readonly RepoServerContext _db;
+ private readonly ApiConfig _apiConfig;
+
+ public LatestController(RepoServerContext db, ApiConfig apiConfig)
+ {
+ _db = db;
+ _apiConfig = apiConfig;
+ }
+
+ [HttpGet("{channel}")]
+ [CacheControl(300)]
+ public async Task GetLatest([FromRoute] string channel, CancellationToken ct)
+ {
+ if (!Enum.TryParse(channel, true, out var firmwareChannel))
+ {
+ return Problem(FirmwareError.FirmwareInvalidChannel);
+ }
+
+ var visibleChannels = ReleaseChannels.VisibleTo(firmwareChannel);
+ var latest = await _db.FirmwareVersions
+ .Where(v => visibleChannels.Contains(v.Channel))
+ .OrderByNewest()
+ .Include(v => v.RepositoryNavigation)
+ .Include(v => v.Artifacts)
+ .Include(v => v.ReleaseNotes)
+ .FirstOrDefaultAsync(ct);
+
+ if (latest is null)
+ {
+ return Problem(FirmwareError.FirmwareVersionNotFound);
+ }
+
+ var boardIds = latest.Artifacts.Select(a => a.BoardId).Distinct().ToList();
+ var boards = await _db.FirmwareBoards
+ .Include(b => b.ChipNavigation)
+ .Where(b => boardIds.Contains(b.Id))
+ .ToListAsync(ct);
+
+ var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/');
+ return Ok(FirmwareResponseMapper.ToReleaseDto(latest, boards, cdnBase));
+ }
+
+ /// Board name (e.g. "Wemos-D1-Mini-ESP32") or board id.
+ [HttpGet("{channel}/{board}")]
+ [CacheControl(300)]
+ public async Task GetLatestForBoard(
+ [FromRoute] string channel,
+ [FromRoute] string board,
+ [FromQuery] string? version,
+ CancellationToken ct)
+ {
+ if (!Enum.TryParse(channel, true, out var firmwareChannel))
+ {
+ return Problem(FirmwareError.FirmwareInvalidChannel);
+ }
+
+ // Resolved before the up-to-date check so an unknown board always reports 404 rather than
+ // being masked as "no update needed".
+ var resolved = await _db.ResolveBoardAsync(board, ct);
+ if (resolved is not { } boardRef)
+ {
+ return Problem(FirmwareError.FirmwareBoardNotFound);
+ }
+
+ var visibleChannels = ReleaseChannels.VisibleTo(firmwareChannel);
+ var latestVersion = await _db.FirmwareVersions
+ .Where(v => visibleChannels.Contains(v.Channel))
+ .OrderByNewest()
+ .Select(v => v.Version)
+ .FirstOrDefaultAsync(ct);
+
+ if (latestVersion is null)
+ {
+ return Problem(FirmwareError.FirmwareVersionNotFound);
+ }
+
+ // String equality (not semver) is intentional: rollbacks — if a version is pulled
+ // and an older version becomes "latest", the hub's string compare will still differ
+ // and trigger an update. See firmware-api-spec.md §4.2.
+ if (!string.IsNullOrWhiteSpace(version) && string.Equals(version, latestVersion, StringComparison.Ordinal))
+ {
+ return NoContent();
+ }
+
+ var artifacts = await _db.FirmwareArtifacts
+ .Where(a => a.Version == latestVersion && a.BoardId == boardRef.Id)
+ .ToListAsync(ct);
+
+ if (artifacts.Count == 0)
+ {
+ return Problem(FirmwareError.FirmwareBoardNotFound);
+ }
+
+ var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/');
+ return Ok(new FirmwareBoardReleaseResponseDto
+ {
+ Version = latestVersion,
+ BoardId = boardRef.Name,
+ Artifacts = artifacts
+ .Select(a => FirmwareResponseMapper.ToArtifactDto(a, latestVersion, cdnBase))
+ .ToList()
+ });
+ }
+}
diff --git a/RepositoryServer/Controllers/V2/Firmware/ManifestController.cs b/RepositoryServer/Controllers/V2/Firmware/ManifestController.cs
new file mode 100644
index 0000000..751a0bf
--- /dev/null
+++ b/RepositoryServer/Controllers/V2/Firmware/ManifestController.cs
@@ -0,0 +1,131 @@
+using Asp.Versioning;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using OpenShock.RepositoryServer.Enums;
+using OpenShock.RepositoryServer.Models.Firmware;
+using OpenShock.RepositoryServer.RepoServerDb;
+using OpenShock.RepositoryServer.Utils;
+
+namespace OpenShock.RepositoryServer.Controllers.V2.Firmware;
+
+[ApiVersion("2.0")]
+[ApiController]
+[Route("/{version:apiVersion}/firmware/manifest")]
+public sealed class ManifestController : OpenShockControllerBase
+{
+ private static readonly ReleaseChannel[] AllChannels =
+ [
+ ReleaseChannel.Stable,
+ ReleaseChannel.Beta,
+ ReleaseChannel.Develop
+ ];
+
+ private readonly RepoServerContext _db;
+
+ public ManifestController(RepoServerContext db)
+ {
+ _db = db;
+ }
+
+ [HttpGet]
+ [CacheControl(300)]
+ public async Task GetManifest(CancellationToken ct)
+ {
+ // DbContext is not thread-safe; queries run sequentially. The manifest is cached
+ // at the HTTP layer (max-age=300) so serialization here is a non-issue.
+ var latest = new Dictionary();
+ foreach (var ch in AllChannels)
+ {
+ var visibleChannels = ReleaseChannels.VisibleTo(ch);
+ var version = await _db.FirmwareVersions
+ .Where(v => visibleChannels.Contains(v.Channel))
+ .OrderByNewest()
+ .Select(v => v.Version)
+ .FirstOrDefaultAsync(ct);
+
+ if (version is not null)
+ {
+ latest[ch.ToString().ToLowerInvariant()] = version;
+ }
+ }
+
+ var boardRows = await _db.FirmwareBoards
+ .Include(b => b.ChipNavigation)
+ .Include(b => b.UsbDevices)
+ .OrderBy(b => b.Name)
+ .ToListAsync(ct);
+
+ var chipRows = await _db.FirmwareChips
+ .Include(c => c.UsbDevices)
+ .OrderBy(c => c.Name)
+ .ToListAsync(ct);
+
+ var filterRows = await _db.UsbSerialFilters
+ .OrderBy(f => f.Vid).ThenBy(f => f.Pid)
+ .Select(f => new FirmwareUsbSerialFilterDto { Vid = f.Vid, Pid = f.Pid })
+ .ToListAsync(ct);
+
+ var deviceRows = await _db.UsbDevices
+ .OrderBy(d => d.Vid).ThenBy(d => d.Pid)
+ .Select(d => new FirmwareUsbDeviceDto
+ {
+ Id = d.Id,
+ Vid = d.Vid,
+ Pid = d.Pid,
+ Name = d.Name
+ })
+ .ToListAsync(ct);
+
+ var boards = boardRows
+ .Select(b => new FirmwareBoardDto
+ {
+ Name = b.Name,
+ ChipName = b.ChipNavigation.Name,
+ Discontinued = b.Discontinued,
+ UsbDevices = b.UsbDevices
+ .Select(d => new FirmwareUsbDeviceDto { Id = d.Id, Vid = d.Vid, Pid = d.Pid, Name = d.Name })
+ .ToList()
+ })
+ .ToList();
+
+ var chips = chipRows
+ .Select(c => new FirmwareChipDto
+ {
+ Name = c.Name,
+ Architecture = EnumNaming.FormatArchitecture(c.Architecture),
+ UsbDevices = c.UsbDevices
+ .Select(d => new FirmwareUsbDeviceDto { Id = d.Id, Vid = d.Vid, Pid = d.Pid, Name = d.Name })
+ .ToList()
+ })
+ .ToList();
+
+ // Postgres sorts enum columns by label creation order (alphabetical with MapEnum),
+ // not severity rank — order on the CLR enum value in memory instead.
+ var advisoryRows = await _db.FirmwareAdvisories.ToListAsync(ct);
+ var advisories = advisoryRows
+ .OrderBy(a => a.Severity)
+ .ThenBy(a => a.Title)
+ .Select(a => new FirmwareAdvisoryDto
+ {
+ Severity = a.Severity.ToString().ToLowerInvariant(),
+ Title = a.Title,
+ Content = a.Content,
+ AffectedVersions = a.AffectedVersions,
+ Url = a.Url
+ })
+ .ToList();
+
+ var response = new FirmwareManifestResponse
+ {
+ Channels = AllChannels.Select(c => c.ToString().ToLowerInvariant()).ToList(),
+ Latest = latest,
+ Boards = boards,
+ Chips = chips,
+ UsbSerialFilters = filterRows,
+ UsbDevices = deviceRows,
+ Advisories = advisories
+ };
+
+ return Ok(response);
+ }
+}
diff --git a/RepositoryServer/Controllers/V2/Firmware/ReleasesController.cs b/RepositoryServer/Controllers/V2/Firmware/ReleasesController.cs
new file mode 100644
index 0000000..034a3c9
--- /dev/null
+++ b/RepositoryServer/Controllers/V2/Firmware/ReleasesController.cs
@@ -0,0 +1,662 @@
+using System.Security.Cryptography;
+using System.Security.Claims;
+using System.Text.Json;
+using Asp.Versioning;
+using FlexLabs.EntityFrameworkCore.Upsert;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Npgsql;
+using OneOf;
+using OpenShock.RepositoryServer.Config;
+using OpenShock.RepositoryServer.Enums;
+using OpenShock.RepositoryServer.Models.Firmware;
+using OpenShock.RepositoryServer.Problems;
+using OpenShock.RepositoryServer.RepoServerDb;
+using OpenShock.RepositoryServer.Services;
+using OpenShock.RepositoryServer.Utils;
+using Semver;
+
+namespace OpenShock.RepositoryServer.Controllers.V2.Firmware;
+
+[ApiVersion("2.0")]
+[ApiController]
+[Route("/{version:apiVersion}/firmware/releases")]
+[Authorize(AuthenticationSchemes = AuthSchemas.CiCdToken, Policy = AuthSchemas.Policies.PublishFirmware)]
+public class ReleasesController : OpenShockControllerBase
+{
+ private static readonly Dictionary ArtifactFieldNames = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["app"] = FirmwareArtifactType.App,
+ ["staticfs"] = FirmwareArtifactType.StaticFs,
+ ["merged"] = FirmwareArtifactType.Merged,
+ ["bootloader"] = FirmwareArtifactType.Bootloader,
+ ["partitions"] = FirmwareArtifactType.Partitions,
+ };
+
+ private readonly RepoServerContext _db;
+ private readonly IStorageService _storage;
+ private readonly ApiConfig _apiConfig;
+ private readonly IDiscordNotificationService _discord;
+ private readonly TimeProvider _timeProvider;
+ private readonly ILogger _logger;
+
+ public ReleasesController(
+ RepoServerContext db,
+ IStorageService storage,
+ ApiConfig apiConfig,
+ IDiscordNotificationService discord,
+ TimeProvider timeProvider,
+ ILogger logger)
+ {
+ _db = db;
+ _storage = storage;
+ _apiConfig = apiConfig;
+ _discord = discord;
+ _timeProvider = timeProvider;
+ _logger = logger;
+ }
+
+ // ---- Init Release ----
+
+ [HttpPost]
+ public async Task InitRelease(
+ [FromBody] InitReleaseRequest request,
+ [FromQuery(Name = "nofail")] bool? nofailQuery,
+ CancellationToken ct)
+ {
+ var nofail = nofailQuery == true || Request.Query.ContainsKey("nofail");
+
+ if (!SemVersion.TryParse(request.Version, SemVersionStyles.Strict, out _))
+ {
+ return Problem(FirmwareError.FirmwareInvalidSemver);
+ }
+
+ if (!Enum.TryParse(request.Channel, true, out var channel))
+ {
+ return Problem(FirmwareError.FirmwareInvalidChannel);
+ }
+
+ if (request.Boards.Count == 0)
+ {
+ return Problem(FirmwareError.FirmwareReleaseBoardsEmpty);
+ }
+
+ // Published versions are immutable (spec §4.4). Without this, re-initialising an existing
+ // version would let a second release overwrite live artifacts at the same storage keys before
+ // any publish call, and an abandoned one would later have them deleted by the TTL job.
+ if (await _db.FirmwareVersions.AnyAsync(v => v.Version == request.Version, ct))
+ {
+ return Problem(FirmwareError.FirmwareVersionAlreadyPublished);
+ }
+
+ var existingStaging = await _db.FirmwareReleases
+ .AnyAsync(r => r.Version == request.Version &&
+ (r.Status == ReleaseStatus.Staging || r.Status == ReleaseStatus.Editing),
+ ct);
+ if (existingStaging)
+ {
+ return Problem(FirmwareError.FirmwareReleaseAlreadyStaging);
+ }
+
+ var (declaredBoards, unknownBoards) = await _db.ResolveBoardsAsync(request.Boards, ct);
+ if (unknownBoards.Count > 0)
+ {
+ return Problem(FirmwareError.FirmwareBoardsNotFound(unknownBoards));
+ }
+
+ // Source traceability — from OIDC claims (attached by GitHubOidcAuthentication).
+ if (!TryReadSourceClaims(out var sourceClaims, out var missing))
+ {
+ return Problem(FirmwareError.FirmwareInvalidChangelog($"Missing OIDC source claims: {missing}"));
+ }
+
+ // Parse changelog.
+ var parseResult = ChangelogParser.Parse(request.Changelog);
+ ReleaseStatus status;
+ IReadOnlyList notes;
+
+ if (parseResult.TryPickT0(out var parsedNotes, out var error))
+ {
+ status = ReleaseStatus.Staging;
+ notes = parsedNotes;
+ }
+ else if (nofail)
+ {
+ status = ReleaseStatus.Editing;
+ notes = Array.Empty();
+ }
+ else
+ {
+ return Problem(FirmwareError.FirmwareInvalidChangelog(DescribeParseError(error)));
+ }
+
+ var release = new FirmwareRelease
+ {
+ Id = Guid.NewGuid(),
+ Version = request.Version,
+ Channel = channel,
+ RepositoryId = sourceClaims.RepositoryId,
+ CommitHash = sourceClaims.CommitHash,
+ Ref = sourceClaims.Ref,
+ RunId = sourceClaims.RunId,
+ // Npgsql refuses a DateTimeOffset with a non-zero offset for `timestamp with time zone`,
+ // which would surface as a generic 500. CI runners in a non-UTC zone emit exactly that.
+ ReleaseDate = request.ReleaseDate.ToUniversalTime(),
+ Status = status,
+ DeclaredBoards = declaredBoards.Select(b => b.Id).ToArray(),
+ CreatedAt = _timeProvider.GetUtcNow(),
+ };
+
+ _db.FirmwareReleases.Add(release);
+
+ for (var i = 0; i < notes.Count; i++)
+ {
+ var n = notes[i];
+ _db.FirmwareStagedReleaseNotes.Add(new FirmwareStagedReleaseNote
+ {
+ ReleaseId = release.Id,
+ Index = i,
+ SectionType = Enum.Parse(n.Type, true),
+ Title = n.Title,
+ Content = n.Content,
+ });
+ }
+
+ try
+ {
+ await _db.SaveChangesAsync(ct);
+ }
+ catch (DbUpdateException ex) when (IsOpenReleaseConflict(ex))
+ {
+ // Lost the race against a concurrent job for the same tag. The check above catches this in
+ // the common case; ix_firmware_releases_open_version closes the read-then-insert window,
+ // and both paths surface as the same 409.
+ return Problem(FirmwareError.FirmwareReleaseAlreadyStaging);
+ }
+
+ if (status == ReleaseStatus.Editing)
+ {
+ await _discord.NotifyReleaseNotesNeedEditingAsync(release.Id, release.Version, channel.ToString().ToLowerInvariant(), ct);
+ }
+
+ return Created((string?)null, new InitReleaseResponse
+ {
+ Id = release.Id,
+ Status = status.ToString().ToLowerInvariant()
+ });
+ }
+
+ // ---- Upload Board Artifacts ----
+
+ /// Board name (e.g. "Wemos-D1-Mini-ESP32") or board UUID.
+ [HttpPut("{releaseId:guid}/boards/{board}")]
+ [Consumes("multipart/form-data")]
+ [RequestSizeLimit(64 * 1024 * 1024)]
+ public async Task UploadBoardArtifacts(
+ [FromRoute] Guid releaseId,
+ [FromRoute] string board,
+ CancellationToken ct)
+ {
+ var release = await _db.FirmwareReleases.FirstOrDefaultAsync(r => r.Id == releaseId, ct);
+ if (release is null)
+ {
+ return Problem(FirmwareError.FirmwareReleaseNotFound);
+ }
+
+ if (!IsOwnedByCaller(release))
+ {
+ return Problem(FirmwareError.FirmwareReleaseNotOwned);
+ }
+
+ if (release.Status != ReleaseStatus.Staging && release.Status != ReleaseStatus.Editing)
+ {
+ return Problem(FirmwareError.FirmwareReleaseNotEditable);
+ }
+
+ var resolved = await _db.ResolveBoardAsync(board, ct);
+ if (resolved is not { } boardRef)
+ {
+ return Problem(FirmwareError.FirmwareBoardNotFound);
+ }
+
+ if (!release.DeclaredBoards.Contains(boardRef.Id))
+ {
+ return Problem(FirmwareError.FirmwareBoardNotDeclared);
+ }
+
+ var boardId = boardRef.Id;
+ var boardEntity = await _db.FirmwareBoards.FirstAsync(b => b.Id == boardId, ct);
+
+ var files = Request.Form.Files;
+ if (files.Count == 0)
+ {
+ return Problem(FirmwareError.FirmwareManifestKeysMismatch("No artifact files uploaded"));
+ }
+
+ var sha256FormValue = Request.Form["sha256"].ToString();
+ if (string.IsNullOrWhiteSpace(sha256FormValue))
+ {
+ return Problem(FirmwareError.FirmwareManifestKeysMismatch("Missing required 'sha256' form field"));
+ }
+
+ Dictionary? expectedHashes;
+ try
+ {
+ expectedHashes = JsonSerializer.Deserialize>(
+ sha256FormValue,
+ new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+ }
+ catch (JsonException)
+ {
+ return Problem(FirmwareError.FirmwareManifestKeysMismatch("'sha256' form field is not valid JSON"));
+ }
+
+ if (expectedHashes is null)
+ {
+ return Problem(FirmwareError.FirmwareManifestKeysMismatch("'sha256' form field is null"));
+ }
+
+ // Validate file field names and build an uploaded-type map.
+ var uploadedByType = new Dictionary();
+ foreach (var file in files)
+ {
+ if (!ArtifactFieldNames.TryGetValue(file.Name, out var artifactType))
+ {
+ return Problem(FirmwareError.FirmwareInvalidArtifactType);
+ }
+ uploadedByType[artifactType] = file;
+ }
+
+ // Normalize expected manifest keys to enum values.
+ var normalizedExpected = new Dictionary();
+ foreach (var kv in expectedHashes)
+ {
+ if (!ArtifactFieldNames.TryGetValue(kv.Key, out var artifactType))
+ {
+ return Problem(FirmwareError.FirmwareInvalidArtifactType);
+ }
+ normalizedExpected[artifactType] = kv.Value;
+ }
+
+ // Keys must match exactly (spec §5.2).
+ var onlyInFiles = uploadedByType.Keys.Except(normalizedExpected.Keys).ToList();
+ var onlyInManifest = normalizedExpected.Keys.Except(uploadedByType.Keys).ToList();
+ if (onlyInFiles.Count > 0 || onlyInManifest.Count > 0)
+ {
+ var detail = $"Uploaded-only: [{string.Join(", ", onlyInFiles)}]; manifest-only: [{string.Join(", ", onlyInManifest)}]";
+ return Problem(FirmwareError.FirmwareManifestKeysMismatch(detail));
+ }
+
+ // Validate required artifact types per board config.
+ if (boardEntity.RequiredArtifactTypes.Length > 0)
+ {
+ var missingRequired = boardEntity.RequiredArtifactTypes
+ .Where(r => !uploadedByType.ContainsKey(r))
+ .Select(r => r.ToString().ToLowerInvariant())
+ .ToList();
+ if (missingRequired.Count > 0)
+ {
+ return Problem(FirmwareError.FirmwareMissingRequiredArtifacts(boardRef.Name, missingRequired));
+ }
+ }
+
+ // Read and verify every artifact BEFORE touching storage or the database.
+ //
+ // The previous order — delete prior staged rows, then hash-and-upload each file in turn,
+ // then report mismatches — meant one good file alongside one bad one left the good blob on the
+ // CDN with no row referencing it. Neither abort nor the TTL job could ever find it, because
+ // both enumerate staged rows. It also destroyed the board's previously valid staged artifacts
+ // on the way to failing.
+ var verified = new List<(FirmwareArtifactType Type, byte[] Bytes, string Hash)>();
+ var mismatches = new List();
+
+ foreach (var (artifactType, file) in uploadedByType)
+ {
+ await using var memory = new MemoryStream();
+ await file.CopyToAsync(memory, ct);
+ var bytes = memory.ToArray();
+ var actual = Convert.ToHexString(SHA256.HashData(bytes));
+
+ var expected = normalizedExpected[artifactType];
+ if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase))
+ {
+ mismatches.Add($"{artifactType.ToString().ToLowerInvariant()} (expected={expected}, actual={actual})");
+ continue;
+ }
+
+ verified.Add((artifactType, bytes, actual));
+ }
+
+ if (mismatches.Count > 0)
+ {
+ return Problem(FirmwareError.FirmwareSha256Mismatch(string.Join("; ", mismatches)));
+ }
+
+ var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/');
+ var uploadedArtifacts = new List();
+
+ await using var uploadTransaction = await _db.Database.BeginTransactionAsync(ct);
+
+ // Replacing this board's staged rows. Uploads overwrite at the same deterministic staging
+ // keys, so a re-upload of the same board never leaves a stale blob behind.
+ await _db.FirmwareStagedArtifacts
+ .Where(a => a.ReleaseId == releaseId && a.BoardId == boardId)
+ .ExecuteDeleteAsync(ct);
+
+ foreach (var (artifactType, bytes, hash) in verified)
+ {
+ // Staged, not published: the public key is written only by PublishRelease.
+ var stagingPath = FirmwareArtifactFileNames.BuildStagingPath(releaseId, boardId, artifactType);
+
+ await using var uploadStream = new MemoryStream(bytes);
+ await _storage.UploadFileAsync(stagingPath, uploadStream, ct);
+
+ _db.FirmwareStagedArtifacts.Add(new FirmwareStagedArtifact
+ {
+ ReleaseId = releaseId,
+ BoardId = boardId,
+ ArtifactType = artifactType,
+ HashSha256 = Convert.FromHexString(hash),
+ FileSize = bytes.Length,
+ });
+
+ uploadedArtifacts.Add(new FirmwareArtifactDto
+ {
+ Type = artifactType.ToString().ToLowerInvariant(),
+ Url = FirmwareArtifactFileNames.BuildUrl(cdnBase, release.Version, boardId, artifactType),
+ Sha256Hash = hash,
+ FileSize = bytes.Length,
+ });
+ }
+
+ await _db.SaveChangesAsync(ct);
+ await uploadTransaction.CommitAsync(ct);
+ return Ok(uploadedArtifacts);
+ }
+
+ // ---- Publish Release ----
+
+ [HttpPost("{releaseId:guid}/publish")]
+ public async Task PublishRelease([FromRoute] Guid releaseId, CancellationToken ct)
+ {
+ var release = await _db.FirmwareReleases
+ .Include(r => r.StagedArtifacts)
+ .Include(r => r.StagedReleaseNotes.OrderBy(n => n.Index))
+ .FirstOrDefaultAsync(r => r.Id == releaseId, ct);
+
+ if (release is null)
+ {
+ return Problem(FirmwareError.FirmwareReleaseNotFound);
+ }
+
+ if (!IsOwnedByCaller(release))
+ {
+ return Problem(FirmwareError.FirmwareReleaseNotOwned);
+ }
+
+ if (release.Status == ReleaseStatus.Editing)
+ {
+ return Problem(FirmwareError.FirmwareReleaseNotesNotFinalized);
+ }
+
+ if (release.Status != ReleaseStatus.Staging)
+ {
+ return Problem(FirmwareError.FirmwareReleaseNotStaging);
+ }
+
+ var boards = await _db.FirmwareBoards
+ .Where(b => release.DeclaredBoards.Contains(b.Id))
+ .ToDictionaryAsync(b => b.Id, ct);
+
+ var uploadedBoardIds = release.StagedArtifacts.Select(a => a.BoardId).Distinct().ToHashSet();
+ var missingBoards = release.DeclaredBoards
+ .Where(b => !uploadedBoardIds.Contains(b))
+ .Select(b => boards.TryGetValue(b, out var board) ? board.Name : b.ToString())
+ .ToList();
+ if (missingBoards.Count > 0)
+ {
+ return Problem(FirmwareError.FirmwareReleaseIncomplete(missingBoards));
+ }
+
+ foreach (var boardId in release.DeclaredBoards)
+ {
+ if (!boards.TryGetValue(boardId, out var board) || board.RequiredArtifactTypes.Length == 0)
+ continue;
+
+ var stagedTypes = release.StagedArtifacts
+ .Where(a => a.BoardId == boardId)
+ .Select(a => a.ArtifactType)
+ .ToHashSet();
+ var missingTypes = board.RequiredArtifactTypes
+ .Where(r => !stagedTypes.Contains(r))
+ .Select(r => r.ToString().ToLowerInvariant())
+ .ToList();
+ if (missingTypes.Count > 0)
+ {
+ return Problem(FirmwareError.FirmwareMissingRequiredArtifacts(board.Name, missingTypes));
+ }
+ }
+
+ // Promote staged objects to their published keys before touching the database.
+ //
+ // Storage is not transactional, so one of the two orderings has to carry the risk. Copying
+ // first means a later database failure leaves unreferenced objects at published keys, which
+ // are invisible (no version row points at them) and are overwritten by a subsequent publish
+ // of the same version. Committing first would instead publish a version whose artifacts are
+ // not all present yet — hubs would download 404s. Unreferenced bytes beat a broken release.
+ var promoted = new List();
+ try
+ {
+ foreach (var staged in release.StagedArtifacts)
+ {
+ var stagingPath = FirmwareArtifactFileNames.BuildStagingPath(
+ release.Id, staged.BoardId, staged.ArtifactType);
+ var publishedPath = FirmwareArtifactFileNames.BuildStoragePath(
+ release.Version, staged.BoardId, staged.ArtifactType);
+
+ await _storage.CopyFileAsync(stagingPath, publishedPath, ct);
+ promoted.Add(publishedPath);
+ }
+ }
+ catch (Exception ex)
+ {
+ // Roll back the partial promotion so a half-populated version directory is not left
+ // behind. Best effort: if this fails too, the objects stay unreferenced and harmless.
+ _logger.LogError(ex, "Failed to promote staged artifacts for release {ReleaseId}", release.Id);
+ await TryDeleteAllAsync(promoted, ct);
+ throw;
+ }
+
+ await using var transaction = await _db.Database.BeginTransactionAsync(ct);
+
+ var versionEntity = new FirmwareVersion
+ {
+ Version = release.Version,
+ Channel = release.Channel,
+ ReleaseDate = release.ReleaseDate,
+ RepositoryId = release.RepositoryId,
+ CommitHash = release.CommitHash,
+ Ref = release.Ref,
+ RunId = release.RunId,
+ };
+ await _db.FirmwareVersions.Upsert(versionEntity).On(v => v.Version).RunAsync(ct);
+
+ await _db.FirmwareArtifacts.Where(a => a.Version == release.Version).ExecuteDeleteAsync(ct);
+ foreach (var staged in release.StagedArtifacts)
+ {
+ _db.FirmwareArtifacts.Add(new FirmwareArtifact
+ {
+ Version = release.Version,
+ BoardId = staged.BoardId,
+ ArtifactType = staged.ArtifactType,
+ HashSha256 = staged.HashSha256,
+ FileSize = staged.FileSize,
+ });
+ }
+
+ await _db.FirmwareReleaseNotes.Where(n => n.Version == release.Version).ExecuteDeleteAsync(ct);
+ foreach (var staged in release.StagedReleaseNotes)
+ {
+ _db.FirmwareReleaseNotes.Add(new FirmwareReleaseNote
+ {
+ Version = release.Version,
+ Index = staged.Index,
+ SectionType = staged.SectionType,
+ Title = staged.Title,
+ Content = staged.Content,
+ });
+ }
+
+ release.Status = ReleaseStatus.Published;
+
+ try
+ {
+ await _db.SaveChangesAsync(ct);
+ await transaction.CommitAsync(ct);
+ }
+ catch
+ {
+ await TryDeleteAllAsync(promoted, ct);
+ throw;
+ }
+
+ // The release is live; its staging copies are now dead weight. Deleting them is best effort —
+ // failing here would abort a publish that has already succeeded, and the TTL job does not
+ // revisit published releases, so the worst case is some orphaned staging objects.
+ try
+ {
+ await _storage.DeleteDirectoryAsync(FirmwareArtifactFileNames.BuildStagingPrefix(release.Id), ct);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex,
+ "Published release {ReleaseId} but failed to clear its staging prefix", release.Id);
+ }
+
+ await _discord.NotifyFirmwareReleasePublishedAsync(
+ release.Version,
+ release.Channel.ToString().ToLowerInvariant(),
+ release.CommitHash,
+ ct);
+
+ return CreatedAtAction(
+ nameof(VersionsController.GetVersion),
+ "Versions",
+ new { firmwareVersion = release.Version },
+ null);
+ }
+
+ // ---- Abort Release ----
+
+ [HttpDelete("{releaseId:guid}")]
+ public async Task AbortRelease([FromRoute] Guid releaseId, CancellationToken ct)
+ {
+ var release = await _db.FirmwareReleases
+ .Include(r => r.StagedArtifacts)
+ .FirstOrDefaultAsync(r => r.Id == releaseId, ct);
+
+ if (release is null)
+ {
+ return Problem(FirmwareError.FirmwareReleaseNotFound);
+ }
+
+ if (!IsOwnedByCaller(release))
+ {
+ return Problem(FirmwareError.FirmwareReleaseNotOwned);
+ }
+
+ if (release.Status != ReleaseStatus.Staging && release.Status != ReleaseStatus.Editing)
+ {
+ return Problem(FirmwareError.FirmwareReleaseNotEditable);
+ }
+
+ // Everything this release wrote lives under its own staging prefix, so it can be dropped
+ // wholesale with no risk of deleting an object a published version is serving.
+ await _storage.DeleteDirectoryAsync(FirmwareArtifactFileNames.BuildStagingPrefix(release.Id), ct);
+
+ release.Status = ReleaseStatus.Aborted;
+ await _db.SaveChangesAsync(ct);
+
+ return NoContent();
+ }
+
+ // ---- Helpers ----
+
+ private readonly record struct SourceClaims(Guid RepositoryId, string CommitHash, string? Ref, string? RunId);
+
+ ///
+ /// Best-effort deletion used to unwind a partial promotion. Never throws: it runs on paths that
+ /// are already failing, and turning a cleanup error into the reported fault would hide the cause.
+ ///
+ private async Task TryDeleteAllAsync(IEnumerable paths, CancellationToken ct)
+ {
+ foreach (var path in paths)
+ {
+ try
+ {
+ await _storage.DeleteFileAsync(path, ct);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to remove promoted artifact {Path} while unwinding", path);
+ }
+ }
+ }
+
+ ///
+ /// True when the failure is the partial unique index guarding one open release per version.
+ ///
+ private static bool IsOpenReleaseConflict(DbUpdateException ex) =>
+ ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation } pg
+ && pg.ConstraintName == "ix_firmware_releases_open_version";
+
+ ///
+ /// Confirms the authenticated repository is the one that created this release.
+ ///
+ ///
+ /// Every registered repository presents an equally valid CI/CD principal, so authentication alone
+ /// says nothing about which release the caller may touch. Without this check any
+ /// authorized repository could inject binaries into another's in-flight release, publish it under
+ /// that repository's identity and commit hash, or abort it.
+ ///
+ private bool IsOwnedByCaller(FirmwareRelease release)
+ {
+ var rawRepoId = User.FindFirstValue(AuthSchemas.CiCdClaims.RepositoryId);
+ return Guid.TryParse(rawRepoId, out var callerRepositoryId)
+ && release.RepositoryId == callerRepositoryId;
+ }
+
+ private bool TryReadSourceClaims(out SourceClaims claims, out string missing)
+ {
+ var rawRepoId = User.FindFirstValue(AuthSchemas.CiCdClaims.RepositoryId);
+ var commitHash = User.FindFirstValue(AuthSchemas.CiCdClaims.CommitHash);
+ var refValue = User.FindFirstValue(AuthSchemas.CiCdClaims.Ref);
+ var runId = User.FindFirstValue(AuthSchemas.CiCdClaims.RunId);
+
+ if (string.IsNullOrWhiteSpace(rawRepoId) || !Guid.TryParse(rawRepoId, out var repoId))
+ {
+ claims = default;
+ missing = AuthSchemas.CiCdClaims.RepositoryId;
+ return false;
+ }
+ if (string.IsNullOrWhiteSpace(commitHash))
+ {
+ claims = default;
+ missing = AuthSchemas.CiCdClaims.CommitHash;
+ return false;
+ }
+
+ claims = new SourceClaims(repoId, commitHash, refValue, runId);
+ missing = string.Empty;
+ return true;
+ }
+
+ private static string DescribeParseError(ChangelogParseError error) => error switch
+ {
+ ChangelogParseError.Empty => "Changelog is empty or whitespace-only",
+ ChangelogParseError.NoHeadings => "Changelog contains no '### Heading' sections",
+ ChangelogParseError.AllSectionsEmpty => "All changelog sections are empty",
+ _ => error.ToString()
+ };
+}
diff --git a/RepositoryServer/Controllers/V2/Firmware/VersionsController.cs b/RepositoryServer/Controllers/V2/Firmware/VersionsController.cs
new file mode 100644
index 0000000..a2fd3c2
--- /dev/null
+++ b/RepositoryServer/Controllers/V2/Firmware/VersionsController.cs
@@ -0,0 +1,150 @@
+using Asp.Versioning;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using OpenShock.RepositoryServer.Config;
+using OpenShock.RepositoryServer.Enums;
+using OpenShock.RepositoryServer.Models.Firmware;
+using OpenShock.RepositoryServer.Problems;
+using OpenShock.RepositoryServer.RepoServerDb;
+using OpenShock.RepositoryServer.Utils;
+
+namespace OpenShock.RepositoryServer.Controllers.V2.Firmware;
+
+[ApiVersion("2.0")]
+[ApiController]
+[Route("/{version:apiVersion}/firmware/versions")]
+public sealed class VersionsController : OpenShockControllerBase
+{
+ private const int DefaultLimit = 20;
+ private const int MaxLimit = 100;
+
+ private readonly RepoServerContext _db;
+ private readonly ApiConfig _apiConfig;
+
+ public VersionsController(RepoServerContext db, ApiConfig apiConfig)
+ {
+ _db = db;
+ _apiConfig = apiConfig;
+ }
+
+ [HttpGet]
+ [CacheControl(3600)]
+ public async Task ListVersions(
+ [FromQuery] string? channel,
+ [FromQuery] int? limit,
+ [FromQuery] int? offset,
+ CancellationToken ct)
+ {
+ IQueryable query = _db.FirmwareVersions
+ .Include(v => v.RepositoryNavigation)
+ .Include(v => v.ReleaseNotes);
+
+ if (!string.IsNullOrWhiteSpace(channel))
+ {
+ if (!Enum.TryParse(channel, true, out var firmwareChannel))
+ {
+ return Problem(FirmwareError.FirmwareInvalidChannel);
+ }
+ // Cascading, so a beta subscriber's history includes the stable releases they can install.
+ var visibleChannels = ReleaseChannels.VisibleTo(firmwareChannel);
+ query = query.Where(v => visibleChannels.Contains(v.Channel));
+ }
+
+ var total = await query.CountAsync(ct);
+
+ var effectiveLimit = Math.Clamp(limit ?? DefaultLimit, 1, MaxLimit);
+ var effectiveOffset = Math.Max(offset ?? 0, 0);
+
+ var rows = await query
+ .OrderByNewest()
+ .Skip(effectiveOffset)
+ .Take(effectiveLimit)
+ .ToListAsync(ct);
+
+ var summaries = rows
+ .Select(v => new FirmwareVersionSummary
+ {
+ Version = v.Version,
+ Channel = v.Channel.ToString().ToLowerInvariant(),
+ ReleaseDate = v.ReleaseDate,
+ Source = FirmwareSourceDto.From(v),
+ ReleaseNotes = v.ReleaseNotes
+ .OrderBy(n => n.Index)
+ .Select(n => new FirmwareReleaseNoteDto
+ {
+ Type = n.SectionType.ToString().ToLowerInvariant(),
+ Title = n.Title,
+ Content = n.Content
+ })
+ .ToList()
+ })
+ .ToList();
+
+ return Ok(new VersionListResponse { Versions = summaries, Total = total });
+ }
+
+ [HttpGet("{firmwareVersion}")]
+ [CacheControl(86400, immutable: true)]
+ public async Task GetVersion([FromRoute] string firmwareVersion, CancellationToken ct)
+ {
+ var version = await _db.FirmwareVersions
+ .Include(v => v.RepositoryNavigation)
+ .Include(v => v.Artifacts)
+ .Include(v => v.ReleaseNotes)
+ .FirstOrDefaultAsync(v => v.Version == firmwareVersion, ct);
+
+ if (version is null)
+ {
+ return Problem(FirmwareError.FirmwareVersionNotFound);
+ }
+
+ var boardIds = version.Artifacts.Select(a => a.BoardId).Distinct().ToList();
+ var boards = await _db.FirmwareBoards
+ .Include(b => b.ChipNavigation)
+ .Where(b => boardIds.Contains(b.Id))
+ .ToListAsync(ct);
+
+ var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/');
+ return Ok(FirmwareResponseMapper.ToReleaseDto(version, boards, cdnBase));
+ }
+
+ /// Board name (e.g. "Wemos-D1-Mini-ESP32") or board id.
+ [HttpGet("{firmwareVersion}/{board}")]
+ [CacheControl(86400, immutable: true)]
+ public async Task GetVersionForBoard(
+ [FromRoute] string firmwareVersion,
+ [FromRoute] string board,
+ CancellationToken ct)
+ {
+ var exists = await _db.FirmwareVersions.AnyAsync(v => v.Version == firmwareVersion, ct);
+ if (!exists)
+ {
+ return Problem(FirmwareError.FirmwareVersionNotFound);
+ }
+
+ var resolved = await _db.ResolveBoardAsync(board, ct);
+ if (resolved is not { } boardRef)
+ {
+ return Problem(FirmwareError.FirmwareBoardNotFound);
+ }
+
+ var artifacts = await _db.FirmwareArtifacts
+ .Where(a => a.Version == firmwareVersion && a.BoardId == boardRef.Id)
+ .ToListAsync(ct);
+
+ if (artifacts.Count == 0)
+ {
+ return Problem(FirmwareError.FirmwareBoardNotFound);
+ }
+
+ var cdnBase = _apiConfig.Firmware.CdnBaseUrl.TrimEnd('/');
+ return Ok(new FirmwareBoardReleaseResponseDto
+ {
+ Version = firmwareVersion,
+ BoardId = boardRef.Name,
+ Artifacts = artifacts
+ .Select(a => FirmwareResponseMapper.ToArtifactDto(a, firmwareVersion, cdnBase))
+ .ToList()
+ });
+ }
+}
diff --git a/RepositoryServer/Enums/AdvisorySeverity.cs b/RepositoryServer/Enums/AdvisorySeverity.cs
new file mode 100644
index 0000000..f941a99
--- /dev/null
+++ b/RepositoryServer/Enums/AdvisorySeverity.cs
@@ -0,0 +1,13 @@
+namespace OpenShock.RepositoryServer.Enums;
+
+///
+/// Severity level for a firmware advisory surfaced on the manifest endpoint.
+/// Lowercase member names so the Postgres enum values match the public API contract
+/// (critical, warning, info).
+///
+public enum AdvisorySeverity
+{
+ Critical,
+ Warning,
+ Info
+}
diff --git a/RepositoryServer/Enums/DiscordNotificationEvent.cs b/RepositoryServer/Enums/DiscordNotificationEvent.cs
new file mode 100644
index 0000000..5136cf9
--- /dev/null
+++ b/RepositoryServer/Enums/DiscordNotificationEvent.cs
@@ -0,0 +1,13 @@
+namespace OpenShock.RepositoryServer.Enums;
+
+///
+/// Notification kinds a Discord webhook can subscribe to. Subscribing per event lets maintainer
+/// alerts go somewhere other than a public releases channel.
+///
+public enum DiscordNotificationEvent
+{
+ FirmwareReleasePublished,
+ DesktopModuleVersionPublished,
+ ReleaseNotesNeedEditing,
+ StagedReleaseExpired
+}
diff --git a/RepositoryServer/Enums/FirmwareArtifactType.cs b/RepositoryServer/Enums/FirmwareArtifactType.cs
new file mode 100644
index 0000000..8bbf9ec
--- /dev/null
+++ b/RepositoryServer/Enums/FirmwareArtifactType.cs
@@ -0,0 +1,10 @@
+namespace OpenShock.RepositoryServer.Enums;
+
+public enum FirmwareArtifactType
+{
+ Merged,
+ App,
+ Bootloader,
+ Partitions,
+ StaticFs
+}
diff --git a/RepositoryServer/Enums/FirmwareChipArchitecture.cs b/RepositoryServer/Enums/FirmwareChipArchitecture.cs
new file mode 100644
index 0000000..1ed0b7b
--- /dev/null
+++ b/RepositoryServer/Enums/FirmwareChipArchitecture.cs
@@ -0,0 +1,7 @@
+namespace OpenShock.RepositoryServer.Enums;
+
+public enum FirmwareChipArchitecture
+{
+ Xtensa,
+ RiscV
+}
diff --git a/RepositoryServer/Enums/ReleaseChannel.cs b/RepositoryServer/Enums/ReleaseChannel.cs
new file mode 100644
index 0000000..f39d321
--- /dev/null
+++ b/RepositoryServer/Enums/ReleaseChannel.cs
@@ -0,0 +1,8 @@
+namespace OpenShock.RepositoryServer.Enums;
+
+public enum ReleaseChannel
+{
+ Stable,
+ Beta,
+ Develop
+}
diff --git a/RepositoryServer/Enums/ReleaseNoteSectionType.cs b/RepositoryServer/Enums/ReleaseNoteSectionType.cs
new file mode 100644
index 0000000..88e6a71
--- /dev/null
+++ b/RepositoryServer/Enums/ReleaseNoteSectionType.cs
@@ -0,0 +1,9 @@
+namespace OpenShock.RepositoryServer.Enums;
+
+public enum ReleaseNoteSectionType
+{
+ Warning,
+ Info,
+ Breaking,
+ Section
+}
diff --git a/RepositoryServer/Enums/ReleaseStatus.cs b/RepositoryServer/Enums/ReleaseStatus.cs
new file mode 100644
index 0000000..14d9e4f
--- /dev/null
+++ b/RepositoryServer/Enums/ReleaseStatus.cs
@@ -0,0 +1,10 @@
+namespace OpenShock.RepositoryServer.Enums;
+
+public enum ReleaseStatus
+{
+ Staging,
+ Editing,
+ Published,
+ Archived,
+ Aborted
+}
diff --git a/RepositoryServer/Enums/RepositoryProvider.cs b/RepositoryServer/Enums/RepositoryProvider.cs
new file mode 100644
index 0000000..4b019f4
--- /dev/null
+++ b/RepositoryServer/Enums/RepositoryProvider.cs
@@ -0,0 +1,11 @@
+namespace OpenShock.RepositoryServer.Enums;
+
+///
+/// Source-code provider for the shared repositories table. Only GitHub is
+/// supported today; new providers require a matching OIDC handler and URL builder.
+/// Member name uses single-word casing so the Postgres enum value is github.
+///
+public enum RepositoryProvider
+{
+ Github
+}
diff --git a/RepositoryServer/Enums/RepositoryScope.cs b/RepositoryServer/Enums/RepositoryScope.cs
new file mode 100644
index 0000000..5da49fd
--- /dev/null
+++ b/RepositoryServer/Enums/RepositoryScope.cs
@@ -0,0 +1,15 @@
+namespace OpenShock.RepositoryServer.Enums;
+
+///
+/// What a registered repository is permitted to publish.
+///
+///
+/// Firmware and desktop module ingestion share one authentication scheme, so without scopes any
+/// repository registered to publish desktop modules could also initialise and publish firmware
+/// releases. Scopes keep an onboarding grant limited to what it was meant for.
+///
+public enum RepositoryScope
+{
+ PublishFirmware,
+ PublishModules
+}
diff --git a/DesktopRepositoryServer/Errors/AuthResultError.cs b/RepositoryServer/Errors/AuthResultError.cs
similarity index 77%
rename from DesktopRepositoryServer/Errors/AuthResultError.cs
rename to RepositoryServer/Errors/AuthResultError.cs
index 421c60d..0109425 100644
--- a/DesktopRepositoryServer/Errors/AuthResultError.cs
+++ b/RepositoryServer/Errors/AuthResultError.cs
@@ -1,7 +1,7 @@
using System.Net;
-using OpenShock.Desktop.RepositoryServer.Problems;
+using OpenShock.RepositoryServer.Problems;
-namespace OpenShock.Desktop.RepositoryServer.Errors;
+namespace OpenShock.RepositoryServer.Errors;
public static class AuthResultError
{
diff --git a/DesktopRepositoryServer/Errors/ExceptionError.cs b/RepositoryServer/Errors/ExceptionError.cs
similarity index 51%
rename from DesktopRepositoryServer/Errors/ExceptionError.cs
rename to RepositoryServer/Errors/ExceptionError.cs
index 4425b49..a8c3762 100644
--- a/DesktopRepositoryServer/Errors/ExceptionError.cs
+++ b/RepositoryServer/Errors/ExceptionError.cs
@@ -1,6 +1,6 @@
-using OpenShock.Desktop.RepositoryServer.Problems;
+using OpenShock.RepositoryServer.Problems;
-namespace OpenShock.Desktop.RepositoryServer.Errors;
+namespace OpenShock.RepositoryServer.Errors;
public static class ExceptionError
{
diff --git a/DesktopRepositoryServer/ExceptionHandler/ExceptionHandler.cs b/RepositoryServer/ExceptionHandler/ExceptionHandler.cs
similarity index 90%
rename from DesktopRepositoryServer/ExceptionHandler/ExceptionHandler.cs
rename to RepositoryServer/ExceptionHandler/ExceptionHandler.cs
index b6f074e..51f1b8a 100644
--- a/DesktopRepositoryServer/ExceptionHandler/ExceptionHandler.cs
+++ b/RepositoryServer/ExceptionHandler/ExceptionHandler.cs
@@ -1,8 +1,8 @@
using System.Net;
using Microsoft.AspNetCore.Diagnostics;
-using OpenShock.Desktop.RepositoryServer.Errors;
+using OpenShock.RepositoryServer.Errors;
-namespace OpenShock.Desktop.RepositoryServer.ExceptionHandler;
+namespace OpenShock.RepositoryServer.ExceptionHandler;
public sealed class OpenShockExceptionHandler : IExceptionHandler
{
@@ -19,7 +19,7 @@ public async ValueTask TryHandleAsync(HttpContext context, Exception excep
{
_logger.LogError(exception, "An unhandled exception occurred while processing the request.");
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
-
+
var responseObject = ExceptionError.Exception;
responseObject.AddContext(context);
diff --git a/DesktopRepositoryServer/ExceptionHandler/RequestInfo.cs b/RepositoryServer/ExceptionHandler/RequestInfo.cs
similarity index 87%
rename from DesktopRepositoryServer/ExceptionHandler/RequestInfo.cs
rename to RepositoryServer/ExceptionHandler/RequestInfo.cs
index a59ae1b..c6e87b8 100644
--- a/DesktopRepositoryServer/ExceptionHandler/RequestInfo.cs
+++ b/RepositoryServer/ExceptionHandler/RequestInfo.cs
@@ -1,5 +1,5 @@
// ReSharper disable UnusedAutoPropertyAccessor.Global
-namespace OpenShock.Desktop.RepositoryServer.ExceptionHandler;
+namespace OpenShock.RepositoryServer.ExceptionHandler;
public sealed class RequestInfo
{
diff --git a/DesktopRepositoryServer/Migrations/20250315184152_Initial.Designer.cs b/RepositoryServer/Migrations/20250315184152_Initial.Designer.cs
similarity index 87%
rename from DesktopRepositoryServer/Migrations/20250315184152_Initial.Designer.cs
rename to RepositoryServer/Migrations/20250315184152_Initial.Designer.cs
index b40cfa2..905a9c4 100644
--- a/DesktopRepositoryServer/Migrations/20250315184152_Initial.Designer.cs
+++ b/RepositoryServer/Migrations/20250315184152_Initial.Designer.cs
@@ -4,11 +4,11 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
-using OpenShock.Desktop.RepositoryServer.RepoServerDb;
+using OpenShock.RepositoryServer.RepoServerDb;
#nullable disable
-namespace OpenShock.Desktop.RepositoryServer.Migrations
+namespace OpenShock.RepositoryServer.Migrations
{
[DbContext(typeof(MigrationOpenShockContext))]
[Migration("20250315184152_Initial")]
@@ -24,7 +24,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
- modelBuilder.Entity("OpenShock.Desktop.RepositoryServer.RepoServerDb.Module", b =>
+ modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.Module", b =>
{
b.Property("Id")
.HasMaxLength(128)
@@ -58,7 +58,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
b.ToTable("modules", (string)null);
});
- modelBuilder.Entity("OpenShock.Desktop.RepositoryServer.RepoServerDb.Version", b =>
+ modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.Version", b =>
{
b.Property("VersionName")
.HasMaxLength(64)
@@ -99,9 +99,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
b.ToTable("versions", (string)null);
});
- modelBuilder.Entity("OpenShock.Desktop.RepositoryServer.RepoServerDb.Version", b =>
+ modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.Version", b =>
{
- b.HasOne("OpenShock.Desktop.RepositoryServer.RepoServerDb.Module", "ModuleNavigation")
+ b.HasOne("OpenShock.RepositoryServer.RepoServerDb.Module", "ModuleNavigation")
.WithMany("Versions")
.HasForeignKey("Module")
.OnDelete(DeleteBehavior.Cascade)
@@ -111,7 +111,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
b.Navigation("ModuleNavigation");
});
- modelBuilder.Entity("OpenShock.Desktop.RepositoryServer.RepoServerDb.Module", b =>
+ modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.Module", b =>
{
b.Navigation("Versions");
});
diff --git a/DesktopRepositoryServer/Migrations/20250315184152_Initial.cs b/RepositoryServer/Migrations/20250315184152_Initial.cs
similarity index 98%
rename from DesktopRepositoryServer/Migrations/20250315184152_Initial.cs
rename to RepositoryServer/Migrations/20250315184152_Initial.cs
index 91b0dea..1c88190 100644
--- a/DesktopRepositoryServer/Migrations/20250315184152_Initial.cs
+++ b/RepositoryServer/Migrations/20250315184152_Initial.cs
@@ -2,7 +2,7 @@
#nullable disable
-namespace OpenShock.Desktop.RepositoryServer.Migrations
+namespace OpenShock.RepositoryServer.Migrations
{
///
public partial class Initial : Migration
diff --git a/RepositoryServer/Migrations/20260813123629_UnifiedRepositoryServer.Designer.cs b/RepositoryServer/Migrations/20260813123629_UnifiedRepositoryServer.Designer.cs
new file mode 100644
index 0000000..ffb9696
--- /dev/null
+++ b/RepositoryServer/Migrations/20260813123629_UnifiedRepositoryServer.Designer.cs
@@ -0,0 +1,827 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using OpenShock.RepositoryServer.Enums;
+using OpenShock.RepositoryServer.RepoServerDb;
+
+#nullable disable
+
+namespace OpenShock.RepositoryServer.Migrations
+{
+ [DbContext(typeof(MigrationOpenShockContext))]
+ [Migration("20260813123629_UnifiedRepositoryServer")]
+ partial class UnifiedRepositoryServer
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "advisory_severity", new[] { "critical", "info", "warning" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "discord_notification_event", new[] { "desktop_module_version_published", "firmware_release_published", "release_notes_need_editing", "staged_release_expired" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "firmware_artifact_type", new[] { "app", "bootloader", "merged", "partitions", "static_fs" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "firmware_chip_architecture", new[] { "risc_v", "xtensa" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "firmware_release_note_type", new[] { "breaking", "info", "section", "warning" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "release_channel", new[] { "beta", "develop", "stable" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "release_status", new[] { "aborted", "archived", "editing", "published", "staging" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "repository_provider", new[] { "github" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "repository_scope", new[] { "publish_firmware", "publish_modules" });
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("OpenShock.RepositoryServer.RepoServerDb.DiscordWebhook", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Enabled")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("enabled");
+
+ b.PrimitiveCollection("Events")
+ .IsRequired()
+ .HasColumnType("discord_notification_event[]")
+ .HasColumnName("events");
+
+ b.Property