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:
mika kuns
2026-06-10 13:57:39 +02:00
parent 23c3065f20
commit cee051bb6d
6 changed files with 207 additions and 12 deletions

View 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));
}
}

View File

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