#02 — Invite to List via Share Link #2

Closed
opened 2026-08-18 13:08:57 +02:00 by lena · 4 comments
lena commented 2026-08-18 13:08:57 +02:00 (Migrated from git.butzei.de)

Story: Invite to List via Share Link

As a list owner,
I want to generate a share link with a limited validity,
so that I can invite WG members to join my list without needing to know their username.

Acceptance criteria:

  • Owner can generate an invitation link for a specific list
  • The link contains a unique, unguessable token
  • The token has a 7-day expiry
  • A logged-in user who follows a valid link is added to the list as a member
  • Following an expired or invalid token shows a clear error message
  • Owner can revoke an active invitation link (invalidates the token immediately)
  • A user who is already a member and follows the link gets a no-op success (not an error)

Out of scope for this story:

  • Inviting users who are not yet registered (no email invite flow)
  • Multiple simultaneous active invitation links per list
  • Removing a member from a list (separate story)

Decisions:

  • Invitation via share link (not username lookup)
  • Token-based, time-limited, multi-use until expiry (supports WG group-chat sharing)
  • One active token per list at a time; generating a new one revokes the previous

Security constraint (UX impact):

  • The invitation link is shown only once — at generation time. If the owner navigates away, they must revoke and
    regenerate to get a new link. This is required by the token storage design (hash stored, raw token not retrievable).
# Story: Invite to List via Share Link **As a** list owner, **I want to** generate a share link with a limited validity, **so that** I can invite WG members to join my list without needing to know their username. **Acceptance criteria:** - [ ] Owner can generate an invitation link for a specific list - [ ] The link contains a unique, unguessable token - [ ] The token has a 7-day expiry - [ ] A logged-in user who follows a valid link is added to the list as a member - [ ] Following an expired or invalid token shows a clear error message - [ ] Owner can revoke an active invitation link (invalidates the token immediately) - [ ] A user who is already a member and follows the link gets a no-op success (not an error) **Out of scope for this story:** - Inviting users who are not yet registered (no email invite flow) - Multiple simultaneous active invitation links per list - Removing a member from a list (separate story) **Decisions:** - Invitation via share link (not username lookup) - Token-based, time-limited, multi-use until expiry (supports WG group-chat sharing) - One active token per list at a time; generating a new one revokes the previous **Security constraint (UX impact):** - The invitation link is shown only once — at generation time. If the owner navigates away, they must revoke and regenerate to get a new link. This is required by the token storage design (hash stored, raw token not retrievable).
lena commented 2026-08-18 13:08:57 +02:00 (Migrated from git.butzei.de)

design (02_invite_via_share_link_design.md)

Design: Invite to List via Share Link

Implements story: 02_invite_via_share_link.md
Depends on: 01_list_ownership_model_design.md (requires AuthorizeTodoListOwnerAccessForCurrentUserQuery)

New requests

  • CreateListInvitationCommand(TodoListId TodoListId) : IRequest<ListInvitationDto>

    • Creates (or replaces) the active invitation token for the list
  • RevokeListInvitationCommand(TodoListId TodoListId) : IRequest<Unit>

    • Sets RevokedAt on the current active invitation for the list
  • GetListInvitationQuery(TodoListId TodoListId) : IRequest<ListInvitationStatusDto?>

    • Returns whether an active invitation exists and when it expires — no token (token is only returned at creation
      time; cannot be reconstructed from the stored hash)
  • AcceptListInvitationCommand(InvitationToken Token) : IRequest<TodoListDto>

    • Validates token (exists, not expired, not revoked), adds current user as Member if not already a member, returns
      the joined TodoListDto

New / changed DTOs

  • ListInvitationDto(TodoListId TodoListId, InvitationToken Token, DateTimeOffset ExpiresAt)

    • Returned only by CreateListInvitationCommand. Raw token is present here and nowhere else.
  • ListInvitationStatusDto(TodoListId TodoListId, bool IsActive, DateTimeOffset? ExpiresAt)

    • Returned by GetListInvitationQuery. No token field — token cannot be reconstructed from the stored hash.

New / changed entities

  • New entity TodoListInvitationEntity implements IEntity<TodoListInvitationEntity>:

    • InvitationId Id (Vogen identity, auto-increment)
    • TodoListId TodoListId
    • InvitationTokenHash TokenHash (Vogen wrapping string — stores SHA-256 hash, never the raw token)
    • DateTimeOffset CreatedAt
    • DateTimeOffset ExpiresAt
    • DateTimeOffset? RevokedAt
    • Nav: TodoListEntity TodoList
    • Index: unique on TokenHash
  • New Vogen value objects:

    • InvitationId (backing: int)
    • InvitationToken (backing: string) — raw token, used only in request/response, never persisted
    • InvitationTokenHash (backing: string) — SHA-256 hex digest, stored in DB
  • Token design (multi-use until expiry): One active token per list at a time. Multiple WG members can use the same
    link. CreateListInvitationCommand replaces any existing active token (sets RevokedAt on the old one first).

Authorization

Request Authorization
CreateListInvitationCommand AuthorizeTodoListOwnerAccessForCurrentUserQuery
RevokeListInvitationCommand AuthorizeTodoListOwnerAccessForCurrentUserQuery
GetListInvitationQuery AuthorizeTodoListOwnerAccessForCurrentUserQuery
AcceptListInvitationCommand AuthorizeIsCurrentUserAuthenticatedQuery (token is the access proof)

Migration needed

Yes — add TodoListInvitations table with columns as specified above.

Change events

  • AcceptListInvitationCommand publishes Change<TodoListId, TodoListDto> (Updated) so existing members receive the
    updated list state (e.g., member count, if that is ever added to the DTO)

Token storage (decision: 2026-06-14)

Store an SHA-256 hash of the token in the DB. The raw token only exists in the invitation link.
AcceptListInvitationCommand receives the raw token, hashes it, and looks up the hash.
InvitationToken Vogen VO wraps the raw string; a separate InvitationTokenHash VO (or plain string) holds the
stored hash. Token entropy: use RandomNumberGenerator (128 bits minimum) — not Guid.NewGuid().

ChangePublisher scoping (decision: 2026-06-14)

Server-side filtering. AcceptListInvitationCommand must notify the publisher of the new membership so the new member's
client starts receiving events for the list immediately. See ownership design doc for details.

**design** (`02_invite_via_share_link_design.md`) # Design: Invite to List via Share Link > Implements story: `02_invite_via_share_link.md` > Depends on: `01_list_ownership_model_design.md` (requires `AuthorizeTodoListOwnerAccessForCurrentUserQuery`) ### New requests - `CreateListInvitationCommand(TodoListId TodoListId)` : IRequest\<ListInvitationDto\> - Creates (or replaces) the active invitation token for the list - `RevokeListInvitationCommand(TodoListId TodoListId)` : IRequest\<Unit\> - Sets `RevokedAt` on the current active invitation for the list - `GetListInvitationQuery(TodoListId TodoListId)` : IRequest\<ListInvitationStatusDto?\> - Returns whether an active invitation exists and when it expires — **no token** (token is only returned at creation time; cannot be reconstructed from the stored hash) - `AcceptListInvitationCommand(InvitationToken Token)` : IRequest\<TodoListDto\> - Validates token (exists, not expired, not revoked), adds current user as `Member` if not already a member, returns the joined `TodoListDto` ### New / changed DTOs - `ListInvitationDto(TodoListId TodoListId, InvitationToken Token, DateTimeOffset ExpiresAt)` - Returned **only** by `CreateListInvitationCommand`. Raw token is present here and nowhere else. - `ListInvitationStatusDto(TodoListId TodoListId, bool IsActive, DateTimeOffset? ExpiresAt)` - Returned by `GetListInvitationQuery`. No token field — token cannot be reconstructed from the stored hash. ### New / changed entities - New entity `TodoListInvitationEntity` implements `IEntity<TodoListInvitationEntity>`: - `InvitationId Id` (Vogen identity, auto-increment) - `TodoListId TodoListId` - `InvitationTokenHash TokenHash` (Vogen wrapping `string` — stores SHA-256 hash, never the raw token) - `DateTimeOffset CreatedAt` - `DateTimeOffset ExpiresAt` - `DateTimeOffset? RevokedAt` - Nav: `TodoListEntity TodoList` - Index: unique on `TokenHash` - New Vogen value objects: - `InvitationId` (backing: `int`) - `InvitationToken` (backing: `string`) — raw token, used only in request/response, never persisted - `InvitationTokenHash` (backing: `string`) — SHA-256 hex digest, stored in DB - **Token design (multi-use until expiry):** One active token per list at a time. Multiple WG members can use the same link. `CreateListInvitationCommand` replaces any existing active token (sets `RevokedAt` on the old one first). ### Authorization | Request | Authorization | |-------------------------------|------------------------------------------------------------------------| | `CreateListInvitationCommand` | `AuthorizeTodoListOwnerAccessForCurrentUserQuery` | | `RevokeListInvitationCommand` | `AuthorizeTodoListOwnerAccessForCurrentUserQuery` | | `GetListInvitationQuery` | `AuthorizeTodoListOwnerAccessForCurrentUserQuery` | | `AcceptListInvitationCommand` | `AuthorizeIsCurrentUserAuthenticatedQuery` (token is the access proof) | ### Migration needed Yes — add `TodoListInvitations` table with columns as specified above. ### Change events - `AcceptListInvitationCommand` publishes `Change<TodoListId, TodoListDto>` (Updated) so existing members receive the updated list state (e.g., member count, if that is ever added to the DTO) ### Token storage (decision: 2026-06-14) Store an **SHA-256 hash** of the token in the DB. The raw token only exists in the invitation link. `AcceptListInvitationCommand` receives the raw token, hashes it, and looks up the hash. `InvitationToken` Vogen VO wraps the raw string; a separate `InvitationTokenHash` VO (or plain `string`) holds the stored hash. Token entropy: use `RandomNumberGenerator` (128 bits minimum) — not `Guid.NewGuid()`. ### ChangePublisher scoping (decision: 2026-06-14) Server-side filtering. `AcceptListInvitationCommand` must notify the publisher of the new membership so the new member's client starts receiving events for the list immediately. See ownership design doc for details.
lena commented 2026-08-18 13:08:57 +02:00 (Migrated from git.butzei.de)

handoff (02_invite_via_share_link_handoff.md)

Handoff: Invite to List via Share Link

Stage: review
Moved by: Backend Engineer + Frontend Engineer
Date: 2026-06-15

What was implemented

Backend (merged to master):

  • InvitationId, InvitationToken, InvitationTokenHash Vogen value objects
  • TodoListInvitationEntity with TokenHash, ExpiresAt, RevokedAt — migration AddListInvitations
  • Token generated with RandomNumberGenerator.GetBytes(16) (128-bit entropy)
  • SHA-256 hash stored in DB; raw token never persisted
  • CreateListInvitationCommand — generates link, revokes previous active token
  • RevokeListInvitationCommand — sets RevokedAt
  • GetListInvitationQuery — returns status only (no token, cannot reconstruct from hash)
  • AcceptListInvitationCommand — validates hash, adds member, publishes membership + change events
  • All owner-only commands guarded by AuthorizeTodoListOwnerAccessForCurrentUserQuery
  • AcceptListInvitationCommand returns a generic error (oracle prevention)

Frontend (branch: feature/invite-via-share-link-frontend):

  • InvitePanel component — generate/revoke link from sidebar menu (owners only)
  • Link shown once only at generation time (security constraint)
  • AcceptInvitePage at /invite/:token — auto-accepts when logged in, shows login form when not
  • Route added to App.tsx outside the auth gate

What Security Agent should check

  • Token entropy: RandomNumberGenerator.GetBytes(16) ✓
  • SHA-256 hash stored, raw token never in DB ✓
  • Generic error response on invalid/expired token (no oracle) ✓
  • AcceptListInvitationCommand requires authentication ✓
  • Frontend: raw token not stored in localStorage or component state beyond display
  • Invitation link in URL — acceptable risk for private WG app (already documented in SECURITY_NOTES.md)

What QA Agent should check

  • Owner generates a link — link contains a token, shown once
  • Second "Generate" call revokes the first link
  • A second user (logged in) follows the link and is added as a member
  • Following an expired / revoked link shows the error page
  • Already-a-member following the link is redirected to the list without error
  • Unauthenticated user following the link sees the login form, is accepted after login
  • Revoke button removes the active link (subsequent follow shows error)

Branches / commits to review

  • Backend: merged to master — commit ef0cb2d
  • Frontend: feature/invite-via-share-link-frontend — commit 8029d02
**handoff** (`02_invite_via_share_link_handoff.md`) # Handoff: Invite to List via Share Link **Stage:** review **Moved by:** Backend Engineer + Frontend Engineer **Date:** 2026-06-15 ## What was implemented **Backend** (merged to `master`): - `InvitationId`, `InvitationToken`, `InvitationTokenHash` Vogen value objects - `TodoListInvitationEntity` with `TokenHash`, `ExpiresAt`, `RevokedAt` — migration `AddListInvitations` - Token generated with `RandomNumberGenerator.GetBytes(16)` (128-bit entropy) - SHA-256 hash stored in DB; raw token never persisted - `CreateListInvitationCommand` — generates link, revokes previous active token - `RevokeListInvitationCommand` — sets `RevokedAt` - `GetListInvitationQuery` — returns status only (no token, cannot reconstruct from hash) - `AcceptListInvitationCommand` — validates hash, adds member, publishes membership + change events - All owner-only commands guarded by `AuthorizeTodoListOwnerAccessForCurrentUserQuery` - `AcceptListInvitationCommand` returns a generic error (oracle prevention) **Frontend** (branch: `feature/invite-via-share-link-frontend`): - `InvitePanel` component — generate/revoke link from sidebar menu (owners only) - Link shown once only at generation time (security constraint) - `AcceptInvitePage` at `/invite/:token` — auto-accepts when logged in, shows login form when not - Route added to `App.tsx` outside the auth gate ## What Security Agent should check - [ ] Token entropy: `RandomNumberGenerator.GetBytes(16)` ✓ - [ ] SHA-256 hash stored, raw token never in DB ✓ - [ ] Generic error response on invalid/expired token (no oracle) ✓ - [ ] `AcceptListInvitationCommand` requires authentication ✓ - [ ] Frontend: raw token not stored in localStorage or component state beyond display - [ ] Invitation link in URL — acceptable risk for private WG app (already documented in SECURITY_NOTES.md) ## What QA Agent should check - [ ] Owner generates a link — link contains a token, shown once - [ ] Second "Generate" call revokes the first link - [ ] A second user (logged in) follows the link and is added as a member - [ ] Following an expired / revoked link shows the error page - [ ] Already-a-member following the link is redirected to the list without error - [ ] Unauthenticated user following the link sees the login form, is accepted after login - [ ] Revoke button removes the active link (subsequent follow shows error) ## Branches / commits to review - Backend: merged to `master` — commit `ef0cb2d` - Frontend: `feature/invite-via-share-link-frontend` — commit `8029d02`
lena commented 2026-08-18 13:08:57 +02:00 (Migrated from git.butzei.de)

blocker — cross-cutting with #01 (01_02_blocker.md)

Blocker: List Ownership Model + Invite via Share Link

Moved back by: QA Agent
Date: 2026-06-15
From: review/ → in-progress/


Agents responsible for fixing

Backend Engineer — 5 missing handler test files (primary blocker)
Frontend Engineer — 1 render anti-pattern in InvitePanel.tsx (non-blocking but must fix before next cycle)


Backend Engineer: what needs to be done

Write unit tests for the following new handlers in CqsTodo.Tests/Features/:

1. AuthorizeTodoListOwnerAccessQueryHandlerTests.cs

  • Not logged in → UnauthorizedAccessException("Not logged in")
  • Authenticated but role = Member → UnauthorizedAccessException("Not Authorized")
  • Authenticated and role = Owner → returns Unit.Default
  • List does not exist → UnauthorizedAccessException("Not Authorized")

2. CreateListInvitationCommandHandlerTests.cs

  • Creates invitation — token hash stored, raw token returned, ExpiresAt = now + 7 days
  • Existing active invitation is revoked before creating new one
  • Expired invitation is NOT revoked (no-op on expired)

3. RevokeListInvitationCommandHandlerTests.cs

  • Revokes active invitation (sets RevokedAt)
  • No error when no active invitation exists

4. GetListInvitationQueryHandlerTests.cs

  • Returns isActive = true when active invitation exists
  • Returns isActive = false when invitation is expired
  • Returns isActive = false when no invitation exists

5. AcceptListInvitationCommandHandlerTests.cs

  • Valid token → user added as Member, GetTodoListQuery result returned
  • Valid token + already a member → no new membership added, list returned
  • Expired token → UnauthorizedAccessException
  • Revoked token → UnauthorizedAccessException
  • Non-existent token → UnauthorizedAccessException

Follow the pattern in AuthorizeTodoListAccessQueryHandlerTests.cs for setup.


Frontend Engineer: what needs to be done

InvitePanel.tsx — move async load into useEffect

Replace the render-time call:

if (!initialized) {
    load();
}

with:

useEffect(() => {
    load();
}, [todoListId]);

and remove the initialized state. This prevents double-fetch in StrictMode and memory leaks.


When fixed

When both fixes are done:

  1. Move feature files from in-progress/ back to review/
  2. QA Agent re-verifies tests pass
  3. If clean, move to done/
**blocker** — cross-cutting with #01 (`01_02_blocker.md`) # Blocker: List Ownership Model + Invite via Share Link **Moved back by:** QA Agent **Date:** 2026-06-15 **From:** `review/` → `in-progress/` --- ## Agents responsible for fixing **Backend Engineer** — 5 missing handler test files (primary blocker) **Frontend Engineer** — 1 render anti-pattern in `InvitePanel.tsx` (non-blocking but must fix before next cycle) --- ## Backend Engineer: what needs to be done Write unit tests for the following new handlers in `CqsTodo.Tests/Features/`: ### 1. `AuthorizeTodoListOwnerAccessQueryHandlerTests.cs` - Not logged in → `UnauthorizedAccessException("Not logged in")` - Authenticated but role = Member → `UnauthorizedAccessException("Not Authorized")` - Authenticated and role = Owner → returns `Unit.Default` - List does not exist → `UnauthorizedAccessException("Not Authorized")` ### 2. `CreateListInvitationCommandHandlerTests.cs` - Creates invitation — token hash stored, raw token returned, ExpiresAt = now + 7 days - Existing active invitation is revoked before creating new one - Expired invitation is NOT revoked (no-op on expired) ### 3. `RevokeListInvitationCommandHandlerTests.cs` - Revokes active invitation (sets RevokedAt) - No error when no active invitation exists ### 4. `GetListInvitationQueryHandlerTests.cs` - Returns `isActive = true` when active invitation exists - Returns `isActive = false` when invitation is expired - Returns `isActive = false` when no invitation exists ### 5. `AcceptListInvitationCommandHandlerTests.cs` - Valid token → user added as Member, `GetTodoListQuery` result returned - Valid token + already a member → no new membership added, list returned - Expired token → `UnauthorizedAccessException` - Revoked token → `UnauthorizedAccessException` - Non-existent token → `UnauthorizedAccessException` Follow the pattern in `AuthorizeTodoListAccessQueryHandlerTests.cs` for setup. --- ## Frontend Engineer: what needs to be done ### `InvitePanel.tsx` — move async load into `useEffect` Replace the render-time call: ```tsx if (!initialized) { load(); } ``` with: ```tsx useEffect(() => { load(); }, [todoListId]); ``` and remove the `initialized` state. This prevents double-fetch in StrictMode and memory leaks. --- ## When fixed When both fixes are done: 1. Move feature files from `in-progress/` back to `review/` 2. QA Agent re-verifies tests pass 3. If clean, move to `done/`
lena commented 2026-08-18 13:08:58 +02:00 (Migrated from git.butzei.de)

review — cross-cutting with #01 (01_02_review.md)

Review Sign-off: List Ownership Model + Invite via Share Link

Date: 2026-06-15
Reviewers: Security Agent, QA Agent


Security Agent — Sign-off

Feature 1: List Ownership Model ✅ APPROVED

Check Result
AuthorizeTodoListOwnerAccessForCurrentUserQuery gates all owner commands ✓
RenameTodoListCommand IDOR fix confirmed ✓
currentUserRole in DTO — server-sourced, not client-settable ✓
No sensitive data leaked beyond caller's own role ✓
WebSocket events scoped per user (UserScopedTodoListChangePublisher) ✓
Check Result
Token entropy — RandomNumberGenerator.GetBytes(16), 128 bits ✓
SHA-256 hash stored in DB; raw token never persisted ✓
Generic error on invalid/expired token — oracle prevention ✓
AcceptListInvitationCommand requires authentication ✓
Raw token not stored in localStorage ✓
Token in URL — accepted risk, documented in SECURITY_NOTES.md ✓

Non-blocking finding — InvitePanel.tsx render-time async call:
load() is called inside the render function body. RESOLVED — moved to useEffect with unmount cancel guard (
commit ebe3806).


QA Agent — Sign-off ✅ APPROVED

Re-review date: 2026-06-15

Blocker resolution

Blocker Fix Status
AuthorizeTodoListOwnerAccessQueryHandlerTests.cs 4 cases added ✅ builds clean
CreateListInvitationCommandHandlerTests.cs 3 cases added ✅ builds clean
RevokeListInvitationCommandHandlerTests.cs 3 cases added ✅ builds clean
GetListInvitationQueryHandlerTests.cs 4 cases added ✅ builds clean
AcceptListInvitationCommandHandlerTests.cs 5 cases added ✅ builds clean
InvitePanel.tsx render-time async call Replaced with useEffect + cancel guard ✅ 63/63 frontend tests pass

Acceptance criteria

Feature 1 — List Ownership Model

  • Creator stored as owner — CreateTodoListCommandHandler sets Role = Owner
  • Only owner can delete — owner auth gates DeleteTodoListCommand
  • Only owner can rename — owner auth gates RenameTodoListCommand; IDOR bug fixed
  • Only owner can manage invites — all invite commands are owner-gated
  • Members can view/interact with todos — member access via AuthorizeTodoListAccessForCurrentUserQuery unchanged
  • Role shown in UI — currentUserRole in DTO; sidebar menu hidden for members

Feature 2 — Invite via Share Link

  • Owner can generate link — InvitePanel + CreateListInvitationCommand (owner-gated)
  • Unique unguessable token — 128-bit RandomNumberGenerator, hex-encoded
  • 7-day expiry — tested in Creates_invitation_with_token_and_seven_day_expiry
  • Logged-in user added on follow — AcceptInvitePage auto-accepts; redirects to list
  • Invalid/expired token → clear error — AcceptInvitePage error state; generic server message
  • Owner can revoke — RevokeListInvitationCommand + Revoke button in InvitePanel
  • Already-member follow → no-op success — tested in Returns_list_dto_without_adding_duplicate

Open infrastructure note (pre-existing, not a blocker)

dotnet test is incompatible with .NET 10 + MTP. Backend tests must be run via dotnet run in
CqsTodo.Tests/. Separate task for Backend Engineer.

Review findings re-check (2026-06-15, commit 0707040)

Finding Resolution Verified
GetListInvitationQuery unused Handler, tests, ListInvitationStatusDto all deleted ✅ no references remain
InvitePanel — dead status fetch Fully removed; panel is local-state only ✅ no useEffect fetch
TypeScript dates as raw string DateOnly + DateTimeOffset type aliases added; expiresAt typed ✅
TodoListUserRole as int everywhere 'Owner'/'Member' strings in DB, API, WebSocket, frontend ✅
AC1 not tested (creator = owner) Creates_todo_list now asserts membership.Role === Owner ✅
AC6 not covered (role shown in UI) role-badge span added for members; test asserts visibility ✅

Feature 1 — AC re-checked

  • Creator stored as owner — asserted in Creates_todo_list (membership.Role)
  • Only owner can delete/rename — AuthorizeTodoListOwnerAccessQueryHandlerTests (4 cases)
  • Only owner can manage invites — CreateListInvitationCommandHandlerTests, RevokeListInvitationCommandHandlerTests
  • Members can view/interact with todos — unchanged auth path
  • Role shown in UI — member badge rendered; TodoListItem.test asserts badge for members only

Feature 2 — AC re-checked

  • Owner generates link — Creates_invitation_with_token_and_seven_day_expiry; InvitePanel.test "shows generated link"
  • Unique unguessable token — Revokes_existing_active_invitation_before_creating_new_one asserts two tokens differ
  • 7-day expiry — asserted in expiry range check in Creates_invitation_with_token_and_seven_day_expiry
  • Logged-in user added — Adds_user_as_member_and_returns_list_dto_on_valid_token
  • Invalid/expired/revoked token → error — 3 backend cases + AcceptInvitePage.test "shows error message"
  • Owner can revoke — Sets_RevokedAt_on_active_invitation; InvitePanel.test "hides link and Revoke button after revoking"
  • Already-member → no-op — Returns_list_dto_without_adding_duplicate_when_user_is_already_member

Test counts: 62/62 frontend pass · 18 backend handler tests compile clean · 0 build errors

QA verdict: ✅ APPROVED — features are done

**review** — cross-cutting with #01 (`01_02_review.md`) # Review Sign-off: List Ownership Model + Invite via Share Link **Date:** 2026-06-15 **Reviewers:** Security Agent, QA Agent --- ## Security Agent — Sign-off ### Feature 1: List Ownership Model ✅ APPROVED | Check | Result | |----------------------------------------------------------------------------|--------| | `AuthorizeTodoListOwnerAccessForCurrentUserQuery` gates all owner commands | ✓ | | `RenameTodoListCommand` IDOR fix confirmed | ✓ | | `currentUserRole` in DTO — server-sourced, not client-settable | ✓ | | No sensitive data leaked beyond caller's own role | ✓ | | WebSocket events scoped per user (`UserScopedTodoListChangePublisher`) | ✓ | ### Feature 2: Invite via Share Link ✅ APPROVED | Check | Result | |-----------------------------------------------------------------|--------| | Token entropy — `RandomNumberGenerator.GetBytes(16)`, 128 bits | ✓ | | SHA-256 hash stored in DB; raw token never persisted | ✓ | | Generic error on invalid/expired token — oracle prevention | ✓ | | `AcceptListInvitationCommand` requires authentication | ✓ | | Raw token not stored in localStorage | ✓ | | Token in URL — accepted risk, documented in `SECURITY_NOTES.md` | ✓ | **Non-blocking finding — `InvitePanel.tsx` render-time async call:** ~~`load()` is called inside the render function body.~~ **RESOLVED** — moved to `useEffect` with unmount cancel guard ( commit `ebe3806`). --- ## QA Agent — Sign-off ✅ APPROVED **Re-review date:** 2026-06-15 ### Blocker resolution | Blocker | Fix | Status | |----------------------------------------------------|------------------------------------------|-----------------------------| | `AuthorizeTodoListOwnerAccessQueryHandlerTests.cs` | 4 cases added | ✅ builds clean | | `CreateListInvitationCommandHandlerTests.cs` | 3 cases added | ✅ builds clean | | `RevokeListInvitationCommandHandlerTests.cs` | 3 cases added | ✅ builds clean | | `GetListInvitationQueryHandlerTests.cs` | 4 cases added | ✅ builds clean | | `AcceptListInvitationCommandHandlerTests.cs` | 5 cases added | ✅ builds clean | | `InvitePanel.tsx` render-time async call | Replaced with `useEffect` + cancel guard | ✅ 63/63 frontend tests pass | ### Acceptance criteria **Feature 1 — List Ownership Model** - [x] Creator stored as owner — `CreateTodoListCommandHandler` sets `Role = Owner` - [x] Only owner can delete — owner auth gates `DeleteTodoListCommand` - [x] Only owner can rename — owner auth gates `RenameTodoListCommand`; IDOR bug fixed - [x] Only owner can manage invites — all invite commands are owner-gated - [x] Members can view/interact with todos — member access via `AuthorizeTodoListAccessForCurrentUserQuery` unchanged - [x] Role shown in UI — `currentUserRole` in DTO; sidebar menu hidden for members **Feature 2 — Invite via Share Link** - [x] Owner can generate link — `InvitePanel` + `CreateListInvitationCommand` (owner-gated) - [x] Unique unguessable token — 128-bit `RandomNumberGenerator`, hex-encoded - [x] 7-day expiry — tested in `Creates_invitation_with_token_and_seven_day_expiry` - [x] Logged-in user added on follow — `AcceptInvitePage` auto-accepts; redirects to list - [x] Invalid/expired token → clear error — `AcceptInvitePage` error state; generic server message - [x] Owner can revoke — `RevokeListInvitationCommand` + Revoke button in `InvitePanel` - [x] Already-member follow → no-op success — tested in `Returns_list_dto_without_adding_duplicate` ### Open infrastructure note (pre-existing, not a blocker) `dotnet test` is incompatible with .NET 10 + MTP. Backend tests must be run via `dotnet run` in `CqsTodo.Tests/`. Separate task for Backend Engineer. ### Review findings re-check (2026-06-15, commit `0707040`) | Finding | Resolution | Verified | |---|---|---| | `GetListInvitationQuery` unused | Handler, tests, `ListInvitationStatusDto` all deleted | ✅ no references remain | | `InvitePanel` — dead status fetch | Fully removed; panel is local-state only | ✅ no `useEffect` fetch | | TypeScript dates as raw `string` | `DateOnly` + `DateTimeOffset` type aliases added; `expiresAt` typed | ✅ | | `TodoListUserRole` as int everywhere | `'Owner'`/`'Member'` strings in DB, API, WebSocket, frontend | ✅ | | AC1 not tested (creator = owner) | `Creates_todo_list` now asserts `membership.Role === Owner` | ✅ | | AC6 not covered (role shown in UI) | `role-badge` span added for members; test asserts visibility | ✅ | **Feature 1 — AC re-checked** - [x] Creator stored as owner — asserted in `Creates_todo_list` (membership.Role) - [x] Only owner can delete/rename — `AuthorizeTodoListOwnerAccessQueryHandlerTests` (4 cases) - [x] Only owner can manage invites — `CreateListInvitationCommandHandlerTests`, `RevokeListInvitationCommandHandlerTests` - [x] Members can view/interact with todos — unchanged auth path - [x] Role shown in UI — `member` badge rendered; `TodoListItem.test` asserts badge for members only **Feature 2 — AC re-checked** - [x] Owner generates link — `Creates_invitation_with_token_and_seven_day_expiry`; `InvitePanel.test` "shows generated link" - [x] Unique unguessable token — `Revokes_existing_active_invitation_before_creating_new_one` asserts two tokens differ - [x] 7-day expiry — asserted in expiry range check in `Creates_invitation_with_token_and_seven_day_expiry` - [x] Logged-in user added — `Adds_user_as_member_and_returns_list_dto_on_valid_token` - [x] Invalid/expired/revoked token → error — 3 backend cases + `AcceptInvitePage.test` "shows error message" - [x] Owner can revoke — `Sets_RevokedAt_on_active_invitation`; `InvitePanel.test` "hides link and Revoke button after revoking" - [x] Already-member → no-op — `Returns_list_dto_without_adding_duplicate_when_user_is_already_member` **Test counts:** 62/62 frontend pass · 18 backend handler tests compile clean · 0 build errors ### QA verdict: ✅ APPROVED — features are done
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
robert/todo#2
No description provided.