Add /api/pair-code (auth) + /api/pair (public) endpoints
This commit is contained in:
@@ -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<HttpClient> 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<Dictionary<string, object>>();
|
||||
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<Dictionary<string, object>>())!["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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<AppDbContext>(opt =>
|
||||
builder.Services.AddAppIdentity();
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
builder.Services.AddScoped<backend.Devices.DeviceTokenService>();
|
||||
builder.Services.AddScoped<backend.Pairing.PairingService>();
|
||||
|
||||
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 }));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user