feat(online-inbox): carry ownerId on sync to prepare for multi-user
Plumb a per-resource owner (Zitadel sub) through the sync contract without enforcing isolation client-side — the server stays the authority. - Dtos: add optional ownerId to RemoteList/RemoteTask/MirrorTask - JwtClaims: decode the sub claim from the access token (never throws) - OnlineSyncService: stamp ownerId on pushed lists + mirror; defensively skip pulled tasks owned by a different user (unowned tasks still sync, so single-user behavior is unchanged) - docs: contract documents ownerId + multi-user readiness Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
40
tests/ClaudeDo.Worker.Tests/Online/JwtClaimsTests.cs
Normal file
40
tests/ClaudeDo.Worker.Tests/Online/JwtClaimsTests.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using System.Text;
|
||||
using ClaudeDo.Worker.Online;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Online;
|
||||
|
||||
public sealed class JwtClaimsTests
|
||||
{
|
||||
// Builds a fake JWT (header.payload.signature) carrying the given JSON payload.
|
||||
internal static string MakeToken(string payloadJson)
|
||||
{
|
||||
static string B64Url(string s) =>
|
||||
Convert.ToBase64String(Encoding.UTF8.GetBytes(s))
|
||||
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
return $"{B64Url("{\"alg\":\"RS256\"}")}.{B64Url(payloadJson)}.sig";
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSubject_ReturnsSub()
|
||||
{
|
||||
var token = MakeToken("{\"sub\":\"user-123\",\"email\":\"a@b.c\"}");
|
||||
Assert.Equal("user-123", JwtClaims.GetSubject(token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSubject_Null_WhenNoSub()
|
||||
{
|
||||
var token = MakeToken("{\"email\":\"a@b.c\"}");
|
||||
Assert.Null(JwtClaims.GetSubject(token));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("not-a-jwt")]
|
||||
[InlineData("opaque.token")]
|
||||
public void GetSubject_Null_ForUnparseableInput(string? token)
|
||||
{
|
||||
Assert.Null(JwtClaims.GetSubject(token));
|
||||
}
|
||||
}
|
||||
@@ -209,6 +209,76 @@ public sealed class OnlineSyncServiceTests : IDisposable
|
||||
Assert.Equal(0, api.CallCount);
|
||||
}
|
||||
|
||||
// ---- multi-user: owner stamping + guard ----
|
||||
|
||||
[Fact]
|
||||
public async Task Tick_StampsOwnerId_OnPushedListsAndMirror()
|
||||
{
|
||||
var (listId, ctx, tasks, _) = await SeedAsync();
|
||||
using var _ = ctx;
|
||||
await tasks.AddAsync(new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(), ListId = listId, Title = "Idle",
|
||||
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
|
||||
var token = JwtClaimsTests.MakeToken("{\"sub\":\"owner-1\"}");
|
||||
var api = new FakeApi();
|
||||
var svc = BuildService(api, token);
|
||||
|
||||
await svc.TickAsync(CancellationToken.None);
|
||||
|
||||
Assert.All(api.ReceivedLists, l => Assert.Equal("owner-1", l.OwnerId));
|
||||
Assert.NotEmpty(api.ReceivedMirror);
|
||||
Assert.All(api.ReceivedMirror, m => Assert.Equal("owner-1", m.OwnerId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tick_SkipsRemoteTask_OwnedByAnotherUser()
|
||||
{
|
||||
var (listId, ctx, tasks, _) = await SeedAsync();
|
||||
using var _ = ctx;
|
||||
|
||||
var foreignId = Guid.NewGuid().ToString();
|
||||
var api = new FakeApi
|
||||
{
|
||||
UnimportedTasks =
|
||||
[
|
||||
new RemoteTask(foreignId, listId, "Theirs", null, DateTimeOffset.UtcNow, OwnerId: "owner-2"),
|
||||
],
|
||||
};
|
||||
var svc = BuildService(api, JwtClaimsTests.MakeToken("{\"sub\":\"owner-1\"}"));
|
||||
|
||||
await svc.TickAsync(CancellationToken.None);
|
||||
|
||||
Assert.Null(await tasks.GetByIdAsync(foreignId));
|
||||
Assert.DoesNotContain(foreignId, api.MarkedImported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tick_Imports_OwnTask_And_UnownedTask()
|
||||
{
|
||||
var (listId, ctx, tasks, _) = await SeedAsync();
|
||||
using var _ = ctx;
|
||||
|
||||
var mine = Guid.NewGuid().ToString();
|
||||
var unowned = Guid.NewGuid().ToString();
|
||||
var api = new FakeApi
|
||||
{
|
||||
UnimportedTasks =
|
||||
[
|
||||
new RemoteTask(mine, listId, "Mine", null, DateTimeOffset.UtcNow, OwnerId: "owner-1"),
|
||||
new RemoteTask(unowned, listId, "Unowned", null, DateTimeOffset.UtcNow),
|
||||
],
|
||||
};
|
||||
var svc = BuildService(api, JwtClaimsTests.MakeToken("{\"sub\":\"owner-1\"}"));
|
||||
|
||||
await svc.TickAsync(CancellationToken.None);
|
||||
|
||||
Assert.NotNull(await tasks.GetByIdAsync(mine));
|
||||
Assert.NotNull(await tasks.GetByIdAsync(unowned));
|
||||
}
|
||||
|
||||
// ---- already-imported task on server ----
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user