verifyAuthOrganizationStoreOwnershipConformance function

Future<void> verifyAuthOrganizationStoreOwnershipConformance(
  1. AuthOrganizationStore store
)

Verifies the atomic owner-preservation contract of store.

The supplied store must use an isolated namespace. The verifier creates several organizations and intentionally submits stale and concurrent ownership-bearing mutations. Durable adapters should run this verifier in their own test suites.

Implementation

Future<void> verifyAuthOrganizationStoreOwnershipConformance(
  AuthOrganizationStore store,
) async {
  if (store is! AuthOrganizationMembershipMutationStore) {
    throw const AuthOrganizationStoreConformanceFailure(
      'membership.capability',
      'AuthOrganizationMembershipMutationStore is required',
    );
  }
  if (store is! AuthOrganizationAtomicMutationStore) {
    throw const AuthOrganizationStoreConformanceFailure(
      'mutation.capability',
      'AuthOrganizationAtomicMutationStore is required',
    );
  }
  final mutationStore = store as AuthOrganizationMembershipMutationStore;
  final atomicStore = store as AuthOrganizationAtomicMutationStore;
  await _case('membership.concurrent-last-owner', () async {
    final fixture = await _twoOwners(store, 'concurrent');
    final results = await Future.wait([
      _capture(
        () => mutationStore.mutateOrganizationMembership(
          _demote(fixture.first, fixture.first),
        ),
      ),
      _capture(
        () => mutationStore.mutateOrganizationMembership(
          _demote(fixture.second, fixture.second),
        ),
      ),
    ]);
    _check(results.where((result) => result == null).length == 1, 'winners');
    final owners = (await store.listMembers(
      fixture.organization.id,
    )).where((member) => member.roles.contains('owner'));
    _check(owners.length == 1, 'owner count');
  });

  await _case('membership.stale-actor', () async {
    final fixture = await _twoOwners(store, 'stale-actor');
    await mutationStore.mutateOrganizationMembership(
      _demote(fixture.second, fixture.first),
    );
    final error = await _capture(
      () => mutationStore.mutateOrganizationMembership(
        AuthOrganizationMembershipMutation(
          kind: AuthOrganizationMembershipMutationKind.remove,
          actorMembership: fixture.first,
          targetMembership: fixture.second,
          creatorRole: 'owner',
        ),
      ),
    );
    _check(error == 'organization_forbidden', 'stale actor result');
    final remaining = await store.findMember(
      fixture.organization.id,
      fixture.second.userId,
    );
    _check(remaining?.roles.contains('owner') ?? false, 'remaining owner');
  });

  await _case('membership.failure-atomicity', () async {
    final fixture = await _oneOwner(store, 'rollback');
    final error = await _capture(
      () => mutationStore.mutateOrganizationMembership(
        AuthOrganizationMembershipMutation(
          kind: AuthOrganizationMembershipMutationKind.remove,
          actorMembership: fixture.owner,
          targetMembership: fixture.owner,
          creatorRole: 'owner',
        ),
      ),
    );
    _check(error == 'last_owner', 'failure code');
    final member = await store.findMember(
      fixture.organization.id,
      fixture.owner.userId,
    );
    _check(member?.roles.contains('owner') ?? false, 'owner rollback');
  });

  await _case('invitation.idempotent-retry-and-binding', () async {
    final fixture = await _oneOwner(store, 'invite-replay');
    final now = DateTime.utc(2030);
    final invitation = _invitation(
      fixture.organization.id,
      id: 'invite-replay-first',
      email: 'invitee@example.com',
      inviterId: fixture.owner.userId,
      now: now,
    );
    final idempotency = AuthOrganizationIdempotency(
      key: 'invite-replay-key',
      organizationId: fixture.organization.id,
      actorId: fixture.owner.userId,
      operationId: 'organization.inviteMember',
      fingerprint: 'fingerprint-a',
    );
    final first = await atomicStore.executeOrganizationMutation(
      AuthOrganizationCreateInvitationCommand(
        actorMembership: fixture.owner,
        invitation: invitation,
        invitationLimit: 1,
        replacePending: false,
        idempotency: idempotency,
      ),
    );
    final replay = await atomicStore.executeOrganizationMutation(
      AuthOrganizationCreateInvitationCommand(
        actorMembership: fixture.owner,
        invitation: _invitation(
          fixture.organization.id,
          id: 'invite-replay-second',
          email: 'invitee@example.com',
          inviterId: fixture.owner.userId,
          now: now,
        ),
        invitationLimit: 1,
        replacePending: false,
        idempotency: idempotency,
      ),
    );
    _check(replay.value.id == first.value.id, 'deterministic replay');
    final conflict = await _capture(
      () => atomicStore.executeOrganizationMutation(
        AuthOrganizationCreateInvitationCommand(
          actorMembership: fixture.owner,
          invitation: invitation,
          invitationLimit: 1,
          replacePending: false,
          idempotency: AuthOrganizationIdempotency(
            key: idempotency.key,
            organizationId: fixture.organization.id,
            actorId: fixture.owner.userId,
            operationId: 'organization.inviteMember',
            fingerprint: 'fingerprint-b',
          ),
        ),
      ),
    );
    _check(conflict == 'idempotency_key_conflict', 'binding conflict');
  });

  await _case('invitation.replace-failure-rollback', () async {
    final fixture = await _oneOwner(store, 'invite-rollback');
    final now = DateTime.utc(2030);
    final first = _invitation(
      fixture.organization.id,
      id: 'invite-rollback-first',
      email: 'first@example.com',
      inviterId: fixture.owner.userId,
      now: now,
    );
    final collision = _invitation(
      fixture.organization.id,
      id: 'invite-rollback-collision',
      email: 'second@example.com',
      inviterId: fixture.owner.userId,
      now: now,
    );
    await store.createInvitation(first);
    await store.createInvitation(collision);
    await store.transitionInvitation(
      collision.id,
      AuthOrganizationInvitationStatus.canceled,
      now: now,
    );
    final error = await _capture(
      () => atomicStore.executeOrganizationMutation(
        AuthOrganizationCreateInvitationCommand(
          actorMembership: fixture.owner,
          invitation: _invitation(
            fixture.organization.id,
            id: collision.id,
            email: first.email,
            inviterId: fixture.owner.userId,
            now: now,
          ),
          invitationLimit: null,
          replacePending: true,
          idempotency: AuthOrganizationIdempotency(
            key: 'invite-rollback-key',
            organizationId: fixture.organization.id,
            actorId: fixture.owner.userId,
            operationId: 'organization.inviteMember',
            fingerprint: 'rollback-fingerprint',
          ),
        ),
      ),
    );
    _check(error == 'invitation_exists', 'failure code');
    _check(
      (await store.findInvitation(first.id))?.status ==
          AuthOrganizationInvitationStatus.pending,
      'pending invitation rollback',
    );
  });

  await _case('team-member.concurrent-capacity', () async {
    final fixture = await _oneOwner(store, 'team-capacity');
    final now = DateTime.utc(2030);
    final first = AuthOrganizationMember(
      id: 'team-capacity-member-1',
      organizationId: fixture.organization.id,
      userId: 'team-capacity-user-2',
      roles: const ['member'],
      createdAt: now,
    );
    final second = AuthOrganizationMember(
      id: 'team-capacity-member-2',
      organizationId: fixture.organization.id,
      userId: 'team-capacity-user-3',
      roles: const ['member'],
      createdAt: now,
    );
    await store.addMember(first);
    await store.addMember(second);
    final team = AuthOrganizationTeam(
      id: 'team-capacity-team',
      organizationId: fixture.organization.id,
      name: 'Capacity',
      createdAt: now,
      updatedAt: now,
    );
    await store.createTeam(team);
    final outcomes = await Future.wait([
      _capture(
        () => atomicStore.executeOrganizationMutation(
          AuthOrganizationTeamMemberMutationCommand(
            kind: AuthOrganizationTeamMemberMutationKind.add,
            actorMembership: fixture.owner,
            team: team,
            teamMember: AuthOrganizationTeamMember(
              id: 'team-capacity-link-1',
              teamId: team.id,
              userId: first.userId,
              createdAt: now,
            ),
            memberLimit: 1,
            idempotency: _idempotency(
              'team-capacity-add-1',
              fixture,
              'organization.addTeamMember',
            ),
          ),
        ),
      ),
      _capture(
        () => atomicStore.executeOrganizationMutation(
          AuthOrganizationTeamMemberMutationCommand(
            kind: AuthOrganizationTeamMemberMutationKind.add,
            actorMembership: fixture.owner,
            team: team,
            teamMember: AuthOrganizationTeamMember(
              id: 'team-capacity-link-2',
              teamId: team.id,
              userId: second.userId,
              createdAt: now,
            ),
            memberLimit: 1,
            idempotency: _idempotency(
              'team-capacity-add-2',
              fixture,
              'organization.addTeamMember',
            ),
          ),
        ),
      ),
    ]);
    _check(outcomes.where((value) => value == null).length == 1, 'winner');
    _check((await store.listTeamMembers(team.id)).length == 1, 'capacity');
  });

  if (store case final AuthOrganizationUserDeletionStore deletionStore) {
    await _case('user-deletion.concurrent-last-owner', () async {
      final fixture = await _twoOwners(store, 'user-deletion');
      final results = await Future.wait([
        _capture(
          () => deletionStore.deleteUserData(
            fixture.first.userId,
            creatorRole: 'owner',
          ),
        ),
        _capture(
          () => deletionStore.deleteUserData(
            fixture.second.userId,
            creatorRole: 'owner',
          ),
        ),
      ]);
      _check(results.where((result) => result == null).length == 1, 'winners');
      final owners = (await store.listMembers(
        fixture.organization.id,
      )).where((member) => member.roles.contains('owner'));
      _check(owners.length == 1, 'owner count');
    });
  }
}