From e5ae228738392914e7ba5d9bc90d66aef4295e37 Mon Sep 17 00:00:00 2001 From: Assistant builder Date: Thu, 11 Jun 2026 19:00:42 +0000 Subject: [PATCH] Add /api/devices CRUD (list, get, rename, revoke) with owner scoping --- backend.tests/DevicesEndpointsTests.cs | 77 ++++++++++++++++++++++++ backend/Devices/DevicesEndpoints.cs | 83 ++++++++++++++++++++++++++ backend/Program.cs | 2 + 3 files changed, 162 insertions(+) create mode 100644 backend.tests/DevicesEndpointsTests.cs create mode 100644 backend/Devices/DevicesEndpoints.cs diff --git a/backend.tests/DevicesEndpointsTests.cs b/backend.tests/DevicesEndpointsTests.cs new file mode 100644 index 0000000..2bdc00a --- /dev/null +++ b/backend.tests/DevicesEndpointsTests.cs @@ -0,0 +1,77 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Xunit; + +namespace backend.tests; + +public class DevicesEndpointsTests +{ + private static async Task<(HttpClient signedIn, string code)> SetupWithPairCode(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!" }); + var codeRes = await client.PostAsJsonAsync("/api/pair-code", new { name = "kitchen" }); + var body = await codeRes.Content.ReadFromJsonAsync>(); + return (client, body!["code"].ToString()!); + } + + [Fact] + public async Task User_sees_only_their_own_devices() + { + using var factory = new ApiFactory(); + var (alice, codeA) = await SetupWithPairCode(factory, "alice@example.com"); + await factory.CreateClient().PostAsJsonAsync("/api/pair", + new { code = codeA, hostname = "rpi", client_version = "0.1" }); + + var (bob, codeB) = await SetupWithPairCode(factory, "bob@example.com"); + await factory.CreateClient().PostAsJsonAsync("/api/pair", + new { code = codeB, hostname = "rpi", client_version = "0.1" }); + + var aliceList = await alice.GetStringAsync("/api/devices"); + var bobList = await bob.GetStringAsync("/api/devices"); + + aliceList.Should().Contain("kitchen"); + bobList.Should().Contain("kitchen"); + aliceList.Should().NotBe(bobList); + } + + [Fact] + public async Task Rename_device_updates_name() + { + using var factory = new ApiFactory(); + var (client, code) = await SetupWithPairCode(factory); + await factory.CreateClient().PostAsJsonAsync("/api/pair", + new { code, hostname = "rpi", client_version = "0.1" }); + + var listJson = await client.GetFromJsonAsync>>("/api/devices"); + var id = listJson![0]["id"].ToString(); + + var patchRes = await client.PatchAsJsonAsync($"/api/devices/{id}", new { name = "living-room" }); + patchRes.StatusCode.Should().Be(HttpStatusCode.OK); + + var after = await client.GetStringAsync($"/api/devices/{id}"); + after.Should().Contain("living-room"); + } + + [Fact] + public async Task Revoke_device_sets_is_revoked_true() + { + using var factory = new ApiFactory(); + var (client, code) = await SetupWithPairCode(factory); + await factory.CreateClient().PostAsJsonAsync("/api/pair", + new { code, hostname = "rpi", client_version = "0.1" }); + + var listJson = await client.GetFromJsonAsync>>("/api/devices"); + var id = listJson![0]["id"].ToString(); + + var del = await client.DeleteAsync($"/api/devices/{id}"); + del.StatusCode.Should().Be(HttpStatusCode.OK); + + var after = await client.GetStringAsync($"/api/devices/{id}"); + after.Should().Contain("\"isRevoked\":true"); + } +} diff --git a/backend/Devices/DevicesEndpoints.cs b/backend/Devices/DevicesEndpoints.cs new file mode 100644 index 0000000..b2ba396 --- /dev/null +++ b/backend/Devices/DevicesEndpoints.cs @@ -0,0 +1,83 @@ +using System.Security.Claims; +using backend.Data; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace backend.Devices; + +public record RenameRequest(string Name); + +public static class DevicesEndpoints +{ + public static IEndpointRouteBuilder MapDevicesEndpoints(this IEndpointRouteBuilder app) + { + var grp = app.MapGroup("/api/devices").RequireAuthorization(); + + grp.MapGet("/", async (AppDbContext db, HttpContext http, CancellationToken ct) => + { + var uid = GetUserId(http); + var devs = await db.Devices + .Where(d => d.OwnerUserId == uid) + .Select(d => new + { + id = d.Id, + name = d.Name, + pairedAt = d.PairedAt, + lastSeenAt = d.LastSeenAt, + isRevoked = d.IsRevoked, + }) + .ToListAsync(ct); + return Results.Ok(devs); + }); + + grp.MapGet("/{id:guid}", async (Guid id, AppDbContext db, HttpContext http, CancellationToken ct) => + { + var uid = GetUserId(http); + var d = await db.Devices.Include(x => x.Config) + .FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == uid, ct); + return d is null + ? Results.NotFound() + : Results.Ok(new + { + id = d.Id, + name = d.Name, + pairedAt = d.PairedAt, + lastSeenAt = d.LastSeenAt, + isRevoked = d.IsRevoked, + config = d.Config, + }); + }); + + grp.MapPatch("/{id:guid}", async ( + Guid id, [FromBody] RenameRequest req, + AppDbContext db, HttpContext http, CancellationToken ct) => + { + var uid = GetUserId(http); + var d = await db.Devices.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == uid, ct); + if (d is null) return Results.NotFound(); + if (string.IsNullOrWhiteSpace(req.Name)) + return Results.BadRequest(new { error = "name is required" }); + d.Name = req.Name.Trim(); + await db.SaveChangesAsync(ct); + return Results.Ok(); + }); + + grp.MapDelete("/{id:guid}", async (Guid id, AppDbContext db, HttpContext http, CancellationToken ct) => + { + var uid = GetUserId(http); + var d = await db.Devices.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == uid, ct); + if (d is null) return Results.NotFound(); + d.IsRevoked = true; + await db.SaveChangesAsync(ct); + return Results.Ok(); + }); + + return app; + } + + private static Guid GetUserId(HttpContext http) + { + var s = http.User.FindFirstValue(ClaimTypes.NameIdentifier); + return Guid.TryParse(s, out var g) ? g : Guid.Empty; + } +} diff --git a/backend/Program.cs b/backend/Program.cs index 34e509e..a8b2553 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -1,5 +1,6 @@ using backend.Auth; using backend.Data; +using backend.Devices; using backend.Pairing; using Microsoft.EntityFrameworkCore; @@ -35,6 +36,7 @@ app.UseAuthorization(); app.MapAuthEndpoints(); app.MapPairingEndpoints(); +app.MapDevicesEndpoints(); app.MapGet("/health", () => Results.Ok(new { ok = true }));