- Docker Desktop installed and running
# Start the full stack (API + PostgreSQL)
docker compose up -d
# Verify containers are running
docker compose ps
# View API logs (includes migration and seed output)
docker compose logs -f apiThe API will:
- Auto-apply all migrations
- Seed test data (tenants, users, listings)
- Listen on port 8080 (container) / 5080 (host)
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand ... CREATE TABLE ...
info: Program[0]
Seeded 2 tenants, 3 users, 5 listings...
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://[::]:8080
API URL: http://localhost:5080 Swagger UI: http://localhost:5080/swagger Health Check: http://localhost:5080/health
PowerShell:
Invoke-RestMethod -Uri "http://localhost:5080/health" -Method GETExpected Output:
Healthy
Pass Criteria: Returns Healthy
PowerShell:
$loginBody = @{
email = "admin@acme.test"
password = "Test123!"
tenant_slug = "acme"
} | ConvertTo-Json
$response = Invoke-RestMethod -Uri "http://localhost:5080/api/auth/login" -Method POST -Body $loginBody -ContentType "application/json"
$response | ConvertTo-Json
# Save token for later tests
$token1 = $response.access_token
Write-Host "Token 1 (Tenant 1): $token1"Expected Output:
{
"success": true,
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 7200,
"user": {
"id": 1,
"email": "admin@acme.test",
"first_name": "Alice",
"last_name": "Admin",
"role": "admin",
"tenant_id": 1,
"tenant_slug": "acme"
}
}Pass Criteria:
success= trueaccess_tokenis presentuser.tenant_id= 1user.tenant_slug= "acme"
PowerShell:
$loginNoTenant = @{
email = "admin@acme.test"
password = "Test123!"
} | ConvertTo-Json
try {
Invoke-RestMethod -Uri "http://localhost:5080/api/auth/login" -Method POST -Body $loginNoTenant -ContentType "application/json"
Write-Host "FAIL: Should have returned 400" -ForegroundColor Red
} catch {
$statusCode = $_.Exception.Response.StatusCode.value__
if ($statusCode -eq 400) {
Write-Host "PASS: Correctly returned 400 (tenant required)" -ForegroundColor Green
} else {
Write-Host "FAIL: Expected 400, got $statusCode" -ForegroundColor Red
}
}Pass Criteria:
- Returns 400 Bad Request with message "Tenant identifier required"
PowerShell:
$loginBody2 = @{
email = "admin@globex.test"
password = "Test123!"
tenant_slug = "globex"
} | ConvertTo-Json
$response2 = Invoke-RestMethod -Uri "http://localhost:5080/api/auth/login" -Method POST -Body $loginBody2 -ContentType "application/json"
$token2 = $response2.access_token
Write-Host "Token 2 (Tenant 2): $token2"Pass Criteria:
user.tenant_id= 2user.tenant_slug= "globex"
PowerShell:
$headers = @{
Authorization = "Bearer $token1"
}
$validateResponse = Invoke-RestMethod -Uri "http://localhost:5080/api/auth/validate" -Method GET -Headers $headers
$validateResponse | ConvertTo-JsonExpected Output:
{
"valid": true,
"user_id": "1",
"tenant_id_claim": "1",
"tenant_id_resolved": 1,
"tenant_context_matches": true,
"role": "admin",
"email": "admin@acme.test"
}Pass Criteria:
valid= truetenant_id_claimmatchestenant_id_resolvedtenant_context_matches= true
PowerShell:
$headers1 = @{ Authorization = "Bearer $token1" }
$users1 = Invoke-RestMethod -Uri "http://localhost:5080/api/users" -Method GET -Headers $headers1
$users1 | ConvertTo-Json -Depth 3Expected Output:
{
"data": [
{
"id": 1,
"email": "admin@acme.test",
"first_name": "Alice",
"last_name": "Admin",
"role": "admin",
"is_active": true
},
{
"id": 3,
"email": "member@acme.test",
"first_name": "Charlie",
"last_name": "Contributor",
"role": "member",
"is_active": true
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 2,
"pages": 1
}
}Pass Criteria:
- Returns exactly 2 users (both from tenant 1)
- Does NOT include
admin@globex.test(tenant 2)
PowerShell:
$headers2 = @{ Authorization = "Bearer $token2" }
$users2 = Invoke-RestMethod -Uri "http://localhost:5080/api/users" -Method GET -Headers $headers2
$users2 | ConvertTo-Json -Depth 3Expected Output:
{
"data": [
{
"id": 2,
"email": "admin@globex.test",
"first_name": "Bob",
"last_name": "Boss",
"role": "admin",
"is_active": true
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 1,
"pages": 1
}
}Pass Criteria:
- Returns exactly 1 user (tenant 2 only)
- Does NOT include any tenant 1 users
Try to access a user from tenant 1 while authenticated as tenant 2:
PowerShell:
# Using tenant 2 token, try to get user ID 1 (belongs to tenant 1)
$headers2 = @{ Authorization = "Bearer $token2" }
try {
$crossTenant = Invoke-RestMethod -Uri "http://localhost:5080/api/users/1" -Method GET -Headers $headers2
Write-Host "FAIL: Should have returned 404" -ForegroundColor Red
} catch {
$statusCode = $_.Exception.Response.StatusCode.value__
if ($statusCode -eq 404) {
Write-Host "PASS: Correctly returned 404 Not Found" -ForegroundColor Green
} else {
Write-Host "FAIL: Expected 404, got $statusCode" -ForegroundColor Red
}
}Pass Criteria:
- Returns 404 Not Found (tenant filter hides user from other tenant)
PowerShell:
# Authenticated as tenant 1, but try to set X-Tenant-ID: 2
$headers = @{
Authorization = "Bearer $token1"
"X-Tenant-ID" = "2"
}
$users = Invoke-RestMethod -Uri "http://localhost:5080/api/users" -Method GET -Headers $headers
Write-Host "User count: $($users.data.Count)"
Write-Host "First user email: $($users.data[0].email)"
# Should still return tenant 1 users, NOT tenant 2
if ($users.data[0].email -eq "admin@acme.test") {
Write-Host "PASS: X-Tenant-ID header was ignored (JWT wins)" -ForegroundColor Green
} else {
Write-Host "FAIL: X-Tenant-ID header overrode JWT tenant" -ForegroundColor Red
}Pass Criteria:
- Returns tenant 1 users despite X-Tenant-ID: 2 header
- Check server logs for warning about ignored header
PowerShell:
# Replace with an actual token from your PHP system
$phpToken = "PASTE_PHP_ISSUED_JWT_HERE"
$headers = @{ Authorization = "Bearer $phpToken" }
$result = Invoke-RestMethod -Uri "http://localhost:5080/api/auth/validate" -Method GET -Headers $headers
$result | ConvertTo-JsonPass Criteria:
- If JWT secret matches PHP: returns valid=true with correct claims
- If JWT secret doesn't match: returns 401 Unauthorized
| Test | Description | Status |
|---|---|---|
| 0 | Health check returns Healthy | [ ] |
| 1 | Login tenant 1 (with tenant_slug) returns token | [ ] |
| 1b | Login without tenant_slug returns 400 | [ ] |
| 2 | Login tenant 2 (with tenant_slug) returns token | [ ] |
| 3 | Validate token returns matching tenant context | [ ] |
| 4 | List users (tenant 1) returns only tenant 1 users | [ ] |
| 5 | List users (tenant 2) returns only tenant 2 users | [ ] |
| 6 | Cross-tenant user access returns 404 | [ ] |
| 7 | X-Tenant-ID header cannot override JWT tenant | [ ] |
| 8 | PHP-issued token validates (if applicable) | [ ] |
| Entity | ID | Slug | Tenant ID | Password | |
|---|---|---|---|---|---|
| Tenant | 1 | acme | - | - | - |
| Tenant | 2 | globex | - | - | - |
| User | 1 | - | 1 | admin@acme.test | Test123! |
| User | 2 | - | 2 | admin@globex.test | Test123! |
| User | 3 | - | 1 | member@acme.test | Test123! |
Set the environment variable or appsettings.json value.
- Ensure PostgreSQL is running
- Verify connection string
- Create database:
CREATE DATABASE nexus;
- Check JWT secret matches between .NET and PHP
- Check token hasn't expired
- Check algorithm is HS256
- Ensure migrations ran:
dotnet ef database update - Ensure seed data ran (check logs on startup)
- Verify tenant ID in token matches expected tenant
Login now requires tenant_slug (or tenant_id). Example:
{
"email": "admin@acme.test",
"password": "Test123!",
"tenant_slug": "acme"
}$baseUrl = "http://localhost:5080"
# Test 0: Health
Write-Host "Test 0: Health Check" -ForegroundColor Cyan
Invoke-RestMethod "$baseUrl/health"
# Test 1: Login Tenant 1 (with tenant_slug)
Write-Host "`nTest 1: Login Tenant 1" -ForegroundColor Cyan
$r1 = Invoke-RestMethod "$baseUrl/api/auth/login" -Method POST -Body '{"email":"admin@acme.test","password":"Test123!","tenant_slug":"acme"}' -ContentType "application/json"
$token1 = $r1.access_token
Write-Host "Tenant 1 token acquired"
# Test 1b: Login without tenant should fail
Write-Host "`nTest 1b: Login without tenant (should fail)" -ForegroundColor Cyan
try {
Invoke-RestMethod "$baseUrl/api/auth/login" -Method POST -Body '{"email":"admin@acme.test","password":"Test123!"}' -ContentType "application/json"
Write-Host "FAIL: Should have returned 400" -ForegroundColor Red
} catch {
Write-Host "PASS: Returned 400 as expected" -ForegroundColor Green
}
# Test 2: Login Tenant 2
Write-Host "`nTest 2: Login Tenant 2" -ForegroundColor Cyan
$r2 = Invoke-RestMethod "$baseUrl/api/auth/login" -Method POST -Body '{"email":"admin@globex.test","password":"Test123!","tenant_slug":"globex"}' -ContentType "application/json"
$token2 = $r2.access_token
Write-Host "Tenant 2 token acquired"
# Test 3: Validate
Write-Host "`nTest 3: Validate Token" -ForegroundColor Cyan
$v = Invoke-RestMethod "$baseUrl/api/auth/validate" -Headers @{Authorization="Bearer $token1"}
Write-Host "tenant_context_matches: $($v.tenant_context_matches)"
# Test 4: List users tenant 1
Write-Host "`nTest 4: List Users (Tenant 1)" -ForegroundColor Cyan
$u1 = Invoke-RestMethod "$baseUrl/api/users" -Headers @{Authorization="Bearer $token1"}
Write-Host "Tenant 1 users: $($u1.data.Count) (expected: 2)"
# Test 5: List users tenant 2
Write-Host "`nTest 5: List Users (Tenant 2)" -ForegroundColor Cyan
$u2 = Invoke-RestMethod "$baseUrl/api/users" -Headers @{Authorization="Bearer $token2"}
Write-Host "Tenant 2 users: $($u2.data.Count) (expected: 1)"
# Test 6: Cross-tenant blocked
Write-Host "`nTest 6: Cross-Tenant Access" -ForegroundColor Cyan
try {
Invoke-RestMethod "$baseUrl/api/users/1" -Headers @{Authorization="Bearer $token2"}
Write-Host "FAIL: Should have returned 404" -ForegroundColor Red
} catch {
Write-Host "PASS: Cross-tenant access blocked (404)" -ForegroundColor Green
}
# Test 7: Header override blocked
Write-Host "`nTest 7: Header Override Blocked" -ForegroundColor Cyan
$u3 = Invoke-RestMethod "$baseUrl/api/users" -Headers @{Authorization="Bearer $token1";"X-Tenant-ID"="2"}
if ($u3.data[0].email -eq "admin@acme.test") {
Write-Host "PASS: X-Tenant-ID header ignored (JWT wins)" -ForegroundColor Green
} else {
Write-Host "FAIL: Header overrode JWT tenant" -ForegroundColor Red
}
Write-Host "`n=== All tests complete ===" -ForegroundColor Green