Add /api/devices CRUD (list, get, rename, revoke) with owner scoping
This commit is contained in:
@@ -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<Dictionary<string, object>>();
|
||||||
|
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<List<Dictionary<string, object>>>("/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<List<Dictionary<string, object>>>("/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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using backend.Auth;
|
using backend.Auth;
|
||||||
using backend.Data;
|
using backend.Data;
|
||||||
|
using backend.Devices;
|
||||||
using backend.Pairing;
|
using backend.Pairing;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ app.UseAuthorization();
|
|||||||
|
|
||||||
app.MapAuthEndpoints();
|
app.MapAuthEndpoints();
|
||||||
app.MapPairingEndpoints();
|
app.MapPairingEndpoints();
|
||||||
|
app.MapDevicesEndpoints();
|
||||||
|
|
||||||
app.MapGet("/health", () => Results.Ok(new { ok = true }));
|
app.MapGet("/health", () => Results.Ok(new { ok = true }));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user