From b5be4c6914f2680e8e88a264748658509c9fb8fd Mon Sep 17 00:00:00 2001 From: Assistant builder Date: Thu, 11 Jun 2026 18:56:41 +0000 Subject: [PATCH] Add /api/pair-code (auth) + /api/pair (public) endpoints --- backend.tests/PairingEndpointsTests.cs | 73 ++++++++++++++++++++++++++ backend/Pairing/PairingEndpoints.cs | 47 +++++++++++++++++ backend/Program.cs | 5 ++ 3 files changed, 125 insertions(+) create mode 100644 backend.tests/PairingEndpointsTests.cs create mode 100644 backend/Pairing/PairingEndpoints.cs diff --git a/backend.tests/PairingEndpointsTests.cs b/backend.tests/PairingEndpointsTests.cs new file mode 100644 index 0000000..88dd60b --- /dev/null +++ b/backend.tests/PairingEndpointsTests.cs @@ -0,0 +1,73 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Xunit; + +namespace backend.tests; + +public class PairingEndpointsTests +{ + private static async Task SignedInClient(ApiFactory factory, string email = "owner@example.com") + { + var client = factory.CreateClient(); + await client.PostAsJsonAsync("/api/auth/register", + new { email, password = "Passw0rd!" }); + await client.PostAsJsonAsync("/api/auth/login", + new { email, password = "Passw0rd!" }); + return client; + } + + [Fact] + public async Task Anonymous_user_cannot_request_a_pairing_code() + { + using var factory = new ApiFactory(); + var client = factory.CreateClient(); + var res = await client.PostAsJsonAsync("/api/pair-code", new { name = "kitchen" }); + res.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Pair_code_then_pair_returns_token_and_device_id() + { + using var factory = new ApiFactory(); + var client = await SignedInClient(factory); + var codeRes = await client.PostAsJsonAsync("/api/pair-code", new { name = "kitchen" }); + codeRes.StatusCode.Should().Be(HttpStatusCode.OK); + var codeBody = await codeRes.Content.ReadFromJsonAsync>(); + var code = codeBody!["code"].ToString()!; + + var pairRes = await factory.CreateClient().PostAsJsonAsync("/api/pair", + new { code, hostname = "rpi", client_version = "0.1" }); + pairRes.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await pairRes.Content.ReadAsStringAsync(); + body.Should().Contain("device_id"); + body.Should().Contain("device_token"); + body.Should().Contain("backend_ws"); + } + + [Fact] + public async Task Pair_with_invalid_code_returns_404() + { + using var factory = new ApiFactory(); + var res = await factory.CreateClient().PostAsJsonAsync("/api/pair", + new { code = "ZZZZZZZZ", hostname = "rpi", client_version = "0.1" }); + res.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Pair_with_consumed_code_returns_404() + { + using var factory = new ApiFactory(); + var client = await SignedInClient(factory); + var codeRes = await client.PostAsJsonAsync("/api/pair-code", new { name = "kitchen" }); + var code = (await codeRes.Content.ReadFromJsonAsync>())!["code"].ToString()!; + + var first = await factory.CreateClient().PostAsJsonAsync("/api/pair", + new { code, hostname = "rpi", client_version = "0.1" }); + first.StatusCode.Should().Be(HttpStatusCode.OK); + + var second = await factory.CreateClient().PostAsJsonAsync("/api/pair", + new { code, hostname = "rpi", client_version = "0.1" }); + second.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} diff --git a/backend/Pairing/PairingEndpoints.cs b/backend/Pairing/PairingEndpoints.cs new file mode 100644 index 0000000..98dc817 --- /dev/null +++ b/backend/Pairing/PairingEndpoints.cs @@ -0,0 +1,47 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Mvc; + +namespace backend.Pairing; + +public record IssueCodeRequest(string Name); +public record PairRequest(string Code, string Hostname, string Client_version); + +public static class PairingEndpoints +{ + public static IEndpointRouteBuilder MapPairingEndpoints(this IEndpointRouteBuilder app) + { + app.MapPost("/api/pair-code", async ( + [FromBody] IssueCodeRequest req, + PairingService svc, + HttpContext http, + CancellationToken ct) => + { + var uidStr = http.User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!Guid.TryParse(uidStr, out var uid)) return Results.Unauthorized(); + if (string.IsNullOrWhiteSpace(req.Name)) + return Results.BadRequest(new { error = "name is required" }); + var pc = await svc.IssueAsync(uid, req.Name.Trim(), ct); + return Results.Ok(new { code = pc.Code, expires_at = pc.ExpiresAt }); + }).RequireAuthorization(); + + app.MapPost("/api/pair", async ( + [FromBody] PairRequest req, + PairingService svc, + HttpContext http, + CancellationToken ct) => + { + var result = await svc.ConsumeAsync(req.Code, ct); + if (result is null) return Results.NotFound(new { error = "code invalid or expired" }); + var scheme = http.Request.Scheme == "https" ? "wss" : "ws"; + var host = http.Request.Host.ToString(); + return Results.Ok(new + { + device_id = result.DeviceId, + device_token = result.DeviceToken, + backend_ws = $"{scheme}://{host}/device", + }); + }); + + return app; + } +} diff --git a/backend/Program.cs b/backend/Program.cs index f4c0978..34e509e 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -1,5 +1,6 @@ using backend.Auth; using backend.Data; +using backend.Pairing; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); @@ -10,6 +11,9 @@ builder.Services.AddDbContext(opt => builder.Services.AddAppIdentity(); builder.Services.AddAuthorization(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + var app = builder.Build(); using (var scope = app.Services.CreateScope()) @@ -30,6 +34,7 @@ app.UseAuthentication(); app.UseAuthorization(); app.MapAuthEndpoints(); +app.MapPairingEndpoints(); app.MapGet("/health", () => Results.Ok(new { ok = true }));