Add the remove-member endpoint, Owner or self #58
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/remove-member-endpoint"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
DELETE /api/groups/{groupId}/members/{userId}: an Owner removing another member, or any member leaving on their own.RequireGroupCapability(GroupCapability.Member); the handler makes a second, narrowerAuthorizeGroupCapabilityAsync(..., GroupCapability.Owner, ...)check only when the target isn't the caller, reusing the shared 403/404 decision rather than duplicating it. Argued in ADR-0053.RemoveAsync's locked CTE, written byte-for-byte identical to task 84'sChangeRoleAsyncover the same predicate, so the two serialise against each other. Verified against task 84's actual implementation (merged forward frommain), including a permanent automated test (RemoveAsyncAndChangeRoleAsync_ConcurrentMutualActionOnTheOnlyTwoOwners_AnOwnerAlwaysSurvives) that fires both concurrently.Integration tests cover Owner/self/Editor/Viewer/non-member/nonexistent-group cases plus two same-endpoint and one cross-endpoint concurrency test.
Verdict: mergeable
Checked both named risks directly, not just by reading:
AuthorizeGroupCapabilityAsync(..., GroupCapability.Owner, ...)call inDeleteGroupMember. Only this endpoint's own tests caught it (AViewerRemovingSomeoneElse,AnEditorRemovingSomeoneElseboth failed);GroupScopedEndpointAuthorisationTests(task 148) stayed green, confirming the structural safety net only seesGroupCapability.Memberon this route, as ADR-0053 states. Coverage here rests entirely onDeleteGroupMemberEndpointTests— same posture as the invitation-response routes under ADR-0051, correctly called out rather than left implicit.RemoveMemberandChangeRole'slocked_ownersCTEs are byte-for-byte identical (diffed directly).RemoveAsyncinto a plain read-then-write. With cold/unwarmed connections the race didn't reproduce in isolated runs — ChangeRoleAsync's single round trip reliably wins before RemoveAsync's extra round trip even reads, which masked the defect. Once run in its normal context (full test class, pool warmed by ~40 preceding tests, matching how it actually runs in CI), bothRemoveAsync_ConcurrentMutualRemovalOfTheOnlyTwoOwners...andRemoveAsyncAndChangeRoleAsync_ConcurrentMutualActionOnTheOnlyTwoOwners_AnOwnerAlwaysSurvivesfailed consistently (5/5 runs) against the broken locking. The test is genuine.RemoveAsync_ANonOwnerWithAPendingOwnerRowSomehowPresent_TreatsAPendingOwnerAsNotProtectingand theChangeRoleAsyncequivalent both exist and pass, matching the SQL guard'sstatus = 'accepted'qualifier.Also verified: last Owner can't remove themselves (409, including personal group); removed member's places stay with the group (ADR-0004, tested explicitly); removing a pending invitee revokes the invitation (204); non-member and nonexistent-group both 404 with identical body; ADR-0053's claims about
PlaceAuthorizationEndpointExtensionsreusingAuthorizeGroupCapabilityAsyncand about task 148's exclusion boundary both check out against the code; ADR number 0053 is free and the README index entry is correctly appended; the merge-forward of PR #57 dropped nothing — diffedRemoveAsync/RemoveMemberagainst the pre-merge branch tip andChangeRoleAsync/ChangeRoleagainstmainbyte-for-byte, only a local variable rename (existing→removeExisting) came out of the merge.Build and full test suite (Api, Infrastructure, Architecture) clean on the pinned SDK (10.0.100).
Nothing to change.
Verdict: changes needed
Re-reviewed the delta 55f5846..76ac7bd.
Merge integrity: clean. Counted SQL constants (11: exactly union of both parents), methods (9 public +
RoleColumnValue), and test attributes (45 = 44-method union of both parents' test files, none dropped, plus the one new cross-writer test) directly rather than trusting the description. ReadGroupMembershipRepository.csandGroupEndpoints.csend to end — both coherent,RemoveMember/RemoveAsync/ChangeRole/ChangeRoleAsyncbyte-identical to their pre-merge sources. No sign of the interleaved-splice failure mode named in the brief.The SQL-safety argument is sound — verified independently, not just re-derived from the ADR's own text. Any row in
locked_ownersisFOR UPDATE-locked and therefore guaranteed to stay an accepted Owner for the statement's duration;PromoteToOwnerAsynccan only ever add rows to the true Owner set, never invalidate one already locked. SoRemoveMember's guard can be stale by omission only (refuses a removal that was in fact safe) and never wrongly permissive. Worked through both commit orderings by hand; both leave ≥1 Owner. This part of ADR-0053's new section does not overclaim.The new test does not prove it, and that claim in ADR-0053 does overclaim.
RemoveAsync_ConcurrentWithPromoteToOwnerOfADifferentMember_NeverLeavesTheGroupWithZeroOwnerspromotes an unrelated, unconditionally-promotable accepted member concurrently with the removal. BecausePromoteToOwnerAsyncalways succeeds in this setup regardless of anythingRemoveAsyncdoes, the assertion (acceptedOwnerCount >= 1) is satisfied by the promoted member alone no matter howRemoveAsyncbehaves — correctly guarded, unguarded, or entirely broken.Confirmed this by deleting
RemoveMember's entire last-Owner guard (unconditionalDELETE, no CTE, no lock) and running the new test: 8/8 standalone runs green, 5/5 full-class runs green. The three pre-existingRemoveAsync-only tests (TheGroupsLastAcceptedOwner,APersonalGroupsSoleOwner,ANonOwnerWithAPendingOwnerRowSomehowPresent) and the mutual-removal concurrency test did catch the same mutation, isolating the gap to this one test. The same shape exists in ADR-0054's ownPromoteToOwnerAsync_ConcurrentWithSelfDemotionOfTheOnlyOwner_NeverLeavesTheGroupWithZeroOwners(not in this PR's diff, but named as the pattern this test "mirrors") — worth knowing even though it's out of scope here.So: the underlying interaction is genuinely safe, but "proves this rather than resting on the argument alone" (ADR-0053, § A third writer) is not true of the test as written — it would pass unchanged if
RemoveAsync's guard were deleted outright. Either soften that sentence to say the test confirms the two operations coexist without throwing/deadlocking rather than that it proves the invariant, or reshape the test soRemoveAsync's own correctness is load-bearing to the assertion (e.g. assert the specific outcome for each commit ordering rather than only the union, or target a scenario — if one exists — where an unguardedRemoveAsyncwould actually zero the count against a concurrent promotion).ADR index: 0053 and 0054 both present, correct numeric order.
OpenApiDocumentTests.cs's one-line delta is just the new route added to the enumerated list — unremarkable. Build and full suite (Api, Infrastructure, Architecture) clean on the pinned SDK (10.0.100).Verdict: mergeable
Re-reviewed the delta 76ac7bd..3fd53c8 — confirmed it touches only
GroupMembershipRepositoryTests.csand ADR-0053, nothing else.The new test discriminates. Deleted
RemoveMember's last-Owner guard entirely (unconditionalDELETE, no CTE, no lock) and ranRemoveAsync_ConcurrentWithPromoteToOwnerOfTheSameSoleOwner_AlwaysRefusesAndThePromotionSucceeds: 10/10 standalone failures, 5/5 full-class failures (alongside the same three soloRemoveAsynctests and the mutual-removal concurrency test as before) — matches the author's reported 5/5. On the shipped, guarded code: 10/10 standalone and 8/8 full-class runs green. Restored the file; diffed byte-for-byte against a pre-mutation backup to confirm the restore was exact.Determinism verified independently, not just re-run. Worked through both lock-acquisition orderings by hand rather than trusting repeated green runs alone:
RemoveMember's CTE locks the row first:locked_owners= {ownerId} only, soEXISTS(... <> @UserId)is false regardless of role/status — the guard structurally cannot pass, since there is only one membership row in this group and it's the target. Deletion is refused; the row is unchanged, soPromoteMemberToOwner'sWHERE status = 'accepted'still matches once it runs and promotion succeeds.PromoteMemberToOwner'sUPDATEtakes the row lock first: it commits a no-op role write,RemoveMember's CTE then acquires the lock and (viaEvalPlanQual) re-reads the still-owner/acceptedrow intolocked_owners= {ownerId} only — same structural refusal.Both orderings collapse to the same pair of outcomes because this is a single-row group: nothing either statement can do creates a second distinct accepted-Owner row for
locked_ownersto find, and nothing deletes the row before promotion'sWHEREcan match it. The exact-outcome assertions are sound by construction, not merely by luck of repeated runs.Promoting an already-Owner is genuinely legitimate, and not a new assumption introduced by this test:
PromoteToOwnerEndpointTests.PromoteToOwner_AnAlreadyOwner_RespondsWith204IdempotentlyandGroupMembershipRepositoryTests.PromoteToOwnerAsync_AnAlreadyAcceptedOwner_PromotesIdempotentlyAndAdvancesUpdatedAtalready cover it (from PR #59), andPromoteMemberToOwner's ownWHEREclause carries no role condition, onlystatus = 'accepted'— so the new test rests on pre-existing, already-tested behaviour rather than a fresh, unverified premise.ADR-0053's revised wording is accurate and doesn't overclaim in either direction. It states plainly that the different-member scenario has no automated test and explains structurally why no test built on two independent rows could discriminate there — matches what I found in the prior review round. It's equally honest about the new test's own scope (same-row only) and explicitly flags the residual risk: a regression that breaks the different-member case without also breaking the same-row case would ship silently. Nothing here overstates coverage the tests don't actually provide.
Build and full suite (Api, Infrastructure, Architecture) clean on the pinned SDK (10.0.100).