Skip to content

Commit d880fe7

Browse files
committed
Implement stuff
1 parent 901f128 commit d880fe7

80 files changed

Lines changed: 4081 additions & 788 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CLAUDE.md‎

Lines changed: 79 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,63 +12,119 @@ Unified repository server for distributing OpenShock desktop app modules and fir
1212
- FlexLabs.EntityFrameworkCore.Upsert for upserts
1313
- MiniValidation for config validation
1414
- Semver NuGet package for semver parsing
15+
- OneOf for discriminated parser results
1516
- TUnit for tests
1617
- Scalar for API docs UI
1718
- Docker (Alpine) with self-signed HTTPS cert generation
1819

1920
## Project Structure
2021
```
2122
RepositoryServer/ # Main web API project
22-
AuthenticationHandlers/ # Custom AdminToken auth handler
23-
Config/ # ApiConfig, DbConfig, RepoConfig, MetricsConfig, FirmwareConfig
23+
AuthenticationHandlers/
24+
AdminTokenAuthentication.cs # AdminToken scheme (static header compare)
25+
GitHubOidcAuthentication.cs # JwtBearer + GitHub OIDC claim handling
26+
# auto-upserts repositories rows on first use
27+
Config/ # ApiConfig, DbConfig, RepoConfig, MetricsConfig,
28+
# FirmwareConfig, FirmwareCiCdConfig, FirmwareAdvisoryConfig, DiscordConfig
2429
Controllers/
2530
OpenShockControllerBase.cs # Shared base controller
26-
V1/ # Desktop endpoints
27-
RepoController.cs # GET /v1/ — desktop module manifest
28-
AdminController.cs # /v1/admin/... — desktop module CRUD
29-
V2/
30-
Firmware/
31-
LatestController.cs # GET /v2/firmware/latest/{channel}
32-
VersionsController.cs # GET /v2/firmware/versions[/{version}]
33-
BoardsController.cs # GET /v2/firmware/boards
34-
ChipsController.cs # GET /v2/firmware/chips
35-
AdminController.cs # /v2/firmware/admin/... CRUD
36-
Errors/ # Static error factories (AuthResultError, ExceptionError)
31+
V1/ # Desktop module endpoints (V1)
32+
RepoController.cs # GET /v1/ — desktop module manifest
33+
AdminController.cs # /v1/admin/... — desktop module CRUD
34+
CiCdController.cs # PUT /v1/cicd/modules/{id}/versions/{v} — desktop zip upload
35+
V2/Firmware/ # Firmware V2 endpoints
36+
ManifestController.cs # GET /v2/firmware/manifest (bootstrap payload)
37+
LatestController.cs # GET /v2/firmware/latest/{channel}[/{boardId}?version=]
38+
VersionsController.cs # GET /v2/firmware/versions[/{version}[/{boardId}]]
39+
BoardsController.cs # GET /v2/firmware/boards
40+
ChipsController.cs # GET /v2/firmware/chips
41+
ReleasesController.cs # POST/PUT/DELETE /v2/firmware/releases/... (CI/CD ingestion)
42+
Admin/ # AdminToken-protected CRUD (split by concern)
43+
RepositoriesController.cs # GET, DELETE /v2/firmware/admin/repositories
44+
VersionsAdminController.cs # DELETE /v2/firmware/admin/versions/{v}
45+
BoardsAdminController.cs # PUT, PATCH, DELETE /v2/firmware/admin/boards/{id}
46+
# + USB attach/detach
47+
ChipsAdminController.cs # PUT, DELETE /v2/firmware/admin/chips/{id} + USB attach/detach
48+
ReleasesAdminController.cs # PUT /v2/firmware/admin/releases/{id}/changelog (fix)
49+
UsbDevicesController.cs # PUT/GET/DELETE /v2/firmware/admin/usb-devices
50+
UsbSerialFiltersController.cs # PUT/GET/DELETE /v2/firmware/admin/usb-serial-filters
51+
Enums/ # ReleaseChannel, FirmwareArtifactType, FirmwareChipArchitecture,
52+
# ReleaseNoteSectionType, ReleaseStatus, RepositoryProvider
53+
Errors/ # AuthResultError, ExceptionError
3754
ExceptionHandler/ # Global IExceptionHandler + RequestInfo logging
38-
Migrations/ # EF Core migrations (Initial + AddFirmwareTables)
55+
Migrations/ # EF Core migrations
3956
Models/ # Desktop module DTOs
40-
Models/Firmware/ # Firmware DTOs (response + admin request)
57+
Models/Firmware/ # Firmware DTOs
58+
FirmwareManifestResponse, FirmwareReleaseDto, FirmwareVersionSummary,
59+
FirmwareBoardDetailDto, FirmwareChipRefDto, FirmwareBoardReleaseResponseDto,
60+
FirmwareSourceDto, FirmwareAdvisoryDto, FirmwareUsbDeviceDto,
61+
FirmwareUsbSerialFilterDto (+ admin variant), RepositoryDto, VersionListResponse,
62+
InitReleaseRequest (+ response), FixChangelogRequest, Upsert* requests
4163
Problems/ # RFC 7807 problem details (OpenShockProblem base)
4264
RepoServerDb/ # EF Core entities + RepoServerContext
43-
Utils/ # ByteArrayHexConverter, SemVersionConverter, etc.
44-
Program.cs # App startup and middleware pipeline
65+
SourceRepository, FirmwareVersion, FirmwareRelease, FirmwareArtifact, FirmwareReleaseNote,
66+
FirmwareStagedArtifact, FirmwareStagedReleaseNote, FirmwareBoard, FirmwareChip,
67+
UsbDevice, UsbSerialFilter, FirmwareChipUsbDevice, FirmwareBoardUsbDevice
68+
Services/
69+
IStorageService (+ BunnyCdn / Local / S3 impls)
70+
IDiscordNotificationService + DiscordNotificationService (webhook, fire-and-forget)
71+
StagedReleaseCleanupService # BackgroundService, PeriodicTimer 5 min, two TTLs
72+
Utils/
73+
ChangelogParser # Markdown → FirmwareReleaseNoteDto[] (OneOf-based)
74+
SourceUrlBuilder # provider-aware commit/ref/run URL construction
75+
FirmwareResponseMapper # shared release/artifact response projections
76+
CacheControlAttribute # filter per spec §8
77+
FirmwareArtifactFileNames, ByteArrayHexConverter, SemVersionConverter
78+
Program.cs # App startup: schemes, DbContextPool, HttpClient,
79+
# HostedService, storage, migrations, etc.
4580
46-
RepositoryServer.Tests/ # TUnit test project
81+
RepositoryServer.Tests/ # TUnit test project (references the main project)
82+
Utils/
83+
ChangelogParserTests
84+
SourceUrlBuilderTests
4785
docker/
4886
RepositoryServer.Dockerfile
4987
entrypoint.sh # Cert gen + `dotnet OpenShock.RepositoryServer.dll`
5088
appsettings.Container.json
5189
```
5290

5391
## Namespace
54-
`OpenShock.RepositoryServer` (renamed from `OpenShock.Desktop.RepositoryServer`)
92+
`OpenShock.RepositoryServer`
5593

5694
## Key Patterns
57-
- **Auth**: Custom `AdminTokenAuthentication` handler, `AuthSchemas.AdminToken` scheme
95+
- **Auth**: Two schemes. `AdminTokenAuthentication` (header compare) for admin endpoints;
96+
JwtBearer + `GitHubOidcAuthentication.Configure(...)` for CI/CD endpoints. The OIDC hook
97+
validates a GitHub Actions OIDC JWT, then looks up (or inserts) a matching row in the
98+
`repositories` table and attaches `AuthSchemas.CiCdClaims` to the principal.
99+
No config allowlist — the DB is authoritative.
58100
- **Error Handling**: `OpenShockProblem` base class → `ToObjectResult()` for RFC 7807
59101
- **Config**: Bind root config to `ApiConfig`, validated with MiniValidator, registered as singleton
102+
- **Background jobs**: `StagedReleaseCleanupService` (`BackgroundService` with `PeriodicTimer`)
103+
periodically aborts expired staging/editing releases per `FirmwareConfig.StagedReleaseTtl`
104+
and `EditingReleaseTtl`
60105
- **DB Migrations**: `MigrationOpenShockContext` subclass for migration tooling, auto-applied on startup
61106
- **JSON**: CamelCase, custom `SemVersionConverter` + `ByteArrayHexConverter`
62107
- **Upsert**: FlexLabs `.Upsert().On().RunAsync()` pattern
108+
- **Caching**: `CacheControlAttribute` action filter — public max-age on 2xx, no-store on errors
63109
- **CORS**: Allow all origins/methods/headers
64110
- **Metrics**: Prometheus at `/metrics`, restricted to private networks
111+
- **Notifications**: Fire-and-forget Discord webhooks via `IDiscordNotificationService`.
112+
Empty `Discord.WebhookUrls` → no-op.
65113

66114
## Database
67-
- PostgreSQL, connection in `ApiConfig.Db.Conn`
115+
- PostgreSQL (15+ required for `NULLS NOT DISTINCT` on `usb_serial_filters`), connection in `ApiConfig.Db.Conn`
116+
- Shared table: `repositories` (PK: id, UNIQUE `(provider, owner, repo)`)
68117
- Desktop tables: `modules` (PK: id), `versions` (composite PK: version+module, FK→modules CASCADE)
69-
- Firmware tables: `firmware_chips`, `firmware_boards`, `firmware_versions`, `firmware_artifacts`, `firmware_release_notes`
70-
- Firmware enums: `firmware_channel`, `firmware_artifact_type`, `firmware_release_note_type`, `firmware_chip_architecture`
118+
- Firmware tables: `firmware_chips`, `firmware_boards`, `firmware_versions`,
119+
`firmware_artifacts`, `firmware_release_notes`, `firmware_releases`,
120+
`firmware_staged_artifacts`, `firmware_staged_release_notes`
121+
- USB catalog: `usb_devices`, `usb_serial_filters`, `firmware_chip_usb_devices`,
122+
`firmware_board_usb_devices`
123+
- Firmware enums: `release_channel`, `firmware_artifact_type`, `firmware_release_note_type`,
124+
`firmware_chip_architecture`, `release_status`, `repository_provider`
71125
- URL convention for firmware artifacts: `{CdnBaseUrl}/{version}/{boardId}/{artifactType}.bin`
126+
- Source-traceability URLs (commit / ref / run) are built server-side per provider
127+
(`Utils/SourceUrlBuilder`) and never stored
72128

73129
## CI/CD
74130
- `ci-build.yml`: Test + build Docker + promote image + deploy prod (on master)

‎RepositoryServer.Tests/RepositoryServer.Tests.csproj‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<Nullable>enable</Nullable>
66
<IsPackable>false</IsPackable>
77
<IsTestProject>true</IsTestProject>
8-
<LangVersion>13</LangVersion>
8+
<LangVersion>default</LangVersion>
99
<AssemblyName>OpenShock.RepositoryServer.Tests</AssemblyName>
1010
<RootNamespace>OpenShock.RepositoryServer.Tests</RootNamespace>
1111
<Company>OpenShock.RepositoryServer.Tests</Company>
@@ -14,4 +14,7 @@
1414
<ItemGroup>
1515
<PackageReference Include="TUnit" />
1616
</ItemGroup>
17-
</Project>
17+
<ItemGroup>
18+
<ProjectReference Include="..\RepositoryServer\RepositoryServer.csproj" />
19+
</ItemGroup>
20+
</Project>

‎RepositoryServer.Tests/UnitTest1.cs‎

Lines changed: 0 additions & 10 deletions
This file was deleted.
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
using OpenShock.RepositoryServer.Utils;
2+
3+
namespace OpenShock.RepositoryServer.Tests.Utils;
4+
5+
public class ChangelogParserTests
6+
{
7+
[Test]
8+
public async Task Parse_Empty_ReturnsEmptyError()
9+
{
10+
var result = ChangelogParser.Parse("");
11+
await Assert.That(result.IsT1).IsTrue();
12+
await Assert.That(result.AsT1).IsEqualTo(ChangelogParseError.Empty);
13+
}
14+
15+
[Test]
16+
public async Task Parse_Whitespace_ReturnsEmptyError()
17+
{
18+
var result = ChangelogParser.Parse(" \n\n\n ");
19+
await Assert.That(result.IsT1).IsTrue();
20+
await Assert.That(result.AsT1).IsEqualTo(ChangelogParseError.Empty);
21+
}
22+
23+
[Test]
24+
public async Task Parse_NoHeadings_ReturnsNoHeadingsError()
25+
{
26+
var result = ChangelogParser.Parse("Just some text\nwithout headings.");
27+
await Assert.That(result.IsT1).IsTrue();
28+
await Assert.That(result.AsT1).IsEqualTo(ChangelogParseError.NoHeadings);
29+
}
30+
31+
[Test]
32+
public async Task Parse_AllSectionsEmpty_ReturnsAllSectionsEmptyError()
33+
{
34+
var result = ChangelogParser.Parse("### Info\n\n### Warning\n\n### Breaking\n");
35+
await Assert.That(result.IsT1).IsTrue();
36+
await Assert.That(result.AsT1).IsEqualTo(ChangelogParseError.AllSectionsEmpty);
37+
}
38+
39+
[Test]
40+
public async Task Parse_MapsBreakingWarningInfo_ToLowercaseEnumNames()
41+
{
42+
var input = "### Breaking\nLorem\n### Warning\nIpsum\n### Info\nDolor";
43+
var result = ChangelogParser.Parse(input);
44+
45+
await Assert.That(result.IsT0).IsTrue();
46+
var notes = result.AsT0;
47+
await Assert.That(notes).HasCount(3);
48+
await Assert.That(notes[0].Type).IsEqualTo("breaking");
49+
await Assert.That(notes[1].Type).IsEqualTo("warning");
50+
await Assert.That(notes[2].Type).IsEqualTo("info");
51+
}
52+
53+
[Test]
54+
public async Task Parse_CustomHeading_BecomesSectionWithTitle()
55+
{
56+
var input = "### Features\n- New dashboard\n- Bluetooth pairing";
57+
var result = ChangelogParser.Parse(input);
58+
59+
await Assert.That(result.IsT0).IsTrue();
60+
var notes = result.AsT0;
61+
await Assert.That(notes).HasCount(2);
62+
await Assert.That(notes[0].Type).IsEqualTo("section");
63+
await Assert.That(notes[0].Title).IsEqualTo("Features");
64+
await Assert.That(notes[0].Content).IsEqualTo("New dashboard");
65+
await Assert.That(notes[1].Title).IsEqualTo("Features");
66+
await Assert.That(notes[1].Content).IsEqualTo("Bluetooth pairing");
67+
}
68+
69+
[Test]
70+
public async Task Parse_ExtractsBoldTitlePrefix_WithEmDashSeparator()
71+
{
72+
var input = "### Breaking\n**Config format** — Changed to TOML";
73+
var result = ChangelogParser.Parse(input);
74+
75+
await Assert.That(result.IsT0).IsTrue();
76+
var notes = result.AsT0;
77+
await Assert.That(notes).HasCount(1);
78+
await Assert.That(notes[0].Type).IsEqualTo("breaking");
79+
await Assert.That(notes[0].Title).IsEqualTo("Config format");
80+
await Assert.That(notes[0].Content).IsEqualTo("Changed to TOML");
81+
}
82+
83+
[Test]
84+
public async Task Parse_MultiLineItemWithoutBullets_IsCombined()
85+
{
86+
var input = "### Info\nLine one\nLine two";
87+
var result = ChangelogParser.Parse(input);
88+
89+
await Assert.That(result.IsT0).IsTrue();
90+
var notes = result.AsT0;
91+
await Assert.That(notes).HasCount(1);
92+
await Assert.That(notes[0].Content).IsEqualTo("Line one\nLine two");
93+
}
94+
95+
[Test]
96+
public async Task Parse_PlainItem_NoTitleExtracted()
97+
{
98+
var input = "### Info\n- Fixed WiFi reconnection";
99+
var result = ChangelogParser.Parse(input);
100+
101+
await Assert.That(result.IsT0).IsTrue();
102+
var notes = result.AsT0;
103+
await Assert.That(notes).HasCount(1);
104+
await Assert.That(notes[0].Type).IsEqualTo("info");
105+
await Assert.That(notes[0].Title).IsNull();
106+
await Assert.That(notes[0].Content).IsEqualTo("Fixed WiFi reconnection");
107+
}
108+
109+
[Test]
110+
public async Task Parse_SpecExample_MatchesGoldenStructure()
111+
{
112+
var input = string.Join("\n",
113+
"### Breaking",
114+
"**Config format** — Changed config format to TOML",
115+
"",
116+
"### Warning",
117+
"Requires hub reset after update",
118+
"",
119+
"### Info",
120+
"- Fixed WiFi reconnection",
121+
"- Improved battery life",
122+
"- **OTA** — Added automatic rollback on failed flash",
123+
"",
124+
"### Features",
125+
"- New web dashboard",
126+
"- Bluetooth pairing support");
127+
var result = ChangelogParser.Parse(input);
128+
129+
await Assert.That(result.IsT0).IsTrue();
130+
var notes = result.AsT0;
131+
await Assert.That(notes).HasCount(7);
132+
133+
await Assert.That(notes[0].Type).IsEqualTo("breaking");
134+
await Assert.That(notes[0].Title).IsEqualTo("Config format");
135+
await Assert.That(notes[0].Content).IsEqualTo("Changed config format to TOML");
136+
137+
await Assert.That(notes[1].Type).IsEqualTo("warning");
138+
await Assert.That(notes[1].Content).IsEqualTo("Requires hub reset after update");
139+
140+
await Assert.That(notes[2].Type).IsEqualTo("info");
141+
await Assert.That(notes[2].Content).IsEqualTo("Fixed WiFi reconnection");
142+
await Assert.That(notes[3].Content).IsEqualTo("Improved battery life");
143+
await Assert.That(notes[4].Title).IsEqualTo("OTA");
144+
await Assert.That(notes[4].Content).IsEqualTo("Added automatic rollback on failed flash");
145+
146+
await Assert.That(notes[5].Type).IsEqualTo("section");
147+
await Assert.That(notes[5].Title).IsEqualTo("Features");
148+
await Assert.That(notes[5].Content).IsEqualTo("New web dashboard");
149+
await Assert.That(notes[6].Content).IsEqualTo("Bluetooth pairing support");
150+
}
151+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
using OpenShock.RepositoryServer.Enums;
2+
using OpenShock.RepositoryServer.Utils;
3+
4+
namespace OpenShock.RepositoryServer.Tests.Utils;
5+
6+
public class SourceUrlBuilderTests
7+
{
8+
[Test]
9+
public async Task BuildCommitUrl_Github_ReturnsCanonicalCommitUrl()
10+
{
11+
var url = SourceUrlBuilder.BuildCommitUrl(RepositoryProvider.Github, "openshock", "firmware", "21e43623abcdef1234567890abcdef1234567890");
12+
await Assert.That(url).IsEqualTo("https://github.com/openshock/firmware/commit/21e43623abcdef1234567890abcdef1234567890");
13+
}
14+
15+
[Test]
16+
public async Task BuildCommitUrl_EmptyHash_ReturnsNull()
17+
{
18+
var url = SourceUrlBuilder.BuildCommitUrl(RepositoryProvider.Github, "openshock", "firmware", "");
19+
await Assert.That(url).IsNull();
20+
}
21+
22+
[Test]
23+
public async Task BuildRefUrl_TagRef_ReturnsReleasesTagUrl()
24+
{
25+
var url = SourceUrlBuilder.BuildRefUrl(RepositoryProvider.Github, "openshock", "firmware", "refs/tags/v1.5.1");
26+
await Assert.That(url).IsEqualTo("https://github.com/openshock/firmware/releases/tag/v1.5.1");
27+
}
28+
29+
[Test]
30+
public async Task BuildRefUrl_BranchRef_ReturnsTreeUrl()
31+
{
32+
var url = SourceUrlBuilder.BuildRefUrl(RepositoryProvider.Github, "openshock", "firmware", "refs/heads/main");
33+
await Assert.That(url).IsEqualTo("https://github.com/openshock/firmware/tree/main");
34+
}
35+
36+
[Test]
37+
public async Task BuildRefUrl_BareRef_TreatedAsBranch()
38+
{
39+
var url = SourceUrlBuilder.BuildRefUrl(RepositoryProvider.Github, "openshock", "firmware", "develop");
40+
await Assert.That(url).IsEqualTo("https://github.com/openshock/firmware/tree/develop");
41+
}
42+
43+
[Test]
44+
public async Task BuildRefUrl_Null_ReturnsNull()
45+
{
46+
var url = SourceUrlBuilder.BuildRefUrl(RepositoryProvider.Github, "openshock", "firmware", null);
47+
await Assert.That(url).IsNull();
48+
}
49+
50+
[Test]
51+
public async Task BuildRunUrl_Github_ReturnsActionsRunUrl()
52+
{
53+
var url = SourceUrlBuilder.BuildRunUrl(RepositoryProvider.Github, "openshock", "firmware", "12345678901");
54+
await Assert.That(url).IsEqualTo("https://github.com/openshock/firmware/actions/runs/12345678901");
55+
}
56+
57+
[Test]
58+
public async Task BuildRunUrl_Empty_ReturnsNull()
59+
{
60+
var url = SourceUrlBuilder.BuildRunUrl(RepositoryProvider.Github, "openshock", "firmware", "");
61+
await Assert.That(url).IsNull();
62+
}
63+
}

0 commit comments

Comments
 (0)