Server: send each item at the revision its ciphertext is sealed at, keep team archiving with the owner, refuse suspended accounts, and stop enrolment failing after an hour

- Pull hydrated every historical change entry with the item's current ciphertext under that entry's
  old revision. The AAD binds the revision, so a client opened it at the wrong version, failed, and
  parked its own pending edit as undecryptable. Entries an item has since moved past are left out; the
  later entry carries the same payload under the right number, and a fresh device no longer downloads
  an item once per edit it ever had.
- Deleting a team's last vault archived the team whoever deleted it, so an admin could archive it out
  from under its owner. Only the owner's delete archives it now, and TeamService.ArchiveAsync checks.
- UserStatus was checked nowhere on the request path. ActiveAccountRequirement on both policies answers
  403 account-not-active for a suspended or deprovisioned account.
- The hourly last-seen update moves xmin under the tracked user, and enrolment then saved that user
  against the stale token: a 500 for anybody enrolling more than an hour after first signing in. The
  entity is reloaded after the update.
This commit is contained in:
jaap-jan committed 2026-10-02 13:16:48 +02:00
1 parent f332d9a56f
commit 6846f67c20
14 files changed
+277 -12

No files matched your search

@@ -276,6 +276,52 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
second.Changes.Count.ShouldBe(1);
}
/// <summary>
/// An item edited several times is pulled once, at the revision its ciphertext is sealed at.
/// </summary>
/// <remarks>
/// The table holds only the newest payload, and its AAD binds the newest version. Hydrating each older
/// log entry with it paired revision 1 or 2 with a payload sealed at 3, which no client can open — and a
/// client that could not open it parked its own pending edit as undecryptable.
/// </remarks>
[Fact]
public async Task Pull_OfAnItemEditedSinceItsEarlierEntries_SendsOnlyItsCurrentRevision()
{
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
var create = NewCreateBatch();
var entityId = create.Operations[0].EntityId;
await client.PostContractAsync(PushUrl(vaultId), create);
await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
[
NewOperation(entityId, expectedVersion: 1, envelope: [2, 2, 2]),
]));
await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
[
NewOperation(entityId, expectedVersion: 2, envelope: [3, 3, 3]),
]));
var pulled = await (await client.PostContractAsync(
PullUrl(vaultId),
new SyncPullRequest(null, null, null))).Content.ReadContractAsync<SyncPullResponse>();
pulled.ShouldNotBeNull();
var change = pulled.Changes.ShouldHaveSingleItem();
change.Version.ShouldBe(3);
change.Payload!.Envelope.ShouldBe([3, 3, 3]);
// And the cursor is past all three entries, so the next pull does not offer the old ones again.
var next = await (await client.PostContractAsync(
PullUrl(vaultId),
new SyncPullRequest(pulled.NextCursor, null, null))).Content.ReadContractAsync<SyncPullResponse>();
next!.Changes.ShouldBeEmpty();
}
[Fact]
public async Task Pull_WithACursorFromAnotherVault_Is400()
{