[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
+56
View File
@@ -0,0 +1,56 @@
# Acceptance Criteria
Derived from validation rules, test assertions, configuration limits, and health check patterns found in the codebase.
## Authentication
| # | Criterion | Threshold | Source |
|---|-----------|-----------|--------|
| AC-1 | Login with valid credentials returns a JWT token | Token is non-empty string | `Program.cs` `/login` endpoint |
| AC-2 | Login with unknown email returns error code 10 | HTTP 409, `ErrorCode: 10` | `UserService.ValidateUser` throws `NoEmailFound` |
| AC-3 | Login with wrong password returns error code 30 | HTTP 409, `ErrorCode: 30` | `UserService.ValidateUser` throws `WrongPassword` |
| AC-4 | JWT token expires after configured hours | Token `exp` claim = now + `TokenLifetimeHours` | `AuthService.CreateToken`, default 4 hours |
| AC-5 | JWT token contains user ID, email, and role claims | Claims: `NameIdentifier`, `Name`, `Role` | `AuthService.CreateToken` |
## User Management
| # | Criterion | Threshold | Source |
|---|-----------|-----------|--------|
| AC-6 | Registration rejects email < 8 characters | Validation error, code `EmailLengthIncorrect` | `RegisterUserValidator` |
| AC-7 | Registration rejects invalid email format | Validation error, code `WrongEmail` | `RegisterUserValidator` |
| AC-8 | Registration rejects password < 8 characters | Validation error, code `PasswordLengthIncorrect` | `RegisterUserValidator` |
| AC-9 | Registration rejects duplicate email | HTTP 409, `ErrorCode: 20` (EmailExists) | `UserService.RegisterUser` |
| AC-10 | Password is stored as SHA-384 hash, never plaintext | `PasswordHash` column contains Base64 of SHA-384 | `Security.ToHash()` |
| AC-11 | User listing supports optional email and role filters | Filters applied via `WhereIf` | `UserService.GetUsers` |
| AC-12 | Only ApiAdmin role can create, list, modify, or delete users | Endpoints require `apiAdminPolicy` | `Program.cs` authorization |
## Hardware Binding
| # | Criterion | Threshold | Source |
|---|-----------|-----------|--------|
| AC-13 | First hardware check stores the hardware fingerprint | `hardware` column updated from null to provided string | `UserService.CheckHardwareHash` |
| AC-14 | Subsequent hardware checks compare hash of provided hardware against stored | Hash comparison via `Security.GetHWHash` | `UserService.CheckHardwareHash` |
| AC-15 | Hardware mismatch returns error code 40 | HTTP 409, `ErrorCode: 40` (HardwareIdMismatch) | `UserService.CheckHardwareHash` |
| AC-16 | Admin can reset hardware by setting it to null | `PUT /users/hardware/set` with null hardware | `UserService.UpdateHardware` |
## Resource Management
| # | Criterion | Threshold | Source |
|---|-----------|-----------|--------|
| AC-17 | File upload supports up to 200 MB | Kestrel `MaxRequestBodySize = 209715200` | `Program.cs` |
| AC-18 | Uploaded file is saved to configured resource folder | File written to `ResourcesConfig.ResourcesFolder` | `ResourcesService.SaveResource` |
| AC-19 | Resource download returns AES-256-CBC encrypted stream | Encryption via `Security.EncryptTo` | `ResourcesService.GetEncryptedResource` |
| AC-20 | Encrypted resource can be decrypted with same key | Round-trip encrypt/decrypt preserves data | `SecurityTest.EncryptDecryptTest` ✓ |
| AC-21 | Large files (hundreds of MB) can be encrypted/decrypted | Round-trip works for ~400 MB files | `SecurityTest.EncryptDecryptLargeFileTest` ✓ |
| AC-22 | Missing file upload returns error code 60 | HTTP 409, `ErrorCode: 60` (NoFileProvided) | `ResourcesService.SaveResource` |
| AC-23 | Installer download returns latest `AzaionSuite.Iterative*` file | Scans installer folder, returns first match | `ResourcesService.GetInstaller` |
| AC-24 | Only ApiAdmin can clear resource folders | `POST /resources/clear` requires `apiAdminPolicy` | `Program.cs` |
## API Behavior
| # | Criterion | Threshold | Source |
|---|-----------|-----------|--------|
| AC-25 | Business errors return HTTP 409 with JSON `{ErrorCode, Message}` | Handled by `BusinessExceptionHandler` | `BusinessExceptionHandler.TryHandleAsync` |
| AC-26 | Swagger UI available in Development environment | `app.UseSwagger()` conditional on `IsDevelopment` | `Program.cs` |
| AC-27 | Root URL redirects to /swagger | URL rewrite rule | `Program.cs` |
| AC-28 | CORS allows requests from admin.azaion.com | Origins: `https://admin.azaion.com`, `http://admin.azaion.com` | `Program.cs` |
@@ -0,0 +1,77 @@
# Input Data Parameters
## Database Schema
### Table: `users`
| Column | Type | Nullable | Description |
|--------|------|----------|-------------|
| `id` | uuid | No | Primary key |
| `email` | varchar(160) | No | User identifier |
| `password_hash` | varchar(255) | No | SHA-384 hash (Base64) |
| `hardware` | text | Yes | Raw hardware fingerprint |
| `hardware_hash` | varchar(120) | Yes | Unused column (legacy) |
| `role` | varchar(20) | No | Text enum: None, Operator, Validator, CompanionPC, Admin, ResourceUploader, ApiAdmin |
| `user_config` | varchar(512) | Yes | JSON: `{ QueueOffsets: { AnnotationsOffset, AnnotationsConfirmOffset, AnnotationsCommandsOffset } }` |
| `created_at` | timestamp | No | Default: `now()` |
| `last_login` | timestamp | Yes | Updated on hardware check |
| `is_enabled` | bool | No | Default: `true` |
## API Request Schemas
### POST /login
```json
{ "Email": "string", "Password": "string" }
```
### POST /users
```json
{ "Email": "string", "Password": "string", "Role": "RoleEnum (int)" }
```
### PUT /users/hardware/set
```json
{ "Email": "string", "Hardware": "string|null" }
```
### PUT /users/queue-offsets/set
```json
{ "Email": "string", "Offsets": { "AnnotationsOffset": 0, "AnnotationsConfirmOffset": 0, "AnnotationsCommandsOffset": 0 } }
```
### POST /resources/get/{dataFolder?}
```json
{ "Password": "string", "Hardware": "string", "FileName": "string" }
```
### POST /resources/check
```json
{ "Hardware": "string" }
```
### POST /resources/{dataFolder?}
Multipart form data with `IFormFile` field.
## Configuration Sections
### ConnectionStrings
```json
{ "AzaionDb": "Host=...;Database=azaion;Username=azaion_reader;Password=...", "AzaionDbAdmin": "Host=...;Database=azaion;Username=azaion_admin;Password=..." }
```
### JwtConfig
```json
{ "Issuer": "AzaionApi", "Audience": "Annotators/OrangePi/Admins", "Secret": "...", "TokenLifetimeHours": 4 }
```
### ResourcesConfig
```json
{ "ResourcesFolder": "Content", "SuiteInstallerFolder": "suite", "SuiteStageInstallerFolder": "suite-stage" }
```
## Resource Files
The system stores and serves:
- **AI models and DLLs** — stored in `ResourcesFolder`, served encrypted per-user
- **Production installers** — files matching `AzaionSuite.Iterative*` in `SuiteInstallerFolder`
- **Staging installers** — files matching `AzaionSuite.Iterative*` in `SuiteStageInstallerFolder`
@@ -0,0 +1,86 @@
# Expected Results
Maps every input data item to its quantifiable expected result.
Tests use this mapping to compare actual system output against known-correct answers.
## Result Format Legend
| Result Type | When to Use | Example |
|-------------|-------------|---------|
| Exact value | Output must match precisely | `status_code: 200`, `error_code: 10` |
| Threshold | Output must exceed or stay below a limit | `latency < 500ms` |
| Pattern match | Output must match a string/regex pattern | `body contains "token"` |
| Schema match | Output structure must conform to a schema | `response matches { Token: string }` |
## Input → Expected Result Mapping
### Authentication
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|---|-------|-------------------|-----------------|------------|-----------|---------------|
| 1 | `POST /login { "Email": "admin@azaion.com", "Password": "<valid>" }` | Valid credentials | HTTP 200, body: `{ "token": "<non-empty JWT>" }` | exact (status), pattern (token is non-empty string) | N/A | N/A |
| 2 | `POST /login { "Email": "nonexistent@x.com", "Password": "any" }` | Unknown email | HTTP 409, body: `{ "ErrorCode": 10, "Message": "No such email found." }` | exact (status, ErrorCode, Message) | N/A | N/A |
| 3 | `POST /login { "Email": "admin@azaion.com", "Password": "wrongpw" }` | Wrong password | HTTP 409, body: `{ "ErrorCode": 30, "Message": "Passwords do not match." }` | exact (status, ErrorCode, Message) | N/A | N/A |
### User Registration
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|---|-------|-------------------|-----------------|------------|-----------|---------------|
| 4 | `POST /users { "Email": "newuser@azaion.com", "Password": "validpass123", "Role": 10 }` | Valid registration (ApiAdmin auth) | HTTP 200, user created in DB | exact (status) | N/A | N/A |
| 5 | `POST /users { "Email": "short", "Password": "validpass123", "Role": 10 }` | Email too short (<8 chars) | HTTP 400, validation error with code `EmailLengthIncorrect` | exact (status), substring (error code) | N/A | N/A |
| 6 | `POST /users { "Email": "notanemail", "Password": "validpass123", "Role": 10 }` | Invalid email format (>=8 chars but not email) | HTTP 400, validation error with code `WrongEmail` | exact (status), substring (error code) | N/A | N/A |
| 7 | `POST /users { "Email": "valid@azaion.com", "Password": "short", "Role": 10 }` | Password too short (<8 chars) | HTTP 400, validation error with code `PasswordLengthIncorrect` | exact (status), substring (error code) | N/A | N/A |
| 8 | `POST /users { "Email": "admin@azaion.com", "Password": "validpass123", "Role": 10 }` | Duplicate email | HTTP 409, body: `{ "ErrorCode": 20, "Message": "Email already exists." }` | exact (status, ErrorCode, Message) | N/A | N/A |
| 9 | `POST /users` (no auth header) | Unauthorized registration attempt | HTTP 401 | exact (status) | N/A | N/A |
| 10 | `POST /users` (Operator role token) | Non-admin registration attempt | HTTP 403 | exact (status) | N/A | N/A |
### User Retrieval
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|---|-------|-------------------|-----------------|------------|-----------|---------------|
| 11 | `GET /users/current` (valid JWT) | Get current user | HTTP 200, body contains email matching JWT claims | exact (status), exact (email matches claim) | N/A | N/A |
| 12 | `GET /users` (ApiAdmin JWT) | List all users | HTTP 200, body is array of User objects | exact (status), schema (array of users) | N/A | N/A |
| 13 | `GET /users?searchEmail=admin` (ApiAdmin JWT) | Filter by email substring | HTTP 200, all returned users have "admin" in email | exact (status), substring (filter applied) | N/A | N/A |
| 14 | `GET /users?searchRole=10` (ApiAdmin JWT) | Filter by Operator role | HTTP 200, all returned users have Role=Operator | exact (status, role filter) | N/A | N/A |
### Hardware Binding
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|---|-------|-------------------|-----------------|------------|-----------|---------------|
| 15 | `POST /resources/check { "Hardware": "CPU: Test..." }` (user with no hardware) | First-time hardware binding | HTTP 200, body: `true`, user's hardware column updated in DB | exact (status, body) | N/A | N/A |
| 16 | `POST /resources/check { "Hardware": "CPU: Test..." }` (same user, same hardware) | Repeat check with matching hardware | HTTP 200, body: `true` | exact (status, body) | N/A | N/A |
| 17 | `POST /resources/check { "Hardware": "DIFFERENT_HW" }` (user with different stored hardware) | Hardware mismatch | HTTP 409, body: `{ "ErrorCode": 40, "Message": "Hardware mismatch!..." }` | exact (status, ErrorCode), substring (Message) | N/A | N/A |
### Resource Management
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|---|-------|-------------------|-----------------|------------|-----------|---------------|
| 18 | `POST /resources (multipart: testfile.txt, 100 bytes)` | Upload small file | HTTP 200, file exists at ResourcesFolder/testfile.txt | exact (status), file exists | N/A | N/A |
| 19 | `POST /resources/subfolder (multipart: testfile.txt)` | Upload to subfolder | HTTP 200, file exists at ResourcesFolder/subfolder/testfile.txt | exact (status), file exists | N/A | N/A |
| 20 | `GET /resources/list` (authenticated) | List resources in root folder | HTTP 200, body is array of filenames including "testfile.txt" | exact (status), set_contains (filename) | N/A | N/A |
| 21 | `POST /resources/get { "Password": "validpass123", "Hardware": "<matching>", "FileName": "testfile.txt" }` | Download encrypted resource | HTTP 200, content-type: `application/octet-stream`, body decrypts to original file content | exact (status, content-type), exact (decrypted content matches original) | N/A | N/A |
| 22 | `POST /resources (no file)` | Upload with no file | HTTP 409, body: `{ "ErrorCode": 60 }` | exact (status, ErrorCode) | N/A | N/A |
### Encryption Round-Trip
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|---|-------|-------------------|-----------------|------------|-----------|---------------|
| 23 | Plaintext string "Hello World..." + key derived from (email, password, hwHash) | Encrypt then decrypt | Decrypted output == original plaintext | exact (string equality) | N/A | N/A |
| 24 | Large file (~400 MB) + key | Encrypt then decrypt large file | SHA-256 of decrypted file == SHA-256 of original | exact (hash equality) | N/A | N/A |
### User Lifecycle
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|---|-------|-------------------|-----------------|------------|-----------|---------------|
| 25 | `PUT /users/test@azaion.com/set-role/50` (ApiAdmin JWT) | Change role to ResourceUploader | HTTP 200, user's role updated in DB to `ResourceUploader` | exact (status, DB role) | N/A | N/A |
| 26 | `PUT /users/test@azaion.com/disable` (ApiAdmin JWT) | Disable user | HTTP 200, user's is_enabled=false in DB | exact (status, DB flag) | N/A | N/A |
| 27 | `PUT /users/test@azaion.com/enable` (ApiAdmin JWT) | Enable user | HTTP 200, user's is_enabled=true in DB | exact (status, DB flag) | N/A | N/A |
| 28 | `DELETE /users/test@azaion.com` (ApiAdmin JWT) | Delete user | HTTP 200, user no longer exists in DB | exact (status), exact (user gone) | N/A | N/A |
### API Behavior
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|---|-------|-------------------|-----------------|------------|-----------|---------------|
| 29 | `GET /` | Root URL | HTTP 302 redirect to `/swagger` | exact (status 302, Location header contains "swagger") | N/A | N/A |
| 30 | Any endpoint from non-allowed origin | CORS rejection | No `Access-Control-Allow-Origin` header in response | exact (header absent) | N/A | N/A |
| 31 | `OPTIONS /login` from `https://admin.azaion.com` | CORS preflight from allowed origin | `Access-Control-Allow-Origin: https://admin.azaion.com` | exact (header value) | N/A | N/A |
+38
View File
@@ -0,0 +1,38 @@
# Problem Statement
## What is this system?
The Azaion Admin API is the backend management service for the Azaion Suite — a platform for AI-powered data annotation workflows. The suite includes desktop client software (annotators, dataset explorers) that must be securely distributed and controlled.
## What problem does it solve?
The Azaion platform needs to:
1. **Control who can use the software** — only registered, authorized users should access the annotation tools. Different users have different permission levels (operators can annotate, validators can review, admins can manage everything).
2. **Bind software to specific hardware** — prevent unauthorized copying or redistribution of proprietary software components (AI models, DLLs). Each user's resources must be tied to their specific physical machine.
3. **Securely distribute software updates** — deliver installers and resource files (AI models, DLLs) to authorized users, encrypted such that only the intended user on the intended hardware can use them.
4. **Manage the user base** — admins need to create accounts, assign roles, enable/disable users, reset hardware bindings, and track activity (last login).
5. **Support annotation queue coordination** — users participate in annotation queues and need to maintain per-user offset tracking to resume work across sessions.
## Who are the users?
| User Type | Role(s) | What They Do |
|-----------|---------|-------------|
| Annotators | Operator | Use the desktop client to annotate data; submit annotations to queues |
| Validators | Validator | Review annotations from queues, explore datasets |
| Companion PCs | CompanionPC | Automated annotation devices (e.g., OrangePi) |
| Resource Uploaders | ResourceUploader | Upload DLLs and AI models to the server |
| System Administrators | ApiAdmin | Full control: user management, resource management, all operations |
## How does it work at a high level?
1. An admin creates user accounts via the API (or admin web panel at admin.azaion.com)
2. Users authenticate via email/password and receive a JWT token
3. On first resource access, the client sends its hardware fingerprint, which is stored for the user
4. When downloading resources, the API encrypts files using a key derived from the user's email, password, and hardware hash — only that specific user on that specific machine can decrypt
5. Installers (production and staging) are distributed to authenticated users without per-user encryption
6. Users maintain annotation queue offsets that persist across sessions
+42
View File
@@ -0,0 +1,42 @@
# Restrictions
## Software Constraints
| Constraint | Value | Source |
|-----------|-------|--------|
| Runtime | .NET 10.0 | All `.csproj` files target `net10.0` |
| Database | PostgreSQL | `DbFactory` uses `UsePostgreSQL()`, Npgsql provider |
| ORM | linq2db 5.4.1 | No Entity Framework, no migration framework |
| Container base | `mcr.microsoft.com/dotnet/aspnet:10.0` | Dockerfile |
| Build platform | ARM64 | Woodpecker CI pipeline labels `platform: arm64` |
| Max upload size | 200 MB | Kestrel `MaxRequestBodySize = 209715200` |
## Environment Constraints
| Constraint | Value | Source |
|-----------|-------|--------|
| Target OS | Linux (Docker) | Dockerfile `DockerDefaultTargetOS=Linux` |
| DB port | 4312 (non-standard) | `env/db/00_install.sh` |
| CORS origins | `admin.azaion.com` (HTTP + HTTPS) | `Program.cs` CORS policy |
| Secrets | Environment variables (`ASPNETCORE_*` prefix) | `env/api/env.ps1`, no secret manager |
| Deployment model | Single container, no orchestration | `deploy.cmd`, Dockerfile |
## Operational Constraints
| Constraint | Value | Source |
|-----------|-------|--------|
| DB connection model | Two connections: reader + admin | `DbFactory` with `Run` / `RunAdmin` |
| Schema management | Manual SQL scripts (no ORM migrations) | `env/db/*.sql` |
| CI/CD | Build-only (no automated tests in pipeline) | `.woodpecker/build-arm.yml` |
| Private registry | `docker.azaion.com` and `localhost:5000` (CI) | `deploy.cmd`, CI config |
| File storage | Local server filesystem | `ResourcesConfig.ResourcesFolder` |
## Security Constraints
| Constraint | Value | Source |
|-----------|-------|--------|
| Authentication | JWT Bearer (HMAC-SHA256) | `Program.cs` auth config |
| Token lifetime | 4 hours | `appsettings.json` JwtConfig |
| Password hashing | SHA-384 (no per-user salt, no key stretching) | `Security.ToHash()` |
| Resource encryption | AES-256-CBC per-user (key from email + password + HW hash) | `Security.GetApiEncryptionKey`, `Security.EncryptTo` |
| Hardware binding | Single device per user, admin reset required | `UserService.CheckHardwareHash` |
+73
View File
@@ -0,0 +1,73 @@
# Security Approach
## Authentication
- **Mechanism**: JWT Bearer tokens
- **Signing**: HMAC-SHA256 with symmetric key from `JwtConfig.Secret`
- **Validation**: Issuer, Audience, Lifetime, Signing Key — all validated by ASP.NET Core middleware
- **Token lifetime**: 4 hours (configurable via `JwtConfig.TokenLifetimeHours`)
- **Token claims**: UserID (`NameIdentifier`), Email (`Name`), Role (`Role`)
## Authorization
- **Model**: Role-based access control (RBAC)
- **Policies**:
- `apiAdminPolicy` — requires `ApiAdmin` role (used on user CRUD + folder clear endpoints)
- `apiUploaderPolicy` — requires `ResourceUploader` or `ApiAdmin` (defined but never applied — dead code)
- General `[Authorize]` — any authenticated user (used on resource endpoints, queue offsets)
## Password Security
- **Hashing**: SHA-384 (`Security.ToHash`), Base64-encoded
- **No per-user salt**: All passwords use the same hash function without individual salts
- **No key stretching**: Not using bcrypt, scrypt, or Argon2
- **Minimum length**: 8 characters (enforced by FluentValidation)
## Hardware Fingerprint Binding
- **Storage**: Raw hardware string stored in `users.hardware` column
- **Comparison**: Hashed with static salt (`"Azaion_{hw}_%$$$)0_"`) via SHA-384
- **First-use binding**: Hardware auto-stored on first resource check; no admin approval step
- **Reset**: Admin can set hardware to null via `PUT /users/hardware/set`
## Resource Encryption
- **Algorithm**: AES-256-CBC with PKCS7 padding
- **Key derivation**: SHA-256 of `"{email}-{password}-{hwHash}-#%@AzaionKey@%#---"`
- **IV**: Randomly generated per encryption, prepended to ciphertext (first 16 bytes)
- **Scope**: Applied at download time; files stored unencrypted on server
- **Buffer size**: 512 KB streaming buffers
## Database Security
- **Connection separation**: Read-only (`azaion_reader`) and admin (`azaion_admin`) DB users
- **Privileges**: Reader has SELECT only; admin has SELECT, INSERT, UPDATE, DELETE
- **Port**: Non-standard port 4312
## Transport Security
- **CORS**: Restricted to `admin.azaion.com` (HTTP + HTTPS)
- **HTTPS enforcement**: Not configured in code (assumed at reverse proxy level)
## Input Validation
- **Framework**: FluentValidation (auto-discovered validators)
- **Validated requests**: RegisterUserRequest, GetResourceRequest, SetHWRequest
- **Not validated**: LoginRequest, SetUserQueueOffsetsRequest, CheckResourceRequest (partial)
## Secrets Management
- **Method**: Environment variables with `ASPNETCORE_` prefix
- **Sensitive values**: DB connection strings (passwords), JWT secret
- **Not in source**: `appsettings.json` omits connection strings and JWT secret
## Known Security Observations
1. SHA-384 without per-user salt is vulnerable to rainbow table attacks
2. `hardware_hash` DB column exists but is unused — application computes hashes at runtime
3. No path traversal protection on `dataFolder` parameter in resource endpoints
4. Test file contains hardcoded DB credentials for a remote server
5. No rate limiting on login endpoint
6. No audit trail for security-relevant operations (logins, role changes, user deletions)
7. No HTTPS enforcement in application code
8. Static encryption key salts are hardcoded in source code