diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..e5d8851 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "9.0.19", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/Atm.sln b/Atm.sln index fee2692..d015d3a 100644 --- a/Atm.sln +++ b/Atm.sln @@ -21,6 +21,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atm.Application.Tests", "tests\Atm.Application.Tests\Atm.Application.Tests.csproj", "{2DEDE88D-E43A-4FBC-A2E2-0AE5AD2945B5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atm.Domain.Tests", "tests\Atm.Domain.Tests\Atm.Domain.Tests.csproj", "{D9ACF0B4-7830-4D65-96E2-49EC72B8DD3A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atm.Infrastructure.Tests", "tests\Atm.Infrastructure.Tests\Atm.Infrastructure.Tests.csproj", "{F2E5698E-1037-4F49-A653-1A7D60B909E4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -115,6 +119,30 @@ Global {2DEDE88D-E43A-4FBC-A2E2-0AE5AD2945B5}.Release|x64.Build.0 = Release|Any CPU {2DEDE88D-E43A-4FBC-A2E2-0AE5AD2945B5}.Release|x86.ActiveCfg = Release|Any CPU {2DEDE88D-E43A-4FBC-A2E2-0AE5AD2945B5}.Release|x86.Build.0 = Release|Any CPU + {D9ACF0B4-7830-4D65-96E2-49EC72B8DD3A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D9ACF0B4-7830-4D65-96E2-49EC72B8DD3A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D9ACF0B4-7830-4D65-96E2-49EC72B8DD3A}.Debug|x64.ActiveCfg = Debug|Any CPU + {D9ACF0B4-7830-4D65-96E2-49EC72B8DD3A}.Debug|x64.Build.0 = Debug|Any CPU + {D9ACF0B4-7830-4D65-96E2-49EC72B8DD3A}.Debug|x86.ActiveCfg = Debug|Any CPU + {D9ACF0B4-7830-4D65-96E2-49EC72B8DD3A}.Debug|x86.Build.0 = Debug|Any CPU + {D9ACF0B4-7830-4D65-96E2-49EC72B8DD3A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D9ACF0B4-7830-4D65-96E2-49EC72B8DD3A}.Release|Any CPU.Build.0 = Release|Any CPU + {D9ACF0B4-7830-4D65-96E2-49EC72B8DD3A}.Release|x64.ActiveCfg = Release|Any CPU + {D9ACF0B4-7830-4D65-96E2-49EC72B8DD3A}.Release|x64.Build.0 = Release|Any CPU + {D9ACF0B4-7830-4D65-96E2-49EC72B8DD3A}.Release|x86.ActiveCfg = Release|Any CPU + {D9ACF0B4-7830-4D65-96E2-49EC72B8DD3A}.Release|x86.Build.0 = Release|Any CPU + {F2E5698E-1037-4F49-A653-1A7D60B909E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F2E5698E-1037-4F49-A653-1A7D60B909E4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F2E5698E-1037-4F49-A653-1A7D60B909E4}.Debug|x64.ActiveCfg = Debug|Any CPU + {F2E5698E-1037-4F49-A653-1A7D60B909E4}.Debug|x64.Build.0 = Debug|Any CPU + {F2E5698E-1037-4F49-A653-1A7D60B909E4}.Debug|x86.ActiveCfg = Debug|Any CPU + {F2E5698E-1037-4F49-A653-1A7D60B909E4}.Debug|x86.Build.0 = Debug|Any CPU + {F2E5698E-1037-4F49-A653-1A7D60B909E4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F2E5698E-1037-4F49-A653-1A7D60B909E4}.Release|Any CPU.Build.0 = Release|Any CPU + {F2E5698E-1037-4F49-A653-1A7D60B909E4}.Release|x64.ActiveCfg = Release|Any CPU + {F2E5698E-1037-4F49-A653-1A7D60B909E4}.Release|x64.Build.0 = Release|Any CPU + {F2E5698E-1037-4F49-A653-1A7D60B909E4}.Release|x86.ActiveCfg = Release|Any CPU + {F2E5698E-1037-4F49-A653-1A7D60B909E4}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -127,5 +155,7 @@ Global {685D6187-D16F-4231-A59D-111A4C832F55} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {72D902C9-8BE5-4408-B0EA-F77BCDD7C534} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {2DEDE88D-E43A-4FBC-A2E2-0AE5AD2945B5} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {D9ACF0B4-7830-4D65-96E2-49EC72B8DD3A} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {F2E5698E-1037-4F49-A653-1A7D60B909E4} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/Directory.Build.props b/Directory.Build.props index fd2910d..85cf783 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,5 +3,6 @@ true true latest + true \ No newline at end of file diff --git a/README.md b/README.md index 00a6463..263cc31 100644 --- a/README.md +++ b/README.md @@ -1,223 +1,167 @@ -# ATM [Hexagonal Architecture] +# ATM Web API -Implementation of an ATM system with a Web API interface using ASP.NET Controllers and in-memory storage. +An ATM training project built with .NET 9, ASP.NET Core Controllers, and hexagonal architecture. -## Functionality +Database stage 1 is currently in progress. Microstages 13.1 and 13.2 are implemented; runtime repositories are still temporary in-memory adapters until the EF Core repositories replace them in microstage 13.9. -- creating a user session using AccountNumber + PIN -- creating an administrator session using SystemPassword + AccountNumber -- creating an account, available only through an admin session -- viewing the account balance -- depositing money into an account -- withdrawing money -- viewing transaction history +## Current functionality -Any account-related operation creates a record in the transaction history. +- create expiring user and administrator sessions; +- create an account through an administrator session; +- view an account balance; +- deposit and withdraw money; +- view the legacy in-memory operation history. -## Architecture - -The project follows the hexagonal architecture approach: +The domain model now also contains persistent shapes for users, canonical friendships, accounts, sessions, bank transactions, commission snapshots, and immutable account history entries. Transactional persistence, idempotency enforcement, commissions in runtime use cases, and transfers are deliberately implemented in later microstages. -- **Domain** — domain model and value objects -- **Application** — business logic / use cases through ports -- **Application.Abstractions** — abstractions such as repositories, providers, and similar components -- **Application.Contracts** — request/response contracts and operation results -- **Infrastructure** — implementations of abstractions, including in-memory repositories, and DI registration -- **Presentation** — ASP.NET Web API Controllers and HTTP-to-port mapping +## Architecture -The layers are connected using `Microsoft.Extensions.DependencyInjection`. +- **Atm.Domain** — entities, value objects, invariants, and domain operation results; +- **Atm.Application** — use-case orchestration; +- **Atm.Application.Abstractions** — repository, clock, security, and provider ports; +- **Atm.Application.Contracts** — input/output ports and application results; +- **Atm.Infrastructure** — temporary repositories plus infrastructure adapters; +- **Atm.Presentation** — HTTP controllers and composition root. -Each module provides extension methods for dependency registration: +Domain and Application have no EF Core or PostgreSQL dependency. -- `builder.Services.AddApplication();` -- `builder.Services.AddInfrastructure();` +## Validation and result style -## System Password Configuration +Small value objects such as `AccountNumber`, `UserLogin`, `Money`, and `PinCode` validate themselves in constructors and reject invalid values with `ArgumentException`. -The system password is read through `ISystemPasswordProvider`, implemented in `Atm.Presentation.Common.SystemPasswordProvider`, and is taken from the configuration. +Business operations that can normally be accepted or rejected use a dedicated result type with nested `Success` and `Failure` records. The project intentionally does not introduce a universal `Result` or one result hierarchy per primitive field. -`appsettings.json`: +## Money rules -```json -{ - "SystemAuth": { - "SystemPassword": "your_password_here" - } -} -``` +Amounts use .NET `decimal` and allow at most four fractional digits. There is no artificial `numeric(19,4)` business ceiling: for example, values above `1_000_000_000_000_000` are valid. -## Running the API +This does not mean infinite precision. Values must still fit in `decimal`, and arithmetic is rejected when it would overflow or silently lose a small amount near the representable boundary. Future PostgreSQL mappings use unconstrained `numeric` with scale checks instead of `numeric(19,4)`. -The HTTP launch profile uses: +Commission snapshots retain the applied rate, fixed component, and calculated commission. The formula is: ```text -http://localhost:5015 +round(amount * rate + fixed, 4, MidpointRounding.ToEven) ``` -Swagger UI is available at: +Runtime deposit and withdrawal use cases still have their legacy no-commission behavior until microstage 13.7 wires the new transaction model into one database transaction. -```text -http://localhost:5015/swagger -``` +## PIN security -## API Use Cases +The account entity stores `PinHash`, never `PinCode`. `IPinHasher` is implemented using the versioned Microsoft ASP.NET Core `PasswordHasher`, which generates a unique salt and provides the matching verification routine. -### 1. Create an admin session +Do not log request bodies containing PINs. JWT, cookies, and ASP.NET Core Identity user management are outside this stage. -Use this when an administrator needs to perform admin-only actions, such as creating a new account. +## Clock and sessions -`POST /api/sessions/admin` +`IClock` supplies the current UTC time. Production uses `SystemClock`; tests use a controllable `FixedClock`. This keeps time-dependent domain behavior deterministic. -```json -{ - "systemPassword": "your_password_here", - "accountNumber": "acc-1" -} -``` +A user session references the stable account UUID. A system administrator session is not associated with an account. Sessions have UTC creation, expiration, and optional revocation timestamps. They are active from creation inclusively until expiration exclusively, unless revoked first. -Successful response: +Session lifetimes are validated at startup: ```json { - "sessionId": "admin-session-guid" + "Sessions": { + "UserLifetimeMinutes": 30, + "AdminLifetimeMinutes": 15 + } } ``` -The returned `sessionId` is used as `adminSessionId` in admin-only requests. - -### 2. Create an account - -Requires an admin session. - -`POST /api/accounts/create` +Both values must be positive. -```json -{ - "adminSessionId": "admin-session-guid", - "accountNumber": "acc-1", - "pin": "1234", - "initialBalance": 100 -} -``` +## System password configuration -Successful response: +The administrator password comes from configuration through `ISystemPasswordProvider`: ```json { - "message": "Account created" + "SystemAuth": { + "SystemPassword": "use-a-secret-provider" + } } ``` -### 3. Create a user session +Do not commit a real password. Override the development placeholder with user secrets or an environment variable. -Use this after an account has been created. The user signs in with the account number and PIN. +## API examples -`POST /api/sessions/user` +The local HTTP profile uses `http://localhost:5015`; Swagger is available at `http://localhost:5015/swagger` in Development. -```json -{ - "accountNumber": "acc-1", - "pin": "1234" -} -``` +### Create an administrator session -Successful response: +`POST /api/sessions/admin` ```json { - "sessionId": "user-session-guid" + "systemPassword": "use-a-secret-provider", + "accountNumber": "legacy-admin-context" } ``` -The returned `sessionId` is used as the user session identifier for balance, deposit, withdrawal, and history operations. - -### 4. View balance +`accountNumber` remains required only for backward compatibility with the existing HTTP contract. A system administrator session does not store or authorize against this number. -Requires a user session. +### Create an account -`GET /api/accounts/balance?sessionId=user-session-guid` - -Successful response: +`POST /api/accounts/create` ```json { - "balance": 100 + "adminSessionId": "00000000-0000-0000-0000-000000000001", + "accountNumber": "acc-1", + "pin": "1234", + "initialBalance": 100, + "ownerUserId": "00000000-0000-0000-0000-000000000002" } ``` -### 5. Deposit money - -Requires a user session. - -`POST /api/accounts/deposit` +`ownerUserId` is a breaking addition to the old contract. In the current transitional in-memory implementation only a non-empty UUID is checked. Existence is enforced by the user repository and PostgreSQL foreign key in later persistence microstages. There is intentionally no user/profile HTTP CRUD endpoint in stage 1. -```json -{ - "userSessionId": "user-session-guid", - "amount": 50 -} -``` +### Create a user session -Successful response: +`POST /api/sessions/user` ```json { - "balance": 150 + "accountNumber": "acc-1", + "pin": "1234" } ``` -### 6. Withdraw money - -Requires a user session. +The returned `sessionId` authorizes balance, deposit, withdrawal, and history operations. -`POST /api/accounts/withdraw` +### Account operations -```json -{ - "userSessionId": "user-session-guid", - "amount": 30 -} -``` +- `GET /api/accounts/balance?sessionId={user-session-id}` +- `POST /api/accounts/deposit` with `userSessionId` and `amount` +- `POST /api/accounts/withdraw` with `userSessionId` and `amount` +- `GET /api/accounts/history?sessionId={user-session-id}` -Successful response: +## Build and test -```json -{ - "balance": 120 -} +```powershell +dotnet restore Atm.sln --locked-mode +dotnet build Atm.sln -c Release --no-restore +dotnet test Atm.sln -c Release --no-build ``` -### 7. View transaction history - -Requires a user session. +Tests are separated by architectural layer: -`GET /api/accounts/history?sessionId=user-session-guid` +- `Atm.Domain.Tests` validates value objects, entities, invariants, commissions, and history calculations; +- `Atm.Application.Tests` validates use cases, authorization, and application orchestration through ports; +- `Atm.Infrastructure.Tests` validates concrete infrastructure adapters such as `PinHasher`. -Successful response: +Each test project references only the production layer it is intended to test. Unit tests create mocks or focused test objects directly and do not build the production DI container or start a database. -```json -{ - "transactions": [ - { - "type": "Deposit", - "amount": 50, - "balanceAfter": 150 - }, - { - "type": "Withdraw", - "amount": 30, - "balanceAfter": 120 - } - ] -} -``` +## Transitional limitations -## Authentication and Authorization Model +The following stage-1 work is not yet complete: -The project currently uses application-level sessions instead of ASP.NET Core JWT or cookie authentication. +- `AtmDbContext`, mappings, migration, and PostgreSQL repositories; +- asynchronous persistence ports and cancellation tokens; +- atomic balance/transaction/history commits; +- database-backed idempotency and concurrency handling; +- runtime commission policies and transfers; +- PostgreSQL integration tests, production DI replacement, and CI environment. -- Admin authentication is performed with `SystemPassword`. -- User authentication is performed with `AccountNumber` and `PIN`. -- Authorization is performed by checking the session type: `Admin` or `User`. -- Admin-only operations require an admin session. -- Account operations require a user session. \ No newline at end of file +Do not treat the current in-memory repositories as production persistence. diff --git a/global.json b/global.json new file mode 100644 index 0000000..bf1f678 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "9.0.100", + "rollForward": "latestFeature", + "allowPrerelease": false + } +} \ No newline at end of file diff --git a/src/Atm.Application.Abstractions/Repositories/IAccountRepository.cs b/src/Atm.Application.Abstractions/Repositories/IAccountRepository.cs index 25bc8b8..4770a4b 100644 --- a/src/Atm.Application.Abstractions/Repositories/IAccountRepository.cs +++ b/src/Atm.Application.Abstractions/Repositories/IAccountRepository.cs @@ -1,13 +1,15 @@ -using Atm.Domain.Accounts; +using Atm.Domain.Accounts; using Atm.Domain.ValueObjects; namespace Atm.Application.Abstractions.Repositories; public interface IAccountRepository { + Account? GetById(Guid id); + Account? GetByNumber(AccountNumber accountNumber); void Add(Account account); void Update(Account account); -} \ No newline at end of file +} diff --git a/src/Atm.Application.Abstractions/Security/IPinHasher.cs b/src/Atm.Application.Abstractions/Security/IPinHasher.cs new file mode 100644 index 0000000..0131331 --- /dev/null +++ b/src/Atm.Application.Abstractions/Security/IPinHasher.cs @@ -0,0 +1,10 @@ +using Atm.Domain.ValueObjects; + +namespace Atm.Application.Abstractions.Security; + +public interface IPinHasher +{ + PinHash Hash(PinCode pin); + + bool Verify(PinHash hash, PinCode pin); +} diff --git a/src/Atm.Application.Abstractions/Time/IClock.cs b/src/Atm.Application.Abstractions/Time/IClock.cs new file mode 100644 index 0000000..c9b9680 --- /dev/null +++ b/src/Atm.Application.Abstractions/Time/IClock.cs @@ -0,0 +1,6 @@ +namespace Atm.Application.Abstractions.Time; + +public interface IClock +{ + DateTimeOffset UtcNow { get; } +} diff --git a/src/Atm.Application.Contracts/Accounts/CreateAccount/CreateAccountRequest.cs b/src/Atm.Application.Contracts/Accounts/CreateAccount/CreateAccountRequest.cs index 5df84dc..60b1fac 100644 --- a/src/Atm.Application.Contracts/Accounts/CreateAccount/CreateAccountRequest.cs +++ b/src/Atm.Application.Contracts/Accounts/CreateAccount/CreateAccountRequest.cs @@ -1,7 +1,8 @@ -namespace Atm.Application.Contracts.Accounts.CreateAccount; +namespace Atm.Application.Contracts.Accounts.CreateAccount; public sealed record CreateAccountRequest( Guid AdminSessionId, string AccountNumber, string Pin, - decimal InitialBalance); \ No newline at end of file + decimal InitialBalance, + Guid OwnerUserId); diff --git a/src/Atm.Application.Contracts/packages.lock.json b/src/Atm.Application.Contracts/packages.lock.json index ba655ce..a07e21a 100644 --- a/src/Atm.Application.Contracts/packages.lock.json +++ b/src/Atm.Application.Contracts/packages.lock.json @@ -1,10 +1,6 @@ { "version": 1, "dependencies": { - "net9.0": { - "atm.domain": { - "type": "Project" - } - } + "net9.0": {} } } \ No newline at end of file diff --git a/src/Atm.Application/Accounts/CreateAccountUseCase.cs b/src/Atm.Application/Accounts/CreateAccountUseCase.cs index 4fe3ce3..ceeab30 100644 --- a/src/Atm.Application/Accounts/CreateAccountUseCase.cs +++ b/src/Atm.Application/Accounts/CreateAccountUseCase.cs @@ -1,7 +1,10 @@ -using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Security; +using Atm.Application.Abstractions.Time; using Atm.Application.Common; using Atm.Application.Contracts.Accounts.CreateAccount; using Atm.Domain.Accounts; +using Atm.Domain.Accounts.Results; using Atm.Domain.ValueObjects; namespace Atm.Application.Accounts; @@ -10,43 +13,54 @@ internal sealed class CreateAccountUseCase : ICreateAccountPort { private readonly IAccountRepository _accounts; private readonly SessionAuthorizer _auth; + private readonly IPinHasher _pinHasher; + private readonly IClock _clock; - public CreateAccountUseCase(IAccountRepository accounts, ISessionRepository sessions) + public CreateAccountUseCase( + IAccountRepository accounts, + SessionAuthorizer auth, + IPinHasher pinHasher, + IClock clock) { _accounts = accounts; - _auth = new SessionAuthorizer(sessions); + _auth = auth; + _pinHasher = pinHasher; + _clock = clock; } public CreateAccountResult Execute(CreateAccountRequest request) { AuthorizationResult auth = _auth.AuthorizeAdmin(request.AdminSessionId); + if (auth is AuthorizationResult.Unauthorized unauthorized) + return new CreateAccountResult.Unauthorized(unauthorized.ErrorMessage); - if (auth is AuthorizationResult.Unauthorized u) + AccountNumber number; + PinCode pin; + Money balance; + try { - return new CreateAccountResult.Unauthorized(u.ErrorMessage); + number = new AccountNumber(request.AccountNumber); + pin = new PinCode(request.Pin); + balance = new Money(request.InitialBalance); } - - try + catch (ArgumentException exception) { - var number = new AccountNumber(request.AccountNumber); - var pin = new PinCode(request.Pin); - var balance = new Money(request.InitialBalance); + return new CreateAccountResult.Failure(exception.Message); + } - Account? existing = _accounts.GetByNumber(number); + if (request.OwnerUserId == Guid.Empty) + return new CreateAccountResult.Failure("Owner ID must not be empty."); + if (_accounts.GetByNumber(number) is not null) + return new CreateAccountResult.Failure("Account number already exists."); - if (existing is not null) - { - return new CreateAccountResult.Failure("Account number already exists"); - } + AccountCreationResult result = Account.Create( + Guid.NewGuid(), number, request.OwnerUserId, + _pinHasher.Hash(pin), balance, _clock.UtcNow); - var account = new Account(number, pin, balance); - _accounts.Add(account); + if (result is AccountCreationResult.Failure failure) + return new CreateAccountResult.Failure(failure.ErrorMessage); - return new CreateAccountResult.Success(); - } - catch (Exception e) - { - return new CreateAccountResult.Failure(e.Message); - } + _accounts.Add(((AccountCreationResult.Success)result).Account); + return new CreateAccountResult.Success(); } -} \ No newline at end of file +} diff --git a/src/Atm.Application/Accounts/GetBalanceUseCase.cs b/src/Atm.Application/Accounts/GetBalanceUseCase.cs index 7d6ed8a..d229c0f 100644 --- a/src/Atm.Application/Accounts/GetBalanceUseCase.cs +++ b/src/Atm.Application/Accounts/GetBalanceUseCase.cs @@ -1,4 +1,4 @@ -using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Repositories; using Atm.Application.Common; using Atm.Application.Contracts.Accounts.GetBalance; @@ -18,20 +18,14 @@ public GetBalanceUseCase(IAccountRepository accounts, SessionAuthorizer auth) public GetBalanceResult Execute(GetBalanceRequest request) { AuthorizationResult auth = _auth.AuthorizeUser(request.UserSessionId); - - if (auth is AuthorizationResult.Unauthorized u) - { - return new GetBalanceResult.Unauthorized(u.ErrorMessage); - } + if (auth is AuthorizationResult.Unauthorized unauthorized) + return new GetBalanceResult.Unauthorized(unauthorized.ErrorMessage); var user = (AuthorizationResult.UserSuccess)auth; - Domain.Accounts.Account? acc = _accounts.GetByNumber(user.AccountNumber); - - if (acc is null) - { + Domain.Accounts.Account? account = _accounts.GetById(user.AccountId); + if (account is null) return new GetBalanceResult.Failure("Account not found"); - } - return new GetBalanceResult.Success(acc.Balance.Value); + return new GetBalanceResult.Success(account.Balance.Value); } -} \ No newline at end of file +} diff --git a/src/Atm.Application/Atm.Application.csproj b/src/Atm.Application/Atm.Application.csproj index d64769e..88aff46 100644 --- a/src/Atm.Application/Atm.Application.csproj +++ b/src/Atm.Application/Atm.Application.csproj @@ -7,7 +7,8 @@ - + + diff --git a/src/Atm.Application/Common/AuthorizationResult.cs b/src/Atm.Application/Common/AuthorizationResult.cs index 6587986..a7501ec 100644 --- a/src/Atm.Application/Common/AuthorizationResult.cs +++ b/src/Atm.Application/Common/AuthorizationResult.cs @@ -1,14 +1,12 @@ -using Atm.Domain.ValueObjects; - namespace Atm.Application.Common; public abstract record AuthorizationResult { private AuthorizationResult() { } - public sealed record UserSuccess(AccountNumber AccountNumber) : AuthorizationResult; + public sealed record UserSuccess(Guid AccountId) : AuthorizationResult; public sealed record AdminSuccess : AuthorizationResult; public sealed record Unauthorized(string ErrorMessage) : AuthorizationResult; -} \ No newline at end of file +} diff --git a/src/Atm.Application/Common/SessionAuthorizer.cs b/src/Atm.Application/Common/SessionAuthorizer.cs index 2e8d145..9a06463 100644 --- a/src/Atm.Application/Common/SessionAuthorizer.cs +++ b/src/Atm.Application/Common/SessionAuthorizer.cs @@ -1,4 +1,5 @@ -using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Time; using Atm.Domain.Sessions; namespace Atm.Application.Common; @@ -6,48 +7,35 @@ namespace Atm.Application.Common; public sealed class SessionAuthorizer { private readonly ISessionRepository _sessions; + private readonly IClock _clock; - public SessionAuthorizer(ISessionRepository sessions) + public SessionAuthorizer(ISessionRepository sessions, IClock clock) { _sessions = sessions; + _clock = clock; } public AuthorizationResult AuthorizeUser(Guid sessionId) { - Session? s = _sessions.GetById(sessionId); - - if (s is null) - { - return new AuthorizationResult.Unauthorized("Session not found"); - } - - if (s.Type != SessionType.User) - { + Session? session = _sessions.GetById(sessionId); + if (session is null || !session.IsActive(_clock.UtcNow)) + return new AuthorizationResult.Unauthorized("Session is missing, expired or revoked."); + if (session.Type != SessionType.User) return new AuthorizationResult.Unauthorized("Session type not supported [user session required]"); - } - - if (s.AccountNumber is null) - { - return new AuthorizationResult.Unauthorized("Account number not set"); - } + if (session.AccountId is not Guid accountId) + return new AuthorizationResult.Unauthorized("Account is not set."); - return new AuthorizationResult.UserSuccess(s.AccountNumber); + return new AuthorizationResult.UserSuccess(accountId); } public AuthorizationResult AuthorizeAdmin(Guid sessionId) { - Session? s = _sessions.GetById(sessionId); - - if (s is null) - { - return new AuthorizationResult.Unauthorized("Session not found"); - } - - if (s.Type != SessionType.Admin) - { + Session? session = _sessions.GetById(sessionId); + if (session is null || !session.IsActive(_clock.UtcNow)) + return new AuthorizationResult.Unauthorized("Session is missing, expired or revoked."); + if (session.Type != SessionType.Admin) return new AuthorizationResult.Unauthorized("Session type not supported [admin session required]"); - } return new AuthorizationResult.AdminSuccess(); } -} \ No newline at end of file +} diff --git a/src/Atm.Application/Operations/DepositUseCase.cs b/src/Atm.Application/Operations/DepositUseCase.cs index 47273db..c290f07 100644 --- a/src/Atm.Application/Operations/DepositUseCase.cs +++ b/src/Atm.Application/Operations/DepositUseCase.cs @@ -1,4 +1,5 @@ -using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Time; using Atm.Application.Common; using Atm.Application.Contracts.Operations.Deposit; using Atm.Domain.Operations; @@ -11,12 +12,14 @@ public sealed class DepositUseCase : IDepositPort private readonly IAccountRepository _accounts; private readonly IOperationRepository _operations; private readonly SessionAuthorizer _auth; + private readonly IClock _clock; - public DepositUseCase(IAccountRepository accounts, IOperationRepository operations, SessionAuthorizer auth) + public DepositUseCase(IAccountRepository accounts, IOperationRepository operations, SessionAuthorizer auth, IClock clock) { _accounts = accounts; _operations = operations; _auth = auth; + _clock = clock; } public DepositResult Execute(DepositRequest request) @@ -29,7 +32,7 @@ public DepositResult Execute(DepositRequest request) } var user = (AuthorizationResult.UserSuccess)auth; - Domain.Accounts.Account? acc = _accounts.GetByNumber(user.AccountNumber); + Domain.Accounts.Account? acc = _accounts.GetById(user.AccountId); if (acc is null) { @@ -41,12 +44,12 @@ public DepositResult Execute(DepositRequest request) { amount = new Amount(request.Amount); } - catch (Exception e) + catch (ArgumentException e) { return new DepositResult.Failure(e.Message); } - Domain.Accounts.Results.DepositResult domainResult = acc.Deposit(amount); + Domain.Accounts.Results.DepositResult domainResult = acc.Deposit(amount, _clock.UtcNow); if (domainResult is Domain.Accounts.Results.DepositResult.Failure f) { @@ -54,8 +57,8 @@ public DepositResult Execute(DepositRequest request) } _accounts.Update(acc); - _operations.Add(new OperationRecord(Guid.NewGuid(), user.AccountNumber, OperationType.Deposit, amount, acc.Balance)); + _operations.Add(new OperationRecord(Guid.NewGuid(), acc.Number, OperationType.Deposit, amount, acc.Balance)); return new DepositResult.Success(acc.Balance.Value); } -} \ No newline at end of file +} diff --git a/src/Atm.Application/Operations/GetTransactionHistoryUseCase.cs b/src/Atm.Application/Operations/GetTransactionHistoryUseCase.cs index 78dc825..34ebf12 100644 --- a/src/Atm.Application/Operations/GetTransactionHistoryUseCase.cs +++ b/src/Atm.Application/Operations/GetTransactionHistoryUseCase.cs @@ -1,4 +1,4 @@ -using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Repositories; using Atm.Application.Common; using Atm.Application.Contracts.Operations.GetTransactionHistory; @@ -7,32 +7,41 @@ namespace Atm.Application.Operations; internal sealed class GetTransactionHistoryUseCase : IGetTransactionHistoryPort { private readonly IOperationRepository _operations; + private readonly IAccountRepository _accounts; private readonly SessionAuthorizer _auth; - public GetTransactionHistoryUseCase(IOperationRepository operations, SessionAuthorizer auth) + public GetTransactionHistoryUseCase( + IOperationRepository operations, + IAccountRepository accounts, + SessionAuthorizer auth) { _operations = operations; + _accounts = accounts; _auth = auth; } public GetTransactionHistoryResult Execute(GetTransactionHistoryRequest request) { AuthorizationResult auth = _auth.AuthorizeUser(request.UserSessionId); - - if (auth is AuthorizationResult.Unauthorized u) - { - return new GetTransactionHistoryResult.Unauthorized(u.ErrorMessage); - } + if (auth is AuthorizationResult.Unauthorized unauthorized) + return new GetTransactionHistoryResult.Unauthorized(unauthorized.ErrorMessage); var user = (AuthorizationResult.UserSuccess)auth; - IReadOnlyList ops = _operations.GetByAccountNumber(user.AccountNumber); - var list = new List(ops.Count); - - foreach (Domain.Operations.OperationRecord op in ops) + Domain.Accounts.Account? account = _accounts.GetById(user.AccountId); + if (account is null) + return new GetTransactionHistoryResult.Failure("Account not found"); + + IReadOnlyList operations = + _operations.GetByAccountNumber(account.Number); + var transactions = new List(operations.Count); + foreach (Domain.Operations.OperationRecord operation in operations) { - list.Add(new TransactionDto(op.Type.ToString(), op.Amount.Value, op.BalanceAfter.Value)); + transactions.Add(new TransactionDto( + operation.Type.ToString(), + operation.Amount.Value, + operation.BalanceAfter.Value)); } - return new GetTransactionHistoryResult.Success(list); + return new GetTransactionHistoryResult.Success(transactions); } -} \ No newline at end of file +} diff --git a/src/Atm.Application/Operations/WithdrawUseCase.cs b/src/Atm.Application/Operations/WithdrawUseCase.cs index da1c2b9..607c44c 100644 --- a/src/Atm.Application/Operations/WithdrawUseCase.cs +++ b/src/Atm.Application/Operations/WithdrawUseCase.cs @@ -1,4 +1,5 @@ -using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Time; using Atm.Application.Common; using Atm.Application.Contracts.Operations.Withdraw; using Atm.Domain.Operations; @@ -11,12 +12,14 @@ public sealed class WithdrawUseCase : IWithdrawPort private readonly IAccountRepository _accounts; private readonly IOperationRepository _operations; private readonly SessionAuthorizer _auth; + private readonly IClock _clock; - public WithdrawUseCase(IAccountRepository accounts, IOperationRepository operations, SessionAuthorizer auth) + public WithdrawUseCase(IAccountRepository accounts, IOperationRepository operations, SessionAuthorizer auth, IClock clock) { _accounts = accounts; _operations = operations; _auth = auth; + _clock = clock; } public WithdrawResult Execute(WithdrawRequest request) @@ -29,7 +32,7 @@ public WithdrawResult Execute(WithdrawRequest request) } var user = (AuthorizationResult.UserSuccess)auth; - Domain.Accounts.Account? acc = _accounts.GetByNumber(user.AccountNumber); + Domain.Accounts.Account? acc = _accounts.GetById(user.AccountId); if (acc is null) { @@ -41,20 +44,20 @@ public WithdrawResult Execute(WithdrawRequest request) { amount = new Amount(request.Amount); } - catch (Exception e) + catch (ArgumentException e) { return new WithdrawResult.Failure(e.Message); } - Domain.Accounts.Results.WithdrawResult domainResult = acc.Withdraw(amount); + Domain.Accounts.Results.WithdrawResult domainResult = acc.Withdraw(amount, _clock.UtcNow); if (domainResult is Domain.Accounts.Results.WithdrawResult.Failure result) { return new WithdrawResult.Failure(result.ErrorMessage); } _accounts.Update(acc); - _operations.Add(new OperationRecord(Guid.NewGuid(), user.AccountNumber, OperationType.Withdraw, amount, acc.Balance)); + _operations.Add(new OperationRecord(Guid.NewGuid(), acc.Number, OperationType.Withdraw, amount, acc.Balance)); return new WithdrawResult.Success(acc.Balance.Value); } -} \ No newline at end of file +} diff --git a/src/Atm.Application/Properties/AssemblyInfo.cs b/src/Atm.Application/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..a1a4d7e --- /dev/null +++ b/src/Atm.Application/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Atm.Application.Tests")] diff --git a/src/Atm.Application/Sessions/CreateAdminSessionUseCase.cs b/src/Atm.Application/Sessions/CreateAdminSessionUseCase.cs index ce90160..a8768df 100644 --- a/src/Atm.Application/Sessions/CreateAdminSessionUseCase.cs +++ b/src/Atm.Application/Sessions/CreateAdminSessionUseCase.cs @@ -1,7 +1,10 @@ -using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Time; using Atm.Application.Contracts.Sessions.CreateAdminSession; using Atm.Domain.Sessions; +using Atm.Domain.Sessions.Results; using Atm.Domain.ValueObjects; +using Microsoft.Extensions.Options; namespace Atm.Application.Sessions; @@ -9,39 +12,51 @@ internal sealed class CreateAdminSessionUseCase : ICreateAdminSessionPort { private readonly ISessionRepository _sessions; private readonly ISystemPasswordProvider _passwords; - - public CreateAdminSessionUseCase(ISessionRepository sessions, ISystemPasswordProvider passwords) + private readonly IClock _clock; + private readonly SessionOptions _options; + + public CreateAdminSessionUseCase( + ISessionRepository sessions, + ISystemPasswordProvider passwords, + IClock clock, + IOptions options) { _sessions = sessions; _passwords = passwords; + _clock = clock; + _options = options.Value; } public CreateAdminSessionResult Execute(CreateAdminSessionRequest request) { string expected = _passwords.GetSystemPassword(); - if (string.IsNullOrWhiteSpace(expected)) - { return new CreateAdminSessionResult.Failure("System password is not configured"); - } - - if (request.SystemPassword != expected) - { + if (!string.Equals(request.SystemPassword, expected, StringComparison.Ordinal)) return new CreateAdminSessionResult.Unauthorized("Invalid system password"); - } try { - var accountNumber = new AccountNumber(request.AccountNumber); - var id = Guid.NewGuid(); - - _sessions.Add(new Session(id, SessionType.Admin, accountNumber)); - - return new CreateAdminSessionResult.Success(id); + _ = new AccountNumber(request.AccountNumber); } - catch(Exception ex) + catch (ArgumentException exception) { - return new CreateAdminSessionResult.Failure(ex.Message); + return new CreateAdminSessionResult.Failure(exception.Message); } + + DateTimeOffset createdAtUtc = _clock.UtcNow; + SessionCreationResult result = Session.Create( + Guid.NewGuid(), + SessionType.Admin, + null, + createdAtUtc, + createdAtUtc.AddMinutes(_options.AdminLifetimeMinutes)); + + if (result is SessionCreationResult.Failure failure) + return new CreateAdminSessionResult.Failure(failure.ErrorMessage); + + Session session = ((SessionCreationResult.Success)result).Session; + _sessions.Add(session); + return new CreateAdminSessionResult.Success(session.Id); } -} \ No newline at end of file +} diff --git a/src/Atm.Application/Sessions/CreateUserSessionUseCase.cs b/src/Atm.Application/Sessions/CreateUserSessionUseCase.cs index 6712e64..68937a3 100644 --- a/src/Atm.Application/Sessions/CreateUserSessionUseCase.cs +++ b/src/Atm.Application/Sessions/CreateUserSessionUseCase.cs @@ -1,7 +1,11 @@ -using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Security; +using Atm.Application.Abstractions.Time; using Atm.Application.Contracts.Sessions.CreateUserSession; using Atm.Domain.Sessions; +using Atm.Domain.Sessions.Results; using Atm.Domain.ValueObjects; +using Microsoft.Extensions.Options; namespace Atm.Application.Sessions; @@ -9,36 +13,55 @@ internal sealed class CreateUserSessionUseCase : ICreateUserSessionPort { private readonly ISessionRepository _sessions; private readonly IAccountRepository _accounts; + private readonly IPinHasher _pinHasher; + private readonly IClock _clock; + private readonly SessionOptions _options; - public CreateUserSessionUseCase(ISessionRepository sessions, IAccountRepository accounts) + public CreateUserSessionUseCase( + ISessionRepository sessions, + IAccountRepository accounts, + IPinHasher pinHasher, + IClock clock, + IOptions options) { _sessions = sessions; _accounts = accounts; + _pinHasher = pinHasher; + _clock = clock; + _options = options.Value; } public CreateUserSessionResult Execute(CreateUserSessionRequest request) { + AccountNumber number; + PinCode pin; try { - var number = new AccountNumber(request.AccountNumber); - var pin = new PinCode(request.Pin); - Domain.Accounts.Account? acc = _accounts.GetByNumber(number); + number = new AccountNumber(request.AccountNumber); + pin = new PinCode(request.Pin); + } + catch (ArgumentException exception) + { + return new CreateUserSessionResult.Failure(exception.Message); + } - if (acc is null || acc.Pin.Value != pin.Value) - { - return new CreateUserSessionResult.Unauthorized("Invalid credentials"); - } + Domain.Accounts.Account? account = _accounts.GetByNumber(number); + if (account is null || !_pinHasher.Verify(account.PinHash, pin)) + return new CreateUserSessionResult.Unauthorized("Invalid credentials"); - var id = Guid.NewGuid(); - var session = new Session(id, SessionType.User, number); + DateTimeOffset createdAtUtc = _clock.UtcNow; + SessionCreationResult result = Session.Create( + Guid.NewGuid(), + SessionType.User, + account.Id, + createdAtUtc, + createdAtUtc.AddMinutes(_options.UserLifetimeMinutes)); - _sessions.Add(session); + if (result is SessionCreationResult.Failure failure) + return new CreateUserSessionResult.Failure(failure.ErrorMessage); - return new CreateUserSessionResult.Success(id); - } - catch (Exception ex) - { - return new CreateUserSessionResult.Failure(ex.Message); - } + Session session = ((SessionCreationResult.Success)result).Session; + _sessions.Add(session); + return new CreateUserSessionResult.Success(session.Id); } -} \ No newline at end of file +} diff --git a/src/Atm.Application/Sessions/SessionOptions.cs b/src/Atm.Application/Sessions/SessionOptions.cs new file mode 100644 index 0000000..1ed0139 --- /dev/null +++ b/src/Atm.Application/Sessions/SessionOptions.cs @@ -0,0 +1,10 @@ +namespace Atm.Application.Sessions; + +public sealed class SessionOptions +{ + public const string SectionName = "Sessions"; + + public int UserLifetimeMinutes { get; set; } + + public int AdminLifetimeMinutes { get; set; } +} diff --git a/src/Atm.Application/packages.lock.json b/src/Atm.Application/packages.lock.json index 60a845a..b7879a5 100644 --- a/src/Atm.Application/packages.lock.json +++ b/src/Atm.Application/packages.lock.json @@ -4,9 +4,24 @@ "net9.0": { "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Direct", - "requested": "[10.0.2, )", - "resolved": "10.0.2", - "contentHash": "zOIurr59+kUf9vNcsUkCvKWZv+fPosUZXURZesYkJCvl0EzTc9F7maAO4Cd2WEV7ZJJ0AZrFQvuH6Npph9wdBw==" + "requested": "[9.0.19, )", + "resolved": "9.0.19", + "contentHash": "XgQ0aVWClYjYSBqbYrKC/xyZ1KHf+VVwNF3+d54jcaqnst30Q//ugs/FJwUztLZiph0gp85g7i/eB0556twO0Q==" + }, + "Microsoft.Extensions.Options": { + "type": "Direct", + "requested": "[9.0.19, )", + "resolved": "9.0.19", + "contentHash": "FdPZy4kUSwFlIVZ3EkK9fdHw2RfblUwzflSb4kjVeGf9v2lX9IH+q2RGUovYMR7CVfKMc2y2Db/bZ2b4yiMfOg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19", + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "9hIg8PQiMnpVFIsEHm25Wi1gBrPa2pS1G75uveyDUVLXrNKqBap9WGwQIgO0s6LUgRAOdsSYnbKbNC5b1G5Pcg==" }, "atm.application.abstractions": { "type": "Project", diff --git a/src/Atm.Domain/Accounts/Account.cs b/src/Atm.Domain/Accounts/Account.cs index 0fba7e4..120defa 100644 --- a/src/Atm.Domain/Accounts/Account.cs +++ b/src/Atm.Domain/Accounts/Account.cs @@ -1,37 +1,107 @@ -using Atm.Domain.Accounts.Results; +using Atm.Domain.Accounts.Results; +using Atm.Domain.Common; using Atm.Domain.ValueObjects; namespace Atm.Domain.Accounts; public sealed class Account { - public Account(AccountNumber number, PinCode pin, Money balance) - { - Number = number; - Pin = pin; - Balance = balance; - } + private Account() { } - public AccountNumber Number { get; } + public Guid Id { get; private set; } + public AccountNumber Number { get; private set; } = null!; + public Guid OwnerUserId { get; private set; } + public PinHash PinHash { get; private set; } = null!; + public Money Balance { get; private set; } = null!; + public long Version { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } - public PinCode Pin { get; } + public static AccountCreationResult Create( + Guid id, + AccountNumber? number, + Guid ownerUserId, + PinHash? pinHash, + Money? balance, + DateTimeOffset createdAtUtc) + { + if (id == Guid.Empty || ownerUserId == Guid.Empty) + return new AccountCreationResult.Failure("Account and owner IDs must not be empty."); + if (number is null || pinHash is null || balance is null) + return new AccountCreationResult.Failure("Number, PIN hash and balance are required."); + if (!DomainRules.IsUtc(createdAtUtc)) + return new AccountCreationResult.Failure("Account timestamps must be UTC."); - public Money Balance { get; private set; } + return new AccountCreationResult.Success(new Account + { + Id = id, + Number = number, + OwnerUserId = ownerUserId, + PinHash = pinHash, + Balance = balance, + Version = 0, + CreatedAtUtc = createdAtUtc, + UpdatedAtUtc = createdAtUtc, + }); + } - public DepositResult Deposit(Amount amount) + public DepositResult Deposit(Amount? amount, DateTimeOffset updatedAtUtc) { - Balance = new Money(Balance.Value + amount.Value); + string? error = ValidateChange(amount, updatedAtUtc); + if (error is not null) + return new DepositResult.Failure(error); + + Money balance; + try + { + balance = Balance.Add(amount!); + } + catch (OverflowException) + { + return new DepositResult.Failure("The balance cannot be represented exactly as decimal."); + } + + ApplyBalance(balance, updatedAtUtc); return new DepositResult.Success(Balance); } - public WithdrawResult Withdraw(Amount amount) + public WithdrawResult Withdraw(Amount? amount, DateTimeOffset updatedAtUtc) { - if (Balance.Value < amount.Value) + string? error = ValidateChange(amount, updatedAtUtc); + if (error is not null) + return new WithdrawResult.Failure(error); + if (Balance.Value < amount!.Value) + return new WithdrawResult.Failure("Not enough balance."); + + Money balance; + try + { + balance = Balance.Subtract(amount); + } + catch (OverflowException) { - return new WithdrawResult.Failure("Not enough balance"); + return new WithdrawResult.Failure("The balance cannot be represented exactly as decimal."); } - Balance = new Money(Balance.Value - amount.Value); + ApplyBalance(balance, updatedAtUtc); return new WithdrawResult.Success(Balance); } -} \ No newline at end of file + + private string? ValidateChange(Amount? amount, DateTimeOffset updatedAtUtc) + { + if (amount is null) + return "Amount is required."; + if (!DomainRules.IsUtc(updatedAtUtc) || updatedAtUtc < UpdatedAtUtc) + return "Update time must be UTC and must not precede the previous update."; + if (Version == long.MaxValue) + return "Account version is exhausted."; + return null; + } + + private void ApplyBalance(Money balance, DateTimeOffset updatedAtUtc) + { + Balance = balance; + Version++; + UpdatedAtUtc = updatedAtUtc; + } +} diff --git a/src/Atm.Domain/Accounts/Results/AccountCreationResult.cs b/src/Atm.Domain/Accounts/Results/AccountCreationResult.cs new file mode 100644 index 0000000..e23b0c9 --- /dev/null +++ b/src/Atm.Domain/Accounts/Results/AccountCreationResult.cs @@ -0,0 +1,10 @@ +namespace Atm.Domain.Accounts.Results; + +public abstract record AccountCreationResult +{ + private AccountCreationResult() { } + + public sealed record Success(Account Account) : AccountCreationResult; + + public sealed record Failure(string ErrorMessage) : AccountCreationResult; +} diff --git a/src/Atm.Domain/Common/DomainRules.cs b/src/Atm.Domain/Common/DomainRules.cs new file mode 100644 index 0000000..e5ffa74 --- /dev/null +++ b/src/Atm.Domain/Common/DomainRules.cs @@ -0,0 +1,30 @@ +namespace Atm.Domain.Common; + +internal static class DomainRules +{ + public static bool IsUtc(DateTimeOffset value) + { + return value.Offset == TimeSpan.Zero; + } + + public static bool HasScale(decimal value, int scale) + { + return decimal.Round(value, scale) == value; + } + + public static decimal AddExact(decimal left, decimal right) + { + decimal result = checked(left + right); + if (result - left != right || result - right != left) + throw new OverflowException("The amount cannot be represented exactly as decimal."); + return result; + } + + public static decimal SubtractExact(decimal left, decimal right) + { + decimal result = checked(left - right); + if (left - result != right || result + right != left) + throw new OverflowException("The amount cannot be represented exactly as decimal."); + return result; + } +} diff --git a/src/Atm.Domain/Operations/AccountHistoryEntry.cs b/src/Atm.Domain/Operations/AccountHistoryEntry.cs new file mode 100644 index 0000000..d033777 --- /dev/null +++ b/src/Atm.Domain/Operations/AccountHistoryEntry.cs @@ -0,0 +1,84 @@ +using Atm.Domain.Operations.Results; +using Atm.Domain.ValueObjects; + +namespace Atm.Domain.Operations; + +public sealed class AccountHistoryEntry +{ + private AccountHistoryEntry() { } + + public Guid Id { get; private set; } + + public Guid TransactionId { get; private set; } + + public Guid AccountId { get; private set; } + + public AccountHistoryEntryType EntryType { get; private set; } + + public Money BalanceBefore { get; private set; } = null!; + + public Money BalanceAfter { get; private set; } = null!; + + public DateTimeOffset CreatedAtUtc { get; private set; } + + public static AccountHistoryEntryCreationResult Create( + Guid id, + BankTransaction? transaction, + Guid accountId, + Money? balanceBefore) + { + if (id == Guid.Empty || accountId == Guid.Empty) + return new AccountHistoryEntryCreationResult.Failure("History entry and account IDs must not be empty."); + if (transaction is null || balanceBefore is null) + return new AccountHistoryEntryCreationResult.Failure("Transaction and previous balance are required."); + + AccountHistoryEntryType entryType; + Money balanceAfter; + try + { + if (transaction.Type == BankTransactionType.Deposit && transaction.TargetAccountId == accountId) + { + entryType = AccountHistoryEntryType.Deposit; + balanceAfter = balanceBefore.Add(transaction.GetDepositCreditAmount()); + } + else if (transaction.Type == BankTransactionType.Withdrawal && transaction.SourceAccountId == accountId) + { + entryType = AccountHistoryEntryType.Withdrawal; + balanceAfter = balanceBefore.Subtract(transaction.GetSourceDebitAmount()); + } + else if (transaction.Type == BankTransactionType.Transfer && transaction.SourceAccountId == accountId) + { + entryType = AccountHistoryEntryType.TransferDebit; + balanceAfter = balanceBefore.Subtract(transaction.GetSourceDebitAmount()); + } + else if (transaction.Type == BankTransactionType.Transfer && transaction.TargetAccountId == accountId) + { + entryType = AccountHistoryEntryType.TransferCredit; + balanceAfter = balanceBefore.Add(transaction.Amount); + } + else + { + return new AccountHistoryEntryCreationResult.Failure("Account does not participate in the transaction."); + } + } + catch (InvalidOperationException exception) + { + return new AccountHistoryEntryCreationResult.Failure(exception.Message); + } + catch (OverflowException) + { + return new AccountHistoryEntryCreationResult.Failure("Resulting balance cannot be represented exactly as decimal."); + } + + return new AccountHistoryEntryCreationResult.Success(new AccountHistoryEntry + { + Id = id, + TransactionId = transaction.Id, + AccountId = accountId, + EntryType = entryType, + BalanceBefore = balanceBefore, + BalanceAfter = balanceAfter, + CreatedAtUtc = transaction.CreatedAtUtc, + }); + } +} diff --git a/src/Atm.Domain/Operations/AccountHistoryEntryType.cs b/src/Atm.Domain/Operations/AccountHistoryEntryType.cs new file mode 100644 index 0000000..aa94227 --- /dev/null +++ b/src/Atm.Domain/Operations/AccountHistoryEntryType.cs @@ -0,0 +1,9 @@ +namespace Atm.Domain.Operations; + +public enum AccountHistoryEntryType +{ + Deposit = 1, + Withdrawal = 2, + TransferDebit = 3, + TransferCredit = 4, +} diff --git a/src/Atm.Domain/Operations/BankTransaction.cs b/src/Atm.Domain/Operations/BankTransaction.cs new file mode 100644 index 0000000..b897fda --- /dev/null +++ b/src/Atm.Domain/Operations/BankTransaction.cs @@ -0,0 +1,123 @@ +using Atm.Domain.Common; +using Atm.Domain.Operations.Results; +using Atm.Domain.ValueObjects; + +namespace Atm.Domain.Operations; + +public sealed class BankTransaction +{ + private BankTransaction() { } + + public Guid Id { get; private set; } + + public Guid RequestId { get; private set; } + + public BankTransactionType Type { get; private set; } + + public Guid? SourceAccountId { get; private set; } + + public Guid? TargetAccountId { get; private set; } + + public Amount Amount { get; private set; } = null!; + + public CommissionSnapshot Commission { get; private set; } = null!; + + public DateTimeOffset CreatedAtUtc { get; private set; } + + public static BankTransactionCreationResult Create( + Guid id, + Guid requestId, + BankTransactionType type, + Guid? sourceAccountId, + Guid? targetAccountId, + Amount? amount, + CommissionSnapshot? commission, + DateTimeOffset createdAtUtc) + { + if (id == Guid.Empty || requestId == Guid.Empty) + return new BankTransactionCreationResult.Failure("Transaction and request IDs must not be empty."); + if (!Enum.IsDefined(type)) + return new BankTransactionCreationResult.Failure("Transaction type is not supported."); + if (amount is null || commission is null) + return new BankTransactionCreationResult.Failure("Amount and commission snapshot are required."); + if (!commission.IsCalculatedFor(amount)) + return new BankTransactionCreationResult.Failure("Commission snapshot does not match the transaction amount."); + if (!DomainRules.IsUtc(createdAtUtc)) + return new BankTransactionCreationResult.Failure("Transaction timestamp must be UTC."); + + string? shapeError = ValidateAccountShape(type, sourceAccountId, targetAccountId); + if (shapeError is not null) + return new BankTransactionCreationResult.Failure(shapeError); + + try + { + if (type == BankTransactionType.Deposit) + _ = CalculateDepositCredit(amount, commission); + else + _ = CalculateSourceDebit(amount, commission); + } + catch (InvalidOperationException exception) + { + return new BankTransactionCreationResult.Failure(exception.Message); + } + catch (OverflowException) + { + return new BankTransactionCreationResult.Failure("The transaction amount cannot be represented exactly as decimal."); + } + + return new BankTransactionCreationResult.Success(new BankTransaction + { + Id = id, + RequestId = requestId, + Type = type, + SourceAccountId = sourceAccountId, + TargetAccountId = targetAccountId, + Amount = amount, + Commission = commission, + CreatedAtUtc = createdAtUtc, + }); + } + + public Amount GetDepositCreditAmount() + { + if (Type != BankTransactionType.Deposit) + throw new InvalidOperationException("Only a deposit has a deposit credit amount."); + return CalculateDepositCredit(Amount, Commission); + } + + public Amount GetSourceDebitAmount() + { + if (Type == BankTransactionType.Deposit) + throw new InvalidOperationException("A deposit has no source debit amount."); + return CalculateSourceDebit(Amount, Commission); + } + + private static string? ValidateAccountShape( + BankTransactionType type, + Guid? sourceAccountId, + Guid? targetAccountId) + { + bool hasSource = sourceAccountId is Guid source && source != Guid.Empty; + bool hasTarget = targetAccountId is Guid target && target != Guid.Empty; + + return type switch + { + BankTransactionType.Deposit when !hasSource && hasTarget => null, + BankTransactionType.Withdrawal when hasSource && !hasTarget => null, + BankTransactionType.Transfer when hasSource && hasTarget && sourceAccountId != targetAccountId => null, + _ => "Source and target accounts do not match the transaction type.", + }; + } + + private static Amount CalculateDepositCredit(Amount amount, CommissionSnapshot commission) + { + if (amount.Value <= commission.CommissionAmount.Value) + throw new InvalidOperationException("Deposit amount must exceed its commission."); + return new Amount(DomainRules.SubtractExact(amount.Value, commission.CommissionAmount.Value)); + } + + private static Amount CalculateSourceDebit(Amount amount, CommissionSnapshot commission) + { + return new Amount(DomainRules.AddExact(amount.Value, commission.CommissionAmount.Value)); + } +} diff --git a/src/Atm.Domain/Operations/BankTransactionType.cs b/src/Atm.Domain/Operations/BankTransactionType.cs new file mode 100644 index 0000000..dcd858b --- /dev/null +++ b/src/Atm.Domain/Operations/BankTransactionType.cs @@ -0,0 +1,8 @@ +namespace Atm.Domain.Operations; + +public enum BankTransactionType +{ + Deposit = 1, + Withdrawal = 2, + Transfer = 3, +} diff --git a/src/Atm.Domain/Operations/CommissionSnapshot.cs b/src/Atm.Domain/Operations/CommissionSnapshot.cs new file mode 100644 index 0000000..d0740ab --- /dev/null +++ b/src/Atm.Domain/Operations/CommissionSnapshot.cs @@ -0,0 +1,45 @@ +using Atm.Domain.Common; +using Atm.Domain.ValueObjects; + +namespace Atm.Domain.Operations; + +public sealed class CommissionSnapshot +{ + private CommissionSnapshot() { } + + public CommissionSnapshot(Amount operationAmount, decimal rate, decimal fixedAmount) + { + ArgumentNullException.ThrowIfNull(operationAmount); + if (rate < 0m || rate > 1m || !DomainRules.HasScale(rate, 6)) + throw new ArgumentException("Commission rate must be between zero and one with at most six decimal places.", nameof(rate)); + + var fixedMoney = new Money(fixedAmount); + decimal proportional = checked(operationAmount.Value * rate); + decimal unrounded = DomainRules.AddExact(proportional, fixedMoney.Value); + decimal rounded = decimal.Round(unrounded, 4, MidpointRounding.ToEven); + + Rate = rate; + FixedAmount = fixedMoney; + CommissionAmount = new Money(rounded); + } + + public decimal Rate { get; private set; } + + public Money FixedAmount { get; private set; } = null!; + + public Money CommissionAmount { get; private set; } = null!; + + public bool IsCalculatedFor(Amount operationAmount) + { + ArgumentNullException.ThrowIfNull(operationAmount); + try + { + var expected = new CommissionSnapshot(operationAmount, Rate, FixedAmount.Value); + return expected.CommissionAmount.Value == CommissionAmount.Value; + } + catch (OverflowException) + { + return false; + } + } +} diff --git a/src/Atm.Domain/Operations/Results/AccountHistoryEntryCreationResult.cs b/src/Atm.Domain/Operations/Results/AccountHistoryEntryCreationResult.cs new file mode 100644 index 0000000..3b87e6f --- /dev/null +++ b/src/Atm.Domain/Operations/Results/AccountHistoryEntryCreationResult.cs @@ -0,0 +1,10 @@ +namespace Atm.Domain.Operations.Results; + +public abstract record AccountHistoryEntryCreationResult +{ + private AccountHistoryEntryCreationResult() { } + + public sealed record Success(AccountHistoryEntry Entry) : AccountHistoryEntryCreationResult; + + public sealed record Failure(string ErrorMessage) : AccountHistoryEntryCreationResult; +} diff --git a/src/Atm.Domain/Operations/Results/BankTransactionCreationResult.cs b/src/Atm.Domain/Operations/Results/BankTransactionCreationResult.cs new file mode 100644 index 0000000..aa5023f --- /dev/null +++ b/src/Atm.Domain/Operations/Results/BankTransactionCreationResult.cs @@ -0,0 +1,10 @@ +namespace Atm.Domain.Operations.Results; + +public abstract record BankTransactionCreationResult +{ + private BankTransactionCreationResult() { } + + public sealed record Success(BankTransaction Transaction) : BankTransactionCreationResult; + + public sealed record Failure(string ErrorMessage) : BankTransactionCreationResult; +} diff --git a/src/Atm.Domain/Sessions/Results/SessionCreationResult.cs b/src/Atm.Domain/Sessions/Results/SessionCreationResult.cs new file mode 100644 index 0000000..ac4269d --- /dev/null +++ b/src/Atm.Domain/Sessions/Results/SessionCreationResult.cs @@ -0,0 +1,10 @@ +namespace Atm.Domain.Sessions.Results; + +public abstract record SessionCreationResult +{ + private SessionCreationResult() { } + + public sealed record Success(Session Session) : SessionCreationResult; + + public sealed record Failure(string ErrorMessage) : SessionCreationResult; +} diff --git a/src/Atm.Domain/Sessions/Results/SessionRevocationResult.cs b/src/Atm.Domain/Sessions/Results/SessionRevocationResult.cs new file mode 100644 index 0000000..c71e1b0 --- /dev/null +++ b/src/Atm.Domain/Sessions/Results/SessionRevocationResult.cs @@ -0,0 +1,10 @@ +namespace Atm.Domain.Sessions.Results; + +public abstract record SessionRevocationResult +{ + private SessionRevocationResult() { } + + public sealed record Success(DateTimeOffset RevokedAtUtc) : SessionRevocationResult; + + public sealed record Failure(string ErrorMessage) : SessionRevocationResult; +} diff --git a/src/Atm.Domain/Sessions/Session.cs b/src/Atm.Domain/Sessions/Session.cs index 8284340..6582ace 100644 --- a/src/Atm.Domain/Sessions/Session.cs +++ b/src/Atm.Domain/Sessions/Session.cs @@ -1,34 +1,70 @@ -using Atm.Domain.ValueObjects; +using Atm.Domain.Common; +using Atm.Domain.Sessions.Results; namespace Atm.Domain.Sessions; public sealed class Session { - public Session(Guid id, SessionType type, AccountNumber? accountNumber) + private Session() { } + + public Guid Id { get; private set; } + + public SessionType Type { get; private set; } + + public Guid? AccountId { get; private set; } + + public DateTimeOffset CreatedAtUtc { get; private set; } + + public DateTimeOffset ExpiresAtUtc { get; private set; } + + public DateTimeOffset? RevokedAtUtc { get; private set; } + + public static SessionCreationResult Create( + Guid id, + SessionType type, + Guid? accountId, + DateTimeOffset createdAtUtc, + DateTimeOffset expiresAtUtc) { if (id == Guid.Empty) - { - throw new ArgumentException("Session ID cannot be empty"); - } + return new SessionCreationResult.Failure("Session ID must not be empty."); + if (!Enum.IsDefined(type)) + return new SessionCreationResult.Failure("Session type is not supported."); + if (type == SessionType.User && (accountId is null || accountId == Guid.Empty)) + return new SessionCreationResult.Failure("User session must reference an account."); + if (type == SessionType.Admin && accountId is not null) + return new SessionCreationResult.Failure("System admin session must not reference an account."); + if (!DomainRules.IsUtc(createdAtUtc) || !DomainRules.IsUtc(expiresAtUtc)) + return new SessionCreationResult.Failure("Session timestamps must be UTC."); + if (expiresAtUtc <= createdAtUtc) + return new SessionCreationResult.Failure("Session expiration must follow its creation time."); - if (type == SessionType.User && accountNumber is null) + return new SessionCreationResult.Success(new Session { - throw new ArgumentException("User session must contain account number"); - } - - if (type == SessionType.Admin && accountNumber is null) - { - throw new ArgumentException("Admin session must contain account number"); - } - - Id = id; - Type = type; - AccountNumber = accountNumber; + Id = id, + Type = type, + AccountId = accountId, + CreatedAtUtc = createdAtUtc, + ExpiresAtUtc = expiresAtUtc, + }); } - public Guid Id { get; } + public bool IsActive(DateTimeOffset utcNow) + { + return DomainRules.IsUtc(utcNow) + && utcNow >= CreatedAtUtc + && utcNow < ExpiresAtUtc + && RevokedAtUtc is null; + } - public SessionType Type { get; } + public SessionRevocationResult Revoke(DateTimeOffset revokedAtUtc) + { + if (RevokedAtUtc is DateTimeOffset existing) + return new SessionRevocationResult.Success(existing); + if (!DomainRules.IsUtc(revokedAtUtc) || revokedAtUtc < CreatedAtUtc) + return new SessionRevocationResult.Failure("Revocation time must be UTC and must not precede session creation."); - public AccountNumber? AccountNumber { get; } -} \ No newline at end of file + RevokedAtUtc = revokedAtUtc; + return new SessionRevocationResult.Success(revokedAtUtc); + } +} diff --git a/src/Atm.Domain/Users/Friendship.cs b/src/Atm.Domain/Users/Friendship.cs new file mode 100644 index 0000000..092f1d9 --- /dev/null +++ b/src/Atm.Domain/Users/Friendship.cs @@ -0,0 +1,32 @@ +using Atm.Domain.Common; +using Atm.Domain.Users.Results; + +namespace Atm.Domain.Users; + +public sealed class Friendship +{ + private Friendship() { } + + public Guid UserId { get; private set; } + public Guid FriendUserId { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } + + public static FriendshipCreationResult Create( + Guid firstUserId, Guid secondUserId, DateTimeOffset createdAtUtc) + { + if (firstUserId == Guid.Empty || secondUserId == Guid.Empty) + return new FriendshipCreationResult.Failure("User IDs cannot be empty."); + if (firstUserId == secondUserId) + return new FriendshipCreationResult.Failure("A user cannot befriend themselves."); + if (!DomainRules.IsUtc(createdAtUtc)) + return new FriendshipCreationResult.Failure("Created timestamp must be UTC."); + + bool firstIsSmaller = string.CompareOrdinal(firstUserId.ToString("N"), secondUserId.ToString("N")) < 0; + return new FriendshipCreationResult.Success(new Friendship + { + UserId = firstIsSmaller ? firstUserId : secondUserId, + FriendUserId = firstIsSmaller ? secondUserId : firstUserId, + CreatedAtUtc = createdAtUtc, + }); + } +} diff --git a/src/Atm.Domain/Users/HairColor.cs b/src/Atm.Domain/Users/HairColor.cs new file mode 100644 index 0000000..4e08a88 --- /dev/null +++ b/src/Atm.Domain/Users/HairColor.cs @@ -0,0 +1,13 @@ +namespace Atm.Domain.Users; + +public enum HairColor +{ + Unspecified = 0, + Black = 1, + Brown = 2, + Blond = 3, + Red = 4, + Gray = 5, + White = 6, + Other = 7, +} diff --git a/src/Atm.Domain/Users/Results/FriendshipCreationResult.cs b/src/Atm.Domain/Users/Results/FriendshipCreationResult.cs new file mode 100644 index 0000000..e0d9ec9 --- /dev/null +++ b/src/Atm.Domain/Users/Results/FriendshipCreationResult.cs @@ -0,0 +1,10 @@ +namespace Atm.Domain.Users.Results; + +public abstract record FriendshipCreationResult +{ + private FriendshipCreationResult() { } + + public sealed record Success(Friendship Friendship) : FriendshipCreationResult; + + public sealed record Failure(string ErrorMessage) : FriendshipCreationResult; +} diff --git a/src/Atm.Domain/Users/Results/ProfileUpdateResult.cs b/src/Atm.Domain/Users/Results/ProfileUpdateResult.cs new file mode 100644 index 0000000..57fb3eb --- /dev/null +++ b/src/Atm.Domain/Users/Results/ProfileUpdateResult.cs @@ -0,0 +1,10 @@ +namespace Atm.Domain.Users.Results; + +public abstract record ProfileUpdateResult +{ + private ProfileUpdateResult() { } + + public sealed record Success : ProfileUpdateResult; + + public sealed record Failure(string ErrorMessage) : ProfileUpdateResult; +} diff --git a/src/Atm.Domain/Users/Results/UserCreationResult.cs b/src/Atm.Domain/Users/Results/UserCreationResult.cs new file mode 100644 index 0000000..346f398 --- /dev/null +++ b/src/Atm.Domain/Users/Results/UserCreationResult.cs @@ -0,0 +1,10 @@ +namespace Atm.Domain.Users.Results; + +public abstract record UserCreationResult +{ + private UserCreationResult() { } + + public sealed record Success(User User) : UserCreationResult; + + public sealed record Failure(string ErrorMessage) : UserCreationResult; +} diff --git a/src/Atm.Domain/Users/User.cs b/src/Atm.Domain/Users/User.cs new file mode 100644 index 0000000..bd172e1 --- /dev/null +++ b/src/Atm.Domain/Users/User.cs @@ -0,0 +1,76 @@ +using Atm.Domain.Common; +using Atm.Domain.Users.Results; +using Atm.Domain.ValueObjects; + +namespace Atm.Domain.Users; + +public sealed class User +{ + private User() { } + + public Guid Id { get; private set; } + public UserLogin Login { get; private set; } = null!; + public string Name { get; private set; } = null!; + public short Age { get; private set; } + public UserGender Gender { get; private set; } + public HairColor HairColor { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } + + public static UserCreationResult Create( + Guid id, UserLogin? login, string? name, short age, + UserGender gender, HairColor hairColor, DateTimeOffset createdAtUtc) + { + if (id == Guid.Empty || login is null) + return new UserCreationResult.Failure("User ID and login are required."); + + string? error = ValidateProfile(name, age, gender, hairColor); + if (error is not null) + return new UserCreationResult.Failure(error); + + if (!DomainRules.IsUtc(createdAtUtc)) + return new UserCreationResult.Failure("Created timestamp must be UTC."); + + return new UserCreationResult.Success(new User + { + Id = id, + Login = login, + Name = name!.Trim(), + Age = age, + Gender = gender, + HairColor = hairColor, + CreatedAtUtc = createdAtUtc, + UpdatedAtUtc = createdAtUtc, + }); + } + + public ProfileUpdateResult UpdateProfile( + string? name, short age, UserGender gender, + HairColor hairColor, DateTimeOffset updatedAtUtc) + { + string? error = ValidateProfile(name, age, gender, hairColor); + if (error is not null) + return new ProfileUpdateResult.Failure(error); + + if (!DomainRules.IsUtc(updatedAtUtc) || updatedAtUtc < UpdatedAtUtc) + return new ProfileUpdateResult.Failure("Updated timestamp must be UTC and cannot move backwards."); + + Name = name!.Trim(); + Age = age; + Gender = gender; + HairColor = hairColor; + UpdatedAtUtc = updatedAtUtc; + return new ProfileUpdateResult.Success(); + } + + private static string? ValidateProfile(string? name, short age, UserGender gender, HairColor hairColor) + { + if (string.IsNullOrWhiteSpace(name) || name.Trim().Length > 200) + return "Name must be non-empty and cannot exceed 200 characters."; + if (age is < 0 or > 150) + return "Age must be between zero and 150."; + if (!Enum.IsDefined(gender) || !Enum.IsDefined(hairColor)) + return "Unsupported gender or hair color."; + return null; + } +} diff --git a/src/Atm.Domain/Users/UserGender.cs b/src/Atm.Domain/Users/UserGender.cs new file mode 100644 index 0000000..5e61048 --- /dev/null +++ b/src/Atm.Domain/Users/UserGender.cs @@ -0,0 +1,9 @@ +namespace Atm.Domain.Users; + +public enum UserGender +{ + Unspecified = 0, + Male = 1, + Female = 2, + Other = 3, +} diff --git a/src/Atm.Domain/ValueObjects/AccountNumber.cs b/src/Atm.Domain/ValueObjects/AccountNumber.cs index 2535538..3587f1c 100644 --- a/src/Atm.Domain/ValueObjects/AccountNumber.cs +++ b/src/Atm.Domain/ValueObjects/AccountNumber.cs @@ -1,16 +1,20 @@ -namespace Atm.Domain.ValueObjects; +namespace Atm.Domain.ValueObjects; public sealed class AccountNumber { public AccountNumber(string value) { - if (string.IsNullOrEmpty(value)) - { - throw new ArgumentException("Value cannot be null or empty.", nameof(value)); - } + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Account number is required.", nameof(value)); Value = value.Trim(); + NormalizedValue = Value.ToUpperInvariant(); + + if (Value.Length > 64 || NormalizedValue.Length > 64) + throw new ArgumentException("Account number cannot exceed 64 characters.", nameof(value)); } public string Value { get; } -} \ No newline at end of file + + public string NormalizedValue { get; } +} diff --git a/src/Atm.Domain/ValueObjects/Amount.cs b/src/Atm.Domain/ValueObjects/Amount.cs index 166e4b6..a7f3fd2 100644 --- a/src/Atm.Domain/ValueObjects/Amount.cs +++ b/src/Atm.Domain/ValueObjects/Amount.cs @@ -1,13 +1,16 @@ -namespace Atm.Domain.ValueObjects; +using Atm.Domain.Common; + +namespace Atm.Domain.ValueObjects; public sealed class Amount { public Amount(decimal value) { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value); + if (value <= 0m || !DomainRules.HasScale(value, 4)) + throw new ArgumentException("Amount must be positive with at most four decimal places.", nameof(value)); Value = value; } public decimal Value { get; } -} \ No newline at end of file +} diff --git a/src/Atm.Domain/ValueObjects/Money.cs b/src/Atm.Domain/ValueObjects/Money.cs index 6ac8e22..6095ea7 100644 --- a/src/Atm.Domain/ValueObjects/Money.cs +++ b/src/Atm.Domain/ValueObjects/Money.cs @@ -1,13 +1,13 @@ -namespace Atm.Domain.ValueObjects; +using Atm.Domain.Common; + +namespace Atm.Domain.ValueObjects; public sealed class Money { public Money(decimal value) { - if (value < 0) - { - throw new ArgumentOutOfRangeException(nameof(value), value, "Value must be greater than zero."); - } + if (value < 0m || !DomainRules.HasScale(value, 4)) + throw new ArgumentException("Money must be non-negative with at most four decimal places.", nameof(value)); Value = value; } @@ -16,16 +16,13 @@ public Money(decimal value) public Money Add(Amount amount) { - return new Money(Value + amount.Value); + return new Money(DomainRules.AddExact(Value, amount.Value)); } public Money Subtract(Amount amount) { if (Value < amount.Value) - { - throw new InvalidOperationException("Cannot subtract from non-overflow."); - } - - return new Money(Value - amount.Value); + throw new InvalidOperationException("Not enough balance."); + return new Money(DomainRules.SubtractExact(Value, amount.Value)); } -} \ No newline at end of file +} diff --git a/src/Atm.Domain/ValueObjects/PinCode.cs b/src/Atm.Domain/ValueObjects/PinCode.cs index 088b9de..62dcfa2 100644 --- a/src/Atm.Domain/ValueObjects/PinCode.cs +++ b/src/Atm.Domain/ValueObjects/PinCode.cs @@ -1,21 +1,13 @@ -namespace Atm.Domain.ValueObjects; +namespace Atm.Domain.ValueObjects; public sealed class PinCode { public PinCode(string value) { - if (value.Length != 4) - { - throw new ArgumentOutOfRangeException(nameof(value)); - } - - if (value.Any(c => !char.IsDigit(c))) - { - throw new ArgumentException("Pin code must contain only digits"); - } - + if (value is null || value.Length != 4 || value.Any(character => character is < '0' or > '9')) + throw new ArgumentException("PIN must contain exactly four ASCII digits.", nameof(value)); Value = value; } public string Value { get; } -} \ No newline at end of file +} diff --git a/src/Atm.Domain/ValueObjects/PinHash.cs b/src/Atm.Domain/ValueObjects/PinHash.cs new file mode 100644 index 0000000..b3674e0 --- /dev/null +++ b/src/Atm.Domain/ValueObjects/PinHash.cs @@ -0,0 +1,16 @@ +namespace Atm.Domain.ValueObjects; + +public sealed record PinHash +{ + public PinHash(string value) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > 512) + throw new ArgumentException("PIN hash must contain between 1 and 512 characters.", nameof(value)); + + Value = value; + } + + public string Value { get; } + + public override string ToString() => "[REDACTED]"; +} diff --git a/src/Atm.Domain/ValueObjects/UserLogin.cs b/src/Atm.Domain/ValueObjects/UserLogin.cs new file mode 100644 index 0000000..3a90359 --- /dev/null +++ b/src/Atm.Domain/ValueObjects/UserLogin.cs @@ -0,0 +1,20 @@ +namespace Atm.Domain.ValueObjects; + +public sealed class UserLogin +{ + public UserLogin(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Login is required.", nameof(value)); + + Value = value.Trim(); + NormalizedValue = Value.ToUpperInvariant(); + + if (Value.Length > 64 || NormalizedValue.Length > 64) + throw new ArgumentException("Login cannot exceed 64 characters.", nameof(value)); + } + + public string Value { get; } + + public string NormalizedValue { get; } +} diff --git a/src/Atm.Infrastructure/Atm.Infrastructure.csproj b/src/Atm.Infrastructure/Atm.Infrastructure.csproj index e759e49..4e2e946 100644 --- a/src/Atm.Infrastructure/Atm.Infrastructure.csproj +++ b/src/Atm.Infrastructure/Atm.Infrastructure.csproj @@ -6,7 +6,18 @@ - + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/src/Atm.Infrastructure/Repositories/AccountRepository.cs b/src/Atm.Infrastructure/Repositories/AccountRepository.cs index 44b8616..8a7f0e0 100644 --- a/src/Atm.Infrastructure/Repositories/AccountRepository.cs +++ b/src/Atm.Infrastructure/Repositories/AccountRepository.cs @@ -1,4 +1,4 @@ -using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Repositories; using Atm.Domain.Accounts; using Atm.Domain.ValueObjects; @@ -6,20 +6,26 @@ namespace Atm.Infrastructure.Repositories; public sealed class AccountRepository : IAccountRepository { - private readonly Dictionary _accounts = new(); + private readonly Dictionary _accounts = new(); + + public Account? GetById(Guid id) + { + return _accounts.GetValueOrDefault(id); + } public Account? GetByNumber(AccountNumber accountNumber) { - return _accounts.GetValueOrDefault(accountNumber.Value); + return _accounts.Values.FirstOrDefault( + account => account.Number.NormalizedValue == accountNumber.NormalizedValue); } public void Add(Account account) { - _accounts[account.Number.Value] = account; + _accounts.Add(account.Id, account); } public void Update(Account account) { - _accounts[account.Number.Value] = account; + _accounts[account.Id] = account; } -} \ No newline at end of file +} diff --git a/src/Atm.Infrastructure/Security/PinHasher.cs b/src/Atm.Infrastructure/Security/PinHasher.cs new file mode 100644 index 0000000..3e39ce9 --- /dev/null +++ b/src/Atm.Infrastructure/Security/PinHasher.cs @@ -0,0 +1,28 @@ +using Atm.Application.Abstractions.Security; +using Atm.Domain.ValueObjects; +using Microsoft.AspNetCore.Identity; + +namespace Atm.Infrastructure.Security; + +public sealed class PinHasher : IPinHasher +{ + private readonly PasswordHasher _hasher = new(); + + public PinHash Hash(PinCode pin) + { + return new PinHash(_hasher.HashPassword(this, pin.Value)); + } + + public bool Verify(PinHash hash, PinCode pin) + { + try + { + return _hasher.VerifyHashedPassword(this, hash.Value, pin.Value) + != PasswordVerificationResult.Failed; + } + catch (FormatException) + { + return false; + } + } +} diff --git a/src/Atm.Infrastructure/ServiceCollectionExtensions.cs b/src/Atm.Infrastructure/ServiceCollectionExtensions.cs index afe63f4..0c2d7ba 100644 --- a/src/Atm.Infrastructure/ServiceCollectionExtensions.cs +++ b/src/Atm.Infrastructure/ServiceCollectionExtensions.cs @@ -1,5 +1,9 @@ -using Atm.Infrastructure.Repositories; using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Security; +using Atm.Application.Abstractions.Time; +using Atm.Infrastructure.Repositories; +using Atm.Infrastructure.Security; +using Atm.Infrastructure.Time; using Microsoft.Extensions.DependencyInjection; namespace Atm.Infrastructure; @@ -11,7 +15,9 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); return services; } -} \ No newline at end of file +} diff --git a/src/Atm.Infrastructure/Time/SystemClock.cs b/src/Atm.Infrastructure/Time/SystemClock.cs new file mode 100644 index 0000000..a06421a --- /dev/null +++ b/src/Atm.Infrastructure/Time/SystemClock.cs @@ -0,0 +1,8 @@ +using Atm.Application.Abstractions.Time; + +namespace Atm.Infrastructure.Time; + +public sealed class SystemClock : IClock +{ + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; +} diff --git a/src/Atm.Infrastructure/packages.lock.json b/src/Atm.Infrastructure/packages.lock.json index 9fadaef..a16a968 100644 --- a/src/Atm.Infrastructure/packages.lock.json +++ b/src/Atm.Infrastructure/packages.lock.json @@ -2,11 +2,355 @@ "version": 1, "dependencies": { "net9.0": { + "Microsoft.EntityFrameworkCore": { + "type": "Direct", + "requested": "[9.0.19, )", + "resolved": "9.0.19", + "contentHash": "qWqqxrfEDfi3C/z0S/ACIA2gUnreRPHX74WE/dVnesbztNesYO4NLy76lE6FV/zDP4vH/pkvDr8h3Mq9+cFv/w==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "9.0.19", + "Microsoft.EntityFrameworkCore.Analyzers": "9.0.19", + "Microsoft.Extensions.Caching.Memory": "9.0.19", + "Microsoft.Extensions.Logging": "9.0.19" + } + }, + "Microsoft.EntityFrameworkCore.Design": { + "type": "Direct", + "requested": "[9.0.19, )", + "resolved": "9.0.19", + "contentHash": "O4o69Xne6dJ7+u21YPxevGt8F2wpxGz2bBiSa/SSyqb2/64OZtcbIOB264fuViRj2XgwzhSGErNP75fRVCk8Zw==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "17.8.43", + "Microsoft.Build.Locator": "1.7.8", + "Microsoft.CodeAnalysis.CSharp": "4.8.0", + "Microsoft.CodeAnalysis.CSharp.Workspaces": "4.8.0", + "Microsoft.CodeAnalysis.Workspaces.MSBuild": "4.8.0", + "Microsoft.EntityFrameworkCore.Relational": "9.0.19", + "Microsoft.Extensions.Caching.Memory": "9.0.19", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.19", + "Microsoft.Extensions.DependencyModel": "9.0.19", + "Microsoft.Extensions.Logging": "9.0.19", + "Mono.TextTemplating": "3.0.0", + "System.Text.Json": "9.0.19" + } + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Direct", + "requested": "[9.0.19, )", + "resolved": "9.0.19", + "contentHash": "KAfYGG4LWRNn1G/vMRydxgOjuZoWUvdd5raUcIia/HPg1jUjlQzS386nUx5gt3YfKxpFJaCGxdsQ1nCZWuYR8A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "9.0.19", + "Microsoft.Extensions.Caching.Memory": "9.0.19", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.19", + "Microsoft.Extensions.Logging": "9.0.19" + } + }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Direct", - "requested": "[10.0.2, )", - "resolved": "10.0.2", - "contentHash": "zOIurr59+kUf9vNcsUkCvKWZv+fPosUZXURZesYkJCvl0EzTc9F7maAO4Cd2WEV7ZJJ0AZrFQvuH6Npph9wdBw==" + "requested": "[9.0.19, )", + "resolved": "9.0.19", + "contentHash": "XgQ0aVWClYjYSBqbYrKC/xyZ1KHf+VVwNF3+d54jcaqnst30Q//ugs/FJwUztLZiph0gp85g7i/eB0556twO0Q==" + }, + "Microsoft.Extensions.Identity.Core": { + "type": "Direct", + "requested": "[9.0.19, )", + "resolved": "9.0.19", + "contentHash": "6P2/iMA35j8RrjsJXhqaOy4Sturcu+NXzXSlKguGTzjBEVRf6atWPrKkUuZew9K5S5NbNh8gdYip2dXm8t5fLg==", + "dependencies": { + "Microsoft.AspNetCore.Cryptography.KeyDerivation": "9.0.19", + "Microsoft.Extensions.Logging": "9.0.19", + "Microsoft.Extensions.Options": "9.0.19" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "Direct", + "requested": "[9.0.4, )", + "resolved": "9.0.4", + "contentHash": "mw5vcY2IEc7L+IeGrxpp/J5OSnCcjkjAgJYCm/eD52wpZze8zsSifdqV7zXslSMmfJG2iIUGZyo3KuDtEFKwMQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[9.0.1, 10.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[9.0.1, 10.0.0)", + "Npgsql": "9.0.3" + } + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Microsoft.AspNetCore.Cryptography.Internal": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "zqiWdZ8Jw4/sfTBSFHchU3Iajm8EqLPUq/cUIuUjARwNGJ9hrKR7Yf0bs4WlF+GdPX6Bj7kmaf99aY0OTbLCOg==" + }, + "Microsoft.AspNetCore.Cryptography.KeyDerivation": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "w0ZZWnsz7U2dh5l9eytJYYHG3/HwafewFyZT+NsnBuQAt90cw2hmiX9J9OU1mrtn7EiIJqhRxjzY5kAbmhD2DA==", + "dependencies": { + "Microsoft.AspNetCore.Cryptography.Internal": "9.0.19" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "3aeMZ1N0lJoSyzqiP03hqemtb1BijhsJADdobn/4nsMJ8V1H+CrpuduUe4hlRdx+ikBQju1VGjMD1GJ3Sk05Eg==" + }, + "Microsoft.Build.Framework": { + "type": "Transitive", + "resolved": "17.8.43", + "contentHash": "TA6fCbd9qFSyhIKcUn5BhDN3/JrqpspO9KYsZKUvHyQpQrHVdzy4Hk6egjjBP3T0L562TLZuHEzQwJziyRBEkw==" + }, + "Microsoft.Build.Locator": { + "type": "Transitive", + "resolved": "1.7.8", + "contentHash": "sPy10x527Ph16S2u0yGME4S6ohBKJ69WfjeGG/bvELYeZVmJdKjxgnlL8cJJJLGV/cZIRqSfB12UDB8ICakOog==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.3.4", + "contentHash": "AxkxcPR+rheX0SmvpLVIGLhOUXAKG56a64kV9VQZ4y9gR9ZmPXnqZvHJnmwLSwzrEP6junUF11vuc+aqo5r68g==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "4.8.0", + "contentHash": "/jR+e/9aT+BApoQJABlVCKnnggGQbvGh7BKq2/wI1LamxC+LbzhcLj4Vj7gXCofl1n4E521YfF9w0WcASGg/KA==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.3.4", + "System.Collections.Immutable": "7.0.0", + "System.Reflection.Metadata": "7.0.0", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "4.8.0", + "contentHash": "+3+qfdb/aaGD8PZRCrsdobbzGs1m9u119SkkJt8e/mk3xLJz/udLtS2T6nY27OTXxBBw10HzAbC8Z9w08VyP/g==", + "dependencies": { + "Microsoft.CodeAnalysis.Common": "[4.8.0]" + } + }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "Transitive", + "resolved": "4.8.0", + "contentHash": "3amm4tq4Lo8/BGvg9p3BJh3S9nKq2wqCXfS7138i69TUpo/bD+XvD0hNurpEBtcNZhi1FyutiomKJqVF39ugYA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.CSharp": "[4.8.0]", + "Microsoft.CodeAnalysis.Common": "[4.8.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[4.8.0]" + } + }, + "Microsoft.CodeAnalysis.Workspaces.Common": { + "type": "Transitive", + "resolved": "4.8.0", + "contentHash": "LXyV+MJKsKRu3FGJA3OmSk40OUIa/dQCFLOnm5X8MNcujx7hzGu8o+zjXlb/cy5xUdZK2UKYb9YaQ2E8m9QehQ==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Bcl.AsyncInterfaces": "7.0.0", + "Microsoft.CodeAnalysis.Common": "[4.8.0]", + "System.Composition": "7.0.0", + "System.IO.Pipelines": "7.0.0", + "System.Threading.Channels": "7.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.MSBuild": { + "type": "Transitive", + "resolved": "4.8.0", + "contentHash": "IEYreI82QZKklp54yPHxZNG9EKSK6nHEkeuf+0Asie9llgS1gp0V1hw7ODG+QyoB7MuAnNQHmeV1Per/ECpv6A==", + "dependencies": { + "Microsoft.Build.Framework": "16.10.0", + "Microsoft.CodeAnalysis.Common": "[4.8.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[4.8.0]", + "System.Text.Json": "7.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "igPnmVL3DU2ZH/4DNeQqWttNrQpwwg0q6MWj5peZxtl0QVMDSeFtnt2yhhdSffklZKYsAFko1sh/va3z4y7S6A==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "yI5dQuvigJ6ICQECOlJDtrsUYcuDryv3qsZK1i6ZA/IzMzZ/VPkDzjE4r2LivuyVSGnkDRkH2cp4LmrhXARRKw==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "qe/6m6wxRG+N5xTf4Z3lDBAiVu6oBCUyADJ5OzlFgukD9Vg/6zB3MNnNEFA7amZg8hjcS6F4OZBRmB07A+gmHQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "Y0w5LkBqWkDh5vYTKM+QKlzxpqegEtP86oDhFMXgx4tDkWL2t+rl4h2vxBHkvBtM/gM6PoLK9QCrnoclXtHtVQ==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "9.0.19", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19", + "Microsoft.Extensions.Logging.Abstractions": "9.0.19", + "Microsoft.Extensions.Options": "9.0.19", + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "M6h5lX6QWSSn7FDeRUHePZen/xvxPsuhcX1VpBfefafqavn2BnfxheR7BH5D5AIZGM7Nah0xJKnq2DublD4doQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "o/XFYSiV4a7eiCkf7W1GFAviVhHQAPY5bHrQRsNWAPT3GhJ3Af63vcFQjHivIrTNZITlPPaGWhBy7jykKYYyaQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19" + } + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "Jpe5up2VLMkjKavOXZzWRXBvy8SUPoFCRuVkq1wYrtVwtzuFEc7saw02dltASs5T8DXp6Ff/DPzmU95mDOiw6g==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "3cnWzJ/FLEhLiW+TJTWtTjVj6fwlsP8OrbJqgOP4J9feAKjmT77vmClakHlej+jnzBgpnVU0+SZwtEIOr+KS6A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "9.0.19", + "Microsoft.Extensions.Logging.Abstractions": "9.0.19", + "Microsoft.Extensions.Options": "9.0.19" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "RpqMVoZ+NECJxLOZRjWOfjST0mULs4wb848mxG/CS9SMkVL6yi1sSIda4dGcsB01J2jUFd8hNwsaYl1gplU+Cw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "FdPZy4kUSwFlIVZ3EkK9fdHw2RfblUwzflSb4kjVeGf9v2lX9IH+q2RGUovYMR7CVfKMc2y2Db/bZ2b4yiMfOg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19", + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "9hIg8PQiMnpVFIsEHm25Wi1gBrPa2pS1G75uveyDUVLXrNKqBap9WGwQIgO0s6LUgRAOdsSYnbKbNC5b1G5Pcg==" + }, + "Mono.TextTemplating": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "Npgsql": { + "type": "Transitive", + "resolved": "9.0.3", + "contentHash": "tPvY61CxOAWxNsKLEBg+oR646X4Bc8UmyQ/tJszL/7mEmIXQnnBhVJZrZEEUv0Bstu0mEsHZD5At3EO8zQRAYw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.2" + } + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "dQPcs0U1IKnBdRDBkrCTi1FoajSTBzLcVTpjO4MBCMC7f4pDOIPzgBoX8JjG7X6uZRJ8EBxsi8+DR1JuwjnzOQ==" + }, + "System.Composition": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "tRwgcAkDd85O8Aq6zHDANzQaq380cek9lbMg5Qma46u5BZXq/G+XvIYmu+UI+BIIZ9zssXLYrkTykEqxxvhcmg==", + "dependencies": { + "System.Composition.AttributedModel": "7.0.0", + "System.Composition.Convention": "7.0.0", + "System.Composition.Hosting": "7.0.0", + "System.Composition.Runtime": "7.0.0", + "System.Composition.TypedParts": "7.0.0" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "2QzClqjElKxgI1jK1Jztnq44/8DmSuTSGGahXqQ4TdEV0h9s2KikQZIgcEqVzR7OuWDFPGLHIprBJGQEPr8fAQ==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "IMhTlpCs4HmlD8B+J8/kWfwX7vrBBOs6xyjSTzBlYSs7W4OET4tlkR/Sg9NG8jkdJH9Mymq0qGdYS1VPqRTBnQ==", + "dependencies": { + "System.Composition.AttributedModel": "7.0.0" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "eB6gwN9S+54jCTBJ5bpwMOVerKeUfGGTYCzz3QgDr1P55Gg/Wb27ShfPIhLMjmZ3MoAKu8uUSv6fcCdYJTN7Bg==", + "dependencies": { + "System.Composition.Runtime": "7.0.0" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "aZJ1Zr5Txe925rbo4742XifEyW0MIni1eiUebmcrP3HwLXZ3IbXUj4MFMUH/RmnJOAQiS401leg/2Sz1MkApDw==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "ZK0KNPfbtxVceTwh+oHNGUOYV2WNOHReX2AXipuvkURC7s/jPwoWfsu3SnDBDgofqbiWr96geofdQ2erm/KTHg==", + "dependencies": { + "System.Composition.AttributedModel": "7.0.0", + "System.Composition.Hosting": "7.0.0", + "System.Composition.Runtime": "7.0.0" + } + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "jRn6JYnNPW6xgQazROBLSfpdoczRw694vO5kKvMcNnpXuolEixUyw6IBuBs2Y2mlSX/LdLvyyWmfXhaI3ND1Yg==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "MclTG61lsD9sYdpNz9xsKBzjsmsfCtcMZYXz/IUr2zlhaTaABonlr1ESeompTgM+Xk+IwtGYU7/voh3YWB/fWw==", + "dependencies": { + "System.Collections.Immutable": "7.0.0" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "DjVVcIY/dS/rjb0b42nciHjUc0voQfUtrTos+wC0sgdx0VemTpYhx1jdeHowwFghygJ6QdaytcMHwA8jZ6MHFg==" + }, + "System.Threading.Channels": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "qmeeYNROMsONF6ndEZcIQ+VxR4Q/TX/7uIVLJqtwIWL7dDWeh0l1UIqgo4wYyjG//5lUNhwkLDSFl+pAWO6oiA==" }, "atm.application.abstractions": { "type": "Project", diff --git a/src/Atm.Presentation/Contracts/ApiRequests.cs b/src/Atm.Presentation/Contracts/ApiRequests.cs index ce79d66..cb4f71e 100644 --- a/src/Atm.Presentation/Contracts/ApiRequests.cs +++ b/src/Atm.Presentation/Contracts/ApiRequests.cs @@ -6,7 +6,8 @@ public sealed record CreateAccountApiRequest( [property: JsonPropertyName("adminSessionId")] Guid AdminSessionId, [property: JsonPropertyName("accountNumber")] string AccountNumber, [property: JsonPropertyName("pin")] string Pin, - [property: JsonPropertyName("initialBalance")] decimal InitialBalance); + [property: JsonPropertyName("initialBalance")] decimal InitialBalance, + [property: JsonPropertyName("ownerUserId")] Guid OwnerUserId); public sealed record AmountApiRequest( [property: JsonPropertyName("userSessionId")] Guid UserSessionId, diff --git a/src/Atm.Presentation/Contracts/ApiResponses.cs b/src/Atm.Presentation/Contracts/ApiResponses.cs index 7cbdf07..c2aaf64 100644 --- a/src/Atm.Presentation/Contracts/ApiResponses.cs +++ b/src/Atm.Presentation/Contracts/ApiResponses.cs @@ -5,10 +5,10 @@ namespace Atm.Presentation.Contracts; public sealed record ErrorResponse( [property: JsonPropertyName("error")] string Error); - + public sealed record SessionResponse( [property: JsonPropertyName("sessionId")] Guid SessionId); - + public sealed record BalanceResponse( [property: JsonPropertyName("balance")] decimal Balance); @@ -17,4 +17,3 @@ public sealed record MessageResponse( public sealed record TransactionsResponse( [property: JsonPropertyName("transactions")] IReadOnlyList Transactions); - \ No newline at end of file diff --git a/src/Atm.Presentation/Controllers/AccountsController.cs b/src/Atm.Presentation/Controllers/AccountsController.cs index debec63..2b550f9 100644 --- a/src/Atm.Presentation/Controllers/AccountsController.cs +++ b/src/Atm.Presentation/Controllers/AccountsController.cs @@ -1,4 +1,4 @@ -using Atm.Application.Contracts.Accounts.CreateAccount; +using Atm.Application.Contracts.Accounts.CreateAccount; using Atm.Application.Contracts.Accounts.GetBalance; using Atm.Application.Contracts.Operations.Deposit; using Atm.Application.Contracts.Operations.GetTransactionHistory; @@ -43,7 +43,8 @@ public IActionResult Create([FromBody] CreateAccountApiRequest request) request.AdminSessionId, request.AccountNumber, request.Pin, - request.InitialBalance)); + request.InitialBalance, + request.OwnerUserId)); return result switch { @@ -62,7 +63,7 @@ public IActionResult Balance([FromQuery(Name = "sessionId")] Guid userSessionId) { GetBalanceResult result = _getBalance.Execute( new GetBalanceRequest(userSessionId)); - + return result switch { GetBalanceResult.Success s => Ok(new BalanceResponse(s.Balance)), @@ -116,7 +117,7 @@ public IActionResult History([FromQuery(Name = "sessionId")] Guid userSessionId) { GetTransactionHistoryResult result = _history.Execute( new GetTransactionHistoryRequest(userSessionId)); - + return result switch { GetTransactionHistoryResult.Success s => Ok(new TransactionsResponse(s.Transactions)), diff --git a/src/Atm.Presentation/Program.cs b/src/Atm.Presentation/Program.cs index 8cf0616..74a7068 100644 --- a/src/Atm.Presentation/Program.cs +++ b/src/Atm.Presentation/Program.cs @@ -1,7 +1,8 @@ -using Atm.Application; +using Atm.Application; using Atm.Application.Abstractions.Repositories; using Atm.Infrastructure; using Atm.Presentation.Common; +using ApplicationSessionOptions = Atm.Application.Sessions.SessionOptions; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); @@ -18,6 +19,12 @@ builder.Services.AddApplication(); builder.Services.AddInfrastructure(); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(ApplicationSessionOptions.SectionName)) + .Validate(options => options.UserLifetimeMinutes > 0, "User session lifetime must be positive.") + .Validate(options => options.AdminLifetimeMinutes > 0, "Admin session lifetime must be positive.") + .ValidateOnStart(); + builder.Services.AddSingleton(); WebApplication app = builder.Build(); @@ -31,4 +38,4 @@ app.UseHttpsRedirection(); app.MapControllers(); -app.Run(); \ No newline at end of file +app.Run(); diff --git a/src/Atm.Presentation/appsettings.json b/src/Atm.Presentation/appsettings.json index 52a8f53..27151bb 100644 --- a/src/Atm.Presentation/appsettings.json +++ b/src/Atm.Presentation/appsettings.json @@ -2,6 +2,10 @@ "SystemAuth": { "SystemPassword": "change_me" }, + "Sessions": { + "UserLifetimeMinutes": 30, + "AdminLifetimeMinutes": 15 + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/src/Atm.Presentation/packages.lock.json b/src/Atm.Presentation/packages.lock.json index dfd4ccc..00449bf 100644 --- a/src/Atm.Presentation/packages.lock.json +++ b/src/Atm.Presentation/packages.lock.json @@ -14,21 +14,162 @@ "Swashbuckle.AspNetCore.SwaggerUI": "6.6.2" } }, + "Microsoft.AspNetCore.Cryptography.Internal": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "zqiWdZ8Jw4/sfTBSFHchU3Iajm8EqLPUq/cUIuUjARwNGJ9hrKR7Yf0bs4WlF+GdPX6Bj7kmaf99aY0OTbLCOg==" + }, + "Microsoft.AspNetCore.Cryptography.KeyDerivation": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "w0ZZWnsz7U2dh5l9eytJYYHG3/HwafewFyZT+NsnBuQAt90cw2hmiX9J9OU1mrtn7EiIJqhRxjzY5kAbmhD2DA==", + "dependencies": { + "Microsoft.AspNetCore.Cryptography.Internal": "9.0.19" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "qWqqxrfEDfi3C/z0S/ACIA2gUnreRPHX74WE/dVnesbztNesYO4NLy76lE6FV/zDP4vH/pkvDr8h3Mq9+cFv/w==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "9.0.19", + "Microsoft.EntityFrameworkCore.Analyzers": "9.0.19", + "Microsoft.Extensions.Caching.Memory": "9.0.19", + "Microsoft.Extensions.Logging": "9.0.19" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "igPnmVL3DU2ZH/4DNeQqWttNrQpwwg0q6MWj5peZxtl0QVMDSeFtnt2yhhdSffklZKYsAFko1sh/va3z4y7S6A==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "yI5dQuvigJ6ICQECOlJDtrsUYcuDryv3qsZK1i6ZA/IzMzZ/VPkDzjE4r2LivuyVSGnkDRkH2cp4LmrhXARRKw==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "KAfYGG4LWRNn1G/vMRydxgOjuZoWUvdd5raUcIia/HPg1jUjlQzS386nUx5gt3YfKxpFJaCGxdsQ1nCZWuYR8A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "9.0.19", + "Microsoft.Extensions.Caching.Memory": "9.0.19", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.19", + "Microsoft.Extensions.Logging": "9.0.19" + } + }, "Microsoft.Extensions.ApiDescription.Server": { "type": "Transitive", "resolved": "6.0.5", "contentHash": "Ckb5EDBUNJdFWyajfXzUIMRkhf52fHZOQuuZg/oiu8y7zDCVwD0iHhew6MnThjHmevanpxL3f5ci2TtHQEN6bw==" }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "qe/6m6wxRG+N5xTf4Z3lDBAiVu6oBCUyADJ5OzlFgukD9Vg/6zB3MNnNEFA7amZg8hjcS6F4OZBRmB07A+gmHQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "Y0w5LkBqWkDh5vYTKM+QKlzxpqegEtP86oDhFMXgx4tDkWL2t+rl4h2vxBHkvBtM/gM6PoLK9QCrnoclXtHtVQ==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "9.0.19", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19", + "Microsoft.Extensions.Logging.Abstractions": "9.0.19", + "Microsoft.Extensions.Options": "9.0.19", + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "M6h5lX6QWSSn7FDeRUHePZen/xvxPsuhcX1VpBfefafqavn2BnfxheR7BH5D5AIZGM7Nah0xJKnq2DublD4doQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "o/XFYSiV4a7eiCkf7W1GFAviVhHQAPY5bHrQRsNWAPT3GhJ3Af63vcFQjHivIrTNZITlPPaGWhBy7jykKYYyaQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19" + } + }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "10.0.2", - "contentHash": "zOIurr59+kUf9vNcsUkCvKWZv+fPosUZXURZesYkJCvl0EzTc9F7maAO4Cd2WEV7ZJJ0AZrFQvuH6Npph9wdBw==" + "resolved": "9.0.19", + "contentHash": "XgQ0aVWClYjYSBqbYrKC/xyZ1KHf+VVwNF3+d54jcaqnst30Q//ugs/FJwUztLZiph0gp85g7i/eB0556twO0Q==" + }, + "Microsoft.Extensions.Identity.Core": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "6P2/iMA35j8RrjsJXhqaOy4Sturcu+NXzXSlKguGTzjBEVRf6atWPrKkUuZew9K5S5NbNh8gdYip2dXm8t5fLg==", + "dependencies": { + "Microsoft.AspNetCore.Cryptography.KeyDerivation": "9.0.19", + "Microsoft.Extensions.Logging": "9.0.19", + "Microsoft.Extensions.Options": "9.0.19" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "3cnWzJ/FLEhLiW+TJTWtTjVj6fwlsP8OrbJqgOP4J9feAKjmT77vmClakHlej+jnzBgpnVU0+SZwtEIOr+KS6A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "9.0.19", + "Microsoft.Extensions.Logging.Abstractions": "9.0.19", + "Microsoft.Extensions.Options": "9.0.19" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "RpqMVoZ+NECJxLOZRjWOfjST0mULs4wb848mxG/CS9SMkVL6yi1sSIda4dGcsB01J2jUFd8hNwsaYl1gplU+Cw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "FdPZy4kUSwFlIVZ3EkK9fdHw2RfblUwzflSb4kjVeGf9v2lX9IH+q2RGUovYMR7CVfKMc2y2Db/bZ2b4yiMfOg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19", + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "9hIg8PQiMnpVFIsEHm25Wi1gBrPa2pS1G75uveyDUVLXrNKqBap9WGwQIgO0s6LUgRAOdsSYnbKbNC5b1G5Pcg==" }, "Microsoft.OpenApi": { "type": "Transitive", "resolved": "1.6.14", "contentHash": "tTaBT8qjk3xINfESyOPE2rIellPvB7qpVqiWiyA/lACVvz+xOGiXhFUfohcx82NLbi5avzLW0lx+s6oAqQijfw==" }, + "Npgsql": { + "type": "Transitive", + "resolved": "9.0.3", + "contentHash": "tPvY61CxOAWxNsKLEBg+oR646X4Bc8UmyQ/tJszL/7mEmIXQnnBhVJZrZEEUv0Bstu0mEsHZD5At3EO8zQRAYw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.2" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "mw5vcY2IEc7L+IeGrxpp/J5OSnCcjkjAgJYCm/eD52wpZze8zsSifdqV7zXslSMmfJG2iIUGZyo3KuDtEFKwMQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[9.0.1, 10.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[9.0.1, 10.0.0)", + "Npgsql": "9.0.3" + } + }, "Swashbuckle.AspNetCore.Swagger": { "type": "Transitive", "resolved": "6.6.2", @@ -56,7 +197,8 @@ "Atm.Application.Abstractions": "[1.0.0, )", "Atm.Application.Contracts": "[1.0.0, )", "Atm.Domain": "[1.0.0, )", - "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.2, )" + "Microsoft.Extensions.DependencyInjection.Abstractions": "[9.0.19, )", + "Microsoft.Extensions.Options": "[9.0.19, )" } }, "atm.application.abstractions": { @@ -76,7 +218,11 @@ "dependencies": { "Atm.Application.Abstractions": "[1.0.0, )", "Atm.Domain": "[1.0.0, )", - "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.2, )" + "Microsoft.EntityFrameworkCore": "[9.0.19, )", + "Microsoft.EntityFrameworkCore.Relational": "[9.0.19, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[9.0.19, )", + "Microsoft.Extensions.Identity.Core": "[9.0.19, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[9.0.4, )" } } } diff --git a/tests/Atm.Application.Tests/AccountAuthenticationFlowTests.cs b/tests/Atm.Application.Tests/AccountAuthenticationFlowTests.cs new file mode 100644 index 0000000..2790a13 --- /dev/null +++ b/tests/Atm.Application.Tests/AccountAuthenticationFlowTests.cs @@ -0,0 +1,114 @@ +using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Security; +using Atm.Application.Accounts; +using Atm.Application.Common; +using Atm.Application.Contracts.Accounts.CreateAccount; +using Atm.Application.Contracts.Sessions.CreateUserSession; +using Atm.Application.Sessions; +using Atm.Domain.Accounts; +using Atm.Domain.Sessions; +using Atm.Domain.ValueObjects; +using Microsoft.Extensions.Options; + +namespace Atm.Application.Tests; + +public sealed class AccountAuthenticationFlowTests +{ + [Fact] + public void CreatedAccountStoresOnlyHashAndAcceptsOnlyTheCorrectPin() + { + var accounts = new TestAccountRepository(); + var sessions = new TestSessionRepository(); + var clock = new FixedClock(); + var hasher = new TestPinHasher(); + var adminSessionId = Guid.NewGuid(); + var ownerUserId = Guid.NewGuid(); + sessions.Add(BankingTestData.CreateAdminSession(adminSessionId, clock)); + + var authorizer = new SessionAuthorizer(sessions, clock); + var createAccount = new CreateAccountUseCase(accounts, authorizer, hasher, clock); + CreateAccountResult created = createAccount.Execute(new CreateAccountRequest( + adminSessionId, " account-01 ", "1234", 1_000_000_000_000_000m, ownerUserId)); + + Assert.IsType(created); + Account storedAccount = Assert.IsType(accounts.Account); + Assert.Equal("ACCOUNT-01", storedAccount.Number.NormalizedValue); + Assert.Equal(ownerUserId, storedAccount.OwnerUserId); + Assert.NotEqual("1234", storedAccount.PinHash.Value); + Assert.True(hasher.Verify(storedAccount.PinHash, new PinCode("1234"))); + + IOptions options = Options.Create(new SessionOptions + { + UserLifetimeMinutes = 30, + AdminLifetimeMinutes = 15, + }); + var createSession = new CreateUserSessionUseCase(sessions, accounts, hasher, clock, options); + + Assert.IsType(createSession.Execute( + new CreateUserSessionRequest("account-01", "4321"))); + CreateUserSessionResult.Success signedIn = Assert.IsType( + createSession.Execute(new CreateUserSessionRequest("account-01", "1234"))); + + Session userSession = Assert.IsType(sessions.GetById(signedIn.SessionId)); + Assert.Equal(storedAccount.Id, userSession.AccountId); + Assert.True(userSession.IsActive(clock.UtcNow)); + Assert.Equal(clock.UtcNow.AddMinutes(30), userSession.ExpiresAtUtc); + } + + private sealed class TestAccountRepository : IAccountRepository + { + public Account? Account { get; private set; } + + public Account? GetById(Guid id) + { + return Account?.Id == id ? Account : null; + } + + public Account? GetByNumber(AccountNumber accountNumber) + { + return Account?.Number.NormalizedValue == accountNumber.NormalizedValue ? Account : null; + } + + public void Add(Account account) + { + Account = account; + } + + public void Update(Account account) + { + Account = account; + } + } + + private sealed class TestSessionRepository : ISessionRepository + { + private readonly Dictionary _sessions = new(); + + public Session? GetById(Guid id) + { + return _sessions.GetValueOrDefault(id); + } + + public void Add(Session session) + { + _sessions.Add(session.Id, session); + } + } + + private sealed class TestPinHasher : IPinHasher + { + private readonly Dictionary _pinsByHash = new(); + + public PinHash Hash(PinCode pin) + { + var hash = new PinHash($"test-hash-{Guid.NewGuid():N}"); + _pinsByHash.Add(hash.Value, pin.Value); + return hash; + } + + public bool Verify(PinHash hash, PinCode pin) + { + return _pinsByHash.GetValueOrDefault(hash.Value) == pin.Value; + } + } +} diff --git a/tests/Atm.Application.Tests/BankingTestData.cs b/tests/Atm.Application.Tests/BankingTestData.cs new file mode 100644 index 0000000..3c3f56e --- /dev/null +++ b/tests/Atm.Application.Tests/BankingTestData.cs @@ -0,0 +1,29 @@ +using Atm.Domain.Accounts; +using Atm.Domain.Accounts.Results; +using Atm.Domain.Sessions; +using Atm.Domain.Sessions.Results; +using Atm.Domain.ValueObjects; + +namespace Atm.Application.Tests; + +internal static class BankingTestData +{ + public static Account CreateAccount(AccountNumber number, decimal balance) + { + return Assert.IsType(Account.Create( + Guid.NewGuid(), number, Guid.NewGuid(), + new PinHash("test-only-hash"), new Money(balance), new FixedClock().UtcNow)).Account; + } + + public static Session CreateUserSession(Guid id, Account account, FixedClock clock) + { + return Assert.IsType(Session.Create( + id, SessionType.User, account.Id, clock.UtcNow, clock.UtcNow.AddMinutes(30))).Session; + } + + public static Session CreateAdminSession(Guid id, FixedClock clock) + { + return Assert.IsType(Session.Create( + id, SessionType.Admin, null, clock.UtcNow, clock.UtcNow.AddMinutes(15))).Session; + } +} diff --git a/tests/Atm.Application.Tests/DepositUseCaseTests.cs b/tests/Atm.Application.Tests/DepositUseCaseTests.cs index 565bb54..6edd8e5 100644 --- a/tests/Atm.Application.Tests/DepositUseCaseTests.cs +++ b/tests/Atm.Application.Tests/DepositUseCaseTests.cs @@ -1,10 +1,9 @@ -using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Repositories; using Atm.Application.Common; using Atm.Application.Contracts.Operations.Deposit; using Atm.Application.Operations; using Atm.Domain.Accounts; using Atm.Domain.Operations; -using Atm.Domain.Sessions; using Atm.Domain.ValueObjects; using NSubstitute; @@ -18,29 +17,26 @@ public void DepositWhenValidIncreaseBalanceAndSavesOperation() IAccountRepository accounts = Substitute.For(); ISessionRepository sessions = Substitute.For(); IOperationRepository operations = Substitute.For(); - + var clock = new FixedClock(); var accountNumber = new AccountNumber("aboba"); var sessionId = Guid.NewGuid(); + Account account = BankingTestData.CreateAccount(accountNumber, 100m); - sessions.GetById(sessionId).Returns(new Session(sessionId, SessionType.User, accountNumber)); - - var account = new Account(accountNumber, new PinCode("1234"), new Money(100m)); - accounts.GetByNumber(accountNumber).Returns(account); + sessions.GetById(sessionId).Returns(BankingTestData.CreateUserSession(sessionId, account, clock)); + accounts.GetById(account.Id).Returns(account); - var auth = new SessionAuthorizer(sessions); - var useCase = new DepositUseCase(accounts, operations, auth); + var auth = new SessionAuthorizer(sessions, clock); + var useCase = new DepositUseCase(accounts, operations, auth, clock); DepositResult result = useCase.Execute(new DepositRequest(sessionId, 50m)); Assert.IsType(result); Assert.Equal(150m, ((DepositResult.Success)result).Balance); - - accounts.Received(1).Update(Arg.Is(a => a.Balance.Value == 150m)); - - operations.Received(1).Add(Arg.Is(op => - op.AccountNumber == accountNumber && - op.Type == OperationType.Deposit && - op.Amount.Value == 50m && - op.BalanceAfter.Value == 150m)); + accounts.Received(1).Update(Arg.Is(item => item.Balance.Value == 150m)); + operations.Received(1).Add(Arg.Is(operation => + operation.AccountNumber == accountNumber && + operation.Type == OperationType.Deposit && + operation.Amount.Value == 50m && + operation.BalanceAfter.Value == 150m)); } -} \ No newline at end of file +} diff --git a/tests/Atm.Application.Tests/FixedClock.cs b/tests/Atm.Application.Tests/FixedClock.cs new file mode 100644 index 0000000..af39324 --- /dev/null +++ b/tests/Atm.Application.Tests/FixedClock.cs @@ -0,0 +1,8 @@ +using Atm.Application.Abstractions.Time; + +namespace Atm.Application.Tests; + +internal sealed class FixedClock : IClock +{ + public DateTimeOffset UtcNow { get; set; } = new(2026, 9, 2, 12, 0, 0, TimeSpan.Zero); +} diff --git a/tests/Atm.Application.Tests/SessionAuthorizerTests.cs b/tests/Atm.Application.Tests/SessionAuthorizerTests.cs new file mode 100644 index 0000000..8d3466d --- /dev/null +++ b/tests/Atm.Application.Tests/SessionAuthorizerTests.cs @@ -0,0 +1,33 @@ +using Atm.Application.Abstractions.Repositories; +using Atm.Application.Common; +using Atm.Domain.Accounts; +using Atm.Domain.Sessions; +using Atm.Domain.Sessions.Results; +using Atm.Domain.ValueObjects; +using NSubstitute; + +namespace Atm.Application.Tests; + +public sealed class SessionAuthorizerTests +{ + [Fact] + public void AuthorizerRejectsExpiredAndRevokedSessions() + { + ISessionRepository sessions = Substitute.For(); + var clock = new FixedClock(); + Account account = BankingTestData.CreateAccount(new AccountNumber("session-test"), 0m); + var sessionId = Guid.NewGuid(); + Session session = BankingTestData.CreateUserSession(sessionId, account, clock); + sessions.GetById(sessionId).Returns(session); + var authorizer = new SessionAuthorizer(sessions, clock); + + Assert.IsType(authorizer.AuthorizeUser(sessionId)); + + clock.UtcNow = session.ExpiresAtUtc; + Assert.IsType(authorizer.AuthorizeUser(sessionId)); + + clock.UtcNow = session.CreatedAtUtc; + Assert.IsType(session.Revoke(clock.UtcNow)); + Assert.IsType(authorizer.AuthorizeUser(sessionId)); + } +} diff --git a/tests/Atm.Application.Tests/WithdrawCaseTests.cs b/tests/Atm.Application.Tests/WithdrawCaseTests.cs index 0b53806..f9711d7 100644 --- a/tests/Atm.Application.Tests/WithdrawCaseTests.cs +++ b/tests/Atm.Application.Tests/WithdrawCaseTests.cs @@ -1,10 +1,9 @@ -using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Repositories; using Atm.Application.Common; using Atm.Application.Contracts.Operations.Withdraw; using Atm.Application.Operations; using Atm.Domain.Accounts; using Atm.Domain.Operations; -using Atm.Domain.Sessions; using Atm.Domain.ValueObjects; using NSubstitute; @@ -18,30 +17,27 @@ public void WithdrawWhenEnoughBalanceDecreasesBalanceAndSavesOperation() IAccountRepository accounts = Substitute.For(); ISessionRepository sessions = Substitute.For(); IOperationRepository operations = Substitute.For(); - + var clock = new FixedClock(); var accountNumber = new AccountNumber("aboba-2"); var sessionId = Guid.NewGuid(); + Account account = BankingTestData.CreateAccount(accountNumber, 100m); - sessions.GetById(sessionId).Returns(new Session(sessionId, SessionType.User, accountNumber)); - - var account = new Account(accountNumber, new PinCode("6666"), new Money(100m)); - accounts.GetByNumber(accountNumber).Returns(account); + sessions.GetById(sessionId).Returns(BankingTestData.CreateUserSession(sessionId, account, clock)); + accounts.GetById(account.Id).Returns(account); - var auth = new SessionAuthorizer(sessions); - var useCase = new WithdrawUseCase(accounts, operations, auth); + var auth = new SessionAuthorizer(sessions, clock); + var useCase = new WithdrawUseCase(accounts, operations, auth, clock); WithdrawResult result = useCase.Execute(new WithdrawRequest(sessionId, 40m)); Assert.IsType(result); Assert.Equal(60m, ((WithdrawResult.Success)result).Balance); - - accounts.Received(1).Update(Arg.Is(a => a.Balance.Value == 60m)); - - operations.Received(1).Add(Arg.Is(op => - op.AccountNumber == accountNumber && - op.Type == OperationType.Withdraw && - op.Amount.Value == 40m && - op.BalanceAfter.Value == 60m)); + accounts.Received(1).Update(Arg.Is(item => item.Balance.Value == 60m)); + operations.Received(1).Add(Arg.Is(operation => + operation.AccountNumber == accountNumber && + operation.Type == OperationType.Withdraw && + operation.Amount.Value == 40m && + operation.BalanceAfter.Value == 60m)); } [Fact] @@ -50,17 +46,16 @@ public void WithdrawWhenNotEnoughBalanceReturnsFailureAndDoesNotSave() IAccountRepository accounts = Substitute.For(); ISessionRepository sessions = Substitute.For(); IOperationRepository operations = Substitute.For(); - + var clock = new FixedClock(); var accountNumber = new AccountNumber("aboba-666"); var sessionId = Guid.NewGuid(); + Account account = BankingTestData.CreateAccount(accountNumber, 10m); - sessions.GetById(sessionId).Returns(new Session(sessionId, SessionType.User, accountNumber)); - - var account = new Account(accountNumber, new PinCode("9999"), new Money(10m)); - accounts.GetByNumber(accountNumber).Returns(account); + sessions.GetById(sessionId).Returns(BankingTestData.CreateUserSession(sessionId, account, clock)); + accounts.GetById(account.Id).Returns(account); - var auth = new SessionAuthorizer(sessions); - var useCase = new WithdrawUseCase(accounts, operations, auth); + var auth = new SessionAuthorizer(sessions, clock); + var useCase = new WithdrawUseCase(accounts, operations, auth, clock); WithdrawResult result = useCase.Execute(new WithdrawRequest(sessionId, 20m)); @@ -68,4 +63,4 @@ public void WithdrawWhenNotEnoughBalanceReturnsFailureAndDoesNotSave() accounts.DidNotReceive().Update(Arg.Any()); operations.DidNotReceive().Add(Arg.Any()); } -} \ No newline at end of file +} diff --git a/tests/Atm.Application.Tests/packages.lock.json b/tests/Atm.Application.Tests/packages.lock.json index d8d50bb..efaa490 100644 --- a/tests/Atm.Application.Tests/packages.lock.json +++ b/tests/Atm.Application.Tests/packages.lock.json @@ -59,8 +59,22 @@ }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "10.0.2", - "contentHash": "zOIurr59+kUf9vNcsUkCvKWZv+fPosUZXURZesYkJCvl0EzTc9F7maAO4Cd2WEV7ZJJ0AZrFQvuH6Npph9wdBw==" + "resolved": "9.0.19", + "contentHash": "XgQ0aVWClYjYSBqbYrKC/xyZ1KHf+VVwNF3+d54jcaqnst30Q//ugs/FJwUztLZiph0gp85g7i/eB0556twO0Q==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "FdPZy4kUSwFlIVZ3EkK9fdHw2RfblUwzflSb4kjVeGf9v2lX9IH+q2RGUovYMR7CVfKMc2y2Db/bZ2b4yiMfOg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19", + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "9hIg8PQiMnpVFIsEHm25Wi1gBrPa2pS1G75uveyDUVLXrNKqBap9WGwQIgO0s6LUgRAOdsSYnbKbNC5b1G5Pcg==" }, "Microsoft.TestPlatform.ObjectModel": { "type": "Transitive", @@ -140,7 +154,8 @@ "Atm.Application.Abstractions": "[1.0.0, )", "Atm.Application.Contracts": "[1.0.0, )", "Atm.Domain": "[1.0.0, )", - "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.2, )" + "Microsoft.Extensions.DependencyInjection.Abstractions": "[9.0.19, )", + "Microsoft.Extensions.Options": "[9.0.19, )" } }, "atm.application.abstractions": { diff --git a/tests/Atm.Domain.Tests/AccountTests.cs b/tests/Atm.Domain.Tests/AccountTests.cs new file mode 100644 index 0000000..58a5e74 --- /dev/null +++ b/tests/Atm.Domain.Tests/AccountTests.cs @@ -0,0 +1,63 @@ +using Atm.Domain.Accounts; +using Atm.Domain.Accounts.Results; +using Atm.Domain.ValueObjects; + +namespace Atm.Domain.Tests; + +public class AccountTests +{ + [Fact] + public void CreateRequiresAnOwnerAndUtcTime() + { + var number = new AccountNumber("test"); + var hash = new PinHash("test-only-hash"); + DateTimeOffset now = DomainTestData.UtcNow; + + Assert.IsType(Account.Create( + Guid.NewGuid(), number, Guid.Empty, hash, new Money(0m), now)); + Assert.IsType(Account.Create( + Guid.NewGuid(), number, Guid.NewGuid(), hash, new Money(0m), now.ToOffset(TimeSpan.FromHours(3)))); + } + + [Fact] + public void SuccessfulChangesAdvanceTheVersionAndTimestamp() + { + Account account = DomainTestData.CreateAccount(new AccountNumber("test"), 100m); + DateTimeOffset now = account.CreatedAtUtc.AddMinutes(1); + + Assert.IsType(account.Deposit(new Amount(10m), now)); + Assert.Equal(1, account.Version); + Assert.Equal(now, account.UpdatedAtUtc); + Assert.IsType(account.Withdraw(new Amount(20m), now.AddMinutes(1))); + Assert.Equal(2, account.Version); + Assert.Equal(90m, account.Balance.Value); + } + + [Fact] + public void FailedChangesDoNotMutateTheAccount() + { + Account account = DomainTestData.CreateAccount(new AccountNumber("test"), 100m); + DateTimeOffset now = account.CreatedAtUtc; + + Assert.IsType(account.Withdraw(new Amount(101m), now)); + Assert.IsType(account.Deposit(new Amount(1m), now.AddTicks(-1))); + Assert.Equal(100m, account.Balance.Value); + Assert.Equal(0, account.Version); + Assert.Equal(now, account.UpdatedAtUtc); + } + + [Fact] + public void LargeBalancesHaveNoBusinessCeilingButCannotLosePrecision() + { + Account account = DomainTestData.CreateAccount(new AccountNumber("large"), 1000000000000000m); + Assert.IsType(account.Deposit(new Amount(1m), account.CreatedAtUtc)); + Assert.Equal(1000000000000001m, account.Balance.Value); + + Account largest = DomainTestData.CreateAccount(new AccountNumber("largest"), decimal.MaxValue); + Assert.IsType(largest.Deposit(new Amount(1m), largest.CreatedAtUtc)); + Assert.IsType(largest.Deposit(new Amount(0.0001m), largest.CreatedAtUtc)); + Assert.IsType(largest.Withdraw(new Amount(0.0001m), largest.CreatedAtUtc)); + Assert.Equal(decimal.MaxValue, largest.Balance.Value); + Assert.Equal(0, largest.Version); + } +} diff --git a/tests/Atm.Domain.Tests/Atm.Domain.Tests.csproj b/tests/Atm.Domain.Tests/Atm.Domain.Tests.csproj new file mode 100644 index 0000000..69d6dd0 --- /dev/null +++ b/tests/Atm.Domain.Tests/Atm.Domain.Tests.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/tests/Atm.Domain.Tests/BankTransactionTests.cs b/tests/Atm.Domain.Tests/BankTransactionTests.cs new file mode 100644 index 0000000..c6fa6a3 --- /dev/null +++ b/tests/Atm.Domain.Tests/BankTransactionTests.cs @@ -0,0 +1,153 @@ +using Atm.Domain.Operations; +using Atm.Domain.Operations.Results; +using Atm.Domain.ValueObjects; + +namespace Atm.Domain.Tests; + +public sealed class BankTransactionTests +{ + private static readonly DateTimeOffset Now = new(2026, 9, 4, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void CommissionSnapshotUsesBankersRoundingAndStoresTheTariff() + { + var roundDown = new CommissionSnapshot(new Amount(1.0001m), 0.5m, 0m); + var roundUp = new CommissionSnapshot(new Amount(1.0003m), 0.5m, 0m); + + Assert.Equal(0.5m, roundDown.Rate); + Assert.Equal(0m, roundDown.FixedAmount.Value); + Assert.Equal(0.5000m, roundDown.CommissionAmount.Value); + Assert.Equal(0.5002m, roundUp.CommissionAmount.Value); + } + + [Theory] + [InlineData(-0.000001)] + [InlineData(1.000001)] + [InlineData(0.1234567)] + public void CommissionSnapshotRejectsInvalidRate(decimal rate) + { + Assert.Throws(() => new CommissionSnapshot(new Amount(10m), rate, 0m)); + } + + [Fact] + public void CommissionSnapshotRejectsInvalidFixedAmountAndPrecisionLoss() + { + Assert.Throws(() => new CommissionSnapshot(new Amount(10m), 0m, -1m)); + Assert.Throws(() => new CommissionSnapshot(new Amount(10m), 0m, 0.00001m)); + Assert.Throws(() => + new CommissionSnapshot(new Amount(decimal.MaxValue), 1m, 0.0001m)); + } + + [Fact] + public void CreateEnforcesAccountShapeRequestIdAndUtc() + { + var source = Guid.NewGuid(); + var target = Guid.NewGuid(); + var amount = new Amount(10m); + var commission = new CommissionSnapshot(amount, 0m, 0m); + + Assert.IsType(BankTransaction.Create( + Guid.NewGuid(), Guid.Empty, BankTransactionType.Deposit, null, target, amount, commission, Now)); + Assert.IsType(BankTransaction.Create( + Guid.NewGuid(), Guid.NewGuid(), BankTransactionType.Deposit, source, target, amount, commission, Now)); + Assert.IsType(BankTransaction.Create( + Guid.NewGuid(), Guid.NewGuid(), BankTransactionType.Withdrawal, source, target, amount, commission, Now)); + Assert.IsType(BankTransaction.Create( + Guid.NewGuid(), Guid.NewGuid(), BankTransactionType.Transfer, source, source, amount, commission, Now)); + Assert.IsType(BankTransaction.Create( + Guid.NewGuid(), Guid.NewGuid(), BankTransactionType.Deposit, null, target, amount, commission, + Now.ToOffset(TimeSpan.FromHours(3)))); + } + + [Fact] + public void CreateRejectsMismatchedOrEconomicallyInvalidCommission() + { + var target = Guid.NewGuid(); + var originalAmount = new Amount(100m); + var mismatchedAmount = new Amount(101m); + var commission = new CommissionSnapshot(originalAmount, 0.1m, 0m); + + Assert.IsType(BankTransaction.Create( + Guid.NewGuid(), Guid.NewGuid(), BankTransactionType.Deposit, + null, target, mismatchedAmount, commission, Now)); + + var smallAmount = new Amount(1m); + var fullCommission = new CommissionSnapshot(smallAmount, 0m, 1m); + Assert.IsType(BankTransaction.Create( + Guid.NewGuid(), Guid.NewGuid(), BankTransactionType.Deposit, + null, target, smallAmount, fullCommission, Now)); + } + + [Fact] + public void HistoryEntryCalculatesDepositAndWithdrawalBalances() + { + var accountId = Guid.NewGuid(); + BankTransaction deposit = CreateTransaction( + BankTransactionType.Deposit, null, accountId, 100m, 0m, 10m); + AccountHistoryEntry depositEntry = Assert.IsType( + AccountHistoryEntry.Create(Guid.NewGuid(), deposit, accountId, new Money(10m))).Entry; + + Assert.Equal(AccountHistoryEntryType.Deposit, depositEntry.EntryType); + Assert.Equal(10m, depositEntry.BalanceBefore.Value); + Assert.Equal(100m, depositEntry.BalanceAfter.Value); + Assert.Equal(deposit.Id, depositEntry.TransactionId); + Assert.Equal(deposit.CreatedAtUtc, depositEntry.CreatedAtUtc); + + BankTransaction withdrawal = CreateTransaction( + BankTransactionType.Withdrawal, accountId, null, 20m, 0m, 2m); + AccountHistoryEntry withdrawalEntry = Assert.IsType( + AccountHistoryEntry.Create(Guid.NewGuid(), withdrawal, accountId, new Money(100m))).Entry; + + Assert.Equal(AccountHistoryEntryType.Withdrawal, withdrawalEntry.EntryType); + Assert.Equal(78m, withdrawalEntry.BalanceAfter.Value); + } + + [Fact] + public void HistoryEntryProducesTwoSidesOfATransfer() + { + var source = Guid.NewGuid(); + var target = Guid.NewGuid(); + BankTransaction transaction = CreateTransaction( + BankTransactionType.Transfer, source, target, 10m, 0m, 1m); + + AccountHistoryEntry debit = Assert.IsType( + AccountHistoryEntry.Create(Guid.NewGuid(), transaction, source, new Money(100m))).Entry; + AccountHistoryEntry credit = Assert.IsType( + AccountHistoryEntry.Create(Guid.NewGuid(), transaction, target, new Money(50m))).Entry; + + Assert.Equal(AccountHistoryEntryType.TransferDebit, debit.EntryType); + Assert.Equal(89m, debit.BalanceAfter.Value); + Assert.Equal(AccountHistoryEntryType.TransferCredit, credit.EntryType); + Assert.Equal(60m, credit.BalanceAfter.Value); + Assert.Equal(debit.TransactionId, credit.TransactionId); + } + + [Fact] + public void HistoryEntryFailureDoesNotChangeTheProvidedBalance() + { + var source = Guid.NewGuid(); + BankTransaction transaction = CreateTransaction( + BankTransactionType.Withdrawal, source, null, 10m, 0m, 1m); + var balance = new Money(10m); + + Assert.IsType( + AccountHistoryEntry.Create(Guid.NewGuid(), transaction, source, balance)); + Assert.Equal(10m, balance.Value); + Assert.IsType( + AccountHistoryEntry.Create(Guid.NewGuid(), transaction, Guid.NewGuid(), new Money(100m))); + } + + private static BankTransaction CreateTransaction( + BankTransactionType type, + Guid? source, + Guid? target, + decimal amountValue, + decimal rate, + decimal fixedAmount) + { + var amount = new Amount(amountValue); + var commission = new CommissionSnapshot(amount, rate, fixedAmount); + return Assert.IsType(BankTransaction.Create( + Guid.NewGuid(), Guid.NewGuid(), type, source, target, amount, commission, Now)).Transaction; + } +} diff --git a/tests/Atm.Domain.Tests/DomainTestData.cs b/tests/Atm.Domain.Tests/DomainTestData.cs new file mode 100644 index 0000000..e5e84a4 --- /dev/null +++ b/tests/Atm.Domain.Tests/DomainTestData.cs @@ -0,0 +1,34 @@ +using Atm.Domain.Accounts; +using Atm.Domain.Accounts.Results; +using Atm.Domain.Sessions; +using Atm.Domain.Sessions.Results; +using Atm.Domain.ValueObjects; + +namespace Atm.Domain.Tests; + +internal static class DomainTestData +{ + public static DateTimeOffset UtcNow { get; } = + new(2026, 9, 2, 12, 0, 0, TimeSpan.Zero); + + public static Account CreateAccount(AccountNumber number, decimal balance) + { + return Assert.IsType(Account.Create( + Guid.NewGuid(), + number, + Guid.NewGuid(), + new PinHash("test-only-hash"), + new Money(balance), + UtcNow)).Account; + } + + public static Session CreateAdminSession(Guid id) + { + return Assert.IsType(Session.Create( + id, + SessionType.Admin, + null, + UtcNow, + UtcNow.AddMinutes(15))).Session; + } +} diff --git a/tests/Atm.Domain.Tests/SessionTests.cs b/tests/Atm.Domain.Tests/SessionTests.cs new file mode 100644 index 0000000..595e57e --- /dev/null +++ b/tests/Atm.Domain.Tests/SessionTests.cs @@ -0,0 +1,58 @@ +using Atm.Domain.Sessions; +using Atm.Domain.Sessions.Results; + +namespace Atm.Domain.Tests; + +public sealed class SessionTests +{ + [Fact] + public void CreateEnforcesIdentityShapeAndUtcLifetime() + { + DateTimeOffset now = DomainTestData.UtcNow; + + Assert.IsType(Session.Create( + Guid.Empty, SessionType.Admin, null, now, now.AddMinutes(1))); + Assert.IsType(Session.Create( + Guid.NewGuid(), SessionType.User, null, now, now.AddMinutes(1))); + Assert.IsType(Session.Create( + Guid.NewGuid(), SessionType.Admin, Guid.NewGuid(), now, now.AddMinutes(1))); + Assert.IsType(Session.Create( + Guid.NewGuid(), SessionType.Admin, null, now, now)); + Assert.IsType(Session.Create( + Guid.NewGuid(), + SessionType.Admin, + null, + now.ToOffset(TimeSpan.FromHours(3)), + now.AddMinutes(1))); + } + + [Fact] + public void IsActiveUsesInclusiveStartAndExclusiveExpiration() + { + DateTimeOffset now = DomainTestData.UtcNow; + Session session = DomainTestData.CreateAdminSession(Guid.NewGuid()); + + Assert.True(session.IsActive(now)); + Assert.True(session.IsActive(session.ExpiresAtUtc.AddTicks(-1))); + Assert.False(session.IsActive(session.ExpiresAtUtc)); + Assert.False(session.IsActive(now.AddTicks(-1))); + Assert.False(session.IsActive(now.ToOffset(TimeSpan.FromHours(3)))); + } + + [Fact] + public void RevokeIsIdempotentAndKeepsTheFirstTimestamp() + { + DateTimeOffset now = DomainTestData.UtcNow; + Session session = DomainTestData.CreateAdminSession(Guid.NewGuid()); + DateTimeOffset first = now.AddMinutes(1); + + Assert.IsType(session.Revoke(now.AddTicks(-1))); + Assert.IsType(session.Revoke(first)); + SessionRevocationResult.Success repeated = Assert.IsType( + session.Revoke(first.AddMinutes(1))); + + Assert.Equal(first, repeated.RevokedAtUtc); + Assert.Equal(first, session.RevokedAtUtc); + Assert.False(session.IsActive(first)); + } +} diff --git a/tests/Atm.Domain.Tests/UserTests.cs b/tests/Atm.Domain.Tests/UserTests.cs new file mode 100644 index 0000000..9b6bff3 --- /dev/null +++ b/tests/Atm.Domain.Tests/UserTests.cs @@ -0,0 +1,75 @@ +using Atm.Domain.Users; +using Atm.Domain.Users.Results; +using Atm.Domain.ValueObjects; + +namespace Atm.Domain.Tests; + +public sealed class UserTests +{ + private static readonly DateTimeOffset Now = new DateTimeOffset(2026, 9, 2, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void ValidProfileIsCreatedAndUpdated() + { + User user = CreateUser(); + Assert.Equal("Test User", user.Name); + Assert.IsType( + user.UpdateProfile(" Changed ", 30, UserGender.Other, HairColor.Black, Now.AddMinutes(1))); + Assert.Equal("Changed", user.Name); + Assert.Equal(Now.AddMinutes(1), user.UpdatedAtUtc); + Assert.Equal(Now, user.CreatedAtUtc); + } + + [Theory] + [InlineData(-1)] + [InlineData(151)] + public void InvalidProfileDoesNotPartiallyMutateState(short age) + { + User user = CreateUser(); + Assert.IsType( + user.UpdateProfile("Changed", age, UserGender.Other, HairColor.Black, Now.AddMinutes(1))); + Assert.Equal("Test User", user.Name); + Assert.Equal((short)25, user.Age); + Assert.Equal(HairColor.Unspecified, user.HairColor); + Assert.Equal(Now, user.UpdatedAtUtc); + } + + [Fact] + public void InvalidIdentifiersEnumsAndTimestampsAreRejected() + { + var login = new UserLogin("user"); + Assert.IsType( + User.Create(Guid.Empty, login, "Name", 25, UserGender.Unspecified, HairColor.Unspecified, Now)); + Assert.IsType( + User.Create(Guid.NewGuid(), login, "Name", 25, (UserGender)99, HairColor.Unspecified, Now)); + User user = CreateUser(); + Assert.IsType( + user.UpdateProfile("Name", 25, UserGender.Unspecified, HairColor.Unspecified, Now.AddSeconds(-1))); + Assert.IsType( + user.UpdateProfile("Name", 25, UserGender.Unspecified, HairColor.Unspecified, Now.ToOffset(TimeSpan.FromHours(3)))); + } + + [Fact] + public void FriendshipIsCanonicalAndRejectsSelfReference() + { + var first = Guid.Parse("00000100-0000-0000-0000-000000000001"); + var second = Guid.Parse("000000ff-ffff-ffff-ffff-ffffffffffff"); + Friendship forward = Assert.IsType( + Friendship.Create(first, second, Now)).Friendship; + Friendship reverse = Assert.IsType( + Friendship.Create(second, first, Now)).Friendship; + Assert.Equal(second, forward.UserId); + Assert.Equal(first, forward.FriendUserId); + Assert.Equal(forward.UserId, reverse.UserId); + Assert.Equal(forward.FriendUserId, reverse.FriendUserId); + Assert.IsType(Friendship.Create(first, first, Now)); + Assert.IsType(Friendship.Create(first, Guid.Empty, Now)); + } + + private static User CreateUser() + { + return Assert.IsType( + User.Create(Guid.NewGuid(), new UserLogin("user"), " Test User ", 25, + UserGender.Unspecified, HairColor.Unspecified, Now)).User; + } +} diff --git a/tests/Atm.Domain.Tests/ValueObjectTests.cs b/tests/Atm.Domain.Tests/ValueObjectTests.cs new file mode 100644 index 0000000..2baa8af --- /dev/null +++ b/tests/Atm.Domain.Tests/ValueObjectTests.cs @@ -0,0 +1,80 @@ +using Atm.Domain.ValueObjects; +using System.Globalization; + +namespace Atm.Domain.Tests; + +public sealed class ValueObjectTests +{ + [Fact] + public void NormalizationIsCultureIndependent() + { + CultureInfo original = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("tr-TR"); + var number = new AccountNumber(" idi-01 "); + var login = new UserLogin(" idi "); + Assert.Equal("idi-01", number.Value); + Assert.Equal("IDI-01", number.NormalizedValue); + Assert.Equal("IDI", login.NormalizedValue); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void EmptyIdentifiersAreRejected(string value) + { + Assert.Throws(() => new AccountNumber(value)); + Assert.Throws(() => new UserLogin(value)); + } + + [Fact] + public void LongIdentifiersAreRejected() + { + string value = new string('a', 65); + Assert.Throws(() => new AccountNumber(value)); + Assert.Throws(() => new UserLogin(value)); + } + + [Fact] + public void MoneyHasNoNumeric19BusinessCeiling() + { + var money = new Money(1000000000000000m); + Assert.Equal(1000000000000001m, money.Add(new Amount(1m)).Value); + Assert.Equal(decimal.MaxValue, new Money(decimal.MaxValue).Value); + } + + [Fact] + public void InvalidAmountsAndPrecisionAreRejected() + { + Assert.Throws(() => new Amount(0m)); + Assert.Throws(() => new Amount(-1m)); + Assert.Throws(() => new Amount(1.00001m)); + Assert.Throws(() => new Money(-1m)); + Assert.Throws(() => new Money(1.00001m)); + } + + [Fact] + public void ArithmeticDoesNotSilentlyLoseSmallAmounts() + { + var money = new Money(decimal.MaxValue); + Assert.Throws(() => money.Add(new Amount(1m))); + Assert.Throws(() => money.Add(new Amount(0.0001m))); + Assert.Throws(() => money.Subtract(new Amount(0.0001m))); + } + + [Theory] + [InlineData("123")] + [InlineData("abcd")] + [InlineData("1234")] + public void InvalidPinIsRejectedWithoutEchoingInput(string pin) + { + ArgumentException exception = Assert.Throws(() => new PinCode(pin)); + Assert.DoesNotContain(pin, exception.Message, StringComparison.Ordinal); + } +} diff --git a/tests/Atm.Domain.Tests/packages.lock.json b/tests/Atm.Domain.Tests/packages.lock.json new file mode 100644 index 0000000..04bcc42 --- /dev/null +++ b/tests/Atm.Domain.Tests/packages.lock.json @@ -0,0 +1,115 @@ +{ + "version": 1, + "dependencies": { + "net9.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[6.0.2, )", + "resolved": "6.0.2", + "contentHash": "bJShQ6uWRTQ100ZeyiMqcFlhP7WJ+bCuabUs885dJiBEzMsJMSFr7BOyeCw4rgvQokteGi5rKQTlkhfQPUXg2A==" + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.12.0, )", + "resolved": "17.12.0", + "contentHash": "kt/PKBZ91rFCWxVIJZSgVLk+YR+4KxTuHf799ho8WNiK5ZQpJNAEZCAWX86vcKrs+DiYjiibpYKdGZP6+/N17w==", + "dependencies": { + "Microsoft.CodeCoverage": "17.12.0", + "Microsoft.TestPlatform.TestHost": "17.12.0" + } + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.2, )", + "resolved": "2.9.2", + "contentHash": "7LhFS2N9Z6Xgg8aE5lY95cneYivRMfRI8v+4PATa4S64D5Z/Plkg0qa8dTRHSiGRgVZ/CL2gEfJDE5AUhOX+2Q==", + "dependencies": { + "xunit.analyzers": "1.16.0", + "xunit.assert": "2.9.2", + "xunit.core": "[2.9.2]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[2.8.2, )", + "resolved": "2.8.2", + "contentHash": "vm1tbfXhFmjFMUmS4M0J0ASXz3/U5XvXBa6DOQUL3fEz4Vt6YPhv+ESCarx6M6D+9kJkJYZKCNvJMas1+nVfmQ==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.12.0", + "contentHash": "4svMznBd5JM21JIG2xZKGNanAHNXplxf/kQDFfLHXQ3OnpJkayRK/TjacFjA+EYmoyuNXHo/sOETEfcYtAzIrA==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.12.0", + "contentHash": "TDqkTKLfQuAaPcEb3pDDWnh7b3SyZF+/W9OZvWFp6eJCIiiYFdSB6taE2I6tWrFw5ywhzOb6sreoGJTI6m3rSQ==", + "dependencies": { + "System.Reflection.Metadata": "1.6.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.12.0", + "contentHash": "MiPEJQNyADfwZ4pJNpQex+t9/jOClBGMiCiVVFuELCMSX2nmNfvUor3uFVxNNCg30uxDP8JDYfPnMXQzsfzYyg==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.12.0", + "Newtonsoft.Json": "13.0.1" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.1", + "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "1.6.0", + "contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ==" + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.16.0", + "contentHash": "hptYM7vGr46GUIgZt21YHO4rfuBAQS2eINbFo16CV/Dqq+24Tp+P5gDCACu1AbFfW4Sp/WRfDPSK8fmUUb8s0Q==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.2", + "contentHash": "QkNBAQG4pa66cholm28AxijBjrmki98/vsEh4Sx5iplzotvPgpiotcxqJQMRC8d7RV7nIT8ozh97957hDnZwsQ==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.2", + "contentHash": "O6RrNSdmZ0xgEn5kT927PNwog5vxTtKrWMihhhrT0Sg9jQ7iBDciYOwzBgP2krBEk5/GBXI18R1lKvmnxGcb4w==", + "dependencies": { + "xunit.extensibility.core": "[2.9.2]", + "xunit.extensibility.execution": "[2.9.2]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.9.2", + "contentHash": "Ol+KlBJz1x8BrdnhN2DeOuLrr1I/cTwtHCggL9BvYqFuVd/TUSzxNT5O0NxCIXth30bsKxgMfdqLTcORtM52yQ==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.2", + "contentHash": "rKMpq4GsIUIJibXuZoZ8lYp5EpROlnYaRpwu9Zr0sRZXE7JqJfEEbCsUriZqB+ByXCLFBJyjkTRULMdC+U566g==", + "dependencies": { + "xunit.extensibility.core": "[2.9.2]" + } + }, + "atm.domain": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/Atm.Infrastructure.Tests/Atm.Infrastructure.Tests.csproj b/tests/Atm.Infrastructure.Tests/Atm.Infrastructure.Tests.csproj new file mode 100644 index 0000000..f207203 --- /dev/null +++ b/tests/Atm.Infrastructure.Tests/Atm.Infrastructure.Tests.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/tests/Atm.Infrastructure.Tests/PinHasherTests.cs b/tests/Atm.Infrastructure.Tests/PinHasherTests.cs new file mode 100644 index 0000000..528b558 --- /dev/null +++ b/tests/Atm.Infrastructure.Tests/PinHasherTests.cs @@ -0,0 +1,34 @@ +using Atm.Domain.ValueObjects; +using Atm.Infrastructure.Security; + +namespace Atm.Infrastructure.Tests; + +public class PinHasherTests +{ + [Fact] + public void HashUsesDifferentSaltsAndVerifiesThePin() + { + var hasher = new PinHasher(); + var pin = new PinCode("1234"); + + PinHash first = hasher.Hash(pin); + PinHash second = hasher.Hash(pin); + + Assert.NotEqual(pin.Value, first.Value); + Assert.NotEqual(first.Value, second.Value); + Assert.True(hasher.Verify(first, pin)); + Assert.True(hasher.Verify(second, pin)); + Assert.False(hasher.Verify(first, new PinCode("4321"))); + Assert.Equal("[REDACTED]", first.ToString()); + } + + [Theory] + [InlineData("not-base64")] + [InlineData("AA==")] + public void VerifyInvalidStoredHashReturnsFalse(string value) + { + var hasher = new PinHasher(); + + Assert.False(hasher.Verify(new PinHash(value), new PinCode("1234"))); + } +} diff --git a/tests/Atm.Infrastructure.Tests/packages.lock.json b/tests/Atm.Infrastructure.Tests/packages.lock.json new file mode 100644 index 0000000..43b0953 --- /dev/null +++ b/tests/Atm.Infrastructure.Tests/packages.lock.json @@ -0,0 +1,279 @@ +{ + "version": 1, + "dependencies": { + "net9.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[6.0.2, )", + "resolved": "6.0.2", + "contentHash": "bJShQ6uWRTQ100ZeyiMqcFlhP7WJ+bCuabUs885dJiBEzMsJMSFr7BOyeCw4rgvQokteGi5rKQTlkhfQPUXg2A==" + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.12.0, )", + "resolved": "17.12.0", + "contentHash": "kt/PKBZ91rFCWxVIJZSgVLk+YR+4KxTuHf799ho8WNiK5ZQpJNAEZCAWX86vcKrs+DiYjiibpYKdGZP6+/N17w==", + "dependencies": { + "Microsoft.CodeCoverage": "17.12.0", + "Microsoft.TestPlatform.TestHost": "17.12.0" + } + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.2, )", + "resolved": "2.9.2", + "contentHash": "7LhFS2N9Z6Xgg8aE5lY95cneYivRMfRI8v+4PATa4S64D5Z/Plkg0qa8dTRHSiGRgVZ/CL2gEfJDE5AUhOX+2Q==", + "dependencies": { + "xunit.analyzers": "1.16.0", + "xunit.assert": "2.9.2", + "xunit.core": "[2.9.2]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[2.8.2, )", + "resolved": "2.8.2", + "contentHash": "vm1tbfXhFmjFMUmS4M0J0ASXz3/U5XvXBa6DOQUL3fEz4Vt6YPhv+ESCarx6M6D+9kJkJYZKCNvJMas1+nVfmQ==" + }, + "Microsoft.AspNetCore.Cryptography.Internal": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "zqiWdZ8Jw4/sfTBSFHchU3Iajm8EqLPUq/cUIuUjARwNGJ9hrKR7Yf0bs4WlF+GdPX6Bj7kmaf99aY0OTbLCOg==" + }, + "Microsoft.AspNetCore.Cryptography.KeyDerivation": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "w0ZZWnsz7U2dh5l9eytJYYHG3/HwafewFyZT+NsnBuQAt90cw2hmiX9J9OU1mrtn7EiIJqhRxjzY5kAbmhD2DA==", + "dependencies": { + "Microsoft.AspNetCore.Cryptography.Internal": "9.0.19" + } + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.12.0", + "contentHash": "4svMznBd5JM21JIG2xZKGNanAHNXplxf/kQDFfLHXQ3OnpJkayRK/TjacFjA+EYmoyuNXHo/sOETEfcYtAzIrA==" + }, + "Microsoft.EntityFrameworkCore": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "qWqqxrfEDfi3C/z0S/ACIA2gUnreRPHX74WE/dVnesbztNesYO4NLy76lE6FV/zDP4vH/pkvDr8h3Mq9+cFv/w==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "9.0.19", + "Microsoft.EntityFrameworkCore.Analyzers": "9.0.19", + "Microsoft.Extensions.Caching.Memory": "9.0.19", + "Microsoft.Extensions.Logging": "9.0.19" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "igPnmVL3DU2ZH/4DNeQqWttNrQpwwg0q6MWj5peZxtl0QVMDSeFtnt2yhhdSffklZKYsAFko1sh/va3z4y7S6A==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "yI5dQuvigJ6ICQECOlJDtrsUYcuDryv3qsZK1i6ZA/IzMzZ/VPkDzjE4r2LivuyVSGnkDRkH2cp4LmrhXARRKw==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "KAfYGG4LWRNn1G/vMRydxgOjuZoWUvdd5raUcIia/HPg1jUjlQzS386nUx5gt3YfKxpFJaCGxdsQ1nCZWuYR8A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "9.0.19", + "Microsoft.Extensions.Caching.Memory": "9.0.19", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.19", + "Microsoft.Extensions.Logging": "9.0.19" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "qe/6m6wxRG+N5xTf4Z3lDBAiVu6oBCUyADJ5OzlFgukD9Vg/6zB3MNnNEFA7amZg8hjcS6F4OZBRmB07A+gmHQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "Y0w5LkBqWkDh5vYTKM+QKlzxpqegEtP86oDhFMXgx4tDkWL2t+rl4h2vxBHkvBtM/gM6PoLK9QCrnoclXtHtVQ==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "9.0.19", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19", + "Microsoft.Extensions.Logging.Abstractions": "9.0.19", + "Microsoft.Extensions.Options": "9.0.19", + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "M6h5lX6QWSSn7FDeRUHePZen/xvxPsuhcX1VpBfefafqavn2BnfxheR7BH5D5AIZGM7Nah0xJKnq2DublD4doQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "o/XFYSiV4a7eiCkf7W1GFAviVhHQAPY5bHrQRsNWAPT3GhJ3Af63vcFQjHivIrTNZITlPPaGWhBy7jykKYYyaQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "XgQ0aVWClYjYSBqbYrKC/xyZ1KHf+VVwNF3+d54jcaqnst30Q//ugs/FJwUztLZiph0gp85g7i/eB0556twO0Q==" + }, + "Microsoft.Extensions.Identity.Core": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "6P2/iMA35j8RrjsJXhqaOy4Sturcu+NXzXSlKguGTzjBEVRf6atWPrKkUuZew9K5S5NbNh8gdYip2dXm8t5fLg==", + "dependencies": { + "Microsoft.AspNetCore.Cryptography.KeyDerivation": "9.0.19", + "Microsoft.Extensions.Logging": "9.0.19", + "Microsoft.Extensions.Options": "9.0.19" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "3cnWzJ/FLEhLiW+TJTWtTjVj6fwlsP8OrbJqgOP4J9feAKjmT77vmClakHlej+jnzBgpnVU0+SZwtEIOr+KS6A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "9.0.19", + "Microsoft.Extensions.Logging.Abstractions": "9.0.19", + "Microsoft.Extensions.Options": "9.0.19" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "RpqMVoZ+NECJxLOZRjWOfjST0mULs4wb848mxG/CS9SMkVL6yi1sSIda4dGcsB01J2jUFd8hNwsaYl1gplU+Cw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "FdPZy4kUSwFlIVZ3EkK9fdHw2RfblUwzflSb4kjVeGf9v2lX9IH+q2RGUovYMR7CVfKMc2y2Db/bZ2b4yiMfOg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19", + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "9hIg8PQiMnpVFIsEHm25Wi1gBrPa2pS1G75uveyDUVLXrNKqBap9WGwQIgO0s6LUgRAOdsSYnbKbNC5b1G5Pcg==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.12.0", + "contentHash": "TDqkTKLfQuAaPcEb3pDDWnh7b3SyZF+/W9OZvWFp6eJCIiiYFdSB6taE2I6tWrFw5ywhzOb6sreoGJTI6m3rSQ==", + "dependencies": { + "System.Reflection.Metadata": "1.6.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.12.0", + "contentHash": "MiPEJQNyADfwZ4pJNpQex+t9/jOClBGMiCiVVFuELCMSX2nmNfvUor3uFVxNNCg30uxDP8JDYfPnMXQzsfzYyg==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.12.0", + "Newtonsoft.Json": "13.0.1" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.1", + "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" + }, + "Npgsql": { + "type": "Transitive", + "resolved": "9.0.3", + "contentHash": "tPvY61CxOAWxNsKLEBg+oR646X4Bc8UmyQ/tJszL/7mEmIXQnnBhVJZrZEEUv0Bstu0mEsHZD5At3EO8zQRAYw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.2" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "mw5vcY2IEc7L+IeGrxpp/J5OSnCcjkjAgJYCm/eD52wpZze8zsSifdqV7zXslSMmfJG2iIUGZyo3KuDtEFKwMQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[9.0.1, 10.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[9.0.1, 10.0.0)", + "Npgsql": "9.0.3" + } + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "1.6.0", + "contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ==" + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.16.0", + "contentHash": "hptYM7vGr46GUIgZt21YHO4rfuBAQS2eINbFo16CV/Dqq+24Tp+P5gDCACu1AbFfW4Sp/WRfDPSK8fmUUb8s0Q==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.2", + "contentHash": "QkNBAQG4pa66cholm28AxijBjrmki98/vsEh4Sx5iplzotvPgpiotcxqJQMRC8d7RV7nIT8ozh97957hDnZwsQ==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.2", + "contentHash": "O6RrNSdmZ0xgEn5kT927PNwog5vxTtKrWMihhhrT0Sg9jQ7iBDciYOwzBgP2krBEk5/GBXI18R1lKvmnxGcb4w==", + "dependencies": { + "xunit.extensibility.core": "[2.9.2]", + "xunit.extensibility.execution": "[2.9.2]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.9.2", + "contentHash": "Ol+KlBJz1x8BrdnhN2DeOuLrr1I/cTwtHCggL9BvYqFuVd/TUSzxNT5O0NxCIXth30bsKxgMfdqLTcORtM52yQ==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.2", + "contentHash": "rKMpq4GsIUIJibXuZoZ8lYp5EpROlnYaRpwu9Zr0sRZXE7JqJfEEbCsUriZqB+ByXCLFBJyjkTRULMdC+U566g==", + "dependencies": { + "xunit.extensibility.core": "[2.9.2]" + } + }, + "atm.application.abstractions": { + "type": "Project", + "dependencies": { + "Atm.Domain": "[1.0.0, )" + } + }, + "atm.domain": { + "type": "Project" + }, + "atm.infrastructure": { + "type": "Project", + "dependencies": { + "Atm.Application.Abstractions": "[1.0.0, )", + "Atm.Domain": "[1.0.0, )", + "Microsoft.EntityFrameworkCore": "[9.0.19, )", + "Microsoft.EntityFrameworkCore.Relational": "[9.0.19, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[9.0.19, )", + "Microsoft.Extensions.Identity.Core": "[9.0.19, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[9.0.4, )" + } + } + } + } +} \ No newline at end of file