diff --git a/app/repositories.php b/app/repositories.php index 34581f2..03e4f8d 100644 --- a/app/repositories.php +++ b/app/repositories.php @@ -5,6 +5,9 @@ declare(strict_types=1); use App\Application\Settings\SettingsInterface; use App\Domain\Account\AccountRepository; use App\Domain\Account\AppPassword\AppPasswordRepository; +use App\Domain\Account\EmailToken\EmailTokenRepository; +use App\Domain\Account\InviteCode\InviteCodeRepository; +use App\Domain\Account\RefreshToken\RefreshTokenRepository; use App\Domain\Actor\ActorRepository; use App\Domain\Pds\Atproto\AppView\AppViewClient; use App\Domain\ActorStore\ActorStoreFactory; @@ -16,6 +19,9 @@ use App\Domain\Repo\RepoRootRepository; use App\Domain\Sequencer\SequencerRepository; use App\Infrastructure\Atproto\AppView\GuzzleAppViewClient; use App\Infrastructure\Persistence\Account\AppPassword\InMemoryAppPasswordRepository; +use App\Infrastructure\Persistence\Account\EmailToken\InMemoryEmailTokenRepository; +use App\Infrastructure\Persistence\Account\RefreshToken\InMemoryRefreshTokenRepository; +use App\Infrastructure\Persistence\Account\InviteCode\InMemoryInviteCodeRepository; use App\Infrastructure\Persistence\Account\InMemoryAccountRepository; use App\Infrastructure\Persistence\Actor\InMemoryActorRepository; use App\Infrastructure\Persistence\ActorStore\InMemoryActorStoreFactory; @@ -42,6 +48,9 @@ return function (ContainerBuilder $containerBuilder) { CarWriter::class => autowire(NativeCarWriter::class), CarReader::class => autowire(NativeCarReader::class), SequencerRepository::class => autowire(InMemorySequencerRepository::class), + InviteCodeRepository::class => autowire(InMemoryInviteCodeRepository::class), + RefreshTokenRepository::class => autowire(InMemoryRefreshTokenRepository::class), + EmailTokenRepository::class => autowire(InMemoryEmailTokenRepository::class), RepoRootRepository::class => autowire(InMemoryRepoRootRepository::class), AppViewClient::class => function (ContainerInterface $container): AppViewClient { $settings = $container->get(SettingsInterface::class); diff --git a/src/Domain/Account/EmailToken/EmailToken.php b/src/Domain/Account/EmailToken/EmailToken.php new file mode 100644 index 0000000..91056cf --- /dev/null +++ b/src/Domain/Account/EmailToken/EmailToken.php @@ -0,0 +1,59 @@ +purpose; + } + + public function getDid(): string + { + return $this->did; + } + + public function getToken(): string + { + return $this->token; + } + + public function getRequestedAt(): DateTimeImmutable + { + return $this->requestedAt; + } + + /** + * @return array + */ + #[\ReturnTypeWillChange] + public function jsonSerialize(): array + { + return [ + 'purpose' => $this->purpose, + 'did' => $this->did, + 'requestedAt' => $this->requestedAt->format(DATE_ATOM), + ]; + } +} diff --git a/src/Domain/Account/EmailToken/EmailTokenNotFoundException.php b/src/Domain/Account/EmailToken/EmailTokenNotFoundException.php new file mode 100644 index 0000000..439172f --- /dev/null +++ b/src/Domain/Account/EmailToken/EmailTokenNotFoundException.php @@ -0,0 +1,12 @@ +code; + } + + public function getAvailableUses(): int + { + return $this->availableUses; + } + + public function isDisabled(): bool + { + return $this->disabled; + } + + public function getForAccount(): string + { + return $this->forAccount; + } + + public function getCreatedBy(): string + { + return $this->createdBy; + } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } + + /** + * @return array + */ + #[\ReturnTypeWillChange] + public function jsonSerialize(): array + { + return [ + 'code' => $this->code, + 'availableUses' => $this->availableUses, + 'disabled' => $this->disabled, + 'forAccount' => $this->forAccount, + 'createdBy' => $this->createdBy, + 'createdAt' => $this->createdAt->format(DATE_ATOM), + ]; + } +} diff --git a/src/Domain/Account/InviteCode/InviteCodeNotFoundException.php b/src/Domain/Account/InviteCode/InviteCodeNotFoundException.php new file mode 100644 index 0000000..918049d --- /dev/null +++ b/src/Domain/Account/InviteCode/InviteCodeNotFoundException.php @@ -0,0 +1,12 @@ +code; + } + + public function getUsedBy(): string + { + return $this->usedBy; + } + + public function getUsedAt(): DateTimeImmutable + { + return $this->usedAt; + } + + /** + * @return array + */ + #[\ReturnTypeWillChange] + public function jsonSerialize(): array + { + return [ + 'code' => $this->code, + 'usedBy' => $this->usedBy, + 'usedAt' => $this->usedAt->format(DATE_ATOM), + ]; + } +} diff --git a/src/Domain/Account/RefreshToken/RefreshToken.php b/src/Domain/Account/RefreshToken/RefreshToken.php new file mode 100644 index 0000000..ace6ddf --- /dev/null +++ b/src/Domain/Account/RefreshToken/RefreshToken.php @@ -0,0 +1,62 @@ +id; + } + + public function getDid(): string + { + return $this->did; + } + + public function getExpiresAt(): string + { + return $this->expiresAt; + } + + public function getAppPasswordName(): ?string + { + return $this->appPasswordName; + } + + public function getNextId(): ?string + { + return $this->nextId; + } + + /** + * @return array + */ + #[\ReturnTypeWillChange] + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'did' => $this->did, + 'expiresAt' => $this->expiresAt, + 'appPasswordName' => $this->appPasswordName, + 'nextId' => $this->nextId, + ]; + } +} diff --git a/src/Domain/Account/RefreshToken/RefreshTokenNotFoundException.php b/src/Domain/Account/RefreshToken/RefreshTokenNotFoundException.php new file mode 100644 index 0000000..47accd4 --- /dev/null +++ b/src/Domain/Account/RefreshToken/RefreshTokenNotFoundException.php @@ -0,0 +1,12 @@ + did => EmailToken. + * (Only one active token per purpose+did at a time, matching reference behavior.) + * + * @var array> + */ + private array $tokens = []; + + /** + * @param EmailToken[] $seeds + */ + public function __construct(array $seeds = []) + { + foreach ($seeds as $token) { + $this->tokens[$token->getPurpose()][$token->getDid()] = $token; + } + } + + public function findByPurposeAndDid(string $purpose, string $did): EmailToken + { + if (!isset($this->tokens[$purpose][$did])) { + throw new EmailTokenNotFoundException(); + } + + return $this->tokens[$purpose][$did]; + } + + public function findByPurposeAndToken(string $purpose, string $token): EmailToken + { + foreach ($this->tokens[$purpose] ?? [] as $entry) { + if ($entry->getToken() === $token) { + return $entry; + } + } + + throw new EmailTokenNotFoundException(); + } + + public function save(EmailToken $token): void + { + $this->tokens[$token->getPurpose()][$token->getDid()] = $token; + } + + public function deleteByPurposeAndDid(string $purpose, string $did): void + { + unset($this->tokens[$purpose][$did]); + } +} diff --git a/src/Infrastructure/Persistence/Account/InviteCode/InMemoryInviteCodeRepository.php b/src/Infrastructure/Persistence/Account/InviteCode/InMemoryInviteCodeRepository.php new file mode 100644 index 0000000..8a65f04 --- /dev/null +++ b/src/Infrastructure/Persistence/Account/InviteCode/InMemoryInviteCodeRepository.php @@ -0,0 +1,98 @@ + keyed by code */ + private array $codes = []; + + /** @var array keyed by code */ + private array $uses = []; + + /** + * @param InviteCode[] $seeds + * @param InviteCodeUse[] $useSeeds + */ + public function __construct(array $seeds = [], array $useSeeds = []) + { + foreach ($seeds as $code) { + $this->codes[$code->getCode()] = $code; + } + foreach ($useSeeds as $use) { + $this->uses[$use->getCode()][] = $use; + } + } + + /** + * @return InviteCode[] + */ + public function findAll(): array + { + return array_values($this->codes); + } + + public function findByCode(string $code): InviteCode + { + if (!isset($this->codes[$code])) { + throw new InviteCodeNotFoundException(); + } + + return $this->codes[$code]; + } + + /** + * @return InviteCode[] + */ + public function findAllForAccount(string $did): array + { + return array_values( + array_filter( + $this->codes, + fn(InviteCode $c) => $c->getForAccount() === $did, + ) + ); + } + + public function save(InviteCode $inviteCode): void + { + $this->codes[$inviteCode->getCode()] = $inviteCode; + } + + public function disable(string $code): void + { + if (!isset($this->codes[$code])) { + throw new InviteCodeNotFoundException(); + } + + $old = $this->codes[$code]; + $this->codes[$code] = new InviteCode( + code: $old->getCode(), + availableUses: $old->getAvailableUses(), + disabled: true, + forAccount: $old->getForAccount(), + createdBy: $old->getCreatedBy(), + createdAt: $old->getCreatedAt(), + ); + } + + /** + * @return InviteCodeUse[] + */ + public function findUsesForCode(string $code): array + { + return $this->uses[$code] ?? []; + } + + public function recordUse(InviteCodeUse $use): void + { + $this->uses[$use->getCode()][] = $use; + } +} diff --git a/src/Infrastructure/Persistence/Account/RefreshToken/InMemoryRefreshTokenRepository.php b/src/Infrastructure/Persistence/Account/RefreshToken/InMemoryRefreshTokenRepository.php new file mode 100644 index 0000000..8cd7d76 --- /dev/null +++ b/src/Infrastructure/Persistence/Account/RefreshToken/InMemoryRefreshTokenRepository.php @@ -0,0 +1,65 @@ + keyed by token id */ + private array $tokens = []; + + /** + * @param RefreshToken[] $seeds + */ + public function __construct(array $seeds = []) + { + foreach ($seeds as $token) { + $this->tokens[$token->getId()] = $token; + } + } + + public function findById(string $id): RefreshToken + { + if (!isset($this->tokens[$id])) { + throw new RefreshTokenNotFoundException(); + } + + return $this->tokens[$id]; + } + + /** + * @return RefreshToken[] + */ + public function findAllForDid(string $did): array + { + return array_values( + array_filter( + $this->tokens, + fn(RefreshToken $t) => $t->getDid() === $did, + ) + ); + } + + public function save(RefreshToken $token): void + { + $this->tokens[$token->getId()] = $token; + } + + public function deleteById(string $id): void + { + unset($this->tokens[$id]); + } + + public function deleteAllForDid(string $did): void + { + $this->tokens = array_filter( + $this->tokens, + fn(RefreshToken $t) => $t->getDid() !== $did, + ); + } +} diff --git a/tests/Infrastructure/Persistence/Account/EmailToken/InMemoryEmailTokenRepositoryTest.php b/tests/Infrastructure/Persistence/Account/EmailToken/InMemoryEmailTokenRepositoryTest.php new file mode 100644 index 0000000..ce28f48 --- /dev/null +++ b/tests/Infrastructure/Persistence/Account/EmailToken/InMemoryEmailTokenRepositoryTest.php @@ -0,0 +1,74 @@ +makeToken(); + $repo = new InMemoryEmailTokenRepository([$t]); + + $result = $repo->findByPurposeAndDid(EmailToken::PURPOSE_CONFIRM_EMAIL, 'did:plc:alice'); + $this->assertSame($t, $result); + } + + public function testFindByPurposeAndToken(): void + { + $t = $this->makeToken(); + $repo = new InMemoryEmailTokenRepository([$t]); + + $result = $repo->findByPurposeAndToken(EmailToken::PURPOSE_CONFIRM_EMAIL, 'abc123'); + $this->assertSame($t, $result); + } + + public function testFindThrowsWhenMissing(): void + { + $repo = new InMemoryEmailTokenRepository(); + + $this->expectException(EmailTokenNotFoundException::class); + $repo->findByPurposeAndDid(EmailToken::PURPOSE_RESET_PASSWORD, 'did:plc:alice'); + } + + public function testSaveOverwritesPreviousForSamePurposeAndDid(): void + { + $t1 = $this->makeToken(token: 'old'); + $t2 = $this->makeToken(token: 'new'); + $repo = new InMemoryEmailTokenRepository([$t1]); + $repo->save($t2); + + $result = $repo->findByPurposeAndDid(EmailToken::PURPOSE_CONFIRM_EMAIL, 'did:plc:alice'); + $this->assertSame('new', $result->getToken()); + } + + public function testDeleteByPurposeAndDid(): void + { + $t = $this->makeToken(); + $repo = new InMemoryEmailTokenRepository([$t]); + $repo->deleteByPurposeAndDid(EmailToken::PURPOSE_CONFIRM_EMAIL, 'did:plc:alice'); + + $this->expectException(EmailTokenNotFoundException::class); + $repo->findByPurposeAndDid(EmailToken::PURPOSE_CONFIRM_EMAIL, 'did:plc:alice'); + } +} diff --git a/tests/Infrastructure/Persistence/Account/InviteCode/InMemoryInviteCodeRepositoryTest.php b/tests/Infrastructure/Persistence/Account/InviteCode/InMemoryInviteCodeRepositoryTest.php new file mode 100644 index 0000000..6be49b3 --- /dev/null +++ b/tests/Infrastructure/Persistence/Account/InviteCode/InMemoryInviteCodeRepositoryTest.php @@ -0,0 +1,86 @@ +makeCode('code1'); + $c2 = $this->makeCode('code2'); + $repo = new InMemoryInviteCodeRepository([$c1, $c2]); + + $this->assertCount(2, $repo->findAll()); + } + + public function testFindByCode(): void + { + $code = $this->makeCode(); + $repo = new InMemoryInviteCodeRepository([$code]); + + $this->assertSame($code, $repo->findByCode('pds-abc-1234')); + } + + public function testFindByCodeThrowsWhenMissing(): void + { + $repo = new InMemoryInviteCodeRepository(); + + $this->expectException(InviteCodeNotFoundException::class); + $repo->findByCode('no-such-code'); + } + + public function testFindAllForAccount(): void + { + $c1 = $this->makeCode('c1', 'did:plc:alice'); + $c2 = $this->makeCode('c2', 'did:plc:bob'); + $repo = new InMemoryInviteCodeRepository([$c1, $c2]); + + $results = $repo->findAllForAccount('did:plc:alice'); + $this->assertCount(1, $results); + $this->assertSame('c1', $results[0]->getCode()); + } + + public function testDisable(): void + { + $code = $this->makeCode(); + $repo = new InMemoryInviteCodeRepository([$code]); + $repo->disable('pds-abc-1234'); + + $this->assertTrue($repo->findByCode('pds-abc-1234')->isDisabled()); + } + + public function testRecordUseAndFindUses(): void + { + $code = $this->makeCode(); + $repo = new InMemoryInviteCodeRepository([$code]); + $use = new InviteCodeUse('pds-abc-1234', 'did:plc:bob', new DateTimeImmutable()); + $repo->recordUse($use); + + $uses = $repo->findUsesForCode('pds-abc-1234'); + $this->assertCount(1, $uses); + $this->assertSame('did:plc:bob', $uses[0]->getUsedBy()); + } +} diff --git a/tests/Infrastructure/Persistence/Account/RefreshToken/InMemoryRefreshTokenRepositoryTest.php b/tests/Infrastructure/Persistence/Account/RefreshToken/InMemoryRefreshTokenRepositoryTest.php new file mode 100644 index 0000000..75610c6 --- /dev/null +++ b/tests/Infrastructure/Persistence/Account/RefreshToken/InMemoryRefreshTokenRepositoryTest.php @@ -0,0 +1,63 @@ +makeToken(); + $repo = new InMemoryRefreshTokenRepository([$token]); + + $this->assertSame($token, $repo->findById('token-abc')); + } + + public function testFindByIdThrowsWhenMissing(): void + { + $repo = new InMemoryRefreshTokenRepository(); + + $this->expectException(RefreshTokenNotFoundException::class); + $repo->findById('nope'); + } + + public function testFindAllForDid(): void + { + $t1 = $this->makeToken('t1', 'did:plc:alice'); + $t2 = $this->makeToken('t2', 'did:plc:bob'); + $repo = new InMemoryRefreshTokenRepository([$t1, $t2]); + + $results = $repo->findAllForDid('did:plc:alice'); + $this->assertCount(1, $results); + $this->assertSame('t1', $results[0]->getId()); + } + + public function testDeleteAllForDid(): void + { + $t1 = $this->makeToken('t1', 'did:plc:alice'); + $t2 = $this->makeToken('t2', 'did:plc:alice'); + $repo = new InMemoryRefreshTokenRepository([$t1, $t2]); + + $repo->deleteAllForDid('did:plc:alice'); + $this->assertEmpty($repo->findAllForDid('did:plc:alice')); + } +}