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:
@@ -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