[AZ-189] [AZ-190] [AZ-191] [AZ-192] [AZ-193] [AZ-194] [AZ-195] Add e2e blackbox test suite

Made-with: Cursor
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-04-16 06:25:36 +03:00
parent 1b38e888e1
commit d320d6dd59
98 changed files with 6883 additions and 1 deletions
@@ -0,0 +1,39 @@
# Module: Azaion.AdminApi.BusinessExceptionHandler
## Purpose
ASP.NET Core `IExceptionHandler` that intercepts `BusinessException` instances and converts them to structured HTTP 409 (Conflict) JSON responses.
## Public Interface
| Method | Signature | Description |
|--------|-----------|-------------|
| `TryHandleAsync` | `ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken ct)` | Handles `BusinessException`, returns false for other exception types |
## Internal Logic
- Checks if the exception is a `BusinessException` via pattern matching.
- If not, returns `false` to let other handlers process it.
- If yes: logs as warning, sets HTTP 409 status, serializes `{ ErrorCode, Message }` as JSON via `Newtonsoft.Json`.
## Dependencies
- `BusinessException`, `ExceptionEnum`
- `ILogger<BusinessExceptionHandler>`
- `Newtonsoft.Json`
## Consumers
- Registered in `Program.cs` via `builder.Services.AddExceptionHandler<BusinessExceptionHandler>()`
- Activated by `app.UseExceptionHandler(_ => {})` middleware
## Data Models
Response body: `{ ErrorCode: ExceptionEnum, Message: string }`.
## Configuration
None.
## External Integrations
None.
## Security
Exposes error codes and messages to the client. Messages are user-facing strings from `ExceptionEnum` descriptions.
## Tests
None.
@@ -0,0 +1,93 @@
# Module: Azaion.AdminApi.Program
## Purpose
Application entry point: configures DI, middleware, authentication, authorization, CORS, Swagger, logging, and defines all HTTP endpoints using ASP.NET Core Minimal API.
## Public Interface (HTTP Endpoints)
| Method | Path | Auth | Summary |
|--------|------|------|---------|
| POST | `/login` | Anonymous | Validates credentials, returns JWT token |
| POST | `/users` | ApiAdmin | Creates a new user |
| GET | `/users/current` | Any authenticated | Returns current user from JWT claims |
| GET | `/users` | ApiAdmin | Lists users with optional email/role filters |
| PUT | `/users/hardware/set` | ApiAdmin | Sets a user's hardware fingerprint |
| PUT | `/users/queue-offsets/set` | Any authenticated | Updates user's queue offsets |
| PUT | `/users/{email}/set-role/{role}` | ApiAdmin | Changes a user's role |
| PUT | `/users/{email}/enable` | ApiAdmin | Enables a user account |
| PUT | `/users/{email}/disable` | ApiAdmin | Disables a user account |
| DELETE | `/users/{email}` | ApiAdmin | Removes a user |
| POST | `/resources/{dataFolder?}` | Any authenticated | Uploads a resource file |
| GET | `/resources/list/{dataFolder?}` | Any authenticated | Lists files in a resource folder |
| POST | `/resources/clear/{dataFolder?}` | ApiAdmin | Clears a resource folder |
| POST | `/resources/get/{dataFolder?}` | Any authenticated | Downloads an encrypted resource |
| GET | `/resources/get-installer` | Any authenticated | Downloads latest production installer |
| GET | `/resources/get-installer/stage` | Any authenticated | Downloads latest staging installer |
| POST | `/resources/check` | Any authenticated | Validates hardware fingerprint |
## Internal Logic
### DI Registration
- `IUserService``UserService` (Scoped)
- `IAuthService``AuthService` (Scoped)
- `IResourcesService``ResourcesService` (Scoped)
- `IDbFactory``DbFactory` (Singleton)
- `ICache``MemoryCache` (Scoped)
- `LazyCache` via `AddLazyCache()`
- FluentValidation validators auto-discovered from `RegisterUserValidator` assembly
- `BusinessExceptionHandler` registered as exception handler
### Middleware Pipeline
1. Swagger (dev only)
2. CORS (`AdminCorsPolicy`)
3. Authentication (JWT Bearer)
4. Authorization
5. URL rewrite: root `/``/swagger`
6. Exception handler
### Authorization Policies
- `apiAdminPolicy`: requires `RoleEnum.ApiAdmin` role
- `apiUploaderPolicy`: requires `RoleEnum.ResourceUploader` OR `RoleEnum.ApiAdmin` role
### Configuration Sections
- `JwtConfig` — JWT signing/validation
- `ConnectionStrings` — DB connections
- `ResourcesConfig` — file storage paths
### Kestrel
- Max request body size: 200 MB (for file uploads)
### Logging
- Serilog: console + rolling file (`logs/log.txt`)
### CORS
- Allowed origins: `https://admin.azaion.com`, `http://admin.azaion.com`
- All methods and headers allowed
- Credentials allowed
## Dependencies
All services, configs, entities, and request types from Azaion.Common and Azaion.Services.
## Consumers
None — this is the application entry point.
## Data Models
None defined here.
## Configuration
Reads `JwtConfig`, `ConnectionStrings`, `ResourcesConfig` from `IConfiguration`.
## External Integrations
- PostgreSQL (via DI-registered `DbFactory`)
- Local filesystem (via `ResourcesService`)
## Security
- JWT Bearer authentication with full validation (issuer, audience, lifetime, signing key)
- Role-based authorization policies
- CORS restricted to `admin.azaion.com`
- Request body limit of 200 MB
- Antiforgery disabled for resource upload endpoint
- Password sent via POST body (not URL)
## Tests
None directly; tested indirectly through integration tests.
@@ -0,0 +1,54 @@
# Module: Azaion.Common.BusinessException
## Purpose
Custom exception type for domain-level errors, paired with an `ExceptionEnum` catalog of all business error codes.
## Public Interface
### BusinessException
| Member | Signature | Description |
|--------|-----------|-------------|
| Constructor | `BusinessException(ExceptionEnum exEnum)` | Creates exception with message from `ExceptionEnum`'s `[Description]` attribute |
| `ExceptionEnum` | `ExceptionEnum ExceptionEnum { get; set; }` | The specific error code |
| `GetMessage` | `static string GetMessage(ExceptionEnum exEnum)` | Looks up human-readable message for an error code |
### ExceptionEnum
| Value | Code | Description |
|-------|------|-------------|
| `NoEmailFound` | 10 | No such email found |
| `EmailExists` | 20 | Email already exists |
| `WrongPassword` | 30 | Passwords do not match |
| `PasswordLengthIncorrect` | 32 | Password should be at least 8 characters |
| `EmailLengthIncorrect` | 35 | Email is empty or invalid |
| `WrongEmail` | 37 | (no description attribute) |
| `HardwareIdMismatch` | 40 | Hardware mismatch — unauthorized hardware |
| `BadHardware` | 45 | Hardware should be not empty |
| `WrongResourceName` | 50 | Wrong resource file name |
| `NoFileProvided` | 60 | No file provided |
## Internal Logic
Static constructor eagerly loads all `ExceptionEnum` descriptions into a dictionary via `EnumExtensions.GetDescriptions<ExceptionEnum>()`. Messages are retrieved by dictionary lookup with fallback to `ToString()`.
## Dependencies
- `EnumExtensions` — for `GetDescriptions<T>()`
## Consumers
- `BusinessExceptionHandler` — catches and serializes to HTTP 409 response
- `UserService` — throws for email/password/hardware validation failures
- `ResourcesService` — throws for missing file uploads
- FluentValidation validators — reference `ExceptionEnum` codes in `.WithErrorCode()`
## Data Models
None.
## Configuration
None.
## External Integrations
None.
## Security
Error codes are returned to the client via `BusinessExceptionHandler`. Codes are numeric and messages are user-facing.
## Tests
None.
@@ -0,0 +1,37 @@
# Module: Azaion.Common.Configs.ConnectionStrings
## Purpose
Configuration POCO for PostgreSQL connection strings, bound from `appsettings.json` section `ConnectionStrings`.
## Public Interface
| Property | Type | Description |
|----------|------|-------------|
| `AzaionDb` | `string` | Read-only connection string (used for queries) |
| `AzaionDbAdmin` | `string` | Admin connection string (used for writes: insert, update, delete) |
## Internal Logic
None — pure data class.
## Dependencies
None.
## Consumers
- `DbFactory` constructor — receives `IOptions<ConnectionStrings>` to build data options
## Data Models
None.
## Configuration
Bound via `builder.Configuration.GetSection(nameof(ConnectionStrings))` in `Program.cs`. Expected env vars (from `env/api/env.ps1`):
- `ASPNETCORE_ConnectionStrings__AzaionDb`
- `ASPNETCORE_ConnectionStrings__AzaionDbAdmin`
## External Integrations
None.
## Security
Contains database credentials at runtime; values must not be logged or exposed.
## Tests
Hardcoded in `UserServiceTest` (test credentials).
@@ -0,0 +1,38 @@
# Module: Azaion.Common.Configs.JwtConfig
## Purpose
Configuration POCO for JWT token generation parameters, bound from `appsettings.json` section `JwtConfig`.
## Public Interface
| Property | Type | Description |
|----------|------|-------------|
| `Issuer` | `string` | Token issuer claim |
| `Audience` | `string` | Token audience claim |
| `Secret` | `string` | HMAC-SHA256 signing key |
| `TokenLifetimeHours` | `double` | Token expiry duration in hours |
## Internal Logic
None — pure data class.
## Dependencies
None.
## Consumers
- `Program.cs` — reads `JwtConfig` to configure JWT Bearer authentication middleware
- `AuthService.CreateToken` — uses Issuer, Audience, Secret, TokenLifetimeHours to build JWT tokens
## Data Models
None.
## Configuration
Bound via `builder.Configuration.GetSection(nameof(JwtConfig))`. Expected env var: `ASPNETCORE_JwtConfig__Secret`.
## External Integrations
None.
## Security
`Secret` is the symmetric signing key for all JWT tokens. Must be kept secret and sufficiently long for HMAC-SHA256.
## Tests
None.
@@ -0,0 +1,36 @@
# Module: Azaion.Common.Configs.ResourcesConfig
## Purpose
Configuration POCO for file resource storage paths, bound from `appsettings.json` section `ResourcesConfig`.
## Public Interface
| Property | Type | Description |
|----------|------|-------------|
| `ResourcesFolder` | `string` | Root directory for uploaded resource files |
| `SuiteInstallerFolder` | `string` | Subdirectory for production installer files |
| `SuiteStageInstallerFolder` | `string` | Subdirectory for staging installer files |
## Internal Logic
None — pure data class.
## Dependencies
None.
## Consumers
- `ResourcesService` — uses all three properties to resolve file paths
## Data Models
None.
## Configuration
Bound via `builder.Configuration.GetSection(nameof(ResourcesConfig))` in `Program.cs`.
## External Integrations
None.
## Security
Paths control where files are read from and written to on the server's filesystem.
## Tests
None.
@@ -0,0 +1,36 @@
# Module: Azaion.Common.Database.AzaionDb
## Purpose
linq2db `DataConnection` subclass representing the application's database context.
## Public Interface
| Member | Type | Description |
|--------|------|-------------|
| Constructor | `AzaionDb(DataOptions dataOptions)` | Initializes connection with pre-configured options |
| `Users` | `ITable<User>` | Typed table accessor for the `users` table |
## Internal Logic
Delegates all connection management to the base `DataConnection` class. `Users` property calls `this.GetTable<User>()`.
## Dependencies
- `User` entity
- linq2db (`LinqToDB.Data.DataConnection`, `LinqToDB.ITable<T>`)
## Consumers
- `DbFactory` — creates `AzaionDb` instances inside `Run`/`RunAdmin` methods
## Data Models
Provides access to the `users` table.
## Configuration
Receives `DataOptions` (containing connection string + mapping schema) from `DbFactory`.
## External Integrations
PostgreSQL database via Npgsql.
## Security
None at this level; connection string security is handled by `DbFactory`.
## Tests
Indirectly used by `UserServiceTest`.
@@ -0,0 +1,47 @@
# Module: Azaion.Common.Database.DbFactory
## Purpose
Factory for creating short-lived `AzaionDb` connections, providing separate read-only and admin (write) access patterns.
## Public Interface
### IDbFactory
| Method | Signature | Description |
|--------|-----------|-------------|
| `Run<T>` | `Task<T> Run<T>(Func<AzaionDb, Task<T>> func)` | Execute a read query via the read-only connection |
| `Run` | `Task Run(Func<AzaionDb, Task> func)` | Execute a read query (no return value) |
| `RunAdmin` | `Task RunAdmin(Func<AzaionDb, Task> func)` | Execute a write operation via the admin connection |
### DbFactory (implementation)
Constructor receives `IOptions<ConnectionStrings>` and builds two `DataOptions`:
- `_dataOptions` from `AzaionDb` (reader connection)
- `_dataOptionsAdmin` from `AzaionDbAdmin` (admin connection)
## Internal Logic
- `LoadOptions` validates the connection string is not empty, then builds `DataOptions` with PostgreSQL provider, the schema holder's mapping schema, and SQL trace logging to console.
- Each `Run`/`RunAdmin` call creates a new `AzaionDb` instance, executes the callback, and disposes the connection (`await using`).
## Dependencies
- `AzaionDb`, `AzaionDbSchemaHolder`
- `ConnectionStrings` config
- linq2db `DataOptions`
## Consumers
- `UserService` — all database operations go through `dbFactory.Run` or `dbFactory.RunAdmin`
- Registered as `Singleton` in DI (`Program.cs`)
## Data Models
None.
## Configuration
- `ConnectionStrings.AzaionDb` — reader connection string
- `ConnectionStrings.AzaionDbAdmin` — admin connection string
## External Integrations
PostgreSQL via Npgsql (through linq2db).
## Security
Enforces read/write separation: `Run` uses the read-only connection; `RunAdmin` uses the admin connection with INSERT/UPDATE/DELETE privileges.
## Tests
Directly instantiated in `UserServiceTest` with hardcoded connection strings.
@@ -0,0 +1,43 @@
# Module: Azaion.Common.Database.AzaionDbSchemaHolder
## Purpose
Static holder for the linq2db `MappingSchema` that maps C# entities to PostgreSQL table/column naming conventions and handles custom type conversions.
## Public Interface
| Member | Type | Description |
|--------|------|-------------|
| `MappingSchema` | `static readonly MappingSchema` | Pre-built schema with column name and type mappings |
## Internal Logic
Static constructor:
1. Creates a `MappingSchema` with a global callback that converts all column names to snake_case via `StringExtensions.ToSnakeCase`.
2. Uses `FluentMappingBuilder` to configure the `User` entity:
- Table name: `"users"`
- `Id`: primary key, `DataType.Guid`
- `Role`: stored as text, with custom conversion to/from `RoleEnum` via `Enum.Parse`
- `UserConfig`: stored as nullable JSON text, serialized/deserialized via `Newtonsoft.Json`
## Dependencies
- `User`, `RoleEnum` entities
- `StringExtensions.ToSnakeCase`
- linq2db `MappingSchema`, `FluentMappingBuilder`
- `Newtonsoft.Json`
## Consumers
- `DbFactory.LoadOptions` — passes `MappingSchema` to `DataOptions.UseMappingSchema()`
## Data Models
Defines the ORM mapping for the `users` table.
## Configuration
None — all mappings are compile-time.
## External Integrations
None directly; mappings are used when queries execute against PostgreSQL.
## Security
None.
## Tests
None.
@@ -0,0 +1,46 @@
# Module: Azaion.Common.Entities.RoleEnum
## Purpose
Defines the authorization role hierarchy for the system.
## Public Interface
| Enum Value | Int Value | Description |
|-----------|-----------|-------------|
| `None` | 0 | No role assigned |
| `Operator` | 10 | Annotator access only; can send annotations to queue |
| `Validator` | 20 | Annotator + dataset explorer; can receive annotations from queue |
| `CompanionPC` | 30 | Companion PC role |
| `Admin` | 40 | Admin role |
| `ResourceUploader` | 50 | Can upload DLLs and AI models |
| `ApiAdmin` | 1000 | Full access to all operations |
## Internal Logic
Integer values define a loose hierarchy; higher values don't necessarily imply more permissions — policy-based authorization in `Program.cs` maps specific roles to policies.
## Dependencies
None.
## Consumers
- `User.Role` property type
- `RegisterUserRequest.Role` property type
- `Program.cs` — authorization policies (`apiAdminPolicy`, `apiUploaderPolicy`)
- `AuthService.CreateToken` — embeds role as claim
- `AzaionDbSchemaHolder` — maps Role to/from text in DB
- `UserService.GetUsers` — filters by role
- `UserService.ChangeRole` — updates user role
## Data Models
Part of the `User` entity.
## Configuration
None.
## External Integrations
None.
## Security
Core to the RBAC authorization model. `ApiAdmin` has unrestricted access; `ResourceUploader` can upload resources; other roles have endpoint-level restrictions.
## Tests
None.
@@ -0,0 +1,62 @@
# Module: Azaion.Common.Entities.User
## Purpose
Domain entity representing a system user, plus related value objects `UserConfig` and `UserQueueOffsets`.
## Public Interface
### User
| Property | Type | Description |
|----------|------|-------------|
| `Id` | `Guid` | Primary key |
| `Email` | `string` | Unique user email |
| `PasswordHash` | `string` | SHA-384 hash of plaintext password |
| `Hardware` | `string?` | Raw hardware fingerprint string (set on first resource access) |
| `Role` | `RoleEnum` | Authorization role |
| `CreatedAt` | `DateTime` | Account creation timestamp |
| `LastLogin` | `DateTime?` | Last successful resource-check/hardware-check timestamp |
| `UserConfig` | `UserConfig?` | JSON-serialized user configuration |
| `IsEnabled` | `bool` | Account active flag |
| Method | Signature | Description |
|--------|-----------|-------------|
| `GetCacheKey` | `static string GetCacheKey(string email)` | Returns cache key `"User.{email}"` |
### UserConfig
| Property | Type | Description |
|----------|------|-------------|
| `QueueOffsets` | `UserQueueOffsets?` | Annotation queue offset tracking |
### UserQueueOffsets
| Property | Type | Description |
|----------|------|-------------|
| `AnnotationsOffset` | `ulong` | Offset for annotations queue |
| `AnnotationsConfirmOffset` | `ulong` | Offset for annotation confirmations |
| `AnnotationsCommandsOffset` | `ulong` | Offset for annotation commands |
## Internal Logic
`GetCacheKey` returns empty string for null/empty email to avoid cache key collisions.
## Dependencies
- `RoleEnum`
## Consumers
- All services (`UserService`, `AuthService`, `ResourcesService`) work with `User`
- `AzaionDb` exposes `ITable<User>`
- `AzaionDbSchemaHolder` maps `User` to the `users` PostgreSQL table
- `SetUserQueueOffsetsRequest` uses `UserQueueOffsets`
## Data Models
Maps to PostgreSQL table `users` with columns: `id`, `email`, `password_hash`, `hardware`, `role`, `user_config` (JSON text), `created_at`, `last_login`, `is_enabled`.
## Configuration
None.
## External Integrations
None.
## Security
`PasswordHash` stores SHA-384 hash. `Hardware` stores raw hardware fingerprint (hashed for comparison via `Security.GetHWHash`).
## Tests
Indirectly tested via `UserServiceTest` and `SecurityTest`.
@@ -0,0 +1,37 @@
# Module: Azaion.Common.Extensions.EnumExtensions
## Purpose
Static utility class for extracting `DescriptionAttribute` values from enum members and retrieving default enum values.
## Public Interface
| Method | Signature | Description |
|--------|-----------|-------------|
| `GetDescriptions<T>` | `static Dictionary<T, string> GetDescriptions<T>() where T : Enum` | Returns all enum values mapped to their `[Description]` text |
| `GetDescription` | `static string GetDescription(this Enum enumValue)` | Extension: gets the `[Description]` of a single enum value |
| `GetDefaultValue<TEnum>` | `static TEnum GetDefaultValue<TEnum>() where TEnum : struct` | Returns the `[DefaultValue]` attribute's value for an enum type |
## Internal Logic
- `GetDescriptions<T>` iterates all enum values via `Enum.GetValues`, using a private helper `GetEnumAttrib` to extract the `DescriptionAttribute` via reflection. Falls back to `.ToString()` when no attribute exists.
- `GetEnumAttrib<T, TAttrib>` fetches a custom attribute from an enum member's `FieldInfo`.
## Dependencies
- `System.ComponentModel.DescriptionAttribute`, `System.Reflection` (BCL only)
## Consumers
- `BusinessException` (static constructor calls `GetDescriptions<ExceptionEnum>()`)
## Data Models
None.
## Configuration
None.
## External Integrations
None.
## Security
None.
## Tests
None.
@@ -0,0 +1,34 @@
# Module: Azaion.Common.Extensions.QueryableExtensions
## Purpose
Conditional LINQ `Where` extension for building dynamic query filters.
## Public Interface
| Method | Signature | Description |
|--------|-----------|-------------|
| `WhereIf<TSource>` | `static IQueryable<TSource> WhereIf<TSource>(this IQueryable<TSource> query, bool? condition, Expression<Func<TSource, bool>> truePredicate, Expression<Func<TSource, bool>>? falsePredicate = null)` | Applies `truePredicate` when condition is true, optional `falsePredicate` when false, no-op when null |
## Internal Logic
If `condition` is null, returns the query unmodified. If true, applies `truePredicate`. If false and `falsePredicate` is provided, applies it; otherwise returns unmodified query.
## Dependencies
- `System.Linq.Expressions` (BCL only)
## Consumers
- `UserService.GetUsers` — uses `WhereIf` for optional email and role search filters
## Data Models
None.
## Configuration
None.
## External Integrations
None.
## Security
None.
## Tests
None.
@@ -0,0 +1,34 @@
# Module: Azaion.Common.Extensions.StreamExtensions
## Purpose
Stream-to-string conversion utility.
## Public Interface
| Method | Signature | Description |
|--------|-----------|-------------|
| `ConvertToString` | `static string ConvertToString(this Stream stream)` | Reads entire stream as UTF-8 string, resets position to 0 afterward |
## Internal Logic
Resets stream position to 0, reads via `StreamReader`, then resets again so the stream remains usable.
## Dependencies
- `System.Text.Encoding`, `System.IO.StreamReader` (BCL only)
## Consumers
- `SecurityTest.EncryptDecryptTest` — converts decrypted stream to string for assertion
## Data Models
None.
## Configuration
None.
## External Integrations
None.
## Security
None.
## Tests
Indirectly tested via `SecurityTest.EncryptDecryptTest`.
@@ -0,0 +1,34 @@
# Module: Azaion.Common.Extensions.StringExtensions
## Purpose
Provides a `ToSnakeCase` string extension for converting PascalCase/camelCase identifiers to snake_case.
## Public Interface
| Method | Signature | Description |
|--------|-----------|-------------|
| `ToSnakeCase` | `static string ToSnakeCase(this string text)` | Converts PascalCase to snake_case (e.g., `PasswordHash``password_hash`) |
## Internal Logic
Iterates characters; prepends `_` before each uppercase letter and lowercases it. Returns original text for null/empty/single-char inputs.
## Dependencies
- `System.Text.StringBuilder` (BCL only)
## Consumers
- `AzaionDbSchemaHolder` — uses `ToSnakeCase` to map C# property names to PostgreSQL column names
## Data Models
None.
## Configuration
None.
## External Integrations
None.
## Security
None.
## Tests
None.
@@ -0,0 +1,50 @@
# Module: Azaion.Common.Requests.GetResourceRequest
## Purpose
Request DTOs and validator for resource access endpoints. Contains both `GetResourceRequest` and `CheckResourceRequest`.
## Public Interface
### CheckResourceRequest
| Property | Type | Description |
|----------|------|-------------|
| `Hardware` | `string` | Hardware fingerprint to validate |
### GetResourceRequest
| Property | Type | Description |
|----------|------|-------------|
| `Password` | `string` | User's password (used to derive encryption key) |
| `Hardware` | `string` | Hardware fingerprint for authorization |
| `FileName` | `string` | Resource file to retrieve |
### GetResourceRequestValidator
| Rule | Constraint | Error Code |
|------|-----------|------------|
| `Password` min length | >= 8 chars | `PasswordLengthIncorrect` |
| `Hardware` not empty | Required | `BadHardware` |
| `FileName` not empty | Required | `WrongResourceName` |
## Internal Logic
Validator uses `BusinessException.GetMessage()` to derive user-facing error messages from `ExceptionEnum`.
## Dependencies
- `BusinessException`, `ExceptionEnum`
- FluentValidation
## Consumers
- `Program.cs` `/resources/get/{dataFolder?}` and `/resources/check` endpoints
## Data Models
None.
## Configuration
None.
## External Integrations
None.
## Security
Password is sent in the POST body (not URL) to avoid logging in access logs. Hardware fingerprint validates device authorization.
## Tests
None.
@@ -0,0 +1,36 @@
# Module: Azaion.Common.Requests.LoginRequest
## Purpose
Request DTO for the `/login` endpoint.
## Public Interface
| Property | Type | Description |
|----------|------|-------------|
| `Email` | `string` | User's email address |
| `Password` | `string` | User's plaintext password |
## Internal Logic
None — pure data class. No FluentValidation validator defined for this request.
## Dependencies
None.
## Consumers
- `Program.cs` `/login` endpoint — receives as request body
- `UserService.ValidateUser` — accepts as parameter
## Data Models
None.
## Configuration
None.
## External Integrations
None.
## Security
Carries plaintext password; must only be transmitted over HTTPS.
## Tests
None.
@@ -0,0 +1,46 @@
# Module: Azaion.Common.Requests.RegisterUserRequest
## Purpose
Request DTO and FluentValidation validator for user registration (`POST /users`).
## Public Interface
### RegisterUserRequest
| Property | Type | Description |
|----------|------|-------------|
| `Email` | `string` | New user's email |
| `Password` | `string` | Plaintext password |
| `Role` | `RoleEnum` | Role to assign |
### RegisterUserValidator
| Rule | Constraint | Error Code |
|------|-----------|------------|
| `Email` min length | >= 8 chars | `EmailLengthIncorrect` |
| `Email` format | Valid email address | `WrongEmail` |
| `Password` min length | >= 8 chars | `PasswordLengthIncorrect` |
## Internal Logic
Validator is auto-discovered by `AddValidatorsFromAssemblyContaining<RegisterUserValidator>()` in `Program.cs`.
## Dependencies
- `RoleEnum`, `ExceptionEnum` (from `BusinessException`)
- FluentValidation
## Consumers
- `Program.cs` `/users` endpoint
- `UserService.RegisterUser`
## Data Models
None.
## Configuration
None.
## External Integrations
None.
## Security
Enforces minimum password length of 8 characters and email format validation.
## Tests
None.
@@ -0,0 +1,39 @@
# Module: Azaion.Common.Requests.SetHWRequest
## Purpose
Request DTO and validator for setting a user's hardware fingerprint (`PUT /users/hardware/set`).
## Public Interface
### SetHWRequest
| Property | Type | Description |
|----------|------|-------------|
| `Email` | `string` | Target user's email |
| `Hardware` | `string?` | Hardware fingerprint (null clears it) |
### SetHWRequestValidator
| Rule | Constraint | Error Code |
|------|-----------|------------|
| `Email` not empty | Required | `EmailLengthIncorrect` |
## Dependencies
- `BusinessException`, `ExceptionEnum`
- FluentValidation
## Consumers
- `Program.cs` `/users/hardware/set` endpoint
## Data Models
None.
## Configuration
None.
## External Integrations
None.
## Security
None.
## Tests
None.
@@ -0,0 +1,35 @@
# Module: Azaion.Common.Requests.SetUserQueueOffsetsRequest
## Purpose
Request DTO for updating a user's annotation queue offsets (`PUT /users/queue-offsets/set`).
## Public Interface
| Property | Type | Description |
|----------|------|-------------|
| `Email` | `string` | Target user's email |
| `Offsets` | `UserQueueOffsets` | New queue offset values |
## Internal Logic
None — pure data class. No validator defined.
## Dependencies
- `UserQueueOffsets` (from `Entities/User.cs`)
## Consumers
- `Program.cs` `/users/queue-offsets/set` endpoint
## Data Models
None.
## Configuration
None.
## External Integrations
None.
## Security
None.
## Tests
None.
@@ -0,0 +1,48 @@
# Module: Azaion.Services.AuthService
## Purpose
JWT token creation and current-user resolution from HTTP context claims.
## Public Interface
### IAuthService
| Method | Signature | Description |
|--------|-----------|-------------|
| `GetCurrentUser` | `Task<User?> GetCurrentUser()` | Extracts email from JWT claims, returns full User entity |
| `CreateToken` | `string CreateToken(User user)` | Generates a signed JWT token for the given user |
## Internal Logic
- **GetCurrentUser**: reads `ClaimTypes.Name` from `HttpContext.User.Claims`, then delegates to `IUserService.GetByEmail`.
- **CreateToken**: builds a `SecurityTokenDescriptor` with claims (NameIdentifier = user ID, Name = email, Role = role), signs with HMAC-SHA256 using the configured secret, sets expiry from `JwtConfig.TokenLifetimeHours`.
Private method:
- `GetCurrentUserEmail` — extracts email from claims dictionary.
## Dependencies
- `IHttpContextAccessor` — for accessing current HTTP context
- `IOptions<JwtConfig>` — JWT configuration
- `IUserService` — for `GetByEmail` lookup
- `System.IdentityModel.Tokens.Jwt`
- `Microsoft.IdentityModel.Tokens`
## Consumers
- `Program.cs` `/login` endpoint — calls `CreateToken` after successful validation
- `Program.cs` `/users/current`, `/resources/get`, `/resources/get-installer`, `/resources/check` — call `GetCurrentUser`
## Data Models
None.
## Configuration
Uses `JwtConfig` (Issuer, Audience, Secret, TokenLifetimeHours).
## External Integrations
None.
## Security
- Token includes user ID, email, and role as claims
- Signed with HMAC-SHA256
- Expiry controlled by `TokenLifetimeHours` config
- Token validation parameters are configured in `Program.cs` (ValidateIssuer, ValidateAudience, ValidateLifetime, ValidateIssuerSigningKey)
## Tests
None.
@@ -0,0 +1,41 @@
# Module: Azaion.Services.Cache
## Purpose
In-memory caching abstraction using LazyCache, providing get-or-add and invalidation operations.
## Public Interface
### ICache
| Method | Signature | Description |
|--------|-----------|-------------|
| `GetFromCacheAsync<T>` | `Task<T> GetFromCacheAsync<T>(string key, Func<Task<T>> fetchFunc, TimeSpan? expiration = null)` | Returns cached value or fetches and caches it |
| `Invalidate` | `void Invalidate(string key)` | Removes a key from the cache |
### MemoryCache (implementation)
Default expiration: 4 hours (`TimeSpan.FromHours(4)`).
## Internal Logic
Wraps `LazyCache.CachingService`. `GetFromCacheAsync` uses `GetOrAddAsync` with absolute expiration relative to now. `Invalidate` calls `Remove`.
## Dependencies
- LazyCache (`IAppCache`, `CachingService`)
## Consumers
- `UserService.GetByEmail` — caches user lookups by `User.GetCacheKey(email)`
- `UserService.UpdateHardware`, `UserService.UpdateQueueOffsets`, `UserService.CheckHardwareHash` — invalidate cache after writes
- Registered as `Scoped` in DI (`Program.cs`): `AddScoped<ICache, MemoryCache>`
## Data Models
None.
## Configuration
None — default 4-hour expiration is hardcoded.
## External Integrations
None.
## Security
None.
## Tests
`MemoryCache` is instantiated directly in `UserServiceTest`.
@@ -0,0 +1,49 @@
# Module: Azaion.Services.ResourcesService
## Purpose
File-based resource management: upload, list, download (encrypted), clear, and installer retrieval from the server's filesystem.
## Public Interface
### IResourcesService
| Method | Signature | Description |
|--------|-----------|-------------|
| `GetInstaller` | `(string?, Stream?) GetInstaller(bool isStage)` | Returns the latest installer file (prod or stage) |
| `GetEncryptedResource` | `Task<Stream> GetEncryptedResource(string? dataFolder, string fileName, string key, CancellationToken ct)` | Reads a file and returns it AES-encrypted |
| `SaveResource` | `Task SaveResource(string? dataFolder, IFormFile data, CancellationToken ct)` | Saves an uploaded file to the resource folder |
| `ListResources` | `Task<IEnumerable<string>> ListResources(string? dataFolder, string? search, CancellationToken ct)` | Lists file names in a resource folder, optionally filtered |
| `ClearFolder` | `void ClearFolder(string? dataFolder)` | Deletes all files and subdirectories in the specified folder |
## Internal Logic
- **GetResourceFolder**: resolves the target directory. If `dataFolder` is null/empty, uses `ResourcesConfig.ResourcesFolder` directly; otherwise, appends it as a subdirectory.
- **GetInstaller**: scans the installer folder for files matching `"AzaionSuite.Iterative*"`, returns the first match as a `FileStream`.
- **GetEncryptedResource**: opens the file, encrypts via `Security.EncryptTo` extension into a `MemoryStream`, returns the encrypted stream.
- **SaveResource**: creates the folder if needed, deletes any existing file with the same name, then copies the uploaded file.
- **ListResources**: uses `DirectoryInfo.GetFiles` with optional search pattern.
- **ClearFolder**: iterates and deletes all files and subdirectories.
## Dependencies
- `IOptions<ResourcesConfig>` — folder paths
- `ILogger<ResourcesService>` — logs successful saves
- `BusinessException` — thrown for null file uploads
- `Security.EncryptTo` — stream encryption extension
## Consumers
- `Program.cs` — all `/resources/*` endpoints
## Data Models
None.
## Configuration
Uses `ResourcesConfig` (ResourcesFolder, SuiteInstallerFolder, SuiteStageInstallerFolder).
## External Integrations
Local filesystem for resource storage.
## Security
- Resources are encrypted per-user using a key derived from email + password + hardware hash
- File deletion overwrites existing files before writing new ones
- No path traversal protection on `dataFolder` parameter
## Tests
None.
@@ -0,0 +1,51 @@
# Module: Azaion.Services.Security
## Purpose
Static utility class providing cryptographic operations: password hashing, hardware fingerprint hashing, encryption key derivation, and AES-CBC stream encryption/decryption.
## Public Interface
| Method | Signature | Description |
|--------|-----------|-------------|
| `ToHash` | `static string ToHash(this string str)` | Extension: SHA-384 hash of input, returned as Base64 |
| `GetHWHash` | `static string GetHWHash(string hardware)` | Derives a salted hash from hardware fingerprint string |
| `GetApiEncryptionKey` | `static string GetApiEncryptionKey(string email, string password, string? hardwareHash)` | Derives an AES encryption key from email + password + hardware hash |
| `EncryptTo` | `static async Task EncryptTo(this Stream inputStream, Stream toStream, string key, CancellationToken ct)` | AES-256-CBC encrypts a stream; prepends IV to output |
| `DecryptTo` | `static async Task DecryptTo(this Stream encryptedStream, Stream toStream, string key, CancellationToken ct)` | Reads IV prefix, then AES-256-CBC decrypts stream |
## Internal Logic
- **Password hashing**: `ToHash` uses SHA-384 with UTF-8 encoding, outputting Base64.
- **Hardware hashing**: `GetHWHash` salts the raw hardware string with `"Azaion_{hardware}_%$$$)0_"` before hashing.
- **Encryption key derivation**: `GetApiEncryptionKey` concatenates email, password, and hardware hash with a static salt, then hashes.
- **Encryption**: AES-256-CBC with PKCS7 padding. Key is SHA-256 of the derived key string. IV is randomly generated and prepended to the output stream. Uses 512 KB buffer for streaming.
- **Decryption**: Reads the first 16 bytes as IV, then AES-256-CBC decrypts with PKCS7 padding.
## Dependencies
- `System.Security.Cryptography` (Aes, SHA256, SHA384)
- `System.Text.Encoding`
## Consumers
- `UserService.CheckHardwareHash` — calls `GetHWHash` to verify hardware fingerprint
- `Program.cs` `/resources/get` endpoint — calls `GetApiEncryptionKey`
- `ResourcesService.GetEncryptedResource` — uses `EncryptTo` extension
- `SecurityTest` — directly tests `GetApiEncryptionKey`, `EncryptTo`, `DecryptTo`
## Data Models
None.
## Configuration
- `BUFFER_SIZE = 524288` (512 KB) — hardcoded streaming buffer size
## External Integrations
None.
## Security
Core cryptographic module. Key observations:
- Passwords are hashed with SHA-384 (no per-user salt, no key stretching — not bcrypt/scrypt/argon2)
- Hardware hash uses a static salt
- AES encryption uses SHA-256 of the derived key, with random IV per encryption
- All salts/prefixes are hardcoded constants
## Tests
- `SecurityTest.EncryptDecryptTest` — round-trip encrypt/decrypt of a string
- `SecurityTest.EncryptDecryptLargeFileTest` — round-trip encrypt/decrypt of a ~400 MB generated file
@@ -0,0 +1,62 @@
# Module: Azaion.Services.UserService
## Purpose
Core business logic for user management: registration, authentication, hardware binding, role management, and account lifecycle.
## Public Interface
### IUserService
| Method | Signature | Description |
|--------|-----------|-------------|
| `RegisterUser` | `Task RegisterUser(RegisterUserRequest request, CancellationToken ct)` | Creates a new user with hashed password |
| `ValidateUser` | `Task<User> ValidateUser(LoginRequest request, CancellationToken ct)` | Validates email + password, returns user |
| `GetByEmail` | `Task<User?> GetByEmail(string? email, CancellationToken ct)` | Cached user lookup by email |
| `UpdateHardware` | `Task UpdateHardware(string email, string? hardware, CancellationToken ct)` | Sets/clears user's hardware fingerprint |
| `UpdateQueueOffsets` | `Task UpdateQueueOffsets(string email, UserQueueOffsets offsets, CancellationToken ct)` | Updates user's annotation queue offsets |
| `GetUsers` | `Task<IEnumerable<User>> GetUsers(string? searchEmail, RoleEnum? searchRole, CancellationToken ct)` | Lists users with optional email/role filters |
| `CheckHardwareHash` | `Task<string> CheckHardwareHash(User user, string hardware, CancellationToken ct)` | Validates or initializes hardware binding |
| `ChangeRole` | `Task ChangeRole(string email, RoleEnum newRole, CancellationToken ct)` | Changes a user's role |
| `SetEnableStatus` | `Task SetEnableStatus(string email, bool isEnabled, CancellationToken ct)` | Enables or disables a user account |
| `RemoveUser` | `Task RemoveUser(string email, CancellationToken ct)` | Permanently deletes a user |
## Internal Logic
- **RegisterUser**: checks for duplicate email, hashes password via `Security.ToHash`, inserts via `RunAdmin`.
- **ValidateUser**: finds user by email, compares password hash. Throws `NoEmailFound` or `WrongPassword`.
- **GetByEmail**: uses `ICache.GetFromCacheAsync` with key `User.{email}`.
- **CheckHardwareHash**: on first access (null hardware), stores the raw hardware string and returns the hash. On subsequent access, compares hashes. Throws `HardwareIdMismatch` on mismatch. Also updates `LastLogin` timestamp.
- **UpdateHardware/UpdateQueueOffsets**: use `RunAdmin` for writes, then invalidate cache.
- **GetUsers**: uses `WhereIf` for optional filter predicates.
Private method:
- `UpdateLastLoginDate` — updates `LastLogin` to `DateTime.UtcNow`.
## Dependencies
- `IDbFactory` (database access)
- `ICache` (user caching)
- `Security` (hashing)
- `BusinessException` (domain errors)
- `QueryableExtensions.WhereIf`
- `User`, `UserConfig`, `UserQueueOffsets`, `RoleEnum`
- `RegisterUserRequest`, `LoginRequest`
## Consumers
- `Program.cs` — all `/users/*` endpoints delegate to `IUserService`
- `AuthService.GetCurrentUser` — calls `GetByEmail`
- `Program.cs` `/resources/get` — calls `CheckHardwareHash`
## Data Models
Operates on `User` entity via `AzaionDb.Users` table.
## Configuration
None.
## External Integrations
PostgreSQL via `IDbFactory`.
## Security
- Passwords hashed with SHA-384 (via `Security.ToHash`) before storage
- Hardware binding prevents resource access from unauthorized devices
- Read operations use read-only DB connection; writes use admin connection
## Tests
- `UserServiceTest.CheckHardwareHashTest` — integration test against live database
@@ -0,0 +1,45 @@
# Module: Azaion.Test.SecurityTest
## Purpose
xUnit tests for the `Security` encryption/decryption functionality.
## Public Interface
| Test | Description |
|------|-------------|
| `EncryptDecryptTest` | Round-trip encrypt/decrypt of a ~1 KB string; asserts decrypted output matches original |
| `EncryptDecryptLargeFileTest` | Round-trip encrypt/decrypt of a ~400 MB generated file; compares SHA-256 hashes of original and decrypted files |
## Internal Logic
- **EncryptDecryptTest**: creates a key via `Security.GetApiEncryptionKey`, encrypts a test string to a `MemoryStream`, decrypts back, compares with `FluentAssertions`.
- **EncryptDecryptLargeFileTest**: generates a large JSON file (4M numbers chunked), encrypts, decrypts to a new file, compares file hashes via `SHA256.HashDataAsync`.
Private helpers:
- `CompareFiles` — SHA-256 hash comparison of two files
- `CreateLargeFile` — generates a large file by serializing number dictionaries in 100K chunks
- `StringToStream` — converts a UTF-8 string to a `MemoryStream`
## Dependencies
- `Security` (encrypt/decrypt)
- `StreamExtensions.ConvertToString`
- `FluentAssertions`
- `Newtonsoft.Json`
- xUnit
## Consumers
None — test module.
## Data Models
None.
## Configuration
None.
## External Integrations
Local filesystem (creates/deletes `large.txt` and `large_decrypted.txt` during large file test).
## Security
None.
## Tests
This IS the test module.
@@ -0,0 +1,39 @@
# Module: Azaion.Test.UserServiceTest
## Purpose
xUnit integration test for `UserService.CheckHardwareHash` against a live PostgreSQL database.
## Public Interface
| Test | Description |
|------|-------------|
| `CheckHardwareHashTest` | Looks up a known user by email, then calls `CheckHardwareHash` with a hardware fingerprint string |
## Internal Logic
- Creates a `DbFactory` with hardcoded connection strings pointing to a remote PostgreSQL instance.
- Creates a `UserService` with that factory and a fresh `MemoryCache`.
- Fetches user `spielberg@azaion.com`, then calls `CheckHardwareHash` with a specific hardware string.
- No assertion — the test only verifies no exception is thrown.
## Dependencies
- `UserService`, `DbFactory`, `MemoryCache`
- `ConnectionStrings`, `OptionsWrapper`
- xUnit
## Consumers
None — test module.
## Data Models
None.
## Configuration
Hardcoded connection strings to `188.245.120.247:4312` (remote database).
## External Integrations
Live PostgreSQL database (remote server).
## Security
Contains hardcoded database credentials in source code. This is a security concern — credentials should be in test configuration or environment variables.
## Tests
This IS the test module.