From e5709874bd2ebc175d8b8e44f96281af7754d6cc Mon Sep 17 00:00:00 2001 From: roost-io Date: Wed, 29 Apr 2026 18:57:35 +0530 Subject: [PATCH 1/2] Functional test generated by RoostGPT Using AI Model gpt-5.2-2025-12-11 --- functional_tests/README.md | 17 + .../.roost/roost_metadata.json | 24 + .../roost_test_1777467397.csv | 153 + .../roost_test_1777467397.docx | Bin 0 -> 144237 bytes .../roost_test_1777467397.feature | 3574 +++++++++++++++++ .../roost_test_1777467397.json | 2769 +++++++++++++ .../roost_test_1777467397.xlsx | Bin 0 -> 119103 bytes 7 files changed, 6537 insertions(+) create mode 100644 functional_tests/roost_test_1777467397/.roost/roost_metadata.json create mode 100644 functional_tests/roost_test_1777467397/roost_test_1777467397.csv create mode 100644 functional_tests/roost_test_1777467397/roost_test_1777467397.docx create mode 100644 functional_tests/roost_test_1777467397/roost_test_1777467397.feature create mode 100644 functional_tests/roost_test_1777467397/roost_test_1777467397.json create mode 100644 functional_tests/roost_test_1777467397/roost_test_1777467397.xlsx diff --git a/functional_tests/README.md b/functional_tests/README.md index 1c972b2..3e96704 100644 --- a/functional_tests/README.md +++ b/functional_tests/README.md @@ -65,3 +65,20 @@ --- +**Execution Date:** 4/29/2026, 6:57:34 PM + +**Test Unique Identifier:** "roost_test_1777467397" + +**Input(s):** + 1. Aegis_WebCC_SRS.pdf + Path: /Users/iamdm/Downloads/Aegis_WebCC_SRS.pdf + +**Test Output Folder:** + 1. [roost_test_1777467397.json](roost_test_1777467397/roost_test_1777467397.json) + 2. [roost_test_1777467397.feature](roost_test_1777467397/roost_test_1777467397.feature) + 3. [roost_test_1777467397.csv](roost_test_1777467397/roost_test_1777467397.csv) + 4. [roost_test_1777467397.xlsx](roost_test_1777467397/roost_test_1777467397.xlsx) + 5. [roost_test_1777467397.docx](roost_test_1777467397/roost_test_1777467397.docx) + +--- + diff --git a/functional_tests/roost_test_1777467397/.roost/roost_metadata.json b/functional_tests/roost_test_1777467397/.roost/roost_metadata.json new file mode 100644 index 0000000..2da4d62 --- /dev/null +++ b/functional_tests/roost_test_1777467397/.roost/roost_metadata.json @@ -0,0 +1,24 @@ +{ + "project": { + "name": "roost_test_1777467397", + "created_at": "2026-04-29T13:27:34.368Z", + "updated_at": "2026-04-29T13:27:34.368Z" + }, + "files": { + "input_files": [ + { + "fileName": "roost_test_1777467397.txt", + "fileURI": "/var/tmp/Roost/RoostGPT/aegis-cc/1777467397/functional_tests/roost_test_1777467397/roost_test_1777467397.txt", + "fileSha": "cf83e1357e" + }, + { + "fileName": "Aegis_WebCC_SRS.pdf", + "fileURI": "/var/tmp/Roost/RoostGPT/aegis-cc/1777467397/functional_tests/roost_test_1777467397/Aegis_WebCC_SRS.pdf", + "fileSha": "dcebdb1a12" + } + ] + }, + "api_files": { + "input_files": [] + } +} \ No newline at end of file diff --git a/functional_tests/roost_test_1777467397/roost_test_1777467397.csv b/functional_tests/roost_test_1777467397/roost_test_1777467397.csv new file mode 100644 index 0000000..8d8d1a5 --- /dev/null +++ b/functional_tests/roost_test_1777467397/roost_test_1777467397.csv @@ -0,0 +1,153 @@ +Portal base URL reachable over HTTPS and served via CDN +API base URL /v2 reachable over HTTPS and enforces TLS 1.3 minimum using +API v2 endpoint is reachable over HTTPS (network level) +Register user succeeds (201) and returns onboarding artifacts +Registering the same email twice indicates the account now exists +Register rejects when agree_terms is false and succeeds when corrected +Register returns 409 EMAIL_EXISTS when email already registered +Register returns 422 WEAK_PASSWORD for weak passwords +Registration first_name validation (alpha-only and length 2-50) +Registration rejects malformed email formats +Registration password complexity and boundary length enforcement +Registration date_of_birth format and age >= 18 enforcement +Registration rejects non-E.164 phone_number formats +Registration ssn_last4 must be exactly 4 numeric digits +Registration missing required field returns 400 with error, field, message +Login success returns access_token, refresh_token, expires_in and token works on a protected endpoint +Tampered access token is rejected for protected endpoint +Login returns 401 INVALID_CREDENTIALS for wrong password +Login with MFA enabled accepts optional 6-digit TOTP mfa_code +Account locks after 5 failed login attempts and returns 403 ACCOUNT_LOCKED + unlock_at +Login rate limit returns 429 RATE_LIMITED with retry_after after exceeding 10 req/min per IP +Login fails for unknown email and does not issue tokens +Login device_id UUID v4 validation when provided +remember_me=true extends refresh token TTL to 30 days (observable via cookie/token metadata) +Token refresh returns new access_token and refresh_token (rotation enforced) +Token refresh invalidates old refresh token after successful refresh (single-use) +Token refresh requires refresh_token field (missing/empty fails; valid succeeds) +Auth tokens stored in HttpOnly, Secure cookies and not accessible via document.cookie +Auth tokens are never stored in localStorage or sessionStorage +Credit application auto-save occurs every 60 seconds to localStorage draft +Draft auto-save does not store auth tokens in localStorage +PAN displayed in browser only as masked format +Card fields use iframe tokenisation and no raw PAN in DOM +CSRF cookie policy SameSite=Strict is enforced for portal sessions +Portal session expires after 15 minutes of inactivity +Portal shows 2-minute warning modal before auto-logout +Application Step 1 succeeds and returns application_id and session_token +Step 1 full_legal_name boundary validation (100 accepted, 101 rejected) +Step 1 email must match authenticated user email (reject mismatch) +Step 1 returns 409 DUPLICATE_APPLICATION when active application already in progress +Step 1 phone_number must be E.164 (accept valid, reject invalid) +Step 1 address.street max 100 chars boundary +Step 1 address.city max 60 chars boundary +Step 1 province must be 2-char ISO 3166-2 code +Step 1 postal_code must match Canadian format A1A 1A1 +Step 1 id_type enum enforcement +Step 1 id_number alphanumeric max 20 chars boundary +Step 1 validation failure returns 400 with error, field, message +Application Step 2 succeeds with X-App-Session and returns PENDING_REVIEW and fico_pull_id +Step 2 rejects when EMPLOYED but employer_name missing +Step 2 gross_annual_income boundary validation +Step 2 rejects when sin_consent is false or omitted; succeeds when true +Enforce sequential completion: Step 2 cannot be completed without X-App-Session from Step 1 +Step 2 returns 401 SESSION_EXPIRED when session_token is invalid +Validate X-App-Session required and expiry boundary (29:59 succeeds; 30:01 expires) +Step 2 other_income defaults to 0.00 when omitted +Step 2 monthly_rent boundary allows 0.00 but rejects negative +Step 2 existing_debt_payments enforces Decimal(10,2) precision +Step 2 missing required field returns 400 with error, field, message +Step 3 approved decision returns credit_limit and card_number_masked +Step 3 pending decision includes review_eta_hours +Step 3 declined decision includes reason_code +Step 3 rejects missing/malformed e_signature with 400 SIGNATURE_REQUIRED +Step 3 marketing_opt_in defaults to false when omitted (if observable) +Decision boundary at FICO 680 returns PENDING and includes review_eta_hours +Decision boundary at FICO 599 returns DECLINED and includes reason_code +Step 3 card_product_id must come from GET /v2/products +Initiate transaction approved path returns transaction_id, available_credit, auth_code +Essential service over-limit within 5% buffer returns over_limit_flag true +Initiate transaction returns 402 INSUFFICIENT_FUNDS with available_credit +Initiate transaction returns 403 CARD_INACTIVE when card not Active or Frozen +Initiate transaction rejects transaction_amount <= 0 with 422 INVALID_AMOUNT +Initiate transaction requires exchange_rate when currency_code != CAD +Transaction frequency limit triggers 429 FREQ_EXCEEDED with mfa_required true on 11th transaction +Transaction frequency boundary does not trigger FREQ_EXCEEDED at exactly 10 transactions +Foreign transaction fee calculation applies 1.03 multiplier to (amount × exchange_rate) +Foreign fee is itemised separately as foreign_fee_amount in response +List transactions default from_date is billing cycle start (fixture-based) +List transactions rejects invalid date range with 400 INVALID_DATE_RANGE when to_date < from_date +List transactions enforces per_page max 100 boundary +List transactions returns 403 FORBIDDEN when account not owned by user +List transactions page min 1 boundary and default page=1 +List transactions category filter accepts enum values and rejects unknown +Initiate transaction currency_code defaults to CAD when omitted +Initiate transaction transaction_type enum validation +Initiate transaction description max 255 chars boundary +Initiate transaction enforces account_id ownership (IDOR protection) +Initiate transaction transaction_amount Decimal(10,2) precision validation +Initiate transaction merchant_name max 100 chars boundary +Initiate transaction merchant_id alphanumeric max 32 chars boundary +Initiate transaction requires 4-digit mcc_code +Get account summary success returns required fields +Get account summary include_rewards defaults to false when omitted +Get account summary returns 403 FORBIDDEN for non-owned account +Freeze card succeeds with valid confirm_otp +Unfreeze card succeeds with valid confirm_otp +Card status update rejects invalid transition with 400 INVALID_TRANSITION and allowed_transitions +Card status update fails with 401 OTP_FAILED and attempts_remaining +Card status reason max 255 chars (accept) vs 256 (reject); successful change is audit-logged +Card status update requires 6-digit confirm_otp format +Report card lost/stolen blocks card and schedules replacement +Report lost/stolen returns 409 ALREADY_BLOCKED if card already Blocked or Closed +Report lost/stolen accepts last_known_use in ISO 8601 UTC +Report lost/stolen rejects unknown loss_type and does not block the card +Report lost/stolen accepts optional delivery_address override +Set virtual PIN succeeds with encrypted 4-digit PIN, matching confirm_pin, and session_otp +Set virtual PIN returns 400 PIN_MISMATCH when confirm_pin does not match new_pin +Set virtual PIN returns 400 PIN_FORMAT when PIN is not exactly 4 numeric digits +Set virtual PIN returns 403 CARD_BLOCKED when card is Blocked +Set PIN requires session_otp and rejects missing/incorrect OTP +Set PIN transmits new_pin encrypted (client payload does not contain plaintext PIN) +Retrieve statement defaults to JSON and returns required statement fields +Retrieve statement in PDF when format=PDF +Retrieve statement returns 404 NOT_FOUND when statement missing (JSON or PDF) +Interest computed using ADB formula for a known statement fixture +Interest scales with Days_in_Billing_Cycle boundaries (28/30/31) +Late fee charged when payment_received_date > due_date + 2 days +Late fee boundary - no late fee when payment_received_date equals due_date + 2 days +Rewards accrual for Travel MCC uses floor(amount × 3) (fixture-based) +Rewards rounding uses floor() and never round/ceil (fixture-based) +Statement accuracy - sum(transaction_amount[]) equals total_spend within ±0.01 +Make immediate payment (scheduled_date omitted) returns payment_id and new_balance_estimate (CSRF enforced) +Make scheduled payment returns scheduled_date in response +Payment amount minimum boundary (1.00 accepted; 0.99 rejected) +Payment below minimum_payment_due returns 400 BELOW_MINIMUM with minimum_payment_due +Payment requires bank_account_id from /v2/bank-accounts +Payment rejects unlinked bank_account_id with 422 INVALID_BANK_ACCOUNT +Payment_type enum validation +Payment amount max equals total_balance (at-max accepted; just-over rejected) +State-changing endpoints reject missing/invalid X-CSRF-Token +CSRF token required even when Authorization uses Bearer token +Full PAN never transmitted to frontend (HAR scan) +Selected API responses do not include raw PAN fields or PAN-like sequences +Notifications webhook accepts valid payload and returns notification_id and delivered_at +Notifications webhook rejects unknown alert_type or channel with 400 INVALID_ALERT_TYPE +Notifications webhook enforces message_body max length boundary +Notifications webhook idempotency duplicate idempotency_key returns 409 DUPLICATE_NOTIFICATION +Notifications webhook enforces idempotency_key UUID v4 format +Notifications webhook enforces alert_type enum +Notifications webhook enforces severity enum INFO/WARNING/CRITICAL +WebSocket endpoint reachable for live transaction feed +Right to rescind allowed within 14 days via DELETE /v2/accounts/{id} +Right to rescind rejected after day 14 +Portal and API used by forms negotiate TLS 1.3 (no TLS 1.2 downgrade) +OAuth 2.0 Authorization Code Flow with PKCE is observable during portal login +API p95 latency meets ≤ 1500 ms target for GET /summary under representative load +Portal Time-to-Interactive (TTI) meets ≤ 3s on 4G (5 runs) +API gateway sustains 5000 requests/sec while maintaining response correctness for GET /summary +Auto-scaling triggers at 70% CPU utilization and service remains available +Credit_limit changes create immutable audit log entry with required fields +E2E Register -> Login -> Step1 -> Step2 -> Step3 approved +E2E Login -> Initiate transaction -> Verify it appears in list +E2E Freeze card -> attempt transaction -> 403 CARD_INACTIVE with card_status -> unfreeze cleanup \ No newline at end of file diff --git a/functional_tests/roost_test_1777467397/roost_test_1777467397.docx b/functional_tests/roost_test_1777467397/roost_test_1777467397.docx new file mode 100644 index 0000000000000000000000000000000000000000..4fea55af25fbf64516bc3db061b49630d42f216e GIT binary patch literal 144237 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%0A{hP@g-4-OQ93vIAlS))W*4KrLQ4 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�E^SF8n^LeZqLJ)BU3i)z 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 Km;eI+fd2!X=!*yd literal 0 HcmV?d00001 diff --git a/functional_tests/roost_test_1777467397/roost_test_1777467397.feature b/functional_tests/roost_test_1777467397/roost_test_1777467397.feature new file mode 100644 index 0000000..7a62c42 --- /dev/null +++ b/functional_tests/roost_test_1777467397/roost_test_1777467397.feature @@ -0,0 +1,3574 @@ +Feature: Aegis Card Platform - Portal, API, Security, Applications, Transactions, Billing, and Non-Functional Requirements + + Background: + Given the API base URL is '${API_BASE_URL}' + And the portal base URL is '${PORTAL_BASE_URL}' + And the realtime WebSocket URL is '${REALTIME_WS_URL}' + And the default request headers are set to: + | Header | Value | + | Content-Type | application/json | + | Accept | application/json | + And I can run network inspection tools (curl and openssl) on the test runner + And I can run browser automation with DevTools access for the portal + + # --------------------------------------------------------------------------- + # Platform / TLS / CDN (API + UI) + # --------------------------------------------------------------------------- + + @ui @functional @TC-PLAT-01 + Scenario: Portal base URL reachable over HTTPS and served via CDN + Given I am on the '${PORTAL_BASE_URL}' page + When I wait for the page to finish loading + Then I should see the page loaded without browser security warnings + And the browser should indicate a secure HTTPS connection + And I should see that static assets are successfully downloaded in the network panel + And the response headers for the HTML document should include CDN-indicative headers + And the response headers for at least one static asset should include CDN-indicative headers + + @api @security @TC-PLAT-02 + Scenario Outline: API base URL /v2 reachable over HTTPS and enforces TLS 1.3 minimum using + Given the DNS name 'api.aegiscard.com' resolves to at least one IP address + When I perform a TLS handshake to host 'api.aegiscard.com' on port 443 forcing + Then the TLS handshake result should be + And the negotiated TLS version should be + + Examples: + | tls_mode | handshake_result | negotiated_tls_version | + | TLS1.3 | SUCCESS | TLSv1.3 | + | TLS1.2 | FAILURE | NONE | + + @api @security @TC-PLAT-02 + Scenario: API v2 endpoint is reachable over HTTPS (network level) + Given the DNS name 'api.aegiscard.com' resolves to at least one IP address + When I send a POST request to '/v2/auth/login' with payload: + """ + {} + """ + Then the response status should be one of: + | status | + | 200 | + | 400 | + | 401 | + | 422 | + | 429 | + And the request should not fail due to TLS or connection errors + + # --------------------------------------------------------------------------- + # Registration + # --------------------------------------------------------------------------- + + @api @functional @TC-REG-01 + Scenario: Register user succeeds (201) and returns onboarding artifacts + Given I have generated a unique email 'test+reg01-${RUN_ID}@example.com' + When I send a POST request to '/v2/auth/register' with payload: + """ + { + "first_name": "Alicia", + "last_name": "Tester", + "email": "test+reg01-${RUN_ID}@example.com", + "password": "Str0ng!Passw0rd", + "date_of_birth": "1990-01-15", + "phone_number": "+14165550101", + "ssn_last4": "1234", + "agree_terms": true + } + """ + Then the response status should be 201 + And the response JSON should contain: + | path | + | user_id | + | verification_token | + And the response JSON path 'user_id' should not be empty + And the response JSON path 'verification_token' should not be empty + + @api @negative @TC-REG-01 + Scenario: Registering the same email twice indicates the account now exists + Given I have generated a unique email 'test+reg01dup-${RUN_ID}@example.com' + When I send a POST request to '/v2/auth/register' with payload: + """ + { + "first_name": "Alicia", + "last_name": "Tester", + "email": "test+reg01dup-${RUN_ID}@example.com", + "password": "Str0ng!Passw0rd", + "date_of_birth": "1990-01-15", + "phone_number": "+14165550101", + "ssn_last4": "1234", + "agree_terms": true + } + """ + Then the response status should be 201 + When I send the same POST request to '/v2/auth/register' with the same payload again + Then the response status should not be 201 + + @api @negative @TC-REG-02 + Scenario Outline: Register rejects when agree_terms is false and succeeds when corrected + Given I have generated a unique email '' + When I send a POST request to '/v2/auth/register' with payload: + """ + { + "first_name": "Brandon", + "last_name": "Consent", + "email": "", + "password": "Str0ng!Passw0rd", + "date_of_birth": "1992-06-20", + "phone_number": "+14165550102", + "ssn_last4": "5678", + "agree_terms": + } + """ + Then the response status should be + And the response JSON should contain: + | path | + | user_id | + | verification_token | + + Examples: + | email | agree_terms | status | user_artifacts_presence | + | test+reg02-${RUN_ID}@example.com | false | 400 | not | + | test+reg02fix-${RUN_ID}@example.com| true | 201 | | + + @api @negative @TC-REG-03 + Scenario: Register returns 409 EMAIL_EXISTS when email already registered + Given the email 'test+reg03-existing@example.com' is already registered + When I send a POST request to '/v2/auth/register' with payload: + """ + { + "first_name": "Casey", + "last_name": "Duplicate", + "email": "test+reg03-existing@example.com", + "password": "Str0ng!Passw0rd", + "date_of_birth": "1991-03-10", + "phone_number": "+14165550103", + "ssn_last4": "9012", + "agree_terms": true + } + """ + Then the response status should be 409 + And the response JSON path 'error' should equal 'EMAIL_EXISTS' + And the response JSON should not contain: + | path | + | user_id | + | verification_token | + + @api @negative @TC-REG-04 + Scenario Outline: Register returns 422 WEAK_PASSWORD for weak passwords + Given I have generated a unique email '' + When I send a POST request to '/v2/auth/register' with payload: + """ + { + "first_name": "Password", + "last_name": "Weak", + "email": "", + "password": "", + "date_of_birth": "1990-01-01", + "phone_number": "+14165550104", + "ssn_last4": "1234", + "agree_terms": true + } + """ + Then the response status should be + And the response JSON path 'error' should equal '' + And the response JSON should contain: + | path | + | user_id | + | verification_token | + + Examples: + | email | password | status | error_code | error_assertion | token_presence | + | test+reg04a-${RUN_ID}@example.com | password | 422 | WEAK_PASSWORD | | not | + | test+reg04b-${RUN_ID}@example.com | Abcdefghijk1 | 422 | WEAK_PASSWORD | | not | + | test+reg04ctrl-${RUN_ID}@example.com | Str0ng!Passw0rd | 201 | | not | | + + @api @boundary @TC-REG-05 + Scenario Outline: Registration first_name validation (alpha-only and length 2-50) + Given I have generated a unique email '' + When I send a POST request to '/v2/auth/register' with payload: + """ + { + "first_name": "", + "last_name": "Smith", + "email": "", + "password": "Str0ng!Passw0rd", + "date_of_birth": "1990-01-01", + "phone_number": "+14165550111", + "ssn_last4": "1234", + "agree_terms": true + } + """ + Then the response status should be + And for error responses the response JSON should contain: + | path | + | error | + | field | + | message | + + Examples: + | email | first_name | status | + | test+regfn2-${RUN_ID}@example.com | Al | 201 | + | test+regfn50-${RUN_ID}@example.com | AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA | 201 | + | test+regfn1-${RUN_ID}@example.com | A | 400 | + | test+regfn51-${RUN_ID}@example.com | BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB | 400 | + | test+regfnDigit-${RUN_ID}@example.com| Jo3 | 400 | + | test+regfnHyphen-${RUN_ID}@example.com| Jo-An | 400 | + + @api @negative @TC-REG-07 + Scenario Outline: Registration rejects malformed email formats + When I send a POST request to '/v2/auth/register' with payload: + """ + { + "first_name": "Email", + "last_name": "Format", + "email": "", + "password": "Str0ng!Passw0rd", + "date_of_birth": "1990-01-01", + "phone_number": "+14165550112", + "ssn_last4": "1234", + "agree_terms": true + } + """ + Then the response status should be + And for status 400 the response JSON should contain: + | path | + | error | + | field | + | message | + + Examples: + | email | status | + | test+emailctrl-${RUN_ID}@example.com | 201 | + | plainaddress | 400 | + | missingdomain@ | 400 | + | @missinglocal.example.com | 400 | + | test..dots@example.com | 400 | + | test+bad space@example.com | 400 | + + @api @boundary @TC-REG-08 + Scenario Outline: Registration password complexity and boundary length enforcement + Given I have generated a unique email '' + When I send a POST request to '/v2/auth/register' with payload: + """ + { + "first_name": "Pw", + "last_name": "Rule", + "email": "", + "password": "", + "date_of_birth": "1990-01-01", + "phone_number": "+14165550113", + "ssn_last4": "1234", + "agree_terms": true + } + """ + Then the response status should be + And for status 422 the response JSON path 'error' should equal 'WEAK_PASSWORD' + + Examples: + | email | password | status | + | test+pwdok1-${RUN_ID}@example.com | Aa1!aaaaaaaab | 201 | + | test+pwdlen11-${RUN_ID}@example.com| Aa1!aaaaaaa | 422 | + | test+pwdlower-${RUN_ID}@example.com| aaaaaaaaaaaa | 422 | + | test+pwdupper-${RUN_ID}@example.com| AAAAAAAAAAAA | 422 | + | test+pwddigit-${RUN_ID}@example.com| Aa!aaaaaaaaaa | 422 | + | test+pwdsym-${RUN_ID}@example.com | Aa1aaaaaaaaaaa | 422 | + | test+pwdlen12-${RUN_ID}@example.com| Aa1!aaaaaaaa | 201 | + + @api @boundary @TC-REG-09 + Scenario Outline: Registration date_of_birth format and age >= 18 enforcement + Given I have generated a unique email '' + When I send a POST request to '/v2/auth/register' with payload: + """ + { + "first_name": "Dob", + "last_name": "Rule", + "email": "", + "password": "Str0ng!Passw0rd", + "date_of_birth": "", + "phone_number": "+14165550114", + "ssn_last4": "1234", + "agree_terms": true + } + """ + Then the response status should be + And for status 400 the response JSON should contain: + | path | + | error | + | field | + | message | + + Examples: + | email | date_of_birth | status | + | test+dob18-${RUN_ID}@example.com | ${DOB_EXACTLY_18} | 201 | + | test+dob17-${RUN_ID}@example.com | ${DOB_UNDER_18_BY_1_DAY} | 400 | + | test+dobfmt1-${RUN_ID}@example.com| 04/29/2000 | 400 | + | test+dobfmt2-${RUN_ID}@example.com| 2000-13-01 | 400 | + | test+dobfmt3-${RUN_ID}@example.com| 2000-01-01T00:00:00Z | 400 | + + @api @negative @TC-REG-10 + Scenario Outline: Registration rejects non-E.164 phone_number formats + Given I have generated a unique email '' + When I send a POST request to '/v2/auth/register' with payload: + """ + { + "first_name": "Phone", + "last_name": "Rule", + "email": "", + "password": "Str0ng!Passw0rd", + "date_of_birth": "1990-01-01", + "phone_number": "", + "ssn_last4": "1234", + "agree_terms": true + } + """ + Then the response status should be + And for status 400 the response JSON path 'field' should equal 'phone_number' + + Examples: + | email | phone_number | status | + | test+phoneok-${RUN_ID}@example.com | +14165551234 | 201 | + | test+phonebad1-${RUN_ID}@example.com| 4165551234 | 400 | + | test+phonebad2-${RUN_ID}@example.com| ++14165551234 | 400 | + + @api @boundary @TC-REG-11 + Scenario Outline: Registration ssn_last4 must be exactly 4 numeric digits + Given I have generated a unique email '' + When I send a POST request to '/v2/auth/register' with payload: + """ + { + "first_name": "SSN", + "last_name": "Rule", + "email": "", + "password": "Str0ng!Passw0rd", + "date_of_birth": "1990-01-01", + "phone_number": "+14165550115", + "ssn_last4": "", + "agree_terms": true + } + """ + Then the response status should be + And for status 400 the response JSON path 'field' should equal 'ssn_last4' + + Examples: + | email | ssn_last4 | status | + | test+ssnok-${RUN_ID}@example.com| 1234 | 201 | + | test+ssn3-${RUN_ID}@example.com | 123 | 400 | + | test+ssn5-${RUN_ID}@example.com | 12345 | 400 | + | test+ssnA-${RUN_ID}@example.com | 12A4 | 400 | + + @api @negative @TC-REG-12 + Scenario Outline: Registration missing required field returns 400 with error, field, message + Given I have generated a unique email '' + When I send a POST request to '/v2/auth/register' with payload: + """ + { + "last_name": "Missing", + "email": "", + "password": "Str0ng!Passw0rd", + "date_of_birth": "1990-01-01", + "phone_number": "+14165550116", + "ssn_last4": "1234", + "agree_terms": true + } + """ + Then the response status should be 400 + And the response JSON should contain: + | path | + | error | + | field | + | message | + And the response JSON path 'field' should equal '' + + Examples: + | email | missing_field | + | test+regmissing-${RUN_ID}@example.com | first_name | + + # --------------------------------------------------------------------------- + # Login / MFA / Lockout / Rate limiting + # --------------------------------------------------------------------------- + + @api @functional @TC-LOGIN-01 + Scenario: Login success returns access_token, refresh_token, expires_in and token works on a protected endpoint + Given a registered user exists with email 'test+login01@example.com' and password 'Str0ng!Passw0rd' + When I send a POST request to '/v2/auth/login' with payload: + """ + { + "email": "test+login01@example.com", + "password": "Str0ng!Passw0rd" + } + """ + Then the response status should be 200 + And the response JSON should contain: + | path | + | access_token | + | refresh_token | + | expires_in | + When I store the response JSON path 'access_token' as 'access_token' + And I send a GET request to '/v2/accounts/${ACCOUNT_ID}/summary' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + Then the response status should not be 401 + + @api @negative @TC-LOGIN-01 + Scenario: Tampered access token is rejected for protected endpoint + Given I have a valid access token stored as 'access_token' + When I send a GET request to '/v2/accounts/${ACCOUNT_ID}/summary' with headers: + | Header | Value | + | Authorization | Bearer ${access_token}_TAMPERED | + Then the response status should be one of: + | status | + | 401 | + | 403 | + + @api @negative @TC-LOGIN-02 + Scenario: Login returns 401 INVALID_CREDENTIALS for wrong password + Given a registered user exists with email 'test+login02@example.com' and password 'Str0ng!Passw0rd' + When I send a POST request to '/v2/auth/login' with payload: + """ + { + "email": "test+login02@example.com", + "password": "Wr0ng!Passw0rd" + } + """ + Then the response status should be 401 + And the response JSON path 'error' should equal 'INVALID_CREDENTIALS' + And the response JSON should not contain: + | path | + | access_token | + | refresh_token | + | expires_in | + + @api @functional @TC-LOGIN-03 + Scenario: Login with MFA enabled accepts optional 6-digit TOTP mfa_code + Given a registered MFA-enabled user exists with email 'test+mfa@example.com' and password 'Str0ng!Passw0rd' + And I have generated a valid 6-digit TOTP code for that user as 'mfa_code' + When I send a POST request to '/v2/auth/login' with payload: + """ + { + "email": "test+mfa@example.com", + "password": "Str0ng!Passw0rd", + "mfa_code": "${mfa_code}" + } + """ + Then the response status should be 200 + And the response JSON should contain: + | path | + | access_token | + | refresh_token | + | expires_in | + + @api @state-transition @TC-LOGIN-04 + Scenario: Account locks after 5 failed login attempts and returns 403 ACCOUNT_LOCKED + unlock_at + Given a registered user exists with email 'test+lockout@example.com' and password 'Str0ng!Passw0rd' + When I send 5 POST requests to '/v2/auth/login' with payload: + """ + { + "email": "test+lockout@example.com", + "password": "WrongPass!0000" + } + """ + Then each response status should be 401 + When I send a 6th POST request to '/v2/auth/login' with payload: + """ + { + "email": "test+lockout@example.com", + "password": "WrongPass!0000" + } + """ + Then the response status should be 403 + And the response JSON path 'error' should equal 'ACCOUNT_LOCKED' + And the response JSON should contain: + | path | + | unlock_at | + + @api @resilience @TC-LOGIN-05 + Scenario: Login rate limit returns 429 RATE_LIMITED with retry_after after exceeding 10 req/min per IP + Given a registered user exists with email 'test+rl01@example.com' and password 'ValidPass!12345' + When I send 10 POST requests to '/v2/auth/login' within 60 seconds with payload: + """ + { + "email": "test+rl01@example.com", + "password": "ValidPass!12345" + } + """ + Then each response status should be 200 + When I send 1 more POST request to '/v2/auth/login' within the same 60 seconds with payload: + """ + { + "email": "test+rl01@example.com", + "password": "ValidPass!12345" + } + """ + Then the response status should be 429 + And the response JSON path 'error' should equal 'RATE_LIMITED' + And the response JSON should contain: + | path | + | retry_after | + + @api @negative @TC-LOGIN-06 + Scenario Outline: Login fails for unknown email and does not issue tokens + Given a registered user exists with email 'test+login06-registered@example.com' and password 'ValidPassw0rd!234' + When I send a POST request to '/v2/auth/login' with payload: + """ + { + "email": "", + "password": "ValidPassw0rd!234" + } + """ + Then the response status should be + And the response JSON should contain: + | path | + | access_token | + | refresh_token | + | expires_in | + And for status 401 the response JSON path 'error' should equal 'INVALID_CREDENTIALS' + + Examples: + | email | status | token_presence | + | test+login06-registered@example.com| 200 | | + | test+login06-unknown@example.com | 401 | not | + + @api @boundary @TC-LOGIN-07 + Scenario Outline: Login device_id UUID v4 validation when provided + Given a registered user exists with email 'test+login07@example.com' and password 'ValidPassw0rd!234' + When I send a POST request to '/v2/auth/login' with payload: + """ + { + "email": "test+login07@example.com", + "password": "ValidPassw0rd!234", + "device_id": "" + } + """ + Then the response status should be + And for non-200 responses the response JSON should not contain: + | path | + | access_token | + | refresh_token | + + Examples: + | device_id | status | + | 550e8400-e29b-41d4-a716-446655440000 | 200 | + | 550e8400-e29b-11d4-a716-446655440000 | 400 | + | not-a-uuid | 400 | + + @api @functional @TC-LOGIN-08 + Scenario: remember_me=true extends refresh token TTL to 30 days (observable via cookie/token metadata) + Given a registered user exists with email 'test+login08@example.com' and password 'ValidPassw0rd!234' + When I send a POST request to '/v2/auth/login' with payload: + """ + { + "email": "test+login08@example.com", + "password": "ValidPassw0rd!234", + "remember_me": false + } + """ + Then the response status should be 200 + When I record the refresh token TTL/expiry metadata as 'baseline_refresh_ttl' + And I send a POST request to '/v2/auth/login' with payload: + """ + { + "email": "test+login08@example.com", + "password": "ValidPassw0rd!234", + "remember_me": true + } + """ + Then the response status should be 200 + And I record the refresh token TTL/expiry metadata as 'remember_me_refresh_ttl' + And the 'remember_me_refresh_ttl' should indicate approximately 30 days + + # --------------------------------------------------------------------------- + # Token Refresh + # --------------------------------------------------------------------------- + + @api @functional @TC-REFRESH-01 + Scenario: Token refresh returns new access_token and refresh_token (rotation enforced) + Given a registered user exists with email 'test+rt01@example.com' and password 'ValidPassw0rd!234' + When I send a POST request to '/v2/auth/login' with payload: + """ + { + "email": "test+rt01@example.com", + "password": "ValidPassw0rd!234" + } + """ + Then the response status should be 200 + When I store the response JSON path 'access_token' as 'access_token_1' + And I store the response JSON path 'refresh_token' as 'refresh_token_1' + And I send a POST request to '/v2/auth/token/refresh' with payload: + """ + { + "refresh_token": "${refresh_token_1}" + } + """ + Then the response status should be 200 + And the response JSON should contain: + | path | + | access_token | + | refresh_token | + And the response JSON path 'access_token' should not equal '${access_token_1}' + And the response JSON path 'refresh_token' should not equal '${refresh_token_1}' + + @api @security @TC-REFRESH-02 + Scenario: Token refresh invalidates old refresh token after successful refresh (single-use) + Given a registered user exists with email 'test+rt02@example.com' and password 'ValidPassw0rd!234' + When I send a POST request to '/v2/auth/login' with payload: + """ + { + "email": "test+rt02@example.com", + "password": "ValidPassw0rd!234" + } + """ + Then the response status should be 200 + When I store the response JSON path 'refresh_token' as 'refresh_token_1' + And I send a POST request to '/v2/auth/token/refresh' with payload: + """ + { + "refresh_token": "${refresh_token_1}" + } + """ + Then the response status should be 200 + When I store the response JSON path 'refresh_token' as 'refresh_token_2' + And I send a POST request to '/v2/auth/token/refresh' with payload: + """ + { + "refresh_token": "${refresh_token_1}" + } + """ + Then the response status should be 401 + And the response JSON path 'error' should equal 'TOKEN_INVALID' + When I send a POST request to '/v2/auth/token/refresh' with payload: + """ + { + "refresh_token": "${refresh_token_2}" + } + """ + Then the response status should be 200 + + @api @negative @TC-REFRESH-04 + Scenario Outline: Token refresh requires refresh_token field (missing/empty fails; valid succeeds) + Given a registered user exists with email 'test+rt04@example.com' and password 'ValidPassw0rd!234' + And I have obtained a valid refresh token as 'valid_refresh_token' via login + When I send a POST request to '/v2/auth/token/refresh' with payload: + """ + + """ + Then the response status should be + + Examples: + | payload | status | + | {} | 400 | + | {"refresh_token": ""} | 400 | + | {"refresh_token": "${valid_refresh_token}"} | 200 | + + # --------------------------------------------------------------------------- + # Portal token storage / PCI / CSRF / Session (UI-focused) + # --------------------------------------------------------------------------- + + @ui @security @TC-TOKSTORE-01 + Scenario: Auth tokens stored in HttpOnly, Secure cookies and not accessible via document.cookie + Given I open a clean browser context + And I am on the '${PORTAL_BASE_URL}' page + When I log in via the portal UI with email 'test+cookie01@example.com' and password 'Str0ng!Passw0rd' + Then I should be on an authenticated page + And the auth/session cookies should have the 'HttpOnly' attribute + And the auth/session cookies should have the 'Secure' attribute + When I evaluate JavaScript 'document.cookie' + Then the result should not include token cookie names or token-like values + + @ui @security @TC-TOKSTORE-02 + Scenario: Auth tokens are never stored in localStorage or sessionStorage + Given I open a clean browser context + And I am on the '${PORTAL_BASE_URL}' page + When I log in via the portal UI with email 'test+ls01@example.com' and password 'Str0ng!Passw0rd' + Then I should be on an authenticated page + When I inspect localStorage for token-like keys and values + Then localStorage should not contain access or refresh tokens + When I inspect sessionStorage for token-like keys and values + Then sessionStorage should not contain access or refresh tokens + + @ui @functional @TC-DRAFT-01 + Scenario: Credit application auto-save occurs every 60 seconds to localStorage draft + Given I open a clean browser context + And I am on the credit application form page + And localStorage for the portal origin is empty + When I enter 'Jordan Test' in the 'Full legal name' field + And I enter '+14165550199' in the 'Phone number' field + And I wait 60 seconds + Then localStorage should contain an application draft entry + When I change the 'Phone number' field to '+14165550200' + And I wait 60 seconds + Then the application draft in localStorage should reflect the updated phone number + When I refresh the page + Then the application form should restore values from the saved draft + + @ui @security @TC-DRAFT-02 + Scenario: Draft auto-save does not store auth tokens in localStorage + Given I open a clean browser context + And I am on the '${PORTAL_BASE_URL}' page + When I log in via the portal UI with email 'test+draft02@example.com' and password 'Str0ng!Passw0rd' + And I navigate to the credit application flow + And I enter 'Jordan Test' in the 'Full legal name' field + And I wait 60 seconds + Then localStorage should contain a draft entry + And localStorage should not contain token-like keys or values + And sessionStorage should not contain token-like keys or values + + @ui @security @TC-PAN-01 + Scenario: PAN displayed in browser only as masked format + Given I open a clean browser context + And I am on the '${PORTAL_BASE_URL}' page + When I log in via the portal UI with email 'test+pan01@example.com' and password 'Str0ng!Passw0rd' + And I navigate to the card details area + Then I should see a masked PAN like '**** **** **** 4242' + And the DOM should not contain an unmasked PAN-like digit sequence + + @ui @security @TC-PCI-02 + Scenario: Card fields use iframe tokenisation and no raw PAN in DOM + Given I open a clean browser context + And I am on the '${PORTAL_BASE_URL}' page + When I log in via the portal UI with email 'test+pciform@example.com' and password 'Str0ng!Passw0rd' + And I navigate to the card entry form + Then I should see card input fields rendered inside one or more iframes + When I type a synthetic card number '4242 4242 4242 4242' into the card number field + Then the top-level DOM should not contain '4242424242424242' + And the network payload for the form submission should not contain raw PAN digits + + @ui @security @TC-CSRF-02 + Scenario: CSRF cookie policy SameSite=Strict is enforced for portal sessions + Given I open a clean browser context + And I am on the '${PORTAL_BASE_URL}' page + When I log in via the portal UI with email 'test+samesite@example.com' and password 'Str0ng!Passw0rd' + Then the portal session cookies should have 'SameSite=Strict' + + @ui @security @TC-SESSION-01 + Scenario: Portal session expires after 15 minutes of inactivity + Given I open a clean browser context + And I am on the '${PORTAL_BASE_URL}' page + When I log in via the portal UI with email 'test+session01@example.com' and password 'Str0ng!Passw0rd' + Then I should be on an authenticated page + When I remain inactive for 15 minutes + Then I should be redirected to the login page when accessing a protected page + + @ui @functional @TC-SESSION-02 + Scenario: Portal shows 2-minute warning modal before auto-logout + Given I open a clean browser context + And I am on the '${PORTAL_BASE_URL}' page + When I log in via the portal UI with email 'test+session02@example.com' and password 'Str0ng!Passw0rd' + And I remain inactive for 13 minutes + Then I should see an inactivity warning modal + When I do not interact for 2 more minutes + Then I should be logged out automatically + + # --------------------------------------------------------------------------- + # Applications Step 1 + # --------------------------------------------------------------------------- + + @api @functional @TC-APP1-01 + Scenario: Application Step 1 succeeds and returns application_id and session_token + Given I am authenticated as 'test+app1ok@example.com' with password 'Str0ng!Passw0rd' + When I send a POST request to '/v2/applications/start' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "full_legal_name": "Jordan Avery Test", + "email": "test+app1ok@example.com", + "phone_number": "+14165550101", + "residential_address": { + "street": "100 King St W", + "city": "Toronto", + "province": "ON", + "postal_code": "M5H 1J9" + }, + "id_type": "PASSPORT", + "id_number": "A1B2C3D4" + } + """ + Then the response status should be 201 + And the response JSON should contain: + | path | + | application_id | + | session_token | + + @api @boundary @TC-APP1-02 + Scenario Outline: Step 1 full_legal_name boundary validation (100 accepted, 101 rejected) + Given I am authenticated as '' with password 'Str0ng!Passw0rd' + When I send a POST request to '/v2/applications/start' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "full_legal_name": "", + "email": "", + "phone_number": "+14165550101", + "residential_address": { + "street": "100 King St W", + "city": "Toronto", + "province": "ON", + "postal_code": "M5H 1J9" + }, + "id_type": "PASSPORT", + "id_number": "A1B2C3D4" + } + """ + Then the response status should be + And for status 201 the response JSON should contain: + | path | + | application_id | + | session_token | + And for status 400 the response JSON path 'field' should equal 'full_legal_name' + + Examples: + | user_email | full_legal_name | status | + | test+app1bva100@example.com | ${STRING_LEN_100} | 201 | + | test+app1bva101@example.com | ${STRING_LEN_101} | 400 | + + @api @negative @TC-APP1-03 + Scenario: Step 1 email must match authenticated user email (reject mismatch) + Given I am authenticated as 'test+app1emailA@example.com' with password 'Str0ng!Passw0rd' + When I send a POST request to '/v2/applications/start' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "full_legal_name": "Email Mismatch Test", + "email": "test+app1emailB@example.com", + "phone_number": "+14165550101", + "residential_address": { + "street": "100 King St W", + "city": "Toronto", + "province": "ON", + "postal_code": "M5H 1J9" + }, + "id_type": "PASSPORT", + "id_number": "A1B2C3D4" + } + """ + Then the response status should be 400 + And the response JSON path 'field' should equal 'email' + And the response JSON should not contain: + | path | + | application_id | + | session_token | + + @api @negative @TC-APP1-04 + Scenario: Step 1 returns 409 DUPLICATE_APPLICATION when active application already in progress + Given I am authenticated as 'test+dupapp@example.com' with password 'Str0ng!Passw0rd' + When I send a POST request to '/v2/applications/start' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "full_legal_name": "Dup App Test", + "email": "test+dupapp@example.com", + "phone_number": "+14165550101", + "residential_address": { + "street": "100 King St W", + "city": "Toronto", + "province": "ON", + "postal_code": "M5H 1J9" + }, + "id_type": "PASSPORT", + "id_number": "A1B2C3D4" + } + """ + Then the response status should be 201 + When I send the same POST request to '/v2/applications/start' again with the same payload + Then the response status should be 409 + And the response JSON path 'error' should equal 'DUPLICATE_APPLICATION' + + @api @boundary @TC-APP1-05 + Scenario Outline: Step 1 phone_number must be E.164 (accept valid, reject invalid) + Given I am authenticated as '' with password 'Str0ng!Passw0rd' + When I send a POST request to '/v2/applications/start' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "full_legal_name": "Phone Format Test", + "email": "", + "phone_number": "", + "residential_address": { + "street": "100 King St W", + "city": "Toronto", + "province": "ON", + "postal_code": "M5H 1J9" + }, + "id_type": "PASSPORT", + "id_number": "A1B2C3D4" + } + """ + Then the response status should be + And for status 400 the response JSON path 'field' should equal 'phone_number' + + Examples: + | user_email | phone_number | status | + | test+app1phoneok@example.com | +14165550123 | 201 | + | test+app1phonebad1@example.com | 4165550123 | 400 | + | test+app1phonebad2@example.com | +1 (416) 555-0123 | 400 | + + @api @boundary @TC-APP1-06 + Scenario Outline: Step 1 address.street max 100 chars boundary + Given I am authenticated as '' with password 'Str0ng!Passw0rd' + When I send a POST request to '/v2/applications/start' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "full_legal_name": "Street Length Test", + "email": "", + "phone_number": "+14165550123", + "residential_address": { + "street": "", + "city": "Toronto", + "province": "ON", + "postal_code": "M5H 1J9" + }, + "id_type": "PASSPORT", + "id_number": "A1B2C3D4" + } + """ + Then the response status should be + And for status 400 the response JSON path 'field' should equal 'address.street' + + Examples: + | user_email | street | status | + | test+app1street100@example.com | ${STRING_LEN_100} | 201 | + | test+app1street101@example.com | ${STRING_LEN_101} | 400 | + + @api @boundary @TC-APP1-07 + Scenario Outline: Step 1 address.city max 60 chars boundary + Given I am authenticated as '' with password 'Str0ng!Passw0rd' + When I send a POST request to '/v2/applications/start' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "full_legal_name": "City Length Test", + "email": "", + "phone_number": "+14165550123", + "residential_address": { + "street": "100 King St W", + "city": "", + "province": "ON", + "postal_code": "M5H 1J9" + }, + "id_type": "PASSPORT", + "id_number": "A1B2C3D4" + } + """ + Then the response status should be + And for status 400 the response JSON path 'field' should equal 'address.city' + + Examples: + | user_email | city | status | + | test+app1city60@example.com | ${STRING_LEN_60} | 201 | + | test+app1city61@example.com | ${STRING_LEN_61} | 400 | + + @api @boundary @TC-APP1-08 + Scenario Outline: Step 1 province must be 2-char ISO 3166-2 code + Given I am authenticated as '' with password 'Str0ng!Passw0rd' + When I send a POST request to '/v2/applications/start' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "full_legal_name": "Province Test", + "email": "", + "phone_number": "+14165550123", + "residential_address": { + "street": "100 Test St", + "city": "Toronto", + "province": "", + "postal_code": "M5V 2T6" + }, + "id_type": "PASSPORT", + "id_number": "ZXCV1234" + } + """ + Then the response status should be + + Examples: + | user_email | province | status | + | test+app1provok@example.com | ON | 201 | + | test+app1prov3@example.com | ONT | 400 | + | test+app1prov1@example.com | O | 400 | + + @api @boundary @TC-APP1-09 + Scenario Outline: Step 1 postal_code must match Canadian format A1A 1A1 + Given I am authenticated as '' with password 'Str0ng!Passw0rd' + When I send a POST request to '/v2/applications/start' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "full_legal_name": "Postal Code Test", + "email": "", + "phone_number": "+14165550124", + "residential_address": { + "street": "200 Test Ave", + "city": "Ottawa", + "province": "ON", + "postal_code": "" + }, + "id_type": "DRIVERS_LICENSE", + "id_number": "D1E2F3G4" + } + """ + Then the response status should be + And for status 400 the response JSON path 'field' should equal 'address.postal_code' + + Examples: + | user_email | postal_code | status | + | test+app1pcok@example.com | K1A 0B1 | 201 | + | test+app1pcmissspace@example.com| K1A0B1 | 400 | + | test+app1pcbad@example.com | 123 456 | 400 | + + @api @boundary @TC-APP1-10 + Scenario Outline: Step 1 id_type enum enforcement + Given I am authenticated as '' with password 'Str0ng!Passw0rd' + When I send a POST request to '/v2/applications/start' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "full_legal_name": "ID Type Test", + "email": "", + "phone_number": "+14165550125", + "residential_address": { + "street": "100 King St W", + "city": "Toronto", + "province": "ON", + "postal_code": "M5H 1J9" + }, + "id_type": "", + "id_number": "ZXCV1234" + } + """ + Then the response status should be + + Examples: + | user_email | id_type | status | + | test+app1idpass@example.com| PASSPORT | 201 | + | test+app1iddl@example.com | DRIVERS_LICENSE | 201 | + | test+app1idpr@example.com | PR_CARD | 201 | + | test+app1idbad@example.com | NATIONAL_ID | 400 | + + @api @boundary @TC-APP1-11 + Scenario Outline: Step 1 id_number alphanumeric max 20 chars boundary + Given I am authenticated as '' with password 'Str0ng!Passw0rd' + When I send a POST request to '/v2/applications/start' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "full_legal_name": "ID Number Test", + "email": "", + "phone_number": "+14165550126", + "residential_address": { + "street": "100 King St W", + "city": "Toronto", + "province": "ON", + "postal_code": "K1A 0B1" + }, + "id_type": "PASSPORT", + "id_number": "" + } + """ + Then the response status should be + + Examples: + | user_email | id_number | status | + | test+app1idnum20@example.com | AB12CD34EF56GH78IJ90 | 201 | + | test+app1idnum21@example.com | AB12CD34EF56GH78IJ901 | 400 | + | test+app1idnumhy@example.com | ABC-123 | 400 | + + @api @negative @TC-APP1-12 + Scenario: Step 1 validation failure returns 400 with error, field, message + Given I am authenticated as 'test+app1invalid@example.com' with password 'Str0ng!Passw0rd' + When I send a POST request to '/v2/applications/start' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "full_legal_name": "Invalid Postal", + "email": "test+app1invalid@example.com", + "phone_number": "+14165550127", + "residential_address": { + "street": "100 King St W", + "city": "Toronto", + "province": "ON", + "postal_code": "INVALID" + }, + "id_type": "PASSPORT", + "id_number": "A1B2C3D4" + } + """ + Then the response status should be 400 + And the response JSON should contain: + | path | + | error | + | field | + | message | + + # --------------------------------------------------------------------------- + # Applications Step 2 (X-App-Session, sequencing, boundaries) + # --------------------------------------------------------------------------- + + @api @functional @TC-APP2-01 + Scenario: Application Step 2 succeeds with X-App-Session and returns PENDING_REVIEW and fico_pull_id + Given I am authenticated as 'test+app2ok@example.com' with password 'Str0ng!Passw0rd' + And I have created an application via Step 1 and stored 'application_id' and 'session_token' + When I send a POST request to '/v2/applications/${application_id}/financials' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-App-Session | ${session_token} | + And payload: + """ + { + "employment_status": "SELF_EMPLOYED", + "gross_annual_income": 85000.00, + "other_income": 0.00, + "monthly_rent": 1800.00, + "existing_debt_payments": 350.00, + "sin_consent": true + } + """ + Then the response status should be 200 + And the response JSON path 'status' should equal 'PENDING_REVIEW' + And the response JSON should contain: + | path | + | fico_pull_id | + + @api @negative @TC-APP2-02 + Scenario: Step 2 rejects when EMPLOYED but employer_name missing + Given I am authenticated as 'test+app2emp@example.com' with password 'Str0ng!Passw0rd' + And I have created an application via Step 1 and stored 'application_id' and 'session_token' + When I send a POST request to '/v2/applications/${application_id}/financials' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-App-Session | ${session_token} | + And payload: + """ + { + "employment_status": "EMPLOYED", + "gross_annual_income": 65000.00, + "monthly_rent": 1500.00, + "existing_debt_payments": 200.00, + "sin_consent": true + } + """ + Then the response status should be 400 + And the response JSON should contain: + | path | + | error | + | field | + | message | + And the response JSON path 'field' should equal 'employer_name' + + @api @boundary @TC-APP2-03 + Scenario Outline: Step 2 gross_annual_income boundary validation + Given I am authenticated as 'test+app2income@example.com' with password 'Str0ng!Passw0rd' + And I have created an application via Step 1 and stored 'application_id' and 'session_token' + When I send a POST request to '/v2/applications/${application_id}/financials' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-App-Session | ${session_token} | + And payload: + """ + { + "employment_status": "RETIRED", + "gross_annual_income": , + "monthly_rent": 0.00, + "existing_debt_payments": 0.00, + "sin_consent": true + } + """ + Then the response status should be + And for status 400 the response JSON path 'field' should equal 'gross_annual_income' + + Examples: + | gross_annual_income | status | + | 9999999.99 | 200 | + | 10000000.00 | 400 | + | 0.00 | 400 | + | -1.00 | 400 | + + @api @negative @TC-APP2-04 + Scenario Outline: Step 2 rejects when sin_consent is false or omitted; succeeds when true + Given I am authenticated as 'test+app2consent@example.com' with password 'Str0ng!Passw0rd' + And I have created an application via Step 1 and stored 'application_id' and 'session_token' + When I send a POST request to '/v2/applications/${application_id}/financials' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-App-Session | ${session_token} | + And payload: + """ + + """ + Then the response status should be + And for status 400 the response JSON path 'field' should equal 'sin_consent' + + Examples: + | payload | status | + | {"employment_status":"STUDENT","gross_annual_income":12000.00,"monthly_rent":650.00,"existing_debt_payments":50.00,"sin_consent":false} | 400 | + | {"employment_status":"STUDENT","gross_annual_income":12000.00,"monthly_rent":650.00,"existing_debt_payments":50.00} | 400 | + | {"employment_status":"STUDENT","gross_annual_income":12000.00,"monthly_rent":650.00,"existing_debt_payments":50.00,"sin_consent":true} | 200 | + + @api @state-transition @TC-APPSEQ-01 + Scenario: Enforce sequential completion: Step 2 cannot be completed without X-App-Session from Step 1 + Given I am authenticated as 'test+appseq01@example.com' with password 'Str0ng!Passw0rd' + When I send a POST request to '/v2/applications/11111111-1111-1111-1111-111111111111/financials' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "employment_status": "UNEMPLOYED", + "gross_annual_income": 30000.00, + "monthly_rent": 900.00, + "existing_debt_payments": 150.00, + "sin_consent": true + } + """ + Then the response status should not be 200 + When I create an application via Step 1 and store 'application_id' and 'session_token' + And I send a POST request to '/v2/applications/${application_id}/financials' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-App-Session | ${session_token} | + And payload: + """ + { + "employment_status": "UNEMPLOYED", + "gross_annual_income": 30000.00, + "monthly_rent": 900.00, + "existing_debt_payments": 150.00, + "sin_consent": true + } + """ + Then the response status should be 200 + And the response JSON path 'status' should equal 'PENDING_REVIEW' + And the response JSON should contain: + | path | + | fico_pull_id | + When I send a POST request to '/v2/applications/${application_id}/financials' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-App-Session | invalid-token | + And payload: + """ + { + "employment_status": "UNEMPLOYED", + "gross_annual_income": 30000.00, + "monthly_rent": 900.00, + "existing_debt_payments": 150.00, + "sin_consent": true + } + """ + Then the response status should be 401 + And the response JSON path 'error' should equal 'SESSION_EXPIRED' + + @api @negative @TC-APPSEQ-02 + Scenario: Step 2 returns 401 SESSION_EXPIRED when session_token is invalid + Given I am authenticated as 'test+appseq02@example.com' with password 'Str0ng!Passw0rd' + And I have created an application via Step 1 and stored 'application_id' and 'session_token' + When I send a POST request to '/v2/applications/${application_id}/financials' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-App-Session | invalid-session-token-01 | + And payload: + """ + { + "employment_status": "EMPLOYED", + "employer_name": "TestCo", + "gross_annual_income": 75000.00, + "other_income": 0.00, + "monthly_rent": 1500.00, + "existing_debt_payments": 250.00, + "sin_consent": true + } + """ + Then the response status should be 401 + And the response JSON path 'error' should equal 'SESSION_EXPIRED' + + @api @boundary @TC-APPSEQ-03 + Scenario: Validate X-App-Session required and expiry boundary (29:59 succeeds; 30:01 expires) + Given I am authenticated as 'test+appseq03@example.com' with password 'Str0ng!Passw0rd' + When I create an application via Step 1 and store 'application_id_1' and 'session_token_1' and record issuance time as 'T0' + And I send a POST request to '/v2/applications/${application_id_1}/financials' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "employment_status": "SELF_EMPLOYED", + "gross_annual_income": 90000.00, + "other_income": 5000.00, + "monthly_rent": 0.00, + "existing_debt_payments": 300.00, + "sin_consent": true + } + """ + Then the response status should not be 200 + When I wait until elapsed time since 'T0' is 29 minutes and 59 seconds + And I send a POST request to '/v2/applications/${application_id_1}/financials' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-App-Session | ${session_token_1} | + And payload: + """ + { + "employment_status": "SELF_EMPLOYED", + "gross_annual_income": 90000.00, + "other_income": 5000.00, + "monthly_rent": 0.00, + "existing_debt_payments": 300.00, + "sin_consent": true + } + """ + Then the response status should be 200 + When I create an application via Step 1 and store 'application_id_2' and 'session_token_2' and record issuance time as 'T1' + And I wait until elapsed time since 'T1' is 30 minutes and 1 second + And I send a POST request to '/v2/applications/${application_id_2}/financials' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-App-Session | ${session_token_2} | + And payload: + """ + { + "employment_status": "SELF_EMPLOYED", + "gross_annual_income": 90000.00, + "other_income": 5000.00, + "monthly_rent": 0.00, + "existing_debt_payments": 300.00, + "sin_consent": true + } + """ + Then the response status should be 401 + And the response JSON path 'error' should equal 'SESSION_EXPIRED' + + @api @functional @TC-APP2-05 + Scenario Outline: Step 2 other_income defaults to 0.00 when omitted + Given I am authenticated as 'test+app2otherincome@example.com' with password 'Str0ng!Passw0rd' + And I have created an application via Step 1 and stored 'application_id' and 'session_token' + When I send a POST request to '/v2/applications/${application_id}/financials' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-App-Session | ${session_token} | + And payload: + """ + + """ + Then the response status should be 200 + And the response JSON path 'status' should equal 'PENDING_REVIEW' + And the response JSON should contain: + | path | + | fico_pull_id | + + Examples: + | payload | + | {"employment_status":"EMPLOYED","employer_name":"TestCo Ltd","gross_annual_income":85000.00,"monthly_rent":1800.00,"existing_debt_payments":250.00,"sin_consent":true} | + | {"employment_status":"EMPLOYED","employer_name":"TestCo Ltd","gross_annual_income":85000.00,"other_income":0.00,"monthly_rent":1800.00,"existing_debt_payments":250.00,"sin_consent":true} | + + @api @boundary @TC-APP2-06 + Scenario Outline: Step 2 monthly_rent boundary allows 0.00 but rejects negative + Given I am authenticated as 'test+app2rent@example.com' with password 'Str0ng!Passw0rd' + And I have created an application via Step 1 and stored 'application_id' and 'session_token' + When I send a POST request to '/v2/applications/${application_id}/financials' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-App-Session | ${session_token} | + And payload: + """ + { + "employment_status": "RETIRED", + "gross_annual_income": 60000.00, + "monthly_rent": , + "existing_debt_payments": 0.00, + "sin_consent": true + } + """ + Then the response status should be + + Examples: + | monthly_rent | status | + | 0.00 | 200 | + | -0.01 | 400 | + + @api @boundary @TC-APP2-07 + Scenario Outline: Step 2 existing_debt_payments enforces Decimal(10,2) precision + Given I am authenticated as 'test+app2debt@example.com' with password 'Str0ng!Passw0rd' + And I have created an application via Step 1 and stored 'application_id' and 'session_token' + When I send a POST request to '/v2/applications/${application_id}/financials' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-App-Session | ${session_token} | + And payload: + """ + { + "employment_status": "SELF_EMPLOYED", + "gross_annual_income": 120000.00, + "monthly_rent": 1500.00, + "existing_debt_payments": , + "sin_consent": true + } + """ + Then the response status should be + + Examples: + | existing_debt_payments | status | + | 1234.56 | 200 | + | 1234.567 | 400 | + + @api @negative @TC-APP2-08 + Scenario: Step 2 missing required field returns 400 with error, field, message + Given I am authenticated as 'test+app2missing@example.com' with password 'Str0ng!Passw0rd' + And I have created an application via Step 1 and stored 'application_id' and 'session_token' + When I send a POST request to '/v2/applications/${application_id}/financials' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-App-Session | ${session_token} | + And payload: + """ + { + "employment_status": "EMPLOYED", + "employer_name": "TestCo Ltd", + "monthly_rent": 1200.00, + "existing_debt_payments": 100.00, + "sin_consent": true + } + """ + Then the response status should be 400 + And the response JSON should contain: + | path | + | error | + | field | + | message | + + # --------------------------------------------------------------------------- + # Applications Step 3 (decisions, signature, defaults) + # --------------------------------------------------------------------------- + + @api @functional @TC-APP3-01 + Scenario: Step 3 approved decision returns credit_limit and card_number_masked + Given I am authenticated as 'test+app3approved@example.com' with password 'Str0ng!Passw0rd' + And I have completed Step 1 and Step 2 successfully and stored 'application_id' and 'session_token' + And the decisioning stub is configured for application '${application_id}' with FICO value 700 + When I send a POST request to '/v2/applications/${application_id}/submit' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-App-Session | ${session_token} | + And payload: + """ + { + "card_product_id": "AEGIS_GOLD", + "e_signature": "U3ludGhldGljIFNpZ25hdHVyZQ==" + } + """ + Then the response status should be 200 + And the response JSON path 'decision' should equal 'APPROVED' + And the response JSON should contain: + | path | + | credit_limit | + | card_number_masked | + And the response JSON path 'card_number_masked' should not match the regex '\d{13,19}' + + @api @functional @TC-APP3-02 + Scenario: Step 3 pending decision includes review_eta_hours + Given I am authenticated as 'test+app3pending@example.com' with password 'Str0ng!Passw0rd' + And I have completed Step 1 and Step 2 successfully and stored 'application_id' and 'session_token' + And the decisioning stub is configured for application '${application_id}' with FICO value 650 + When I send a POST request to '/v2/applications/${application_id}/submit' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-App-Session | ${session_token} | + And payload: + """ + { + "card_product_id": "AEGIS_GOLD", + "e_signature": "VGVzdCBTaWduYXR1cmU=", + "marketing_opt_in": true + } + """ + Then the response status should be 200 + And the response JSON path 'decision' should equal 'PENDING' + And the response JSON should contain: + | path | + | review_eta_hours | + + @api @functional @TC-APP3-03 + Scenario: Step 3 declined decision includes reason_code + Given I am authenticated as 'test+app3decline@example.com' with password 'Str0ng!Passw0rd' + And I have completed Step 1 and Step 2 successfully and stored 'application_id' and 'session_token' + And the decisioning stub is configured for application '${application_id}' with FICO value 550 + When I send a POST request to '/v2/applications/${application_id}/submit' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-App-Session | ${session_token} | + And payload: + """ + { + "card_product_id": "AEGIS_GOLD", + "e_signature": "RGVjbGluZSBUZXN0IFNpZw==" + } + """ + Then the response status should be 200 + And the response JSON path 'decision' should equal 'DECLINED' + And the response JSON should contain: + | path | + | reason_code | + And the response body should not match the regex '\d{13,19}' + + @api @negative @TC-APP3-04 + Scenario Outline: Step 3 rejects missing/malformed e_signature with 400 SIGNATURE_REQUIRED + Given I am authenticated as 'test+app3sig@example.com' with password 'Str0ng!Passw0rd' + And I have completed Step 1 and Step 2 successfully and stored 'application_id' and 'session_token' + When I send a POST request to '/v2/applications/${application_id}/submit' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-App-Session | ${session_token} | + And payload: + """ + + """ + Then the response status should be 400 + And the response JSON path 'error' should equal 'SIGNATURE_REQUIRED' + + Examples: + | payload | + | {"card_product_id":"AEGIS_GOLD"} | + | {"card_product_id":"AEGIS_GOLD","e_signature":"not-base64"} | + + @api @functional @TC-APP3-05 + Scenario: Step 3 marketing_opt_in defaults to false when omitted (if observable) + Given I am authenticated as 'test+app3marketing@example.com' with password 'Str0ng!Passw0rd' + And I have completed Step 1 and Step 2 successfully and stored 'application_id' and 'session_token' + When I send a POST request to '/v2/applications/${application_id}/submit' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-App-Session | ${session_token} | + And payload: + """ + { + "card_product_id": "AEGIS_GOLD", + "e_signature": "VGVzdCBTaWduYXR1cmU=" + } + """ + Then the response status should be 200 + And the response JSON should contain: + | path | + | decision | + And if the response JSON contains 'marketing_opt_in' then the response JSON path 'marketing_opt_in' should equal 'false' + + @api @boundary @TC-APP3-08 + Scenario: Decision boundary at FICO 680 returns PENDING and includes review_eta_hours + Given I am authenticated as 'test+app3b680@example.com' with password 'Str0ng!Passw0rd' + And I have completed Step 1 and Step 2 successfully and stored 'application_id' and 'session_token' + And the decisioning stub is configured for application '${application_id}' with FICO value 680 + When I send a POST request to '/v2/applications/${application_id}/submit' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-App-Session | ${session_token} | + And payload: + """ + { + "card_product_id": "AEGIS_GOLD", + "e_signature": "VGVzdCBVc2Vy" + } + """ + Then the response status should be 200 + And the response JSON path 'decision' should equal 'PENDING' + And the response JSON should contain: + | path | + | review_eta_hours | + And the response JSON should not contain: + | path | + | credit_limit | + | card_number_masked | + + @api @boundary @TC-APP3-10 + Scenario: Decision boundary at FICO 599 returns DECLINED and includes reason_code + Given I am authenticated as 'test+app3b599@example.com' with password 'Str0ng!Passw0rd' + And I have completed Step 1 and Step 2 successfully and stored 'application_id' and 'session_token' + And the decisioning stub is configured for application '${application_id}' with FICO value 599 + When I send a POST request to '/v2/applications/${application_id}/submit' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-App-Session | ${session_token} | + And payload: + """ + { + "card_product_id": "AEGIS_GOLD", + "e_signature": "VGVzdCBVc2Vy" + } + """ + Then the response status should be 200 + And the response JSON path 'decision' should equal 'DECLINED' + And the response JSON should contain: + | path | + | reason_code | + + @api @functional @TC-APP3-06 + Scenario: Step 3 card_product_id must come from GET /v2/products + Given I am authenticated as 'test+app3products@example.com' with password 'Str0ng!Passw0rd' + When I send a GET request to '/v2/products' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + Then the response status should be 200 + When I store a valid product id from the response as 'card_product_id_valid' + And I have completed Step 1 and Step 2 successfully and stored 'application_id' and 'session_token' + When I send a POST request to '/v2/applications/${application_id}/submit' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-App-Session | ${session_token} | + And payload: + """ + { + "card_product_id": "${card_product_id_valid}", + "e_signature": "VGVzdCBTaWduYXR1cmU=" + } + """ + Then the response status should be 200 + When I create a new application and complete Step 1 and Step 2 and store 'application_id2' and 'session_token2' + And I send a POST request to '/v2/applications/${application_id2}/submit' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-App-Session | ${session_token2} | + And payload: + """ + { + "card_product_id": "NOT_A_REAL_PRODUCT", + "e_signature": "VGVzdCBTaWduYXR1cmU=" + } + """ + Then the response status should be 400 + And the response JSON should contain: + | path | + | error | + | field | + | message | + + # --------------------------------------------------------------------------- + # Transactions: initiate, limits, FX, list + # --------------------------------------------------------------------------- + + @api @functional @TC-TXN-01 + Scenario: Initiate transaction approved path returns transaction_id, available_credit, auth_code + Given I am authenticated as 'test+txn01@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + When I send a POST request to '/v2/accounts/${account_id}/transactions' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "transaction_amount": 25.50, + "merchant_name": "Test Coffee Shop", + "merchant_id": "TESTMERCH0001", + "mcc_code": "5812", + "currency_code": "CAD", + "transaction_type": "PURCHASE", + "description": "QA approval test" + } + """ + Then the response status should be 200 + And the response JSON should contain: + | path | + | transaction_id | + | available_credit | + | auth_code | + + @api @functional @TC-TXN-02 + Scenario: Essential service over-limit within 5% buffer returns over_limit_flag true + Given I am authenticated as 'test+txn02@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' with available_credit configured to 100.00 + When I send a POST request to '/v2/accounts/${account_id}/transactions' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "transaction_amount": 104.00, + "merchant_name": "Essential Utility", + "merchant_id": "ESSUTIL0001", + "mcc_code": "4900", + "currency_code": "CAD", + "transaction_type": "PURCHASE", + "description": "Essential over-limit buffer test" + } + """ + Then the response status should be 200 + And the response JSON should contain: + | path | + | transaction_id | + | over_limit_flag | + And the response JSON path 'over_limit_flag' should equal 'true' + + @api @negative @TC-TXN-03 + Scenario: Initiate transaction returns 402 INSUFFICIENT_FUNDS with available_credit + Given I am authenticated as 'test+txn03@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' with available_credit configured to 50.00 + When I send a POST request to '/v2/accounts/${account_id}/transactions' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "transaction_amount": 200.00, + "merchant_name": "Test Electronics", + "merchant_id": "TESTELEC0001", + "mcc_code": "5732", + "currency_code": "CAD", + "transaction_type": "PURCHASE" + } + """ + Then the response status should be 402 + And the response JSON path 'error' should equal 'INSUFFICIENT_FUNDS' + And the response JSON should contain: + | path | + | available_credit | + + @api @negative @TC-TXN-04 + Scenario: Initiate transaction returns 403 CARD_INACTIVE when card not Active or Frozen + Given I am authenticated as 'test+txn04@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' with card status configured to 'Blocked' + When I send a POST request to '/v2/accounts/${account_id}/transactions' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "transaction_amount": 10.00, + "merchant_name": "Test Merchant", + "merchant_id": "TESTM0001", + "mcc_code": "5999", + "currency_code": "CAD", + "transaction_type": "PURCHASE" + } + """ + Then the response status should be 403 + And the response JSON path 'error' should equal 'CARD_INACTIVE' + And the response JSON should contain: + | path | + | card_status | + + @api @boundary @TC-TXN-05 + Scenario Outline: Initiate transaction rejects transaction_amount <= 0 with 422 INVALID_AMOUNT + Given I am authenticated as 'test+txn05@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + When I send a POST request to '/v2/accounts/${account_id}/transactions' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "transaction_amount": , + "merchant_name": "Boundary Merchant", + "merchant_id": "BOUND0001", + "mcc_code": "5999", + "currency_code": "CAD", + "transaction_type": "PURCHASE" + } + """ + Then the response status should be + And for status 422 the response JSON path 'error' should equal 'INVALID_AMOUNT' + + Examples: + | amount | status | + | 0.00 | 422 | + | -0.01 | 422 | + | 0.01 | 200 | + + @api @negative @TC-TXN-06 + Scenario: Initiate transaction requires exchange_rate when currency_code != CAD + Given I am authenticated as 'test+txn06@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + When I send a POST request to '/v2/accounts/${account_id}/transactions' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "transaction_amount": 10.00, + "merchant_name": "FX Test Merchant", + "merchant_id": "FXMERCH0001", + "mcc_code": "5999", + "currency_code": "USD", + "transaction_type": "PURCHASE" + } + """ + Then the response status should not be 200 + When I send a POST request to '/v2/accounts/${account_id}/transactions' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "transaction_amount": 10.00, + "merchant_name": "FX Test Merchant", + "merchant_id": "FXMERCH0001", + "mcc_code": "5999", + "currency_code": "USD", + "exchange_rate": 1.350000, + "transaction_type": "PURCHASE" + } + """ + Then the response status should not be the missing-exchange-rate failure from the previous attempt + + @api @resilience @TC-TXNFREQ-01 + Scenario: Transaction frequency limit triggers 429 FREQ_EXCEEDED with mfa_required true on 11th transaction + Given I am authenticated as 'test+txnfreq@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' with sufficient credit and eligible status + When I send 10 POST requests to '/v2/accounts/${account_id}/transactions' within 60 minutes with payload template: + """ + { + "transaction_amount": 1.00, + "merchant_name": "LoadTest Shop", + "merchant_id": "${MERCHANT_ID}", + "mcc_code": "5411", + "currency_code": "CAD", + "transaction_type": "PURCHASE", + "description": "freq test" + } + """ + Then each response status should be 200 + When I send 1 more POST request to '/v2/accounts/${account_id}/transactions' within the same 60 minutes with payload: + """ + { + "transaction_amount": 1.00, + "merchant_name": "LoadTest Shop", + "merchant_id": "LTSHOP11", + "mcc_code": "5411", + "currency_code": "CAD", + "transaction_type": "PURCHASE", + "description": "freq test #11" + } + """ + Then the response status should be 429 + And the response JSON path 'error' should equal 'FREQ_EXCEEDED' + And the response JSON path 'mfa_required' should equal 'true' + + @api @boundary @TC-TXNFREQ-02 + Scenario: Transaction frequency boundary does not trigger FREQ_EXCEEDED at exactly 10 transactions + Given I am authenticated as 'test+txnfreqb@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' with sufficient credit and eligible status + When I send exactly 10 POST requests to '/v2/accounts/${account_id}/transactions' within 60 minutes + Then all 10 responses should have status 200 + And none of the 10 responses should contain error 'FREQ_EXCEEDED' + + @api @functional @TC-FX-01 + Scenario: Foreign transaction fee calculation applies 1.03 multiplier to (amount × exchange_rate) + Given I am authenticated as 'test+fx01@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + And I compute expected_total_cad as (100.00 * 1.250000) * 1.03 rounded to 2 decimals + When I send a POST request to '/v2/accounts/${account_id}/transactions' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "transaction_amount": 100.00, + "merchant_name": "FX Test Merchant", + "merchant_id": "FXM123", + "mcc_code": "5812", + "currency_code": "USD", + "exchange_rate": 1.250000, + "transaction_type": "PURCHASE", + "description": "FX formula test" + } + """ + Then the response status should be 200 + And the response should contain a CAD total field consistent with '${expected_total_cad}' within 0.01 + + @api @functional @TC-FX-02 + Scenario: Foreign fee is itemised separately as foreign_fee_amount in response + Given I am authenticated as 'test+fx02@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + When I send a POST request to '/v2/accounts/${account_id}/transactions' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "transaction_amount": 50.00, + "merchant_name": "FX Fee Merchant", + "merchant_id": "FXFEE50", + "mcc_code": "5999", + "currency_code": "EUR", + "exchange_rate": 1.450000, + "transaction_type": "PURCHASE", + "description": "FX fee itemization test" + } + """ + Then the response status should be 200 + And the response JSON should contain: + | path | + | foreign_fee_amount | + And the response JSON path 'foreign_fee_amount' should be greater than 0 + + @api @functional @TC-TXNLIST-01 + Scenario: List transactions default from_date is billing cycle start (fixture-based) + Given I am authenticated as 'test+txnlist01@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + And the billing cycle start date for the account is '${BILLING_CYCLE_START}' + And there exists at least one transaction before '${BILLING_CYCLE_START}' and at least one on or after it + When I send a GET request to '/v2/accounts/${account_id}/transactions' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + Then the response status should be 200 + And the response should include transactions from on/after '${BILLING_CYCLE_START}' + And the response should exclude transactions strictly before '${BILLING_CYCLE_START}' + + @api @negative @TC-TXNLIST-02 + Scenario: List transactions rejects invalid date range with 400 INVALID_DATE_RANGE when to_date < from_date + Given I am authenticated as 'test+txnlist02@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + When I send a GET request to '/v2/accounts/${account_id}/transactions?from_date=2026-04-10&to_date=2026-04-09' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + Then the response status should be 400 + And the response JSON path 'error' should equal 'INVALID_DATE_RANGE' + And the response JSON should not contain: + | path | + | transactions | + + @api @boundary @TC-TXNLIST-03 + Scenario Outline: List transactions enforces per_page max 100 boundary + Given I am authenticated as 'test+txnlist03@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' with at least 120 transactions seeded + When I send a GET request to '/v2/accounts/${account_id}/transactions?per_page=&page=1' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + Then the response status should be + And for status 200 the response JSON path 'transactions' should have length less than or equal to 100 + + Examples: + | per_page | status | + | 100 | 200 | + | 101 | 400 | + + @api @security @TC-TXNLIST-04 + Scenario: List transactions returns 403 FORBIDDEN when account not owned by user + Given UserA is authenticated and has a token 'tokenA' + And UserB owns an account_id stored as 'account_id_B' + When I send a GET request to '/v2/accounts/${account_id_B}/transactions' with headers: + | Header | Value | + | Authorization | Bearer ${tokenA} | + Then the response status should be 403 + And the response JSON path 'error' should equal 'FORBIDDEN' + And the response JSON should not contain: + | path | + | transactions | + + @api @boundary @TC-TXNLIST-05 + Scenario Outline: List transactions page min 1 boundary and default page=1 + Given I am authenticated as 'test+txnlist05@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + When I send a GET request to '/v2/accounts/${account_id}/transactions' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + Then the response status should be + And for status 200 the response JSON path 'page' should equal + + Examples: + | query | status | page_value | + | | 200 | 1 | + | ?page=1 | 200 | 1 | + | ?page=0 | 400 | 0 | + | ?page=-1 | 400 | 0 | + + @api @functional @TC-TXNLIST-06 + Scenario Outline: List transactions category filter accepts enum values and rejects unknown + Given I am authenticated as 'test+txnlist06@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + When I send a GET request to '/v2/accounts/${account_id}/transactions?category=' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + Then the response status should be + + Examples: + | category | status | + | PURCHASE | 200 | + | FEE | 200 | + | CHARGEBACK | 400 | + + @api @functional @TC-TXN-12 + Scenario: Initiate transaction currency_code defaults to CAD when omitted + Given I am authenticated as 'test+txn12@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + When I send a POST request to '/v2/accounts/${account_id}/transactions' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "transaction_amount": 10.00, + "merchant_name": "Test Merchant CA", + "merchant_id": "TMCA123456", + "mcc_code": "5411", + "transaction_type": "PURCHASE", + "description": "Default currency test" + } + """ + Then the response status should be 200 + And the response JSON should contain: + | path | + | transaction_id | + When I store the response JSON path 'transaction_id' as 'transaction_id' + And I send a GET request to '/v2/accounts/${account_id}/transactions?page=1&per_page=25' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + Then the response status should be 200 + And the transaction with id '${transaction_id}' should have currency_code 'CAD' + + @api @boundary @TC-TXN-14 + Scenario Outline: Initiate transaction transaction_type enum validation + Given I am authenticated as 'test+txn14@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + When I send a POST request to '/v2/accounts/${account_id}/transactions' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "transaction_amount": 1.00, + "merchant_name": "Enum Type Merchant", + "merchant_id": "", + "mcc_code": "5411", + "currency_code": "CAD", + "transaction_type": "" + } + """ + Then the response status should be + + Examples: + | transaction_type | merchant_id | status | + | PURCHASE | ENUM001 | 200 | + | CASH_ADVANCE | ENUM002 | 200 | + | BALANCE_TRANSFER | ENUM003 | 200 | + | REVERSAL | ENUM004 | 400 | + + @api @boundary @TC-TXN-15 + Scenario Outline: Initiate transaction description max 255 chars boundary + Given I am authenticated as 'test+txn15@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + When I send a POST request to '/v2/accounts/${account_id}/transactions' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "transaction_amount": 2.00, + "merchant_name": "Desc Merchant", + "merchant_id": "", + "mcc_code": "5812", + "currency_code": "CAD", + "transaction_type": "PURCHASE", + "description": "" + } + """ + Then the response status should be + + Examples: + | merchant_id | description | status | + | DESC255 | ${STRING_LEN_255} | 200 | + | DESC256 | ${STRING_LEN_256} | 400 | + + @api @security @TC-TXN-07 + Scenario: Initiate transaction enforces account_id ownership (IDOR protection) + Given UserA is authenticated and has a token 'tokenA' and an owned account 'account_id_A' + And UserB is authenticated and has a token 'tokenB' and an owned account 'account_id_B' + When I send a POST request to '/v2/accounts/${account_id_B}/transactions' with headers: + | Header | Value | + | Authorization | Bearer ${tokenA} | + And payload: + """ + { + "transaction_amount": 10.00, + "merchant_name": "Test Merchant", + "merchant_id": "TESTMERCHANT01", + "mcc_code": "5411", + "currency_code": "CAD", + "transaction_type": "PURCHASE", + "description": "Ownership enforcement test" + } + """ + Then the response status should not be 200 + And the response JSON should not contain: + | path | + | transaction_id | + | auth_code | + When I send a POST request to '/v2/accounts/${account_id_B}/transactions' with headers: + | Header | Value | + | Authorization | Bearer ${tokenB} | + And payload: + """ + { + "transaction_amount": 10.00, + "merchant_name": "Test Merchant", + "merchant_id": "TESTMERCHANT01", + "mcc_code": "5411", + "currency_code": "CAD", + "transaction_type": "PURCHASE", + "description": "Owner success control" + } + """ + Then the response status should be 200 + And the response JSON should contain: + | path | + | transaction_id | + + @api @boundary @TC-TXN-08 + Scenario Outline: Initiate transaction transaction_amount Decimal(10,2) precision validation + Given I am authenticated as 'test+txn08@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + When I send a POST request to '/v2/accounts/${account_id}/transactions' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "transaction_amount": "", + "merchant_name": "Precision Merchant", + "merchant_id": "PREC", + "mcc_code": "5411", + "currency_code": "CAD", + "transaction_type": "PURCHASE" + } + """ + Then the response status should be + + Examples: + | amount | suffix | status | + | 99999999.99 | 01 | 200 | + | 0.01 | 02 | 200 | + | 1.001 | 03 | 400 | + | 1.00 | 04 | 200 | + + @api @boundary @TC-TXN-09 + Scenario Outline: Initiate transaction merchant_name max 100 chars boundary + Given I am authenticated as 'test+txn09@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + When I send a POST request to '/v2/accounts/${account_id}/transactions' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "transaction_amount": 10.00, + "merchant_name": "", + "merchant_id": "", + "mcc_code": "5411", + "currency_code": "CAD", + "transaction_type": "PURCHASE" + } + """ + Then the response status should be + + Examples: + | merchant_name | merchant_id | status | + | ${STRING_LEN_100} | MN100 | 200 | + | ${STRING_LEN_101} | MN101 | 400 | + + @api @boundary @TC-TXN-10 + Scenario Outline: Initiate transaction merchant_id alphanumeric max 32 chars boundary + Given I am authenticated as 'test+txn10@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + When I send a POST request to '/v2/accounts/${account_id}/transactions' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "transaction_amount": 12.34, + "merchant_name": "QA Store", + "merchant_id": "", + "mcc_code": "5411", + "currency_code": "CAD", + "transaction_type": "PURCHASE" + } + """ + Then the response status should be + + Examples: + | merchant_id | status | + | A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1 | 200 | + | A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1Z | 400 | + | MERCHANT-01 | 400 | + + @api @negative @TC-TXN-11 + Scenario Outline: Initiate transaction requires 4-digit mcc_code + Given I am authenticated as 'test+txn11@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + When I send a POST request to '/v2/accounts/${account_id}/transactions' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "transaction_amount": 20.00, + "merchant_name": "QA Cafe", + "merchant_id": "QACAFE01", + "mcc_code": "", + "currency_code": "CAD", + "transaction_type": "PURCHASE" + } + """ + Then the response status should be + + Examples: + | mcc_code | suffix | status | + | 5812 | 1 | 200 | + | 123 | 2 | 400 | + | 12345 | 3 | 400 | + | 12A4 | 4 | 400 | + + # --------------------------------------------------------------------------- + # Account Summary (Dashboard API) + # --------------------------------------------------------------------------- + + @api @functional @TC-SUM-01 + Scenario: Get account summary success returns required fields + Given I am authenticated as 'test+sum01@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + When I send a GET request to '/v2/accounts/${account_id}/summary' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + Then the response status should be 200 + And the response JSON should contain: + | path | + | current_balance | + | available_credit | + | credit_limit | + | account_status | + | billing_cycle_end | + | points_balance | + + @api @functional @TC-SUM-02 + Scenario: Get account summary include_rewards defaults to false when omitted + Given I am authenticated as 'test+sum02@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + When I send a GET request to '/v2/accounts/${account_id}/summary' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + Then the response status should be 200 + When I send a GET request to '/v2/accounts/${account_id}/summary?include_rewards=false' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + Then the response status should be 200 + And the response payload for omitted include_rewards should be functionally equivalent to include_rewards=false for rewards sections + + @api @security @TC-SUM-03 + Scenario: Get account summary returns 403 FORBIDDEN for non-owned account + Given UserB is authenticated and has a token 'tokenB' + And UserA owns an account_id stored as 'account_id_A' + When I send a GET request to '/v2/accounts/${account_id_A}/summary' with headers: + | Header | Value | + | Authorization | Bearer ${tokenB} | + Then the response status should be 403 + And the response JSON path 'error' should equal 'FORBIDDEN' + And the response JSON should not contain: + | path | + | current_balance | + | available_credit | + | credit_limit | + + # --------------------------------------------------------------------------- + # Card status / Lost or stolen / PIN + # --------------------------------------------------------------------------- + + @api @state-transition @TC-CARDSTAT-01 + Scenario: Freeze card succeeds with valid confirm_otp + Given I am authenticated as 'test+cardfreeze@example.com' with password 'Str0ng!Passw0rd' + And I have an owned card_id stored as 'card_id' in status 'Active' + And I have a valid 6-digit confirm_otp as 'confirm_otp' + When I send a PATCH request to '/v2/cards/${card_id}/status' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "status": "Frozen", + "reason": "Freeze via test", + "confirm_otp": "${confirm_otp}" + } + """ + Then the response status should be 200 + And the response JSON should contain: + | path | + | card_id | + | new_status | + | updated_at | + And the response JSON path 'new_status' should equal 'Frozen' + + @api @state-transition @TC-CARDSTAT-02 + Scenario: Unfreeze card succeeds with valid confirm_otp + Given I am authenticated as 'test+cardunfreeze@example.com' with password 'Str0ng!Passw0rd' + And I have an owned card_id stored as 'card_id' in status 'Frozen' + And I have a valid 6-digit confirm_otp as 'confirm_otp' + When I send a PATCH request to '/v2/cards/${card_id}/status' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "status": "Active", + "reason": "Unfreeze via test", + "confirm_otp": "${confirm_otp}" + } + """ + Then the response status should be 200 + And the response JSON path 'new_status' should equal 'Active' + And the response JSON should contain: + | path | + | updated_at | + + @api @negative @TC-CARDSTAT-03 + Scenario: Card status update rejects invalid transition with 400 INVALID_TRANSITION and allowed_transitions + Given I am authenticated as 'test+cardbadtransition@example.com' with password 'Str0ng!Passw0rd' + And I have an owned card_id stored as 'card_id' in a state where transition to 'Frozen' is invalid + And I have a valid 6-digit confirm_otp as 'confirm_otp' + When I send a PATCH request to '/v2/cards/${card_id}/status' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "status": "Frozen", + "reason": "Attempt invalid transition", + "confirm_otp": "${confirm_otp}" + } + """ + Then the response status should be 400 + And the response JSON path 'error' should equal 'INVALID_TRANSITION' + And the response JSON should contain: + | path | + | allowed_transitions | + + @api @security @TC-CARDSTAT-04 + Scenario: Card status update fails with 401 OTP_FAILED and attempts_remaining + Given I am authenticated as 'test+cardotpfailed@example.com' with password 'Str0ng!Passw0rd' + And I have an owned card_id stored as 'card_id' in status 'Active' + When I send a PATCH request to '/v2/cards/${card_id}/status' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "status": "Frozen", + "confirm_otp": "000000" + } + """ + Then the response status should be 401 + And the response JSON path 'error' should equal 'OTP_FAILED' + And the response JSON should contain: + | path | + | attempts_remaining | + + @api @audit @TC-CARDSTAT-05 + Scenario Outline: Card status reason max 255 chars (accept) vs 256 (reject); successful change is audit-logged + Given I am authenticated as 'test+cardreason@example.com' with password 'Str0ng!Passw0rd' + And I have an owned card_id stored as 'card_id' in status 'Active' + And I have a valid 6-digit confirm_otp as 'confirm_otp' + When I send a PATCH request to '/v2/cards/${card_id}/status' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "status": "", + "reason": "", + "confirm_otp": "${confirm_otp}" + } + """ + Then the response status should be + And for status 200 the response JSON path 'new_status' should equal '' + + Examples: + | status_value | reason | http_status | + | Frozen | ${STRING_LEN_255} | 200 | + | Active | ${STRING_LEN_256} | 400 | + + @api @boundary @TC-CARDSTAT-06 + Scenario Outline: Card status update requires 6-digit confirm_otp format + Given I am authenticated as 'test+cardotpformat@example.com' with password 'Str0ng!Passw0rd' + And I have an owned card_id stored as 'card_id' in status 'Active' + When I send a PATCH request to '/v2/cards/${card_id}/status' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "status": "Frozen", + "confirm_otp": "" + } + """ + Then the response status should not be 200 + + Examples: + | confirm_otp | + | 12345 | + | 1234567 | + | 12A456 | + + @api @functional @TC-LOST-01 + Scenario Outline: Report card lost/stolen blocks card and schedules replacement + Given I am authenticated as 'test+lost01@example.com' with password 'Str0ng!Passw0rd' + And I have an owned card_id stored as 'card_id' not in status 'Blocked' or 'Closed' + When I send a POST request to '/v2/cards/${card_id}/report-lost' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "loss_type": "" + } + """ + Then the response status should be 200 + And the response JSON should contain: + | path | + | blocked_card_id | + | new_card_eta | + | case_number | + + Examples: + | loss_type | + | LOST | + | STOLEN | + + @api @negative @TC-LOST-02 + Scenario: Report lost/stolen returns 409 ALREADY_BLOCKED if card already Blocked or Closed + Given I am authenticated as 'test+lost02@example.com' with password 'Str0ng!Passw0rd' + And I have an owned card_id stored as 'card_id' in status 'Blocked' + When I send a POST request to '/v2/cards/${card_id}/report-lost' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "loss_type": "STOLEN" + } + """ + Then the response status should be 409 + And the response JSON path 'error' should equal 'ALREADY_BLOCKED' + + @api @boundary @TC-LOST-03 + Scenario: Report lost/stolen accepts last_known_use in ISO 8601 UTC + Given I am authenticated as 'test+lost03@example.com' with password 'Str0ng!Passw0rd' + And I have an owned card_id stored as 'card_id' not in status 'Blocked' or 'Closed' + When I send a POST request to '/v2/cards/${card_id}/report-lost' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "loss_type": "LOST", + "last_known_use": "2026-04-01T13:45:30Z" + } + """ + Then the response status should be 200 + And the response JSON should contain: + | path | + | blocked_card_id | + | new_card_eta | + | case_number | + + @api @negative @TC-LOST-04 + Scenario: Report lost/stolen rejects unknown loss_type and does not block the card + Given I am authenticated as 'test+lost04@example.com' with password 'Str0ng!Passw0rd' + And I have an owned card_id stored as 'card_id' not in status 'Blocked' or 'Closed' + When I send a POST request to '/v2/cards/${card_id}/report-lost' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "loss_type": "MISPLACED", + "last_known_use": "2026-03-21T10:15:30Z" + } + """ + Then the response status should not be 200 + + @api @functional @TC-LOST-05 + Scenario: Report lost/stolen accepts optional delivery_address override + Given I am authenticated as 'test+lost05@example.com' with password 'Str0ng!Passw0rd' + And I have an owned card_id stored as 'card_id' not in status 'Blocked' or 'Closed' + When I send a POST request to '/v2/cards/${card_id}/report-lost' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "loss_type": "LOST", + "last_known_use": "2026-04-01T12:30:00Z", + "delivery_address": { + "street": "100 Test Ave", + "city": "Toronto", + "province": "ON", + "postal_code": "A1A 1A1" + } + } + """ + Then the response status should be 200 + And the response JSON should contain: + | path | + | blocked_card_id | + | new_card_eta | + | case_number | + + @api @functional @TC-PIN-01 + Scenario: Set virtual PIN succeeds with encrypted 4-digit PIN, matching confirm_pin, and session_otp + Given I am authenticated as 'test+pin01@example.com' with password 'Str0ng!Passw0rd' + And I have an owned card_id stored as 'card_id' not in status 'Blocked' + And I have obtained a valid 6-digit session_otp as 'session_otp' from '/v2/auth/otp/request' + And I have RSA-OAEP encrypted '1234' as 'enc_pin_1234' + When I send a PUT request to '/v2/cards/${card_id}/pin' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "new_pin": "${enc_pin_1234}", + "confirm_pin": "${enc_pin_1234}", + "session_otp": "${session_otp}" + } + """ + Then the response status should be 200 + And the response JSON path 'success' should equal 'true' + And the response JSON should contain: + | path | + | updated_at | + + @api @negative @TC-PIN-02 + Scenario: Set virtual PIN returns 400 PIN_MISMATCH when confirm_pin does not match new_pin + Given I am authenticated as 'test+pin02@example.com' with password 'Str0ng!Passw0rd' + And I have an owned card_id stored as 'card_id' not in status 'Blocked' + And I have obtained a valid 6-digit session_otp as 'session_otp' from '/v2/auth/otp/request' + And I have RSA-OAEP encrypted '1234' as 'enc_pin_1234' + And I have RSA-OAEP encrypted '4321' as 'enc_pin_4321' + When I send a PUT request to '/v2/cards/${card_id}/pin' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "new_pin": "${enc_pin_1234}", + "confirm_pin": "${enc_pin_4321}", + "session_otp": "${session_otp}" + } + """ + Then the response status should be 400 + And the response JSON path 'error' should equal 'PIN_MISMATCH' + + @api @boundary @TC-PIN-03 + Scenario Outline: Set virtual PIN returns 400 PIN_FORMAT when PIN is not exactly 4 numeric digits + Given I am authenticated as 'test+pin03@example.com' with password 'Str0ng!Passw0rd' + And I have an owned card_id stored as 'card_id' not in status 'Blocked' + And I have obtained a valid 6-digit session_otp as 'session_otp' from '/v2/auth/otp/request' + And I have RSA-OAEP encrypted '' as 'enc_pin' + When I send a PUT request to '/v2/cards/${card_id}/pin' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "new_pin": "${enc_pin}", + "confirm_pin": "${enc_pin}", + "session_otp": "${session_otp}" + } + """ + Then the response status should be 400 + And the response JSON path 'error' should equal 'PIN_FORMAT' + + Examples: + | pin_plaintext | + | 123 | + | 12345 | + | 12A4 | + + @api @negative @TC-PIN-04 + Scenario: Set virtual PIN returns 403 CARD_BLOCKED when card is Blocked + Given I am authenticated as 'test+pin04@example.com' with password 'Str0ng!Passw0rd' + And I have an owned card_id stored as 'card_id' in status 'Blocked' + And I have obtained a valid 6-digit session_otp as 'session_otp' from '/v2/auth/otp/request' + And I have RSA-OAEP encrypted '1234' as 'enc_pin_1234' + When I send a PUT request to '/v2/cards/${card_id}/pin' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "new_pin": "${enc_pin_1234}", + "confirm_pin": "${enc_pin_1234}", + "session_otp": "${session_otp}" + } + """ + Then the response status should be 403 + And the response JSON path 'error' should equal 'CARD_BLOCKED' + + @api @security @TC-PIN-05 + Scenario Outline: Set PIN requires session_otp and rejects missing/incorrect OTP + Given I am authenticated as 'test+pin05@example.com' with password 'Str0ng!Passw0rd' + And I have an owned card_id stored as 'card_id' not in status 'Blocked' + And I have RSA-OAEP encrypted '1234' as 'enc_pin_1234' + When I send a PUT request to '/v2/cards/${card_id}/pin' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + + """ + Then the response status should be + + Examples: + | payload | status | + | {"new_pin":"${enc_pin_1234}","confirm_pin":"${enc_pin_1234}"} | 400 | + | {"new_pin":"${enc_pin_1234}","confirm_pin":"${enc_pin_1234}","session_otp":"000000"} | 401 | + + @api @security @TC-PIN-06 + Scenario: Set PIN transmits new_pin encrypted (client payload does not contain plaintext PIN) + Given I am authenticated as 'test+pin06@example.com' with password 'Str0ng!Passw0rd' + And I have an owned card_id stored as 'card_id' not in status 'Blocked' + And I have obtained a valid 6-digit session_otp as 'session_otp' from '/v2/auth/otp/request' + And I have RSA-OAEP encrypted '1234' as 'enc_pin_1234' + When I send a PUT request to '/v2/cards/${card_id}/pin' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "new_pin": "${enc_pin_1234}", + "confirm_pin": "${enc_pin_1234}", + "session_otp": "${session_otp}" + } + """ + Then the response status should be 200 + And the last sent request JSON path 'new_pin' should not equal '1234' + And the last sent request body should not contain '1234' + + # --------------------------------------------------------------------------- + # Statements / Billing rules / Rewards / Accuracy + # --------------------------------------------------------------------------- + + @api @functional @TC-STMT-01 + Scenario: Retrieve statement defaults to JSON and returns required statement fields + Given I am authenticated as 'test+stmt01@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + And I have an existing generated statement_id stored as 'statement_id' + When I send a GET request to '/v2/accounts/${account_id}/statements/${statement_id}' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + Then the response status should be 200 + And the response header 'Content-Type' should contain 'application/json' + And the response JSON should contain: + | path | + | statement_date | + | total_spend | + | adb | + | interest_charged | + | late_fee | + | rewards_earned | + | minimum_payment_due | + | due_date | + + @api @functional @TC-STMT-02 + Scenario: Retrieve statement in PDF when format=PDF + Given I am authenticated as 'test+stmt02@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + And I have an existing generated statement_id stored as 'statement_id' + When I send a GET request to '/v2/accounts/${account_id}/statements/${statement_id}?format=PDF' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | Accept | application/pdf | + Then the response status should be 200 + And the response header 'Content-Type' should contain 'application/pdf' + And the response body should start with '%PDF' + + @api @negative @TC-STMT-03 + Scenario Outline: Retrieve statement returns 404 NOT_FOUND when statement missing (JSON or PDF) + Given I am authenticated as 'test+stmt03@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + When I send a GET request to '/v2/accounts/${account_id}/statements/00000000-1111-2222-3333-444444444444' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + Then the response status should be 404 + And the response JSON path 'error' should equal 'NOT_FOUND' + + Examples: + | query | + | | + | ?format=JSON | + + @api @functional @TC-INT-01 + Scenario: Interest computed using ADB formula for a known statement fixture + Given I am authenticated as 'test+int01@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' with statement fixture adb=1000.00 apr=0.1999 days_in_billing_cycle=30 + And I have an existing generated statement_id stored as 'statement_id' + When I send a GET request to '/v2/accounts/${account_id}/statements/${statement_id}?format=JSON' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + Then the response status should be 200 + And the response JSON path 'adb' should equal '1000.00' + And the response JSON path 'interest_charged' should equal '16.43' + + @api @boundary @TC-INT-02 + Scenario Outline: Interest scales with Days_in_Billing_Cycle boundaries (28/30/31) + Given I am authenticated as 'test+int02@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + And I have a generated statement_id '' with fixture adb=2500.00 apr=0.2499 days_in_billing_cycle= + When I send a GET request to '/v2/accounts/${account_id}/statements/?format=JSON' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + Then the response status should be 200 + And the response JSON path 'interest_charged' should equal '' + + Examples: + | statement_id | days | expected_interest | + | ${STMT_ID_28_DAY} | 28 | 47.91 | + | ${STMT_ID_30_DAY} | 30 | 51.33 | + | ${STMT_ID_31_DAY} | 31 | 53.04 | + + @api @functional @TC-LATEFEE-01 + Scenario: Late fee charged when payment_received_date > due_date + 2 days + Given I am authenticated as 'test+latefee01@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' with statement fixture due_date=2026-04-10 payment_received_date=2026-04-13 + And I have an existing generated statement_id stored as 'statement_id' + When I send a GET request to '/v2/accounts/${account_id}/statements/${statement_id}?format=JSON' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + Then the response status should be 200 + And the response JSON path 'late_fee' should equal '35.00' + + @api @boundary @TC-LATEFEE-02 + Scenario: Late fee boundary - no late fee when payment_received_date equals due_date + 2 days + Given I am authenticated as 'test+latefee02@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' with statement fixture due_date=2026-04-10 payment_received_date=2026-04-12 + And I have an existing generated statement_id stored as 'statement_id' + When I send a GET request to '/v2/accounts/${account_id}/statements/${statement_id}?format=JSON' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + Then the response status should be 200 + And the response JSON path 'late_fee' should not equal '35.00' + + @api @functional @TC-REW-01 + Scenario: Rewards accrual for Travel MCC uses floor(amount × 3) (fixture-based) + Given I am authenticated as 'test+rew01@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + And the statement fixture contains a Travel MCC transaction with amount 123.45 in the cycle for statement_id '${statement_id}' + When I send a GET request to '/v2/accounts/${account_id}/statements/${statement_id}?format=JSON' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + Then the response status should be 200 + And the response JSON should contain: + | path | + | rewards_earned | + + @api @boundary @TC-REW-02 + Scenario: Rewards rounding uses floor() and never round/ceil (fixture-based) + Given I am authenticated as 'test+rew02@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + And the statement fixture contains exactly two transactions in the cycle: + | category | amount | + | TRAVEL_MCC | 0.34 | + | OTHER | 1.99 | + And I have an existing generated statement_id stored as 'statement_id' + When I send a GET request to '/v2/accounts/${account_id}/statements/${statement_id}?format=JSON' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + Then the response status should be 200 + And the response JSON path 'rewards_earned' should equal '2' + + @api @functional @TC-STMTACC-01 + Scenario: Statement accuracy - sum(transaction_amount[]) equals total_spend within ±0.01 + Given I am authenticated as 'test+stmtacc01@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + And I have an existing generated statement_id stored as 'statement_id' with cycle start '${CYCLE_START}' and cycle end '${CYCLE_END}' + When I send a GET request to '/v2/accounts/${account_id}/statements/${statement_id}?format=JSON' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + Then the response status should be 200 + When I store the response JSON path 'total_spend' as 'total_spend' + And I send a GET request to '/v2/accounts/${account_id}/transactions?from_date=${CYCLE_START}&to_date=${CYCLE_END}&per_page=100&page=1' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + Then the response status should be 200 + And the computed sum of transaction_amount in the response should be within 0.01 of '${total_spend}' + + # --------------------------------------------------------------------------- + # Payments + # --------------------------------------------------------------------------- + + @api @functional @TC-PAY-01 + Scenario: Make immediate payment (scheduled_date omitted) returns payment_id and new_balance_estimate (CSRF enforced) + Given I am authenticated as 'test+pay01@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + And I have a linked bank_account_id stored as 'bank_account_id' + When I send a POST request to '/v2/accounts/${account_id}/payments' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "payment_amount": 25.00, + "payment_type": "CUSTOM", + "bank_account_id": "${bank_account_id}" + } + """ + Then the response status should not be 200 + When I send a POST request to '/v2/accounts/${account_id}/payments' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-CSRF-Token | ${CSRF_TOKEN} | + And payload: + """ + { + "payment_amount": 25.00, + "payment_type": "CUSTOM", + "bank_account_id": "${bank_account_id}" + } + """ + Then the response status should be 200 + And the response JSON should contain: + | path | + | payment_id | + | new_balance_estimate | + + @api @functional @TC-PAY-02 + Scenario Outline: Make scheduled payment returns scheduled_date in response + Given I am authenticated as 'test+pay02@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + And I have a linked bank_account_id stored as 'bank_account_id' + When I send a POST request to '/v2/accounts/${account_id}/payments' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-CSRF-Token | ${CSRF_TOKEN} | + And payload: + """ + { + "payment_amount": 50.00, + "payment_type": "CUSTOM", + "bank_account_id": "${bank_account_id}", + "scheduled_date": "" + } + """ + Then the response status should be 200 + And the response JSON path 'scheduled_date' should equal '' + And the response JSON should contain: + | path | + | payment_id | + | new_balance_estimate | + + Examples: + | scheduled_date | + | 2026-05-15 | + | 2026-05-16 | + + @api @boundary @TC-PAY-03 + Scenario Outline: Payment amount minimum boundary (1.00 accepted; 0.99 rejected) + Given I am authenticated as 'test+pay03@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + And I have a linked bank_account_id stored as 'bank_account_id' + When I send a POST request to '/v2/accounts/${account_id}/payments' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-CSRF-Token | ${CSRF_TOKEN} | + And payload: + """ + { + "payment_amount": , + "payment_type": "CUSTOM", + "bank_account_id": "${bank_account_id}" + } + """ + Then the response status should be + And for status 200 the response JSON should contain: + | path | + | payment_id | + | new_balance_estimate | + + Examples: + | payment_amount | status | + | 1.00 | 200 | + | 0.99 | 400 | + + @api @negative @TC-PAY-04 + Scenario: Payment below minimum_payment_due returns 400 BELOW_MINIMUM with minimum_payment_due + Given I am authenticated as 'test+pay04@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + And I have a linked bank_account_id stored as 'bank_account_id' + And I have an existing generated statement_id stored as 'statement_id' + When I send a GET request to '/v2/accounts/${account_id}/statements/${statement_id}?format=JSON' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + Then the response status should be 200 + When I store the response JSON path 'minimum_payment_due' as 'minimum_payment_due' + And I compute 'below_min' as '${minimum_payment_due} - 0.01' + And I send a POST request to '/v2/accounts/${account_id}/payments' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-CSRF-Token | ${CSRF_TOKEN} | + And payload: + """ + { + "payment_amount": ${below_min}, + "payment_type": "CUSTOM", + "bank_account_id": "${bank_account_id}" + } + """ + Then the response status should be 400 + And the response JSON path 'error' should equal 'BELOW_MINIMUM' + And the response JSON should contain: + | path | + | minimum_payment_due | + And the response JSON should not contain: + | path | + | payment_id | + + @api @functional @TC-PAY-05 + Scenario: Payment requires bank_account_id from /v2/bank-accounts + Given I am authenticated as 'test+pay05@example.com' with password 'Str0ng!Passw0rd' + When I send a GET request to '/v2/bank-accounts' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + Then the response status should be 200 + When I store a bank_account_id from the response as 'bank_account_id' + And I have an owned account_id stored as 'account_id' + And I send a POST request to '/v2/accounts/${account_id}/payments' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-CSRF-Token | ${CSRF_TOKEN} | + And payload: + """ + { + "payment_amount": 25.00, + "payment_type": "CUSTOM", + "bank_account_id": "${bank_account_id}" + } + """ + Then the response status should be 200 + And the response JSON should contain: + | path | + | payment_id | + + @api @negative @TC-PAY-06 + Scenario: Payment rejects unlinked bank_account_id with 422 INVALID_BANK_ACCOUNT + Given I am authenticated as 'test+pay06@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + When I send a POST request to '/v2/accounts/${account_id}/payments' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-CSRF-Token | ${CSRF_TOKEN} | + And payload: + """ + { + "payment_amount": 50.00, + "payment_type": "CUSTOM", + "bank_account_id": "11111111-2222-4333-8444-555555555555" + } + """ + Then the response status should be 422 + And the response JSON path 'error' should equal 'INVALID_BANK_ACCOUNT' + + @api @boundary @TC-PAY-07 + Scenario Outline: Payment_type enum validation + Given I am authenticated as 'test+pay07@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + And I have a linked bank_account_id stored as 'bank_account_id' + When I send a POST request to '/v2/accounts/${account_id}/payments' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-CSRF-Token | ${CSRF_TOKEN} | + And payload: + """ + { + "payment_amount": 10.00, + "payment_type": "", + "bank_account_id": "${bank_account_id}" + } + """ + Then the response status should be + + Examples: + | payment_type | status | + | MINIMUM | 200 | + | STATEMENT_BALANCE | 200 | + | CUSTOM | 200 | + | FULL_BALANCE | 200 | + | UNKNOWN_TYPE | 400 | + + @api @boundary @TC-PAY-08 + Scenario: Payment amount max equals total_balance (at-max accepted; just-over rejected) + Given I am authenticated as 'test+pay08@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' with total_balance fixture value 250.00 + And I have a linked bank_account_id stored as 'bank_account_id' + When I send a POST request to '/v2/accounts/${account_id}/payments' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-CSRF-Token | ${CSRF_TOKEN} | + And payload: + """ + { + "payment_amount": 250.00, + "payment_type": "CUSTOM", + "bank_account_id": "${bank_account_id}" + } + """ + Then the response status should be 200 + When I send a POST request to '/v2/accounts/${account_id}/payments' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-CSRF-Token | ${CSRF_TOKEN} | + And payload: + """ + { + "payment_amount": 250.01, + "payment_type": "CUSTOM", + "bank_account_id": "${bank_account_id}" + } + """ + Then the response status should not be 200 + + # --------------------------------------------------------------------------- + # CSRF enforcement (API) + # --------------------------------------------------------------------------- + + @api @security @TC-CSRF-01 + Scenario Outline: State-changing endpoints reject missing/invalid X-CSRF-Token + Given I am authenticated as 'test+csrf01@example.com' with password 'Str0ng!Passw0rd' + And I have an owned card_id stored as 'card_id' in status 'Active' + And I have a valid 6-digit confirm_otp as 'confirm_otp' + When I send a PATCH request to '/v2/cards/${card_id}/status' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-CSRF-Token | | + And payload: + """ + { + "status": "Frozen", + "reason": "QA CSRF test", + "confirm_otp": "${confirm_otp}" + } + """ + Then the response status should be + + Examples: + | csrf_token | status | + | | 403 | + | invalid-token | 403 | + | ${CSRF_TOKEN} | 200 | + + @api @security @TC-CSRF-03 + Scenario: CSRF token required even when Authorization uses Bearer token + Given I am authenticated as 'test+csrf03@example.com' with password 'Str0ng!Passw0rd' + And I have an owned card_id stored as 'card_id' in status 'Active' + And I have a valid 6-digit confirm_otp as 'confirm_otp' + When I send a PATCH request to '/v2/cards/${card_id}/status' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "status": "Frozen", + "reason": "QA CSRF+Bearer", + "confirm_otp": "${confirm_otp}" + } + """ + Then the response status should not be 200 + When I send a PATCH request to '/v2/cards/${card_id}/status' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-CSRF-Token | ${CSRF_TOKEN} | + And payload: + """ + { + "status": "Frozen", + "reason": "QA CSRF+Bearer", + "confirm_otp": "${confirm_otp}" + } + """ + Then the response status should be 200 + + # --------------------------------------------------------------------------- + # Security: PAN not transmitted (UI + API observations) + # --------------------------------------------------------------------------- + + @ui @security @TC-PAN-02 + Scenario: Full PAN never transmitted to frontend (HAR scan) + Given I open a clean browser context + And I start network recording + When I log in via the portal UI with email 'test+pan02@example.com' and password 'Str0ng!Passw0rd' + And I navigate through dashboard areas that load card/account data + Then the exported HAR should not contain PAN-like digit sequences of 13 to 19 digits + And the exported HAR should not contain keys 'pan' or 'card_number' with unmasked values + + @api @security @TC-PAN-02 + Scenario Outline: Selected API responses do not include raw PAN fields or PAN-like sequences + Given I am authenticated as 'test+panapi@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + When I send a request to '' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + Then the response status should be 200 + And the response body should not match the regex '\d{13,19}' + And the response body should not contain '"pan"' + And the response body should not contain '"card_number"' + + Examples: + | method | endpoint | + | GET | /v2/accounts/${account_id}/summary | + | GET | /v2/accounts/${account_id}/statements/${STATEMENT_ID}?format=JSON | + + # --------------------------------------------------------------------------- + # Webhooks: Notifications + # --------------------------------------------------------------------------- + + @api @functional @TC-WEBHOOK-01 + Scenario: Notifications webhook accepts valid payload and returns notification_id and delivered_at + Given I generate a UUIDv4 as 'idempotency_key' + When I send a POST request to '/v2/notifications/webhook' with payload: + """ + { + "account_id": "${ACCOUNT_ID}", + "alert_type": "STATEMENT_READY", + "channel": "IN_APP", + "message_body": "Statement is ready", + "severity": "INFO", + "idempotency_key": "${idempotency_key}" + } + """ + Then the response status should be 200 + And the response JSON should contain: + | path | + | notification_id | + | delivered_at | + | channel | + And the response JSON path 'channel' should equal 'IN_APP' + + @api @negative @TC-WEBHOOK-02 + Scenario Outline: Notifications webhook rejects unknown alert_type or channel with 400 INVALID_ALERT_TYPE + Given I generate a UUIDv4 as 'idempotency_key' + When I send a POST request to '/v2/notifications/webhook' with payload: + """ + { + "account_id": "${ACCOUNT_ID}", + "alert_type": "", + "channel": "", + "message_body": "Test", + "severity": "INFO", + "idempotency_key": "${idempotency_key}" + } + """ + Then the response status should be 400 + And the response JSON path 'error' should equal 'INVALID_ALERT_TYPE' + + Examples: + | alert_type | channel | + | UNKNOWN_TYPE | EMAIL | + | LATE_PAYMENT | FAX | + + @api @boundary @TC-WEBHOOK-03 + Scenario Outline: Notifications webhook enforces message_body max length boundary + Given I generate a UUIDv4 as 'idempotency_key' + When I send a POST request to '/v2/notifications/webhook' with payload: + """ + { + "account_id": "${ACCOUNT_ID}", + "alert_type": "STATEMENT_READY", + "channel": "IN_APP", + "message_body": "", + "severity": "INFO", + "idempotency_key": "${idempotency_key}" + } + """ + Then the response status should be + + Examples: + | message_body | status | + | ${STRING_LEN_500} | 200 | + | ${STRING_LEN_501} | 400 | + + @api @resilience @TC-IDEMP-01 + Scenario: Notifications webhook idempotency duplicate idempotency_key returns 409 DUPLICATE_NOTIFICATION + Given I generate a UUIDv4 as 'idempotency_key' + When I send a POST request to '/v2/notifications/webhook' with payload: + """ + { + "account_id": "${ACCOUNT_ID}", + "alert_type": "FRAUD_FLAG", + "channel": "SMS", + "message_body": "Potential fraud detected", + "severity": "CRITICAL", + "idempotency_key": "${idempotency_key}" + } + """ + Then the response status should be 200 + When I send the same POST request to '/v2/notifications/webhook' again with the same payload + Then the response status should be 409 + And the response JSON path 'error' should equal 'DUPLICATE_NOTIFICATION' + + @api @boundary @TC-NOTIF-01 + Scenario Outline: Notifications webhook enforces idempotency_key UUID v4 format + When I send a POST request to '/v2/notifications/webhook' with payload: + """ + { + "account_id": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + "alert_type": "STATEMENT_READY", + "channel": "IN_APP", + "message_body": "Statement is ready.", + "severity": "INFO", + "idempotency_key": "" + } + """ + Then the response status should be + + Examples: + | idempotency_key | status | + | 3fa85f64-5717-4562-b3fc-2c963f66afa6 | 200 | + | not-a-uuid | 400 | + | 6ba7b810-9dad-11d1-80b4-00c04fd430c8 | 400 | + + @api @boundary @TC-NOTIF-02 + Scenario Outline: Notifications webhook enforces alert_type enum + Given I generate a UUIDv4 as 'idempotency_key' + When I send a POST request to '/v2/notifications/webhook' with payload: + """ + { + "account_id": "${ACCOUNT_ID}", + "alert_type": "", + "channel": "EMAIL", + "message_body": "Statement is ready for viewing.", + "severity": "INFO", + "idempotency_key": "${idempotency_key}" + } + """ + Then the response status should be + And for status 400 the response JSON path 'error' should equal 'INVALID_ALERT_TYPE' + + Examples: + | alert_type | status | + | STATEMENT_READY | 200 | + | OVER_LIMIT | 200 | + | OVERLIMIT | 400 | + | STATEMENT_READY | 200 | + + @api @boundary @TC-NOTIF-03 + Scenario Outline: Notifications webhook enforces severity enum INFO/WARNING/CRITICAL + Given I generate a UUIDv4 as 'idempotency_key' + When I send a POST request to '/v2/notifications/webhook' with payload: + """ + { + "account_id": "${ACCOUNT_ID}", + "alert_type": "FRAUD_FLAG", + "channel": "IN_APP", + "message_body": "Security alert.", + "severity": "", + "idempotency_key": "${idempotency_key}" + } + """ + Then the response status should be + And for status 400 the response JSON path 'error' should equal 'INVALID_ALERT_TYPE' + + Examples: + | severity | status | + | INFO | 200 | + | WARNING | 200 | + | CRITICAL | 200 | + | HIGH | 400 | + | critical | 400 | + + # --------------------------------------------------------------------------- + # WebSocket + # --------------------------------------------------------------------------- + + @api @functional @TC-WS-01 + Scenario: WebSocket endpoint reachable for live transaction feed + Given the DNS name 'realtime.aegiscard.com' resolves to at least one IP address + When I open a WebSocket connection to '${REALTIME_WS_URL}' + Then the WebSocket handshake should result in one of: + | status | + | 101 | + | 401 | + | 403 | + And I capture the handshake response headers for evidence + + # --------------------------------------------------------------------------- + # Right to rescind + # --------------------------------------------------------------------------- + + @api @functional @TC-RESCIND-01 + Scenario: Right to rescind allowed within 14 days via DELETE /v2/accounts/{id} + Given I am authenticated as the owner of account '${RESCIND_ACCOUNT_ID_WITHIN_14_DAYS}' + And the account issuance age is 13 days + When I send a DELETE request to '/v2/accounts/${RESCIND_ACCOUNT_ID_WITHIN_14_DAYS}' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-CSRF-Token | ${CSRF_TOKEN} | + Then the response status should be one of: + | status | + | 200 | + | 204 | + When I send a GET request to '/v2/accounts/${RESCIND_ACCOUNT_ID_WITHIN_14_DAYS}/summary' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + Then the response status should not be 200 + + @api @boundary @TC-RESCIND-02 + Scenario: Right to rescind rejected after day 14 + Given I am authenticated as the owner of account '${RESCIND_ACCOUNT_ID_AFTER_14_DAYS}' + And the account issuance age is 15 days + When I send a DELETE request to '/v2/accounts/${RESCIND_ACCOUNT_ID_AFTER_14_DAYS}' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-CSRF-Token | ${CSRF_TOKEN} | + Then the response status should not be one of: + | status | + | 200 | + | 204 | + When I send a GET request to '/v2/accounts/${RESCIND_ACCOUNT_ID_AFTER_14_DAYS}/summary' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + Then the response status should be 200 + + # --------------------------------------------------------------------------- + # Architecture / OAuth / TLS form submissions (UI + observable security) + # --------------------------------------------------------------------------- + + @ui @security @TC-PCI-01 + Scenario: Portal and API used by forms negotiate TLS 1.3 (no TLS 1.2 downgrade) + Given I open a clean browser context + When I am on the '${PORTAL_BASE_URL}' page + Then the portal security details should show TLS version 'TLS 1.3' + When I attempt a TLS handshake to host 'api.aegiscard.com' on port 443 forcing TLS1.2 + Then the TLS handshake result should be FAILURE + When I attempt a TLS handshake to host 'api.aegiscard.com' on port 443 forcing TLS1.3 + Then the TLS handshake result should be SUCCESS + + @ui @security @TC-AUTH-01 + Scenario: OAuth 2.0 Authorization Code Flow with PKCE is observable during portal login + Given I open a clean browser context + And I start network recording + When I click the 'Log in' control on the portal + Then I should see an authorization request that includes PKCE parameters 'code_challenge' and 'code_challenge_method' + When I complete login with valid credentials + Then I should see a token exchange request that includes a PKCE parameter 'code_verifier' + + # --------------------------------------------------------------------------- + # Non-functional: performance / scaling / autoscaling / audit trail (high-level) + # --------------------------------------------------------------------------- + + @performance @api @TC-APISLA-01 + Scenario: API p95 latency meets ≤ 1500 ms target for GET /summary under representative load + Given a load test profile is configured for endpoint '/v2/accounts/${ACCOUNT_ID}/summary' at 50 rps for 10 minutes + When I execute the load test and collect latency metrics + Then the p95 latency should be less than or equal to 1500 milliseconds + And the non-2xx error rate should be less than or equal to 1 percent + + @performance @ui @TC-TTI-01 + Scenario: Portal Time-to-Interactive (TTI) meets ≤ 3s on 4G (5 runs) + Given I configure network throttling to a 4G profile + When I run 5 TTI measurements against '${PORTAL_BASE_URL}' + Then each run should have TTI less than or equal to 3.0 seconds + + @performance @api @TC-SCALE-01 + Scenario: API gateway sustains 5000 requests/sec while maintaining response correctness for GET /summary + Given a load test profile is configured for endpoint '/v2/accounts/${ACCOUNT_ID}/summary' at 5000 rps for 5 minutes + When I execute the load test and sample 100 responses during the run + Then all sampled responses should be HTTP 200 + And all sampled responses should be valid JSON containing required summary keys + + @resilience @api @TC-AUTOSCALE-01 + Scenario: Auto-scaling triggers at 70% CPU utilization and service remains available + Given I have access to infrastructure metrics for CPU utilization and scaling events + And I have a continuous availability probe running against '/v2/accounts/${ACCOUNT_ID}/summary' + When I ramp load until CPU utilization reaches 70 percent + Then a scale-out event should be observed + And the availability probe should continue to receive HTTP 200 responses during scale-out + + @audit @api @TC-AUDIT-01 + Scenario: Credit_limit changes create immutable audit log entry with required fields + Given I have privileged access to perform a controlled credit_limit change for account '${AUDIT_ACCOUNT_ID}' + When I change the credit_limit from 5000.00 to 6000.00 for account '${AUDIT_ACCOUNT_ID}' + Then an audit log entry should exist for the credit_limit change + And the audit log entry should contain: + | field | + | user_id | + | session_id | + | ip_address | + | timestamp_utc | + And the audit log entry should be immutable on re-query + + # --------------------------------------------------------------------------- + # E2E flows (API-led) + # --------------------------------------------------------------------------- + + @api @e2e @TC-E2E-01 + Scenario: E2E Register -> Login -> Step1 -> Step2 -> Step3 approved + Given I have generated a unique email 'test+e2e01-${RUN_ID}@example.com' + When I send a POST request to '/v2/auth/register' with payload: + """ + { + "first_name": "E2E", + "last_name": "User", + "email": "test+e2e01-${RUN_ID}@example.com", + "password": "Str0ng!Passw0rd", + "date_of_birth": "1990-01-15", + "phone_number": "+14165550101", + "ssn_last4": "1234", + "agree_terms": true + } + """ + Then the response status should be 201 + When I send a POST request to '/v2/auth/login' with payload: + """ + { + "email": "test+e2e01-${RUN_ID}@example.com", + "password": "Str0ng!Passw0rd" + } + """ + Then the response status should be 200 + When I store the response JSON path 'access_token' as 'access_token' + And I send a POST request to '/v2/applications/start' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "full_legal_name": "E2E User", + "email": "test+e2e01-${RUN_ID}@example.com", + "phone_number": "+14165550101", + "residential_address": { + "street": "100 King St W", + "city": "Toronto", + "province": "ON", + "postal_code": "M5H 1J9" + }, + "id_type": "PASSPORT", + "id_number": "A1B2C3D4" + } + """ + Then the response status should be 201 + When I store the response JSON path 'application_id' as 'application_id' + And I store the response JSON path 'session_token' as 'session_token' + And I send a POST request to '/v2/applications/${application_id}/financials' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-App-Session | ${session_token} | + And payload: + """ + { + "employment_status": "SELF_EMPLOYED", + "gross_annual_income": 85000.00, + "monthly_rent": 1800.00, + "existing_debt_payments": 250.00, + "sin_consent": true + } + """ + Then the response status should be 200 + And the decisioning stub is configured for application '${application_id}' with FICO value 700 + When I send a POST request to '/v2/applications/${application_id}/submit' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-App-Session | ${session_token} | + And payload: + """ + { + "card_product_id": "AEGIS_GOLD", + "e_signature": "Sm9obiBEb2U=" + } + """ + Then the response status should be 200 + And the response JSON path 'decision' should equal 'APPROVED' + And the response JSON should contain: + | path | + | credit_limit | + | card_number_masked | + + @api @e2e @TC-E2E-04 + Scenario: E2E Login -> Initiate transaction -> Verify it appears in list + Given I am authenticated as 'test+e2e04@example.com' with password 'Str0ng!Passw0rd' + And I have an owned account_id stored as 'account_id' + When I send a POST request to '/v2/accounts/${account_id}/transactions' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "transaction_amount": 12.34, + "merchant_name": "Test Coffee Shop", + "merchant_id": "TESTMERCH12345", + "mcc_code": "5814", + "currency_code": "CAD", + "transaction_type": "PURCHASE", + "description": "E2E purchase" + } + """ + Then the response status should be 200 + When I store the response JSON path 'transaction_id' as 'transaction_id' + And I send a GET request to '/v2/accounts/${account_id}/transactions?from_date=${TODAY}&to_date=${TODAY}&page=1&per_page=25' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + Then the response status should be 200 + And the transactions list should include an item with transaction_id '${transaction_id}' + + @api @e2e @TC-E2E-06 + Scenario: E2E Freeze card -> attempt transaction -> 403 CARD_INACTIVE with card_status -> unfreeze cleanup + Given I am authenticated as 'test+e2e06@example.com' with password 'Str0ng!Passw0rd' + And I have an owned card_id stored as 'card_id' in status 'Active' + And I have an owned account_id stored as 'account_id' + And I have a valid 6-digit confirm_otp as 'confirm_otp' + When I send a PATCH request to '/v2/cards/${card_id}/status' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-CSRF-Token | ${CSRF_TOKEN} | + And payload: + """ + { + "status": "Frozen", + "reason": "E2E freeze test", + "confirm_otp": "${confirm_otp}" + } + """ + Then the response status should be 200 + When I send a POST request to '/v2/accounts/${account_id}/transactions' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + And payload: + """ + { + "transaction_amount": 5.00, + "merchant_name": "Freeze Test Merchant", + "merchant_id": "FRZ0001", + "mcc_code": "5999", + "currency_code": "CAD", + "transaction_type": "PURCHASE" + } + """ + Then the response status should be 403 + And the response JSON path 'error' should equal 'CARD_INACTIVE' + And the response JSON should contain: + | path | + | card_status | + When I send a PATCH request to '/v2/cards/${card_id}/status' with headers: + | Header | Value | + | Authorization | Bearer ${access_token} | + | X-CSRF-Token | ${CSRF_TOKEN} | + And payload: + """ + { + "status": "Active", + "reason": "E2E unfreeze cleanup", + "confirm_otp": "${confirm_otp}" + } + """ + Then the response status should be 200 + And the response JSON path 'new_status' should equal 'Active' diff --git a/functional_tests/roost_test_1777467397/roost_test_1777467397.json b/functional_tests/roost_test_1777467397/roost_test_1777467397.json new file mode 100644 index 0000000..773f931 --- /dev/null +++ b/functional_tests/roost_test_1777467397/roost_test_1777467397.json @@ -0,0 +1,2769 @@ +[ + { + "type": "functional", + "title": "Portal base URL reachable over HTTPS and served via CDN", + "description": "Verifies the frontend portal is reachable over HTTPS and that the portal is served via CDN as documented. This protects user access reliability and aligns with the documented deployment architecture. Automation: MEDIUM — requires network/TLS and response header observations.", + "testId": "TC-PLAT-01", + "testDescription": "As a cardholder using the browser portal, I can load the portal securely over HTTPS and confirm it is delivered via a CDN as specified.", + "prerequisites": "1) Test environment has outbound internet access and can resolve DNS for portal.aegiscard.com.\n2) Test workstation has a modern browser (Chrome/Edge) and ability to run curl/openssl.\n3) No corporate TLS interception proxy is enabled (or it is bypassed) to allow observing actual TLS version/cert chain.\n4) Ability to capture response headers (e.g., browser devtools Network tab or curl -I).", + "stepsToPerform": "1) Run DNS lookup for portal.aegiscard.com and observe that it resolves to at least one IP address.\n2) In a browser, navigate to https://portal.aegiscard.com and observe the page loads without a browser security warning.\n3) Confirm the browser address bar indicates a secure HTTPS connection (lock icon) and observe certificate details are present.\n4) Using openssl s_client (or equivalent), connect to portal.aegiscard.com:443 and observe the negotiated TLS protocol version.\n5) Refresh the portal page and observe that static assets (e.g., main JS/CSS bundles) are successfully downloaded (HTTP 200/304) in the Network tab.\n6) For the HTML document request, capture response headers and observe the presence of CDN-indicative headers (e.g., Via, X-Cache, CF-Cache-Status, Akamai headers) or similar.\n7) For at least one static asset request, capture response headers and observe similar CDN-indicative headers (or cache-hit semantics) are present.\n8) Repeat header capture for the same asset with a hard refresh and observe caching behavior consistent with CDN delivery (e.g., cache status/header changes) while the asset remains accessible.\n9) Record the observed portal base URL, HTTPS scheme, and evidence of CDN delivery from headers for auditability.", + "expectedResult": "1) https://portal.aegiscard.com is reachable and the portal UI loads over HTTPS without certificate/security warnings.\n2) Connection is negotiated successfully over TLS, and evidence is captured that HTTPS is used end-to-end.\n3) Response headers for the portal and/or static assets indicate delivery via CDN (e.g., presence of CDN/cache headers), supporting the requirement that the portal is \"served via CDN\".", + "sourceCitation": { + "location": "Section 1 Introduction & System Overview, page 2; Section 2 Web Application Architecture, page 2", + "excerpt": "Frontend: https://portal.aegiscard.com — React 18, served via CDN" + } + }, + { + "type": "security", + "title": "API base URL /v2 reachable over HTTPS with TLS 1.3 minimum", + "description": "Verifies the API base URL is reachable over HTTPS and enforces a minimum TLS version of 1.3 as specified. This reduces risk of downgrade attacks and ensures transport security for financial/auth traffic. Automation: HIGH — pure endpoint and TLS handshake assertions.", + "testId": "TC-PLAT-02", + "testDescription": "As an API client, I can reach the documented v2 API endpoint only via HTTPS and confirm the TLS negotiation meets the minimum version requirement.", + "prerequisites": "1) Test runner has network access to api.aegiscard.com on TCP 443.\n2) Tools available: curl and openssl (or equivalent TLS inspection tool).\n3) A known public endpoint path exists to elicit a response (e.g., /v2/auth/login or /v2/auth/register) without requiring prior authentication.\n4) Time sync on the test runner is correct to validate certificate chains.", + "stepsToPerform": "1) Run DNS lookup for api.aegiscard.com and observe that it resolves.\n2) Send an HTTPS request to https://api.aegiscard.com/v2/auth/login with method POST and an empty/invalid body and observe a valid HTTP response is returned (not a network/TLS failure).\n3) Using openssl s_client, connect to api.aegiscard.com:443 and observe the negotiated TLS protocol version.\n4) Attempt to force TLS 1.2 (e.g., openssl s_client -tls1_2) to api.aegiscard.com:443 and observe the handshake is rejected or cannot be completed.\n5) Attempt to force TLS 1.3 (e.g., openssl s_client -tls1_3) and observe the handshake completes.\n6) Confirm the scheme used is HTTPS by verifying requests to https://api.aegiscard.com/v2 return responses while http://api.aegiscard.com/v2 is not used for API access in this test.\n7) Capture and store the TLS handshake output showing TLS 1.3 negotiation for evidence.\n8) Re-run the HTTPS request and observe the API continues to respond over HTTPS after successful TLS 1.3 negotiation.", + "expectedResult": "1) Base API URL https://api.aegiscard.com/v2 is reachable over HTTPS.\n2) TLS negotiation succeeds with TLS 1.3 and evidence is captured.\n3) Attempts to negotiate below TLS 1.3 (e.g., TLS 1.2) fail, demonstrating TLS 1.3 minimum enforcement.", + "sourceCitation": { + "location": "Section 1 Introduction & System Overview, page 2", + "excerpt": "Base API URL https://api.aegiscard.com/v2" + } + }, + { + "type": "functional", + "title": "Register user succeeds (201) and triggers verification email workflow", + "description": "Verifies a new user can be created via the registration endpoint and that the successful response matches the documented 201 outcome including keys tied to verification. This ensures onboarding works and initiates the required email verification workflow. Automation: HIGH — API assertions on status and response keys.", + "testId": "TC-REG-01", + "testDescription": "As a new portal user, I can register with valid identity fields and receive a 201 response indicating account creation and that the verification workflow is triggered.", + "prerequisites": "1) A unique, unused test email is available (e.g., test+reg01-001@example.com).\n2) Test data meets validations: names alpha-only length 2-50, date_of_birth age >= 18, phone E.164, ssn_last4 4 digits, agree_terms true.\n3) API base is reachable at https://api.aegiscard.com/v2.\n4) Ability to observe API response body fields (user_id, verification_token).", + "apiEndpoint": "https://api.aegiscard.com/v2/auth/register", + "httpMethod": "POST", + "requestHeaders": "Content-Type: application/json", + "requestBodyExampleMasked": "{\n \"first_name\": \"Alicia\",\n \"last_name\": \"Tester\",\n \"email\": \"test+reg01-001@example.com\",\n \"password\": \"Str0ng!Passw0rd\",\n \"date_of_birth\": \"1990-01-15\",\n \"phone_number\": \"+14165550101\",\n \"ssn_last4\": \"1234\",\n \"agree_terms\": true\n}", + "stepsToPerform": "1) Generate a unique email address test+reg01-001@example.com and observe it is not previously registered in the test system.\n2) Prepare a registration payload that satisfies all field validations and observe the JSON is well-formed.\n3) Send POST /v2/auth/register with the payload and observe the HTTP status code.\n4) Observe the response body contains a user identifier field named user_id.\n5) Observe the response body contains verification_token.\n6) Verify the response does not indicate an error (no error field present for the 201 success case).\n7) Re-submit the same request payload (same email) and observe it no longer returns 201 (account already exists) to confirm the first call created the account.\n8) Record user_id and verification_token artifacts for potential downstream tests (e.g., verification flow) and observe they are non-empty.\n9) Cleanup expectation: mark the created test user for deletion/purge per test environment policy (if available) and record the identifier for cleanup execution.", + "expectedResult": "1) API returns HTTP 201 for a valid registration.\n2) Response body includes user_id and verification_token.\n3) A follow-up registration attempt with the same email indicates the account now exists (demonstrating the first call created the account and triggered the verification workflow artifacts).", + "sourceCitation": { + "location": "Section 3.1 User Registration, page 4", + "excerpt": "POST /v2/auth/register Creates a new portal user account. Triggers email verification workflow." + } + }, + { + "type": "negative", + "title": "Register rejects when agree_terms is false", + "description": "Verifies registration is rejected when the user does not accept terms, enforcing the documented consent requirement. This protects regulatory/compliance posture by ensuring explicit consent is mandatory. Automation: HIGH — API validation and error response assertion.", + "testId": "TC-REG-02", + "testDescription": "As a new user, if I set agree_terms to false during registration, the system rejects the request per the validation rule.", + "prerequisites": "1) A unique, unused test email is available (e.g., test+reg02-001@example.com).\n2) All other registration fields are valid so only agree_terms triggers rejection.\n3) API base is reachable at https://api.aegiscard.com/v2.\n4) Ability to observe HTTP status and error response fields.", + "apiEndpoint": "https://api.aegiscard.com/v2/auth/register", + "httpMethod": "POST", + "requestHeaders": "Content-Type: application/json", + "requestBodyExampleMasked": "{\n \"first_name\": \"Brandon\",\n \"last_name\": \"Consent\",\n \"email\": \"test+reg02-001@example.com\",\n \"password\": \"Str0ng!Passw0rd\",\n \"date_of_birth\": \"1992-06-20\",\n \"phone_number\": \"+14165550102\",\n \"ssn_last4\": \"5678\",\n \"agree_terms\": false\n}", + "stepsToPerform": "1) Generate a unique email address test+reg02-001@example.com and observe it is not previously registered.\n2) Prepare a registration request where all fields are valid except agree_terms is set to false and observe the JSON is well-formed.\n3) Send POST /v2/auth/register with the payload and observe the HTTP status code is not 201.\n4) Observe the response body contains an error indicator (e.g., error) for validation failure.\n5) Observe the response indicates which field failed (field) and includes a human-readable message (message), if provided.\n6) Repeat the request with agree_terms changed to true and observe the request is now eligible for success (expect 201) to confirm the rejection was specifically due to agree_terms.\n7) Confirm that no user_id is returned in the failure response.\n8) Confirm that no verification_token is returned in the failure response.\n9) Record evidence (request/response) showing rejection when agree_terms is false.", + "expectedResult": "1) Registration request is rejected when agree_terms is false.\n2) Response contains validation error structure (error and field/message as applicable), and does not return user_id/verification_token.\n3) When agree_terms is true (with otherwise identical valid data), registration can succeed (201), confirming the consent gating.", + "sourceCitation": { + "location": "Section 3.1 User Registration field table, page 4", + "excerpt": "agree_terms Boolean ✓ Required Must be true; rejects if false" + } + }, + { + "type": "negative", + "title": "Register returns 409 EMAIL_EXISTS when email already registered", + "description": "Verifies the system enforces email uniqueness and returns the documented conflict code when the email is already registered. This prevents duplicate accounts and supports consistent client error handling. Automation: HIGH — API status/error assertions.", + "testId": "TC-REG-03", + "testDescription": "As a user, if I attempt to register with an email that is already registered, I receive a 409 conflict with the documented EMAIL_EXISTS error.", + "prerequisites": "1) An existing registered user email is available in the test system (e.g., test+reg03-existing@example.com).\n2) The existing user was created previously via POST /v2/auth/register and is not deleted.\n3) API base is reachable at https://api.aegiscard.com/v2.\n4) Ability to observe HTTP status and error code value.", + "apiEndpoint": "https://api.aegiscard.com/v2/auth/register", + "httpMethod": "POST", + "requestHeaders": "Content-Type: application/json", + "requestBodyExampleMasked": "{\n \"first_name\": \"Casey\",\n \"last_name\": \"Duplicate\",\n \"email\": \"test+reg03-existing@example.com\",\n \"password\": \"Str0ng!Passw0rd\",\n \"date_of_birth\": \"1991-03-10\",\n \"phone_number\": \"+14165550103\",\n \"ssn_last4\": \"9012\",\n \"agree_terms\": true\n}", + "errorCodeExpected": "EMAIL_EXISTS", + "expectedHttpStatus": 409, + "stepsToPerform": "1) Confirm test+reg03-existing@example.com is already registered by attempting a second registration and expecting a conflict (do not proceed if it is not registered).\n2) Prepare a new registration payload using the already-registered email and otherwise valid fields.\n3) Send POST /v2/auth/register and observe the HTTP status code.\n4) Observe the response body contains an error key/value indicating EMAIL_EXISTS.\n5) Observe the response does not include user_id and verification_token in this error case.\n6) Retry the same request and observe the same 409 behavior (consistent conflict handling).\n7) Attempt registration with a new unique email (e.g., test+reg03-new@example.com) and observe it can succeed (201) to confirm the service is functioning.\n8) Record response payload and status evidence for the 409 conflict outcome.\n9) Cleanup: flag any newly created user from step 7 for deletion per test policy.", + "expectedResult": "1) API returns HTTP 409 when the email is already registered.\n2) Response body error code is exactly EMAIL_EXISTS.\n3) No success artifacts (user_id, verification_token) are returned for the duplicate-email request.", + "sourceCitation": { + "location": "Section 3.1 User Registration HTTP codes, page 4", + "excerpt": "409 Email already registered error: EMAIL_EXISTS" + } + }, + { + "type": "negative", + "title": "Register returns 422 WEAK_PASSWORD when password complexity not met", + "description": "Verifies password complexity is enforced and the system returns the documented 422 error for weak passwords. This reduces account takeover risk by preventing low-entropy passwords. Automation: HIGH — API validation and error code assertion.", + "testId": "TC-REG-04", + "testDescription": "As a new user, if I submit a password that does not meet the complexity rules, the registration fails with 422 WEAK_PASSWORD.", + "prerequisites": "1) A unique, unused test email is available (e.g., test+reg04-001@example.com).\n2) All other registration fields are valid so only password complexity triggers rejection.\n3) API base is reachable at https://api.aegiscard.com/v2.\n4) Ability to observe HTTP status and error code value in response.", + "apiEndpoint": "https://api.aegiscard.com/v2/auth/register", + "httpMethod": "POST", + "requestHeaders": "Content-Type: application/json", + "testDataMasked": "Weak password examples: \"password\" (no upper/digit/symbol, too short), \"Abcdefghijk1\" (no symbol). Strong control: \"Str0ng!Passw0rd\".", + "stepsToPerform": "1) Generate a unique email test+reg04-001@example.com and observe it is not previously registered.\n2) Prepare a registration payload with a clearly weak password (e.g., \"password\") and otherwise valid fields.\n3) Send POST /v2/auth/register and observe the HTTP status code.\n4) Observe the response body contains an error code value WEAK_PASSWORD.\n5) Confirm the response is not a 201 and does not include user_id or verification_token.\n6) Prepare a second payload for the same email but with a different weak password that violates a different part of the rule (e.g., \"Abcdefghijk1\" missing symbol) and observe the outcome.\n7) Send the second weak-password request and observe it also returns the same 422 WEAK_PASSWORD code.\n8) Send a control request for a new email (e.g., test+reg04-ctrl@example.com) with a strong password \"Str0ng!Passw0rd\" and observe it can succeed (201).\n9) Record evidence for weak-password rejections and control success; cleanup any created control user per test policy.", + "expectedResult": "1) Registration with a password that does not meet complexity rules returns HTTP 422.\n2) Response body error code is exactly WEAK_PASSWORD.\n3) No account creation artifacts (user_id, verification_token) are returned for weak-password requests.", + "sourceCitation": { + "location": "Section 3.1 User Registration HTTP codes, page 4", + "excerpt": "422 Password does not meet complexity rules error: WEAK_PASSWORD" + } + }, + { + "type": "functional", + "title": "Login success returns access_token, refresh_token, expires_in", + "description": "Verifies successful login issues the documented token set and expiry metadata. This is critical to enable authenticated access to account and transaction endpoints. Automation: HIGH — API response assertions.", + "testId": "TC-LOGIN-01", + "testDescription": "As a registered user, I can log in with correct credentials and receive access_token, refresh_token, and expires_in in the response.", + "prerequisites": "1) A registered user exists with known credentials (email + password) created via POST /v2/auth/register.\n2) The account is not locked.\n3) API base is reachable at https://api.aegiscard.com/v2.\n4) Ability to observe response body keys access_token, refresh_token, expires_in.", + "apiEndpoint": "https://api.aegiscard.com/v2/auth/login", + "httpMethod": "POST", + "requestHeaders": "Content-Type: application/json", + "requestBodyExampleMasked": "{\n \"email\": \"test+login01@example.com\",\n \"password\": \"Str0ng!Passw0rd\"\n}", + "expectedHttpStatus": 200, + "stepsToPerform": "1) Ensure the test user test+login01@example.com exists and the password is known (do not proceed if unknown).\n2) Prepare a login request payload with the correct email and password and observe it is well-formed JSON.\n3) Send POST /v2/auth/login and observe the HTTP status code is 200.\n4) Observe the response contains access_token and it is non-empty.\n5) Observe the response contains refresh_token and it is non-empty.\n6) Observe the response contains expires_in and it is present.\n7) Immediately call a protected endpoint that requires a Bearer token (e.g., account summary if available) using the returned access_token and observe the request is accepted (not 401) to confirm usability.\n8) Confirm that using a tampered access_token (e.g., modify 1 character) for the same protected call is rejected (401/403), ruling out trivial token acceptance abuse.\n9) Record the login response keys (not the raw token values in logs) and evidence of successful authenticated call.", + "expectedResult": "1) Login returns HTTP 200.\n2) Response body includes access_token, refresh_token, expires_in.\n3) access_token is usable for authenticated calls, while a tampered token is rejected.", + "sourceCitation": { + "location": "Section 3.2 Login & Token Issuance HTTP codes, page 5", + "excerpt": "200 Login successful access_token, refresh_token, expires_in" + } + }, + { + "type": "negative", + "title": "Login returns 401 INVALID_CREDENTIALS for wrong password", + "description": "Verifies incorrect credentials are rejected with the documented 401 error code. This prevents unauthorized access and ensures consistent client behavior on authentication failure. Automation: HIGH — API status and error code assertion.", + "testId": "TC-LOGIN-02", + "testDescription": "As a user, if I provide a valid email but wrong password, login fails with 401 INVALID_CREDENTIALS.", + "prerequisites": "1) A registered user exists with known correct password (email: test+login02@example.com).\n2) The account is not currently locked (fewer than 5 recent failed attempts).\n3) API base is reachable at https://api.aegiscard.com/v2.\n4) Ability to observe HTTP status and error code in response.", + "apiEndpoint": "https://api.aegiscard.com/v2/auth/login", + "httpMethod": "POST", + "requestHeaders": "Content-Type: application/json", + "requestBodyExampleMasked": "{\n \"email\": \"test+login02@example.com\",\n \"password\": \"Wr0ng!Passw0rd\"\n}", + "errorCodeExpected": "INVALID_CREDENTIALS", + "expectedHttpStatus": 401, + "stepsToPerform": "1) Confirm the user test+login02@example.com exists in the test system.\n2) Prepare a login payload with the correct email and an incorrect password.\n3) Send POST /v2/auth/login and observe the HTTP status code.\n4) Observe the response body contains error: INVALID_CREDENTIALS.\n5) Confirm the response does not contain access_token, refresh_token, or expires_in.\n6) Immediately retry login with the correct password and observe that it succeeds (200) to confirm the account is valid and not locked.\n7) Confirm the successful login response includes access_token, refresh_token, expires_in.\n8) Perform cleanup by invalidating/ending any created session if the environment supports it (or discard tokens) and ensure no further failed attempts are executed to avoid lockout.\n9) Record evidence of the 401 INVALID_CREDENTIALS response for wrong password.", + "expectedResult": "1) Login with wrong password returns HTTP 401.\n2) Response error code is exactly INVALID_CREDENTIALS.\n3) No tokens (access_token/refresh_token/expires_in) are issued on failed login.", + "sourceCitation": { + "location": "Section 3.2 Login & Token Issuance HTTP codes, page 5", + "excerpt": "401 Invalid credentials error: INVALID_CREDENTIALS" + } + }, + { + "type": "functional", + "title": "Login with MFA enabled accepts optional 6-digit TOTP mfa_code", + "description": "Verifies the login request supports the optional mfa_code field as documented for accounts with MFA enabled. This ensures MFA-capable accounts can authenticate using a TOTP code. Automation: MEDIUM — requires an MFA-enabled test account and a TOTP generator synchronized with the secret.", + "testId": "TC-LOGIN-03", + "testDescription": "As a user with MFA enabled, I can include a 6-digit TOTP in the login request using the optional mfa_code field and complete authentication.", + "prerequisites": "1) A test user exists with MFA enabled on the account (email: test+mfa@example.com).\n2) The current valid 6-digit TOTP for the test user can be generated at runtime (shared secret available in secure test vault).\n3) API base is reachable at https://api.aegiscard.com/v2.\n4) Account is not locked and credentials are valid.", + "apiEndpoint": "https://api.aegiscard.com/v2/auth/login", + "httpMethod": "POST", + "requestHeaders": "Content-Type: application/json", + "stepsToPerform": "1) Generate the current 6-digit TOTP for the MFA-enabled test user (capture only that it is 6 digits; do not persist the secret).\n2) Prepare a login payload including email and password for test+mfa@example.com and include mfa_code with the generated 6-digit value.\n3) Send POST /v2/auth/login and observe the HTTP status code.\n4) Observe the response contains access_token.\n5) Observe the response contains refresh_token.\n6) Observe the response contains expires_in.\n7) Perform an authenticated API call using the returned access_token and observe it succeeds (not 401).\n8) Repeat login omitting mfa_code (keeping credentials same) and observe whether the behavior differs for MFA-enabled accounts; record the actual outcome for product review (do not assert a specific rejection unless documented).\n9) Record evidence that providing mfa_code is accepted and results in token issuance for the MFA-enabled account.", + "expectedResult": "1) Login request including mfa_code as a 6-digit value succeeds for an MFA-enabled account.\n2) Response contains access_token, refresh_token, expires_in on success.\n3) access_token can be used for authenticated API access.", + "sourceCitation": { + "location": "Section 3.2 Login & Token Issuance field table, page 5", + "excerpt": "mfa_code String Optional 6-digit TOTP if MFA enabled on account" + } + }, + { + "type": "state-transition", + "title": "Account locks after 5 failed login attempts and returns 403 ACCOUNT_LOCKED + unlock_at", + "description": "Verifies the account transitions into a locked state after repeated failed login attempts and returns the documented error and unlock timestamp. This mitigates brute-force attacks and provides clients with lockout timing. Automation: MEDIUM — requires controlled repeated failures and timing capture.", + "testId": "TC-LOGIN-04", + "testDescription": "As an attacker/abuse path, repeated wrong-password attempts should lock the account after 5 failures, and the API should respond with 403 ACCOUNT_LOCKED including unlock_at on subsequent attempts.", + "prerequisites": "1) A registered test user exists (email: test+lockout@example.com) with a known correct password.\n2) The account is currently in an unlocked state (no existing lock).\n3) API base is reachable at https://api.aegiscard.com/v2.\n4) Test runner can perform 6 sequential login attempts within a short period and capture response bodies.", + "apiEndpoint": "https://api.aegiscard.com/v2/auth/login", + "httpMethod": "POST", + "requestHeaders": "Content-Type: application/json", + "errorCodeExpected": "ACCOUNT_LOCKED", + "expectedHttpStatus": 403, + "stepsToPerform": "1) Confirm the account can successfully log in with the correct password (baseline) and then discard the issued tokens to avoid side effects.\n2) Send login attempt #1 with the correct email and an incorrect password and observe HTTP 401 with error INVALID_CREDENTIALS.\n3) Send login attempt #2 with incorrect password and observe HTTP 401 INVALID_CREDENTIALS.\n4) Send login attempt #3 with incorrect password and observe HTTP 401 INVALID_CREDENTIALS.\n5) Send login attempt #4 with incorrect password and observe HTTP 401 INVALID_CREDENTIALS.\n6) Send login attempt #5 with incorrect password and observe the lockout behavior is triggered (record the response; if still 401, proceed to the next step as the lock may apply starting immediately after the 5th failure).\n7) Send login attempt #6 (can be wrong password or correct password) and observe HTTP 403 with error ACCOUNT_LOCKED.\n8) In the 403 response, observe unlock_at is present and is a parseable timestamp value.\n9) Attempt login again immediately and observe it remains 403 ACCOUNT_LOCKED until unlock time (do not wait for unlock unless test environment allows; record that it remains locked).\n10) Cleanup expectation: coordinate account unlock/reset in test environment (admin action or wait until unlock_at) so the user is usable for other tests; record unlock_at for follow-up.", + "expectedResult": "1) After 5 failed login attempts, the account enters a locked state.\n2) Subsequent login attempts return HTTP 403 with error ACCOUNT_LOCKED.\n3) The 403 response includes unlock_at, providing the time when login may be retried.", + "sourceCitation": { + "location": "Section 3.2 Login & Token Issuance HTTP codes, page 5", + "excerpt": "403 Account locked after 5 failed attempts error: ACCOUNT_LOCKED, unlock_at" + } + }, + { + "type": "resilience", + "title": "Login rate limit returns 429 RATE_LIMITED with retry_after (10 req/min per IP)", + "description": "Verifies that the login endpoint enforces the documented per-IP rate limit and returns the specified 429 response with RATE_LIMITED and retry_after. This protects the authentication surface from brute-force and abusive traffic patterns. Automation: HIGH — pure API assertions under controlled request rate.", + "testId": "TC-LOGIN-05", + "testDescription": "As a client attempting repeated logins from the same public IP, when the rate exceeds the documented threshold, the system must throttle additional login requests and return the defined error contract including retry guidance.", + "prerequisites": "1) A registered portal user exists with known credentials (synthetic), e.g., email=test+rl01@example.com.\n2) Test runner can send all requests from a single stable egress IP (no NAT IP rotation during the minute).\n3) No prior login rate-limit counters are active for the egress IP for at least 60 seconds before test start.\n4) API base URL reachable: https://api.aegiscard.com/v2.", + "stepsToPerform": "1) Prepare valid login payload for the test user (email=test+rl01@example.com, password=ValidPass!12345) and confirm it succeeds once with HTTP 200 when sent alone.\n2) Start a 60-second window timer (T0) and ensure all subsequent requests originate from the same egress IP.\n3) Send 10 POST requests to /v2/auth/login within the same 60-second window (e.g., one every ~5 seconds) using the same valid payload; observe each response.\n4) Verify that each of these first 10 requests returns HTTP 200 and includes response body keys access_token, refresh_token, expires_in.\n5) Immediately send an 11th POST request to /v2/auth/login before the 60-second window ends; observe the response.\n6) Verify the 11th request returns HTTP 429.\n7) Verify the 429 response body contains error exactly equal to RATE_LIMITED.\n8) Verify the 429 response body contains retry_after and that it is a positive value (present and > 0) suitable for retry guidance.\n9) Wait for the retry_after duration (or until 60 seconds have elapsed since T0, whichever is longer), then send one more POST /v2/auth/login with valid payload.\n10) Verify the post-wait login attempt is accepted (HTTP 200) and returns access_token, refresh_token, expires_in (i.e., throttling is not permanent).", + "expectedResult": "1) When the per-IP threshold is exceeded, POST /v2/auth/login returns HTTP 429 with response body containing error: RATE_LIMITED and retry_after.\n2) The rate limit is scoped to the IP-based time window (after waiting the indicated period/window, login returns HTTP 200 again).\n3) Abuse path ruled out: excessive automated login attempts from one IP are throttled rather than continuously issuing tokens.", + "apiEndpoint": "https://api.aegiscard.com/v2/auth/login", + "httpMethod": "POST", + "expectedHttpStatus": "429", + "errorCodeExpected": "RATE_LIMITED", + "rateLimitScenario": "Exceed 10 req/min per IP by issuing 11 login requests within 60 seconds from same IP", + "testDataMasked": "email=test+rl01@example.com, password=ValidPass!12345", + "sourceCitation": { + "location": "Section 3.2 Login & Token Issuance, HTTP Code table (Page 5)", + "excerpt": "429 Rate limit exceeded (10 req/min per IP) error: RATE_LIMITED, retry_after" + } + }, + { + "type": "functional", + "title": "Token refresh returns new access_token and refresh_token (rotation enforced)", + "description": "Verifies that refreshing with a valid refresh token issues a new access token and a new refresh token, consistent with rotation enforcement. This reduces session hijack risk by ensuring refresh tokens are rotated. Automation: HIGH — pure API assertions.", + "testId": "TC-REFRESH-01", + "testDescription": "As an authenticated user, when I call the token refresh endpoint with a valid refresh token, the system issues a new access token and a new refresh token per the documented refresh contract (rotation enforced).", + "prerequisites": "1) A registered portal user exists with valid credentials (synthetic), e.g., email=test+rt01@example.com.\n2) A successful login has been performed via POST /v2/auth/login, yielding a refresh_token artifact (opaque token) from the login response.\n3) The refresh_token is unexpired and has not been used previously.\n4) Test environment allows capturing response bodies for token comparison (do not log tokens to shared logs).", + "stepsToPerform": "1) Send POST /v2/auth/login with valid credentials for email=test+rt01@example.com; observe HTTP 200.\n2) Capture refresh_token from the login response body (store as refresh_token_1 in test memory) and confirm it is present.\n3) Capture access_token from the login response body (store as access_token_1) and confirm it is present.\n4) Send POST /v2/auth/token/refresh with JSON body {\"refresh_token\":\"\"}; observe the response.\n5) Verify the refresh response returns HTTP 200.\n6) Verify the refresh response body includes access_token and refresh_token fields.\n7) Capture the returned tokens as access_token_2 and refresh_token_2.\n8) Verify access_token_2 is different from access_token_1 (string inequality).\n9) Verify refresh_token_2 is different from refresh_token_1 (string inequality), demonstrating token rotation.\n10) (Cleanup) Immediately invalidate session by discarding tokens in test memory and ensure they are not persisted anywhere in test artifacts.", + "expectedResult": "1) POST /v2/auth/token/refresh with a valid refresh token returns HTTP 200.\n2) Response body includes access_token and refresh_token, and both are newly issued (access_token changes; refresh_token rotates).\n3) Abuse path ruled out: refresh token reuse across sessions is discouraged by rotation (new refresh token is issued on every successful refresh).", + "apiEndpoint": "https://api.aegiscard.com/v2/auth/token/refresh", + "httpMethod": "POST", + "expectedHttpStatus": "200", + "requestBodyExampleMasked": "{\"refresh_token\":\"\"}", + "validationRulesCovered": "refresh_token required; rotation enforced", + "sourceCitation": { + "location": "Section 3.3 Token Refresh & Logout (Page 5)", + "excerpt": "POST /v2/auth/token/refresh Issues a new access token using a valid refresh token (rotation enforced)." + } + }, + { + "type": "security", + "title": "Token refresh invalidates old refresh token after successful refresh (single-use)", + "description": "Verifies refresh-token single-use semantics by confirming the old refresh token is invalidated after a successful refresh. This prevents replay attacks if a refresh token is stolen. Automation: HIGH — pure API assertions.", + "testId": "TC-REFRESH-02", + "testDescription": "As an authenticated user, after I successfully refresh tokens once, attempting to reuse the old refresh token should fail because the old refresh is invalidated.", + "prerequisites": "1) A registered portal user exists with valid credentials (synthetic), e.g., email=test+rt02@example.com.\n2) A successful login has been performed via POST /v2/auth/login, yielding an initial refresh_token artifact (refresh_token_1).\n3) The initial refresh_token_1 is valid, unexpired, and unused at test start.\n4) The test client can safely store tokens in memory for the duration of the test (no persistence).", + "stepsToPerform": "1) Send POST /v2/auth/login with valid credentials for email=test+rt02@example.com; verify HTTP 200.\n2) Capture refresh_token from login response as refresh_token_1.\n3) Send POST /v2/auth/token/refresh with {\"refresh_token\":\"\"}; verify HTTP 200.\n4) Capture refresh_token from refresh response as refresh_token_2 and confirm it is present.\n5) Immediately attempt to call POST /v2/auth/token/refresh again but using the old token {\"refresh_token\":\"\"}; observe response.\n6) Verify the second refresh attempt (reusing refresh_token_1) returns HTTP 401.\n7) Verify the 401 response body contains error exactly equal to TOKEN_INVALID.\n8) Call POST /v2/auth/token/refresh using the new token {\"refresh_token\":\"\"}; observe response.\n9) Verify this call returns HTTP 200 and returns a further rotated refresh_token (refresh_token_3) and an access_token.\n10) (Cleanup) Discard refresh_token_2/3 and access tokens from test memory and ensure they are not written to logs/artifacts.", + "expectedResult": "1) After a successful refresh, the old refresh token cannot be reused and results in HTTP 401 with error: TOKEN_INVALID.\n2) The newly issued refresh token remains usable for a subsequent refresh (returns HTTP 200), demonstrating correct rotation lifecycle.\n3) Abuse path ruled out: replay of a previously used refresh token is rejected (single-use).", + "apiEndpoint": "https://api.aegiscard.com/v2/auth/token/refresh", + "httpMethod": "POST", + "expectedHttpStatus": "401 (on replay of old refresh token)", + "errorCodeExpected": "TOKEN_INVALID", + "idempotencyOrReplayScenario": "Attempt to reuse old refresh token after successful refresh", + "sourceCitation": { + "location": "Section 3.3 Token Refresh & Logout, HTTP Code table (Page 5)", + "excerpt": "New access token issued; old refresh invalidated" + } + }, + { + "type": "negative", + "title": "Token refresh returns 401 TOKEN_INVALID for expired or already used refresh token", + "description": "Verifies the refresh endpoint rejects invalid refresh tokens with the exact 401 TOKEN_INVALID error. This ensures expired or replayed refresh tokens cannot be used to mint new access tokens. Automation: HIGH — pure API assertions.", + "testId": "TC-REFRESH-03", + "testDescription": "As a client, when I attempt to refresh tokens using a refresh token that is already used, the API must return the documented 401 TOKEN_INVALID response.", + "prerequisites": "1) A registered portal user exists with valid credentials (synthetic), e.g., email=test+rt03@example.com.\n2) A successful login has been performed and an initial refresh token (refresh_token_1) is available.\n3) refresh_token_1 can be consumed once successfully to make it 'already used' for the negative check.\n4) Test harness can assert HTTP status and response error fields.", + "stepsToPerform": "1) Send POST /v2/auth/login with valid credentials for email=test+rt03@example.com; verify HTTP 200.\n2) Capture refresh_token from login response as refresh_token_1.\n3) Send POST /v2/auth/token/refresh with {\"refresh_token\":\"\"}; verify HTTP 200 (this consumes refresh_token_1).\n4) Confirm the successful refresh response contains a new refresh_token (store as refresh_token_2) to prove the first refresh succeeded.\n5) Attempt POST /v2/auth/token/refresh again using the already used token {\"refresh_token\":\"\"}; observe response.\n6) Verify the response HTTP status is 401.\n7) Verify the response body contains error exactly equal to TOKEN_INVALID.\n8) Verify that no access_token or refresh_token is returned in the 401 response body (i.e., token issuance does not occur on invalid refresh).\n9) (Control) Optionally call POST /v2/auth/token/refresh with {\"refresh_token\":\"\"} and verify HTTP 200 to confirm the endpoint still functions for valid tokens.\n10) (Cleanup) Discard any tokens from test memory to avoid leakage.", + "expectedResult": "1) POST /v2/auth/token/refresh with an already used refresh token returns HTTP 401 with error: TOKEN_INVALID.\n2) No new tokens are issued when TOKEN_INVALID occurs (no access_token/refresh_token minted in that response).\n3) Abuse path ruled out: replaying a used refresh token cannot extend a session or obtain new access.", + "apiEndpoint": "https://api.aegiscard.com/v2/auth/token/refresh", + "httpMethod": "POST", + "expectedHttpStatus": "401", + "errorCodeExpected": "TOKEN_INVALID", + "sourceCitation": { + "location": "Section 3.3 Token Refresh & Logout, HTTP Code table (Page 5)", + "excerpt": "401 Refresh token expired or already used error: TOKEN_INVALID" + } + }, + { + "type": "security", + "title": "Auth tokens stored in HttpOnly, Secure cookies (not accessible to JS)", + "description": "Verifies the portal stores authentication tokens in cookies marked HttpOnly and Secure, preventing JavaScript access and mitigating XSS token theft. Automation: MEDIUM — requires browser execution and JS-access checks.", + "testId": "TC-TOKSTORE-01", + "testDescription": "As a web portal user, after authentication, tokens must be stored in a way that is not accessible to browser JavaScript (HttpOnly, Secure cookies) to reduce the risk of token exfiltration via XSS.", + "prerequisites": "1) Access to the web portal https://portal.aegiscard.com in a test browser with devtools automation (e.g., Playwright/Cypress).\n2) A registered portal user exists with valid credentials (synthetic), e.g., email=test+cookie01@example.com.\n3) Test environment is configured to allow login without MFA for this user (or provide valid mfa_code if enabled).\n4) Ability to inspect cookies and execute JS in the browser context (document.cookie, Storage APIs).", + "stepsToPerform": "1) Open https://portal.aegiscard.com in a clean browser context (no pre-existing cookies/storage) and verify no auth cookies are present.\n2) Perform login through the portal UI (or the portal’s login flow that triggers auth) using email=test+cookie01@example.com and a valid password; observe successful navigation to an authenticated area.\n3) Open browser devtools (or automation API) and list cookies set for portal.aegiscard.com and/or api.aegiscard.com relevant to auth.\n4) For each cookie used for session/auth, verify the cookie has the HttpOnly attribute set.\n5) For each cookie used for session/auth, verify the cookie has the Secure attribute set.\n6) In the authenticated portal page, execute JavaScript expression document.cookie and capture the returned string.\n7) Verify that document.cookie output does not include any access token or refresh token values (no token-like strings and no cookie names associated with auth tokens if those cookies are HttpOnly).\n8) Trigger an authenticated API call from the portal (e.g., load dashboard) and verify the request succeeds without the client manually attaching tokens in JS (i.e., cookies are used by the browser).\n9) Attempt to read cookies via JS again after the API call and confirm tokens remain inaccessible.\n10) Log out (if available) or clear the browser context and verify auth cookies are removed/invalidated for the session in the browser storage view.", + "expectedResult": "1) Auth/session cookies are set with HttpOnly and Secure attributes.\n2) JavaScript cannot access token cookies (document.cookie does not expose token values/cookie names stored as HttpOnly).\n3) Abuse path ruled out: token theft via XSS-reading document.cookie is mitigated by HttpOnly token storage.", + "rolesAndAccess": "Portal user (cardholder) authenticating via web portal", + "sourceCitation": { + "location": "Section 3 User Authentication & Session Management (Page 4)", + "excerpt": "Tokens are stored in HttpOnly, Secure cookies" + } + }, + { + "type": "security", + "title": "Auth tokens are never stored in localStorage", + "description": "Verifies the web portal does not persist auth tokens in localStorage, reducing exposure to XSS and shared-device risks. Also validates the negative-space requirement that tokens are not written to localStorage even after login/refresh. Automation: MEDIUM — browser storage inspection.", + "testId": "TC-TOKSTORE-02", + "testDescription": "As a security control, the portal must never store access/refresh tokens in localStorage; only HttpOnly, Secure cookies may be used for token storage.", + "prerequisites": "1) Access to the web portal https://portal.aegiscard.com in a test browser with automation.\n2) A registered portal user exists with valid credentials (synthetic), e.g., email=test+ls01@example.com.\n3) Ability to inspect localStorage and sessionStorage (Application tab or automation APIs).\n4) Ability to perform a token refresh event during the session (e.g., wait until access token is near expiry or trigger refresh via API if supported by the client).", + "stepsToPerform": "1) Launch a clean browser context and navigate to https://portal.aegiscard.com; verify localStorage is empty (or capture baseline keys for later diff).\n2) Attempt to search localStorage keys/values for token-like terms (e.g., access_token, refresh_token, jwt, bearer) and record baseline results.\n3) Log in via portal UI using email=test+ls01@example.com and valid password; verify login succeeds (authenticated landing/dashboard visible).\n4) Immediately inspect localStorage for any newly added keys and values.\n5) Verify localStorage contains no access_token, refresh_token, or JWT values (no raw token strings persisted).\n6) Inspect sessionStorage as an additional check and verify it also contains no access_token/refresh_token/JWT values.\n7) Trigger typical authenticated activity (navigate to account summary/dashboard pages) to cause API calls; then re-check localStorage and sessionStorage for token artifacts.\n8) Trigger a refresh-token rotation event (e.g., keep session active until client refresh occurs or invoke refresh in a controlled manner) and then re-check localStorage and sessionStorage again.\n9) Verify that even after refresh, no auth tokens are present in localStorage.\n10) Log out and confirm localStorage still does not contain any token values post-logout (no lingering tokens).", + "expectedResult": "1) localStorage does not contain auth tokens at any point (post-login, during use, or post-refresh).\n2) The portal continues to function authenticated without relying on localStorage token persistence.\n3) Abuse path ruled out: XSS exfiltration via reading localStorage does not reveal auth tokens because tokens are never stored there.", + "sourceCitation": { + "location": "Section 3 User Authentication & Session Management (Page 4)", + "excerpt": "Tokens are stored in HttpOnly, Secure cookies — never in localStorage." + } + }, + { + "type": "functional", + "title": "Application Step 1 succeeds and returns application_id + session_token (201)", + "description": "Verifies that starting a credit application (Step 1) creates an application and returns the required artifacts application_id and session_token with HTTP 201. This is critical because later steps depend on these artifacts for sequential submission. Automation: HIGH — pure API assertions.", + "testId": "TC-APP1-01", + "testDescription": "As an authenticated applicant, when I submit valid Step 1 personal information, the system must create a new application and return application_id and session_token for subsequent steps.", + "prerequisites": "1) A registered portal user exists and is authenticated via login, with a valid session (Bearer/cookie) and known email test+app1ok@example.com.\n2) No active credit application is currently in progress for this user (to avoid DUPLICATE_APPLICATION).\n3) Test data prepared for Step 1 fields using synthetic values (E.164 phone, Canadian postal code, valid province code).\n4) API base URL reachable: https://api.aegiscard.com/v2.", + "stepsToPerform": "1) Authenticate as the user (login) and confirm you have an authenticated session for API calls.\n2) Prepare Step 1 request body with valid values:\n - full_legal_name: \"Jordan Avery Test\"\n - email: \"test+app1ok@example.com\"\n - phone_number: \"+14165550101\"\n - residential_address: {street:\"100 King St W\", city:\"Toronto\", province:\"ON\", postal_code:\"M5H 1J9\"}\n - id_type: \"PASSPORT\"\n - id_number: \"A1B2C3D4\"\n3) Send POST /v2/applications/start with the Step 1 JSON body; observe response.\n4) Verify HTTP status is 201.\n5) Verify response body contains application_id and that it is a UUID-looking value (non-empty string).\n6) Verify response body contains session_token and that it is non-empty.\n7) Store application_id and session_token as artifacts for subsequent steps (do not persist to disk).\n8) Immediately re-check that the response does not indicate validation failure (no error/field/message expected on 201).\n9) (Optional control) Prepare Step 2 call but do not execute; confirm the necessary artifact names are available exactly as application_id and session_token.\n10) Cleanup expectation: if the environment supports it, ensure the created in-progress application is removed/closed after test run to avoid impacting other tests (otherwise use a unique test user per run).", + "expectedResult": "1) POST /v2/applications/start returns HTTP 201.\n2) Response body includes application_id and session_token exactly as named.\n3) Post-condition: application is created and can be continued using session_token in subsequent steps (artifact availability).", + "apiEndpoint": "https://api.aegiscard.com/v2/applications/start", + "httpMethod": "POST", + "expectedHttpStatus": "201", + "requestBodyExampleMasked": "{\"full_legal_name\":\"Jordan Avery Test\",\"email\":\"test+app1ok@example.com\",\"phone_number\":\"+14165550101\",\"residential_address\":{\"street\":\"100 King St W\",\"city\":\"Toronto\",\"province\":\"ON\",\"postal_code\":\"M5H 1J9\"},\"id_type\":\"PASSPORT\",\"id_number\":\"A1B2C3D4\"}", + "sourceCitation": { + "location": "Section 4.1 Step 1 — Personal Information, HTTP Code table (Page 6)", + "excerpt": "201 Step 1 accepted; application created application_id, session_token" + } + }, + { + "type": "boundary", + "title": "Step 1 full_legal_name boundary: accept max 100 chars; reject >100", + "description": "Verifies boundary value behavior for full_legal_name length in Step 1: the system must accept up to 100 characters and reject anything longer. This prevents data truncation and ensures consistent identity record capture. Automation: HIGH — API-level BVA.", + "testId": "TC-APP1-02", + "testDescription": "As an authenticated applicant, I can submit my legal name up to the maximum allowed length, but the system must reject names exceeding the documented limit.", + "prerequisites": "1) A registered portal user exists and is authenticated; user email is test+app1bva@example.com.\n2) No active application is in progress for this user before each sub-attempt (use two separate fresh users or cleanup between attempts).\n3) Ability to generate deterministic strings of exact length 100 and 101 characters.\n4) All other Step 1 fields are valid and constant across attempts.", + "stepsToPerform": "1) Authenticate as test+app1bva@example.com and confirm authenticated session is active.\n2) Construct full_legal_name_100 as a string of exactly 100 characters (e.g., 100 'A' characters) and verify its length is 100.\n3) Send POST /v2/applications/start with full_legal_name=full_legal_name_100 and otherwise valid Step 1 payload (email matches authenticated user, valid phone/address/id); observe response.\n4) Verify the response is HTTP 201.\n5) Verify the 201 response contains application_id and session_token (capture then discard/cleanup this application to allow the next attempt).\n6) Ensure there is no active application in progress for the user (cleanup or use a second user test+app1bva2@example.com for the >100 case).\n7) Construct full_legal_name_101 as a string of exactly 101 characters (e.g., 101 'B' characters) and verify its length is 101.\n8) Send POST /v2/applications/start with full_legal_name=full_legal_name_101 and otherwise valid Step 1 payload; observe response.\n9) Verify the response is HTTP 400.\n10) Verify the response body contains error, field, message (as per validation failure), and that the failure corresponds to the full_legal_name field (field == \"full_legal_name\").", + "expectedResult": "1) A Step 1 request with full_legal_name length exactly 100 is accepted with HTTP 201 and returns application_id and session_token.\n2) A Step 1 request with full_legal_name length 101 is rejected with HTTP 400 and returns error, field, message indicating validation failure on full_legal_name.\n3) Abuse path ruled out: overly long inputs are not silently truncated; they are rejected as validation failures.", + "apiEndpoint": "https://api.aegiscard.com/v2/applications/start", + "httpMethod": "POST", + "boundaryValues": "full_legal_name length: 100 (accept), 101 (reject)", + "expectedHttpStatus": "201 for length=100; 400 for length=101", + "sourceCitation": { + "location": "Section 4.1 Step 1 — Personal Information, Field table (Page 6)", + "excerpt": "full_legal_name String ✓ Required Max 100 chars; as on government ID" + } + }, + { + "type": "negative", + "title": "Step 1 email must match authenticated user email (reject mismatch)", + "description": "Verifies that Step 1 enforces the rule that the submitted email must match the authenticated user email, preventing identity/ownership spoofing during credit applications. Automation: HIGH — API assertions.", + "testId": "TC-APP1-03", + "testDescription": "As an authenticated user, if I try to start an application using an email different from the email on my authenticated account, the system must reject the request.", + "prerequisites": "1) A registered portal user exists and is authenticated as email=test+app1emailA@example.com.\n2) No active application is currently in progress for this authenticated user.\n3) A different syntactically valid email is available for mismatch attempt, e.g., test+app1emailB@example.com.\n4) All other Step 1 fields are valid (phone E.164, province 2-char code, Canadian postal code, id_type enum, id_number alphanumeric).", + "stepsToPerform": "1) Authenticate via login as the user with email test+app1emailA@example.com; verify authenticated session is established.\n2) Prepare a Step 1 request body where email is set to the mismatching value test+app1emailB@example.com while all other fields are valid.\n3) Send POST /v2/applications/start with the mismatching email payload; observe response.\n4) Verify HTTP status is 400 (validation failure on a field).\n5) Verify the response body contains error, field, message.\n6) Verify field equals \"email\" (indicating the email constraint caused the failure).\n7) Verify that no application_id or session_token is returned in the 400 response.\n8) Immediately send POST /v2/applications/start again with the same payload but with email corrected to test+app1emailA@example.com; observe response.\n9) Verify the corrected request succeeds with HTTP 201 and returns application_id and session_token.\n10) Cleanup: discard created application artifacts or delete/close the in-progress application to avoid DUPLICATE_APPLICATION in later runs.", + "expectedResult": "1) When Step 1 email does not match the authenticated user email, the API rejects with HTTP 400 and returns error, field, message (field=email).\n2) No application is created on the mismatch attempt (no application_id/session_token returned).\n3) Abuse path ruled out: a user cannot start an application under another email identity while authenticated as a different user.", + "apiEndpoint": "https://api.aegiscard.com/v2/applications/start", + "httpMethod": "POST", + "expectedHttpStatus": "400", + "validationRulesCovered": "email must match authenticated user email", + "sourceCitation": { + "location": "Section 4.1 Step 1 — Personal Information, Field table (Page 6)", + "excerpt": "email String ✓ Required Must match authenticated user email" + } + }, + { + "type": "negative", + "title": "Step 1 returns 409 DUPLICATE_APPLICATION when active application already in progress", + "description": "Verifies the system prevents multiple concurrent active applications by returning the specified 409 DUPLICATE_APPLICATION error when an application is already in progress. This protects underwriting workflow integrity and avoids duplicate submissions. Automation: HIGH — pure API assertions.", + "testId": "TC-APP1-04", + "testDescription": "As an authenticated applicant, if I try to start a new application while another is already active, the system must reject the request with the documented conflict error.", + "prerequisites": "1) A registered portal user exists and is authenticated as email=test+dupapp@example.com.\n2) The user has an active application already in progress created via Step 1 (i.e., an existing application has been started and not completed/closed).\n3) The test runner can call POST /v2/applications/start twice under the same authenticated identity.\n4) Step 1 payload data is valid and consistent between attempts.", + "stepsToPerform": "1) Authenticate as test+dupapp@example.com and confirm authenticated session is active.\n2) Send POST /v2/applications/start with a valid Step 1 payload (full_legal_name, matching email, E.164 phone, valid address, id_type, id_number); observe response.\n3) Verify the first request returns HTTP 201 and capture application_id_1 and session_token_1.\n4) Without completing Steps 2/3 and without any cleanup, immediately send a second POST /v2/applications/start with another valid Step 1 payload for the same authenticated user; observe response.\n5) Verify the second request returns HTTP 409.\n6) Verify the 409 response body contains error exactly equal to DUPLICATE_APPLICATION.\n7) Verify the 409 response does not return application_id/session_token for a new application.\n8) Confirm the original application_id_1 and session_token_1 artifacts remain available in test memory (no overwrite occurred).\n9) (Post-condition) Complete cleanup by completing the application flow or removing the in-progress application using test environment admin tools to prevent contamination of subsequent tests.\n10) Re-run a fresh POST /v2/applications/start after cleanup (or with a new user) to confirm the endpoint returns 201 when no active application exists.", + "expectedResult": "1) The second attempt to start Step 1 while an active application exists returns HTTP 409 with error: DUPLICATE_APPLICATION.\n2) The conflict response does not create a new application (no new application_id/session_token returned).\n3) Abuse path ruled out: the user cannot create multiple simultaneous active applications to bypass business controls.", + "apiEndpoint": "https://api.aegiscard.com/v2/applications/start", + "httpMethod": "POST", + "expectedHttpStatus": "409", + "errorCodeExpected": "DUPLICATE_APPLICATION", + "sourceCitation": { + "location": "Section 4.1 Step 1 — Personal Information, HTTP Code table (Page 6)", + "excerpt": "409 Active application already in progress error: DUPLICATE_APPLICATION" + } + }, + { + "type": "functional", + "title": "Application Step 2 succeeds with X-App-Session header and returns PENDING_REVIEW + fico_pull_id", + "description": "Verifies Step 2 financial information can be saved when the Step 1 session_token is provided in the X-App-Session header, and that the response indicates the soft credit pull has been queued. This protects the required session-based sequencing and ensures the client receives the tracking identifier for the queued credit pull. Automation: HIGH — pure API assertions.", + "testId": "TC-APP2-01", + "testDescription": "As an authenticated applicant who has completed Step 1 and received a session_token, I submit valid financial information to Step 2 with the X-App-Session header, and I receive a 200 response containing status PENDING_REVIEW and a fico_pull_id.", + "prerequisites": "1) Applicant has successfully completed Step 1 POST /v2/applications/start and obtained application_id and session_token.\n2) session_token is unexpired (within the stated 30 min window) and available to send as Header: X-App-Session.\n3) Test environment has access to Applications service endpoint POST /v2/applications/{application_id}/financials.\n4) A valid Bearer token session exists for the same authenticated user who started the application.", + "stepsToPerform": "1) Create/identify a synthetic applicant user account and authenticate to obtain a valid Bearer token; observe API calls are authorized for application endpoints.\n2) Call POST /v2/applications/start with valid personal info to create a new application; observe HTTP 201 and capture application_id and session_token.\n3) Prepare a valid Step 2 request body with employment_status=\"SELF_EMPLOYED\", gross_annual_income=85000.00, monthly_rent=1800.00, existing_debt_payments=350.00, sin_consent=true; observe the payload is well-formed JSON.\n4) Send POST /v2/applications/{application_id}/financials with Authorization: Bearer and header X-App-Session: ; observe the server responds.\n5) Verify the HTTP status is 200; observe response body is JSON.\n6) Verify the response contains field status with value \"PENDING_REVIEW\"; record the value for evidence.\n7) Verify the response contains field fico_pull_id and that it is present and non-empty; record the value.\n8) Re-send the same Step 2 request once more with the same X-App-Session header; observe the server continues to accept and returns HTTP 200 (no error expected by spec for repeats).\n9) Confirm the second response also includes status \"PENDING_REVIEW\" and includes a fico_pull_id field (presence assertion).", + "expectedResult": "1) Step 2 returns HTTP 200.\n2) Response body includes status: \"PENDING_REVIEW\".\n3) Response body includes fico_pull_id (present and non-empty).", + "apiEndpoint": "POST /v2/applications/{application_id}/financials", + "httpMethod": "POST", + "requestHeaders": "Authorization: Bearer , X-App-Session: ", + "requestBodyExampleMasked": "{ \"employment_status\": \"SELF_EMPLOYED\", \"gross_annual_income\": 85000.00, \"other_income\": 0.00, \"monthly_rent\": 1800.00, \"existing_debt_payments\": 350.00, \"sin_consent\": true }", + "expectedHttpStatus": "200", + "validationRulesCovered": "Header: X-App-Session required; successful save returns PENDING_REVIEW and fico_pull_id", + "sourceCitation": { + "location": "Section 4.2 Step 2 — Financial Information, HTTP Code table, page 7", + "excerpt": "200 Financials saved; credit pull queued status: PENDING_REVIEW, fico_pull_id" + } + }, + { + "type": "negative", + "title": "Step 2 rejects when employment_status=EMPLOYED but employer_name missing", + "description": "Verifies employer_name becomes mandatory when employment_status is EMPLOYED and that the API rejects missing required conditional fields. This prevents incomplete employment data from being used in credit assessment. Automation: HIGH — pure API assertions.", + "testId": "TC-APP2-02", + "testDescription": "As an applicant submitting Step 2 financials, when I set employment_status to EMPLOYED but omit employer_name, the system must reject the request as an invalid or missing financial field.", + "prerequisites": "1) Applicant has successfully completed Step 1 and has a valid application_id.\n2) Applicant has a valid, unexpired session_token to send in Header: X-App-Session.\n3) Applicant is authenticated and can call application endpoints with a valid Bearer token.\n4) Step 2 endpoint POST /v2/applications/{application_id}/financials is reachable in the test environment.", + "stepsToPerform": "1) Authenticate as a synthetic user and obtain a valid Bearer token; observe authentication succeeds.\n2) Call POST /v2/applications/start with valid personal info; observe HTTP 201 and capture application_id and session_token.\n3) Construct Step 2 request body setting employment_status=\"EMPLOYED\" while intentionally omitting employer_name; include gross_annual_income=65000.00, monthly_rent=1500.00, existing_debt_payments=200.00, sin_consent=true.\n4) Send POST /v2/applications/{application_id}/financials with headers Authorization: Bearer and X-App-Session: ; observe the server response.\n5) Verify the HTTP status is 400; capture response body.\n6) Verify response includes error and includes field and message keys (presence assertion based on 400 response schema shown for Step 2).\n7) Verify the response field references the missing conditional field by asserting field equals \"employer_name\".\n8) Fix the payload by adding employer_name=\"Synthetic Employer Inc\" while keeping employment_status=\"EMPLOYED\"; resend the request with the same headers.\n9) Verify the corrected request no longer returns 400 and instead returns HTTP 200 with status \"PENDING_REVIEW\" (successful path confirmation).", + "expectedResult": "1) When employer_name is omitted with employment_status=\"EMPLOYED\", API returns HTTP 400.\n2) Error response contains keys error, field, message and identifies field: \"employer_name\".\n3) After adding employer_name, API returns HTTP 200 and proceeds to the normal queued-credit-pull response pattern.", + "apiEndpoint": "POST /v2/applications/{application_id}/financials", + "httpMethod": "POST", + "requestHeaders": "Authorization: Bearer , X-App-Session: ", + "requestBodyExampleMasked": "{ \"employment_status\": \"EMPLOYED\", \"gross_annual_income\": 65000.00, \"monthly_rent\": 1500.00, \"existing_debt_payments\": 200.00, \"sin_consent\": true }", + "expectedHttpStatus": "400", + "errorCodeExpected": "400", + "validationRulesCovered": "employer_name required if employment_status = EMPLOYED", + "sourceCitation": { + "location": "Section 4.2 Step 2 — Financial Information, field table, page 7", + "excerpt": "employer_name String Optional Required if employment_status = EMPLOYED" + } + }, + { + "type": "boundary", + "title": "Step 2 gross_annual_income boundary: positive and max 9,999,999.99", + "description": "Verifies Step 2 gross_annual_income enforces the documented numeric constraints: it must be positive and cannot exceed 9,999,999.99. This prevents invalid income values from impacting credit decisions and downstream computations. Automation: HIGH — pure API assertions.", + "testId": "TC-APP2-03", + "testDescription": "As an applicant completing Step 2, I submit gross_annual_income at the maximum allowed value and just over the maximum, and I also test a non-positive value, to confirm the API accepts only positive values up to 9,999,999.99.", + "prerequisites": "1) Applicant can complete Step 1 to obtain application_id and session_token.\n2) session_token is unexpired and will be sent as Header: X-App-Session.\n3) Applicant is authenticated with a valid Bearer token.\n4) Step 2 endpoint is available: POST /v2/applications/{application_id}/financials.", + "stepsToPerform": "1) Authenticate as a synthetic user; observe a valid Bearer token is available.\n2) Call POST /v2/applications/start; observe HTTP 201 and capture application_id and session_token.\n3) Build a baseline valid Step 2 payload (employment_status=\"RETIRED\", monthly_rent=0.00, existing_debt_payments=0.00, sin_consent=true) and set gross_annual_income=9999999.99.\n4) Submit baseline payload to POST /v2/applications/{application_id}/financials with X-App-Session header; observe HTTP response.\n5) Verify HTTP 200 and that response contains status \"PENDING_REVIEW\" and fico_pull_id.\n6) Modify only gross_annual_income to 10000000.00 (just past max) and resubmit with the same headers; observe HTTP response.\n7) Verify HTTP 400 and response contains error, field, message; verify field equals \"gross_annual_income\".\n8) Modify only gross_annual_income to 0.00 (non-positive) and resubmit; observe HTTP response.\n9) Verify HTTP 400 and response contains error, field, message; verify field equals \"gross_annual_income\".\n10) Modify only gross_annual_income to -1.00 and resubmit; observe HTTP response.\n11) Verify HTTP 400 and response contains error, field, message; verify field equals \"gross_annual_income\".", + "expectedResult": "1) With gross_annual_income=9,999,999.99 the API returns HTTP 200 and includes status: \"PENDING_REVIEW\" and fico_pull_id.\n2) With gross_annual_income=10,000,000.00 the API returns HTTP 400 with error payload including field: \"gross_annual_income\".\n3) With gross_annual_income=0.00 or -1.00 the API returns HTTP 400 with error payload including field: \"gross_annual_income\".", + "apiEndpoint": "POST /v2/applications/{application_id}/financials", + "httpMethod": "POST", + "requestHeaders": "Authorization: Bearer , X-App-Session: ", + "boundaryValues": "Accepted: 9999999.99; Rejected: 10000000.00; Rejected: 0.00; Rejected: -1.00", + "validationRulesCovered": "gross_annual_income Positive; max 9,999,999.99; in CAD", + "sourceCitation": { + "location": "Section 4.2 Step 2 — Financial Information, field table, page 7", + "excerpt": "gross_annual_income Decimal ✓ Required Positive; max 9,999,999.99; in CAD" + } + }, + { + "type": "negative", + "title": "Step 2 rejects when sin_consent is not true", + "description": "Verifies the applicant must explicitly provide sin_consent=true to proceed with the credit check at Step 2 and that the API rejects any other value. This enforces consent for the bureau soft credit pull. Automation: HIGH — pure API assertions.", + "testId": "TC-APP2-04", + "testDescription": "As an applicant completing Step 2, when I provide sin_consent=false (or omit the field), the system must reject the request because consent must be true to proceed with the credit check.", + "prerequisites": "1) Applicant has a valid application_id and session_token from Step 1.\n2) session_token is unexpired and sent via Header: X-App-Session.\n3) Applicant is authenticated with a valid Bearer token.\n4) Step 2 endpoint POST /v2/applications/{application_id}/financials is reachable.", + "stepsToPerform": "1) Authenticate as a synthetic user and obtain a valid Bearer token.\n2) Call POST /v2/applications/start; observe HTTP 201 and capture application_id and session_token.\n3) Create a valid Step 2 payload but set sin_consent=false; keep other required fields valid (employment_status=\"STUDENT\", gross_annual_income=12000.00, monthly_rent=650.00, existing_debt_payments=50.00).\n4) Submit Step 2 with X-App-Session header; observe the response.\n5) Verify HTTP status is 400 and response contains error, field, message.\n6) Assert field equals \"sin_consent\".\n7) Modify the payload to omit sin_consent entirely and resubmit; observe the response.\n8) Verify HTTP status is 400 and response contains error, field, message; assert field equals \"sin_consent\".\n9) Modify the payload to set sin_consent=true and resubmit; observe the response.\n10) Verify HTTP 200 and response includes status \"PENDING_REVIEW\" and fico_pull_id.", + "expectedResult": "1) When sin_consent is false, API returns HTTP 400 with error payload and field: \"sin_consent\".\n2) When sin_consent is omitted, API returns HTTP 400 with error payload and field: \"sin_consent\".\n3) When sin_consent is true, API returns HTTP 200 and includes status: \"PENDING_REVIEW\" and fico_pull_id.", + "apiEndpoint": "POST /v2/applications/{application_id}/financials", + "httpMethod": "POST", + "requestHeaders": "Authorization: Bearer , X-App-Session: ", + "validationRulesCovered": "sin_consent must be true", + "sourceCitation": { + "location": "Section 4.2 Step 2 — Financial Information, field table, page 7", + "excerpt": "sin_consent Boolean ✓ Required Must be true to proceed with credit check" + } + }, + { + "type": "functional", + "title": "Step 3 submit approved decision for FICO > 680 includes credit_limit and card_number_masked", + "description": "Verifies Step 3 returns the APPROVED decision branch when the credit score condition is met and that the response includes credit_limit and card_number_masked. This ensures the portal can display the approval outcome while only showing masked PAN data. Automation: MEDIUM — requires a controllable test bureau/FICO outcome or stub.", + "testId": "TC-APP3-01", + "testDescription": "As an applicant who completed Steps 1 and 2, when I submit Step 3 and the credit logic yields FICO > 680, I receive decision APPROVED along with credit_limit and card_number_masked.", + "prerequisites": "1) Applicant has completed Step 1 and Step 2 successfully and holds application_id and session_token.\n2) session_token is unexpired and available for Header: X-App-Session for Step 3.\n3) A valid card_product_id is available (e.g., \"AEGIS_GOLD\") from the products catalog used by Step 3.\n4) Test environment supports deterministically producing an approval outcome: \"Auto-Approved (FICO > 680)\" for the application under test (e.g., via bureau stub/test profile).", + "stepsToPerform": "1) Authenticate as a synthetic applicant and obtain a valid Bearer token.\n2) Complete Step 1 (POST /v2/applications/start); observe HTTP 201 and capture application_id and session_token.\n3) Complete Step 2 (POST /v2/applications/{application_id}/financials) with valid data and sin_consent=true; observe HTTP 200 and capture fico_pull_id.\n4) Ensure test setup/stub is configured so the application will evaluate as \"Auto-Approved (FICO > 680)\" when Step 3 runs; observe configuration is in effect for this application_id.\n5) Build a Step 3 request with card_product_id=\"AEGIS_GOLD\" and a Base64-encoded typed signature (e_signature=\"U3ludGhldGljIFNpZ25hdHVyZQ==\"); omit marketing_opt_in.\n6) Submit POST /v2/applications/{application_id}/submit with Authorization Bearer token and X-App-Session header; observe HTTP response.\n7) Verify HTTP status is 200; capture response body.\n8) Verify response includes decision=\"APPROVED\".\n9) Verify response includes credit_limit (present) and card_number_masked (present).\n10) Verify card_number_masked is masked format (does not contain 13-19 consecutive digits); record for evidence.", + "expectedResult": "1) Step 3 returns HTTP 200 with decision: \"APPROVED\" for the configured approval condition.\n2) Response body includes credit_limit.\n3) Response body includes card_number_masked (masked; no full PAN exposed).", + "apiEndpoint": "POST /v2/applications/{application_id}/submit", + "httpMethod": "POST", + "requestHeaders": "Authorization: Bearer , X-App-Session: ", + "requestBodyExampleMasked": "{ \"card_product_id\": \"AEGIS_GOLD\", \"e_signature\": \"U3ludGhldGljIFNpZ25hdHVyZQ==\" }", + "expectedHttpStatus": "200", + "validationRulesCovered": "Approved decision branch includes credit_limit and card_number_masked", + "sourceCitation": { + "location": "Section 4.3 Step 3 — Decision & Card Selection, HTTP Code table, page 8", + "excerpt": "200 Auto-Approved (FICO > 680) decision: APPROVED, credit_limit, card_number_masked" + } + }, + { + "type": "functional", + "title": "Step 3 submit pending decision for FICO 600-680 includes review_eta_hours", + "description": "Verifies Step 3 returns the PENDING decision branch for the defined FICO band and includes review_eta_hours. This ensures the portal can display an underwriting referral timeline. Automation: MEDIUM — requires a controllable test bureau/FICO outcome or stub.", + "testId": "TC-APP3-02", + "testDescription": "As an applicant who completed Steps 1 and 2, when I submit Step 3 and the credit logic yields FICO 600-680, I receive decision PENDING with review_eta_hours.", + "prerequisites": "1) Applicant has completed Step 1 and Step 2 successfully and holds application_id and session_token.\n2) session_token is unexpired and available for Header: X-App-Session for Step 3.\n3) A valid card_product_id is available for submission (e.g., \"AEGIS_GOLD\").\n4) Test environment supports deterministically producing the referral outcome: \"Referred to underwriter (FICO 600-680)\" for the application under test (via stub/test profile).", + "stepsToPerform": "1) Authenticate as a synthetic applicant and obtain a valid Bearer token.\n2) Complete Step 1 (POST /v2/applications/start); observe HTTP 201 and capture application_id and session_token.\n3) Complete Step 2 with valid financials and sin_consent=true; observe HTTP 200.\n4) Configure the test bureau/stub so the application evaluates as \"Referred to underwriter (FICO 600-680)\" for Step 3; observe configuration is applied.\n5) Build a Step 3 request with card_product_id=\"AEGIS_GOLD\" and e_signature=\"VGVzdCBTaWduYXR1cmU=\"; set marketing_opt_in=true.\n6) Submit POST /v2/applications/{application_id}/submit with Authorization Bearer token and X-App-Session; observe HTTP response.\n7) Verify HTTP 200 and capture response body.\n8) Verify response includes decision=\"PENDING\".\n9) Verify response includes review_eta_hours (present and non-empty).\n10) Verify response does not require credit_limit fields for this path by asserting credit_limit is absent OR null only if your API contract supports such check; otherwise limit to presence check of review_eta_hours and decision.", + "expectedResult": "1) Step 3 returns HTTP 200 with decision: \"PENDING\" under the configured FICO band.\n2) Response body includes review_eta_hours.\n3) Response body matches the pending branch contract (decision + review ETA fields present).", + "apiEndpoint": "POST /v2/applications/{application_id}/submit", + "httpMethod": "POST", + "requestHeaders": "Authorization: Bearer , X-App-Session: ", + "requestBodyExampleMasked": "{ \"card_product_id\": \"AEGIS_GOLD\", \"e_signature\": \"VGVzdCBTaWduYXR1cmU=\", \"marketing_opt_in\": true }", + "expectedHttpStatus": "200", + "validationRulesCovered": "Pending decision branch includes review_eta_hours", + "sourceCitation": { + "location": "Section 4.3 Step 3 — Decision & Card Selection, HTTP Code table, page 8", + "excerpt": "200 Referred to underwriter (FICO 600-680) decision: PENDING, review_eta_hours" + } + }, + { + "type": "functional", + "title": "Step 3 submit declined decision for FICO < 600 includes reason_code", + "description": "Verifies Step 3 returns the DECLINED decision branch when the credit score is below the threshold and includes reason_code. This ensures compliant, explainable decline handling and enables the portal to display the decline reason code. Automation: MEDIUM — requires a controllable test bureau/FICO outcome or stub.", + "testId": "TC-APP3-03", + "testDescription": "As an applicant who completed Steps 1 and 2, when I submit Step 3 and the credit logic yields FICO < 600, I receive decision DECLINED with reason_code.", + "prerequisites": "1) Applicant has completed Step 1 and Step 2 successfully and holds application_id and session_token.\n2) session_token is unexpired and available for Header: X-App-Session for Step 3.\n3) A valid card_product_id is available (e.g., \"AEGIS_GOLD\").\n4) Test environment supports deterministically producing the decline outcome: \"Auto-Declined (FICO < 600)\" for the application under test.", + "stepsToPerform": "1) Authenticate as a synthetic applicant and obtain a valid Bearer token.\n2) Complete Step 1 (POST /v2/applications/start); observe HTTP 201 and capture application_id and session_token.\n3) Complete Step 2 with valid financials and sin_consent=true; observe HTTP 200.\n4) Configure the test bureau/stub so Step 3 evaluates as \"Auto-Declined (FICO < 600)\"; observe configuration is applied.\n5) Build a Step 3 request with card_product_id=\"AEGIS_GOLD\" and e_signature=\"RGVjbGluZSBUZXN0IFNpZw==\".\n6) Submit POST /v2/applications/{application_id}/submit with Authorization and X-App-Session; observe HTTP response.\n7) Verify HTTP 200 and capture response body.\n8) Verify response includes decision=\"DECLINED\".\n9) Verify response includes reason_code (present and non-empty).\n10) Verify response does not include unmasked card data by asserting no field contains a 13-19 digit sequence; record results.", + "expectedResult": "1) Step 3 returns HTTP 200 with decision: \"DECLINED\" under the configured FICO condition.\n2) Response body includes reason_code.\n3) No unmasked card number data is exposed in the decline response payload.", + "apiEndpoint": "POST /v2/applications/{application_id}/submit", + "httpMethod": "POST", + "requestHeaders": "Authorization: Bearer , X-App-Session: ", + "requestBodyExampleMasked": "{ \"card_product_id\": \"AEGIS_GOLD\", \"e_signature\": \"RGVjbGluZSBUZXN0IFNpZw==\" }", + "expectedHttpStatus": "200", + "validationRulesCovered": "Declined decision branch includes reason_code", + "sourceCitation": { + "location": "Section 4.3 Step 3 — Decision & Card Selection, HTTP Code table, page 8", + "excerpt": "200 Auto-Declined (FICO < 600) decision: DECLINED, reason_code" + } + }, + { + "type": "negative", + "title": "Step 3 rejects missing/malformed e_signature with 400 SIGNATURE_REQUIRED", + "description": "Verifies Step 3 enforces the required Base64-encoded typed name signature and rejects missing or malformed signatures with the specified error. This prevents unsigned submissions and supports non-repudiation controls. Automation: HIGH — pure API assertions.", + "testId": "TC-APP3-04", + "testDescription": "As an applicant submitting Step 3, if I omit e_signature or send a malformed value, the system must respond with HTTP 400 and error SIGNATURE_REQUIRED.", + "prerequisites": "1) Applicant has completed Step 1 and Step 2 successfully and holds application_id and session_token.\n2) session_token is unexpired and available for Header: X-App-Session.\n3) A valid card_product_id is available for use in Step 3 (e.g., \"AEGIS_GOLD\").\n4) Applicant is authenticated with a valid Bearer token.", + "stepsToPerform": "1) Authenticate as a synthetic applicant; obtain a valid Bearer token.\n2) Complete Step 1; capture application_id and session_token.\n3) Complete Step 2 with sin_consent=true; observe HTTP 200.\n4) Build a Step 3 request body with card_product_id=\"AEGIS_GOLD\" but omit e_signature; observe JSON is otherwise valid.\n5) Submit POST /v2/applications/{application_id}/submit with Authorization and X-App-Session; observe response.\n6) Verify HTTP status 400 and error equals \"SIGNATURE_REQUIRED\".\n7) Build another Step 3 request with card_product_id=\"AEGIS_GOLD\" and e_signature=\"not-base64\" (malformed); submit again with same headers.\n8) Verify HTTP status 400 and error equals \"SIGNATURE_REQUIRED\".\n9) Build a correct Step 3 request with e_signature set to a valid Base64 string (e.g., \"VGVzdCBTaWduYXR1cmU=\"); submit.\n10) Verify the corrected request does not return 400 SIGNATURE_REQUIRED (i.e., proceeds to HTTP 200 decision response).", + "expectedResult": "1) Missing e_signature returns HTTP 400 with error: \"SIGNATURE_REQUIRED\".\n2) Malformed e_signature returns HTTP 400 with error: \"SIGNATURE_REQUIRED\".\n3) Valid Base64 e_signature is accepted and Step 3 proceeds to an HTTP 200 decision response.", + "apiEndpoint": "POST /v2/applications/{application_id}/submit", + "httpMethod": "POST", + "requestHeaders": "Authorization: Bearer , X-App-Session: ", + "errorCodeExpected": "SIGNATURE_REQUIRED", + "expectedHttpStatus": "400", + "validationRulesCovered": "e_signature missing or malformed -> SIGNATURE_REQUIRED", + "sourceCitation": { + "location": "Section 4.3 Step 3 — Decision & Card Selection, HTTP Code table, page 8", + "excerpt": "400 e_signature missing or malformed error: SIGNATURE_REQUIRED" + } + }, + { + "type": "functional", + "title": "Step 3 marketing_opt_in defaults to false when omitted", + "description": "Verifies Step 3 applies the documented default for marketing_opt_in when the field is not provided. This ensures consistent consent handling and avoids unintended opt-in. Automation: MEDIUM — depends on whether response echoes marketing_opt_in or is queryable; validate via response if present.", + "testId": "TC-APP3-05", + "testDescription": "As an applicant submitting Step 3, when I omit marketing_opt_in, the system should treat it as false by default.", + "prerequisites": "1) Applicant has completed Step 1 and Step 2 successfully and holds application_id and session_token.\n2) session_token is unexpired and sent via Header: X-App-Session.\n3) A valid card_product_id is available for Step 3.\n4) Step 3 response or subsequent fetch in the test environment exposes marketing_opt_in (or a consent flag) to verify the applied default (otherwise this test is blocked).", + "stepsToPerform": "1) Authenticate as a synthetic applicant and obtain a valid Bearer token.\n2) Complete Step 1 to obtain application_id and session_token.\n3) Complete Step 2 with valid financials and sin_consent=true.\n4) Build a Step 3 request body containing card_product_id=\"AEGIS_GOLD\" and a valid Base64 e_signature, and omit marketing_opt_in.\n5) Submit POST /v2/applications/{application_id}/submit with Authorization and X-App-Session; observe HTTP 200.\n6) Inspect the Step 3 response body for marketing_opt_in; if present, record its value.\n7) Assert marketing_opt_in equals false when omitted (if present in response).\n8) Submit another Step 3 request for a fresh application where marketing_opt_in=true is explicitly provided; observe HTTP 200 and compare behavior.\n9) Verify that when explicitly set to true, the response reflects true (if the field is returned) to confirm the field is honored vs defaulted.\n10) If marketing_opt_in is not returned by Step 3 response, mark test as blocked and capture evidence that the API contract does not expose the field needed for verification.", + "expectedResult": "1) When marketing_opt_in is omitted, the system applies the default value false.\n2) Where observable, marketing_opt_in in the response (or retrieved application record) is false.\n3) When marketing_opt_in=true is explicitly provided, it is not defaulted to false (where observable).", + "apiEndpoint": "POST /v2/applications/{application_id}/submit", + "httpMethod": "POST", + "requestHeaders": "Authorization: Bearer , X-App-Session: ", + "requestBodyExampleMasked": "{ \"card_product_id\": \"AEGIS_GOLD\", \"e_signature\": \"VGVzdCBTaWduYXR1cmU=\" }", + "expectedHttpStatus": "200", + "validationRulesCovered": "marketing_opt_in Boolean Optional Default false", + "sourceCitation": { + "location": "Section 4.3 Step 3 — Decision & Card Selection, field table, page 7", + "excerpt": "marketing_opt_in Boolean Optional Default false" + } + }, + { + "type": "state-transition", + "title": "Enforce sequential completion: Step 2 cannot be completed before Step 1 issues session_token", + "description": "Verifies the application workflow enforces sequential ordering by requiring the Step 1-issued session_token to accompany Step 2. This protects against out-of-order or spoofed submissions and ensures the server enforces the intended application state machine. Automation: HIGH — pure API assertions.", + "testId": "TC-APPSEQ-01", + "testDescription": "As an applicant, I cannot submit Step 2 financial information unless Step 1 has been completed and a session_token has been issued, because each step must be completed in order and the token must accompany Step 2.", + "prerequisites": "1) API base URL is reachable and Step 2 endpoint exists: POST /v2/applications/{application_id}/financials.\n2) A valid authenticated Bearer token is available for the test user.\n3) A synthetically generated UUID is available to use as a non-existent or not-started application_id (e.g., \"11111111-1111-1111-1111-111111111111\").\n4) No Step 1 has been executed for the chosen application_id used in the negative attempt.", + "stepsToPerform": "1) Authenticate as a synthetic user and obtain a valid Bearer token; observe token is accepted for API calls.\n2) Choose an application_id that has not been created via Step 1 (synthetic UUID); record it for the test evidence.\n3) Prepare a valid Step 2 payload (employment_status=\"UNEMPLOYED\", gross_annual_income=30000.00, monthly_rent=900.00, existing_debt_payments=150.00, sin_consent=true).\n4) Call POST /v2/applications/{application_id}/financials WITHOUT the X-App-Session header; observe the server response.\n5) Verify the request is rejected (non-200) and capture status code and response payload for evidence.\n6) Now execute Step 1 properly by calling POST /v2/applications/start with valid personal info; observe HTTP 201 and capture application_id and session_token.\n7) Call POST /v2/applications/{application_id}/financials for the created application_id, this time including header X-App-Session: ; observe response.\n8) Verify HTTP 200 and response includes status \"PENDING_REVIEW\" and fico_pull_id.\n9) Attempt Step 2 again using the created application_id but with an invalid X-App-Session value (e.g., \"invalid-token\"); observe response.\n10) Verify HTTP 401 and error equals \"SESSION_EXPIRED\" when session_token is invalid.", + "expectedResult": "1) Step 2 attempt before Step 1 issues session_token is rejected (non-200), demonstrating steps are enforced in order.\n2) After completing Step 1 and sending X-App-Session with the issued session_token, Step 2 succeeds with HTTP 200 and returns status: \"PENDING_REVIEW\" and fico_pull_id.\n3) Step 2 with an invalid X-App-Session token returns HTTP 401 with error: \"SESSION_EXPIRED\".", + "apiEndpoint": "POST /v2/applications/{application_id}/financials", + "httpMethod": "POST", + "requestHeaders": "Authorization: Bearer (and optionally X-App-Session depending on step)", + "sessionExpiryScenario": "Invalid token should be treated as 'expired or invalid' for 401 SESSION_EXPIRED", + "sourceCitation": { + "location": "Section 4 Credit Application Web Flow (intro paragraph), page 6", + "excerpt": "Each step must be completed in order; the system issues a session_token on Step 1 that must accompany Steps 2 and 3." + } + }, + { + "type": "negative", + "title": "Step 2 returns 401 SESSION_EXPIRED when session_token is expired/invalid", + "description": "Verifies the credit application Step 2 endpoint rejects an expired or invalid application session token with the documented HTTP status and error code, preventing unauthorized or replayed progression through the multi-step application flow. Automation: HIGH — pure API assertions.", + "testId": "TC-APPSEQ-02", + "testDescription": "As an authenticated applicant who already completed Step 1 and received a session_token, when I submit Step 2 financial information with an expired/invalid X-App-Session value, the API must deny the request with 401 and error SESSION_EXPIRED.", + "prerequisites": "1) Authenticated user session is active (valid login) and can call /v2/applications/start.\n2) Step 1 completed successfully via POST /v2/applications/start returning application_id and session_token.\n3) Test harness ability to send Step 2 request with a deliberately invalid or expired session token value in header X-App-Session.\n4) Known-valid application_id from Step 1 is available for use in the Step 2 path parameter.", + "stepsToPerform": "1) Call POST /v2/applications/start with valid personal info and observe HTTP 201 is returned.\n2) Capture application_id from the Step 1 response and observe that session_token is present in the response body.\n3) Construct an invalid session token value (e.g., replace session_token with synthetic value \"invalid-session-token-0001\") and observe it differs from the captured session_token.\n4) Prepare a valid Step 2 request body with employment_status=\"EMPLOYED\", employer_name=\"TestCo\", gross_annual_income=75000.00, other_income=0.00, monthly_rent=1500.00, existing_debt_payments=250.00, sin_consent=true and observe it satisfies required fields.\n5) Send POST /v2/applications/{application_id}/financials using the captured application_id but set header X-App-Session to the invalid token value and observe the API response is received.\n6) Observe the HTTP status code is 401.\n7) Observe the response body contains error=\"SESSION_EXPIRED\".\n8) Re-send the same Step 2 request with the valid captured session_token in header X-App-Session and observe the API returns a non-401 response (expected success path is HTTP 200 if financial fields are valid).\n9) Verify abuse-path protection: repeat Step 2 call with a second different invalid X-App-Session value and observe it is also rejected with HTTP 401 and error SESSION_EXPIRED.\n10) Cleanup: discard the created application_id (no further steps executed) and observe no subsequent Step 2 success artifacts (status/ fico_pull_id) were obtained during the invalid-token attempts.", + "expectedResult": "1) Step 2 request with invalid/expired X-App-Session returns HTTP 401.\n2) Response body includes error: SESSION_EXPIRED.\n3) Using the correct session_token in X-App-Session does not return 401 (proves rejection is tied to invalid/expired token, not the application_id).", + "sourceCitation": { + "location": "Section 4.2 Step 2 HTTP codes, page 7", + "excerpt": "401 session_token expired or invalid error: SESSION_EXPIRED" + }, + "apiEndpoint": "POST /v2/applications/{application_id}/financials", + "httpMethod": "POST", + "requestHeaders": "Authorization: Bearer ; X-App-Session: ; Content-Type: application/json", + "requestBodyExampleMasked": "{\"employment_status\":\"EMPLOYED\",\"employer_name\":\"TestCo\",\"gross_annual_income\":75000.00,\"other_income\":0.00,\"monthly_rent\":1500.00,\"existing_debt_payments\":250.00,\"sin_consent\":true}", + "expectedHttpStatus": 401, + "errorCodeExpected": "SESSION_EXPIRED", + "rolesAndAccess": "Authenticated applicant (cardholder portal user)", + "automationFeasibility": "HIGH" + }, + { + "type": "boundary", + "title": "Validate X-App-Session header required and expiry is 30 minutes", + "description": "Validates that Step 2 requires the X-App-Session header and that the session_token lifetime boundary at 30 minutes is enforced, protecting the sequential application flow from stale session reuse. Automation: MEDIUM — requires time control or clock manipulation to assert the 30-minute boundary precisely.", + "testId": "TC-APPSEQ-03", + "testDescription": "As an authenticated applicant who has a Step 1 session_token, when I submit Step 2 without X-App-Session it should be rejected, and when I submit Step 2 at 29:59 it should be accepted but at 30:01 it should be rejected due to expiry.", + "prerequisites": "1) Authenticated user session is active and can call /v2/applications/start.\n2) Step 1 completed successfully via POST /v2/applications/start returning application_id and session_token.\n3) Ability to measure elapsed time since Step 1 issuance (or use a controllable clock in test environment) to hit 29:59 and 30:01 marks.\n4) A valid Step 2 request body is prepared with sin_consent=true and other required fields.", + "stepsToPerform": "1) Call POST /v2/applications/start and observe HTTP 201.\n2) Capture application_id and session_token from the response and record issuance timestamp T0 (test harness time).\n3) Prepare Step 2 valid payload: employment_status=\"SELF_EMPLOYED\", gross_annual_income=90000.00, other_income=5000.00, monthly_rent=0.00, existing_debt_payments=300.00, sin_consent=true and observe it is complete.\n4) Send POST /v2/applications/{application_id}/financials with NO X-App-Session header and observe the API response is received.\n5) Observe the request is rejected (non-200) and record the HTTP status and error/field/message returned.\n6) Wait until elapsed time since T0 is 29 minutes 59 seconds (T0+00:29:59) and observe the target time is reached.\n7) Send POST /v2/applications/{application_id}/financials with header X-App-Session set to the captured session_token and observe the API response is received.\n8) Observe HTTP 200 is returned and response includes status=\"PENDING_REVIEW\" and fico_pull_id.\n9) Create a second application by repeating Step 1 to obtain a new application_id2 and session_token2 and record its issuance time T1.\n10) Wait until elapsed time since T1 is 30 minutes 1 second (T1+00:30:01) and observe the target time is reached.\n11) Send POST /v2/applications/{application_id2}/financials with header X-App-Session=session_token2 and observe the API response is received.\n12) Observe the request is rejected with HTTP 401 and response error=\"SESSION_EXPIRED\" (expiry boundary enforced).", + "expectedResult": "1) Step 2 without the X-App-Session header is rejected (request does not succeed).\n2) At T0+29:59, Step 2 with X-App-Session=session_token succeeds with HTTP 200 and returns status: PENDING_REVIEW and fico_pull_id.\n3) At T1+30:01, Step 2 with X-App-Session=session_token2 fails with HTTP 401 and error: SESSION_EXPIRED.", + "sourceCitation": { + "location": "Section 4.2 Step 2 field table, page 7", + "excerpt": "session_token String ✓ Required Header: X-App-Session; expires 30 min" + }, + "apiEndpoint": "POST /v2/applications/{application_id}/financials", + "httpMethod": "POST", + "boundaryValues": "Expiry boundary at 30 minutes: test at 29:59 (expected valid) and 30:01 (expected expired).", + "requestHeaders": "Authorization: Bearer ; X-App-Session: ; Content-Type: application/json", + "requestBodyExampleMasked": "{\"employment_status\":\"SELF_EMPLOYED\",\"gross_annual_income\":90000.00,\"other_income\":5000.00,\"monthly_rent\":0.00,\"existing_debt_payments\":300.00,\"sin_consent\":true}", + "automationFeasibility": "MEDIUM" + }, + { + "type": "functional", + "title": "Credit application auto-save occurs every 60 seconds to localStorage draft", + "description": "Verifies the portal credit application form periodically persists a draft to browser localStorage at the documented interval, reducing risk of data loss during multi-step entry. Automation: MEDIUM — requires browser automation with localStorage inspection and time-based assertions.", + "testId": "TC-DRAFT-01", + "testDescription": "As an applicant filling out the credit application, my in-progress entries should be auto-saved every 60 seconds into localStorage as a draft so that a page refresh can restore my progress.", + "prerequisites": "1) Access to the web portal application flow UI where the credit application multi-step form is available.\n2) Test browser profile starts with empty localStorage for portal origin (https://portal.aegiscard.com).\n3) Ability to observe and read localStorage entries for the portal origin via automation tooling (e.g., Playwright/Cypress).\n4) Stable environment where timers are not throttled (avoid background-tab timer clamping).", + "stepsToPerform": "1) Open https://portal.aegiscard.com and observe the credit application form is reachable.\n2) Clear localStorage for the portal origin and observe localStorage is empty (no draft entries present).\n3) Start entering Step 1 personal information fields (e.g., full legal name \"Jordan Test\", phone \"+14165550199\") and observe values appear in the UI input controls.\n4) Immediately inspect localStorage and observe no new draft entry is required to exist yet (before 60 seconds elapse).\n5) Wait 60 seconds from completion of Step 3 action and observe the time threshold is reached.\n6) Inspect localStorage and observe a new/updated draft entry exists (key/value pair) representing the application draft.\n7) Modify one field value (e.g., change phone to \"+14165550200\") and observe the UI shows the updated value.\n8) Wait another 60 seconds and observe the time threshold is reached again.\n9) Inspect localStorage and observe the draft entry is updated to reflect the modified value.\n10) Refresh the page and observe the application form restores values consistent with the latest saved draft (or the application draft is available to resume based on portal behavior).\n11) Cleanup: clear localStorage for the portal origin and observe the draft entry is removed to avoid cross-test contamination.", + "expectedResult": "1) A draft is written to localStorage after 60 seconds of in-progress application data entry.\n2) After another 60 seconds following edits, the localStorage draft is updated (not stale).\n3) Refreshing the page allows restoring/resuming from the localStorage draft (observable persistence across reload).", + "sourceCitation": { + "location": "Section 4 Credit Application Web Flow, page 6", + "excerpt": "Auto-save occurs every 60 seconds to {c('localStorage')} as a draft." + }, + "rolesAndAccess": "Portal user/applicant", + "automationFeasibility": "MEDIUM" + }, + { + "type": "security", + "title": "Draft auto-save does not store auth tokens in localStorage (conflict check)", + "description": "Ensures the portal’s localStorage draft auto-save does not leak authentication tokens into localStorage, aligning with the documented token storage policy and reducing token theft risk via XSS. Automation: HIGH — browser automation + storage inspection.", + "testId": "TC-DRAFT-02", + "testDescription": "As an authenticated portal user filling a credit application, the app may store a draft in localStorage, but it must never store access_token/refresh_token (or any auth tokens) in localStorage.", + "prerequisites": "1) Portal user can authenticate successfully and reaches an authenticated state.\n2) Test browser allows inspection of localStorage for https://portal.aegiscard.com.\n3) localStorage is cleared at test start for the portal origin.\n4) Credit application UI is accessible after login so autosave can run at least once (≥60 seconds).", + "stepsToPerform": "1) Open https://portal.aegiscard.com and log in with a test user and observe the session becomes authenticated.\n2) Inspect browser storage and observe authentication tokens are not visible in localStorage at baseline.\n3) Clear localStorage for the portal origin and observe it is empty.\n4) Navigate to the credit application flow and enter a small amount of draft data (e.g., name \"Jordan Test\", address \"100 Test St\") and observe values are present in the UI.\n5) Wait 60 seconds and observe the autosave interval elapses.\n6) Inspect localStorage and observe at least one draft entry exists.\n7) Search all localStorage keys and values for token-like fields/strings (case-insensitive): \"access_token\", \"refresh_token\", \"jwt\", \"Authorization\", \"Bearer\" and observe none are present.\n8) Additionally, inspect sessionStorage (if used by the app) for the same token-like fields and observe none are stored there (tokens must be in cookies per requirement).\n9) Inspect document.cookie and observe tokens are not readable via JS if stored as HttpOnly (cookie value should be inaccessible for HttpOnly cookies).\n10) Trigger another autosave cycle by editing a field and waiting 60 seconds; re-scan localStorage and observe token-like fields are still absent.\n11) Cleanup: clear localStorage and log out; observe localStorage remains without any auth token artifacts.", + "expectedResult": "1) localStorage contains a draft after autosave runs, but does not contain access/refresh/JWT tokens.\n2) Token-like fields (e.g., access_token, refresh_token) are absent from localStorage values and keys.\n3) Abuse-path ruled out: even after multiple autosaves, no auth tokens appear in localStorage (prevents token exfiltration via XSS targeting localStorage).", + "sourceCitation": { + "location": "Section 3 User Authentication & Session Management, page 4 (token storage) + Section 4 Credit Application Web Flow, page 6 (draft)", + "excerpt": "Tokens are stored in HttpOnly, Secure cookies — never in localStorage." + }, + "rolesAndAccess": "Authenticated portal user", + "automationFeasibility": "HIGH" + }, + { + "type": "functional", + "title": "Initiate transaction approved path returns transaction_id, available_credit, auth_code", + "description": "Verifies the approved transaction authorization path returns the documented fields, supporting downstream posting, UI confirmation, and reconciliation. Automation: HIGH — pure API assertions.", + "testId": "TC-TXN-01", + "testDescription": "As a cardholder initiating a web transaction, when the transaction is approved the API must return transaction_id, available_credit, and auth_code.", + "prerequisites": "1) Authenticated user has a valid Bearer token.\n2) User has an account_id that belongs to the authenticated user.\n3) Card status for the account is Active or Frozen (not otherwise restricted by the spec outcome table).\n4) Account has sufficient available credit for the test transaction_amount.", + "stepsToPerform": "1) Obtain/confirm a valid Bearer token for the test user and observe it can access protected endpoints.\n2) Identify a test account_id belonging to the authenticated user and observe it is a UUID.\n3) Prepare a transaction request payload with transaction_amount=25.50, merchant_name=\"Test Coffee Shop\", merchant_id=\"TESTMERCH0001\", mcc_code=\"5812\", currency_code=\"CAD\", transaction_type=\"PURCHASE\", description=\"QA approval test\" and observe required fields are present.\n4) Send POST /v2/accounts/{account_id}/transactions with the payload and observe the API responds.\n5) Observe HTTP status code is 200.\n6) Observe the response body contains transaction_id and it is non-empty.\n7) Observe the response body contains available_credit and it is a numeric value.\n8) Observe the response body contains auth_code and it is non-empty.\n9) Re-submit the same request once more (without any idempotency key specified in the SRS) and observe the API returns a response (record it) to assess abuse risk; do not assert idempotency behavior since it is not specified.\n10) Cleanup: if test environment supports it, mark/void the created transaction via internal test tools; otherwise record transaction_id for later reconciliation and observe it is available for tracking.", + "expectedResult": "1) Approved transaction returns HTTP 200.\n2) Response includes transaction_id, available_credit, auth_code.\n3) The returned transaction_id is usable as a reference for subsequent investigation/reconciliation in test logs.", + "sourceCitation": { + "location": "Section 5.1 Initiate a Web Payment HTTP codes, page 9", + "excerpt": "200 Approved; credit updated (ISO 8583: 00) transaction_id, available_credit, auth_code" + }, + "apiEndpoint": "POST /v2/accounts/{account_id}/transactions", + "httpMethod": "POST", + "requestHeaders": "Authorization: Bearer ; X-CSRF-Token: ; Content-Type: application/json", + "requestBodyExampleMasked": "{\"transaction_amount\":25.50,\"merchant_name\":\"Test Coffee Shop\",\"merchant_id\":\"TESTMERCH0001\",\"mcc_code\":\"5812\",\"currency_code\":\"CAD\",\"transaction_type\":\"PURCHASE\",\"description\":\"QA approval test\"}", + "expectedHttpStatus": 200, + "rolesAndAccess": "Authenticated account owner", + "automationFeasibility": "HIGH" + }, + { + "type": "functional", + "title": "Initiate transaction essential service over-limit uses 5% buffer and returns over_limit_flag true", + "description": "Verifies the essential-service over-limit approval branch is represented in the API response with the documented flag, supporting compliant handling of essential transactions that can exceed the limit with a defined buffer. Automation: MEDIUM — requires controlled test data to hit the over-limit-with-buffer condition.", + "testId": "TC-TXN-02", + "testDescription": "As a cardholder making an essential-service purchase that slightly exceeds available credit but falls within the 5% buffer, the transaction should be approved and explicitly marked as over-limit via over_limit_flag: true.", + "prerequisites": "1) Authenticated user has a valid Bearer token.\n2) account_id belongs to the authenticated user.\n3) Test environment can configure the account’s available credit so that the attempted transaction exceeds it but remains within the documented 5% buffer.\n4) Card status is Active or Frozen (not triggering CARD_INACTIVE).", + "stepsToPerform": "1) Retrieve current available credit for the account using GET /v2/accounts/{account_id}/summary (or internal test tool) and observe available_credit value.\n2) Adjust test account available credit to a controlled value using test setup (e.g., set available_credit=100.00 CAD) and observe the setup confirmation.\n3) Calculate a transaction_amount that is >100.00 but within 5% buffer (e.g., 104.00) and observe it is 4% over.\n4) Prepare the transaction payload with transaction_amount=104.00, merchant_name=\"Essential Utility\", merchant_id=\"ESSUTIL0001\", mcc_code=\"4900\", currency_code=\"CAD\", transaction_type=\"PURCHASE\", description=\"Essential over-limit buffer test\" and observe required fields are present.\n5) Send POST /v2/accounts/{account_id}/transactions and observe the API responds.\n6) Observe HTTP status code is 200.\n7) Observe the response includes transaction_id and observe over_limit_flag is present and equals true.\n8) Observe the response does not contain error fields (since it is a 200 success branch).\n9) Capture and store transaction_id for cleanup/reconciliation and observe it is non-empty.\n10) Cleanup: revert any test credit configuration to default and observe account state is reset for subsequent tests.", + "expectedResult": "1) API returns HTTP 200 for the essential service over-limit scenario.\n2) Response includes transaction_id and over_limit_flag: true.\n3) No insufficient-funds error is returned for this within-buffer over-limit condition.", + "sourceCitation": { + "location": "Section 5.1 Initiate a Web Payment HTTP codes, page 9", + "excerpt": "Essential service over-limit (5% buffer\nused)" + }, + "apiEndpoint": "POST /v2/accounts/{account_id}/transactions", + "httpMethod": "POST", + "testDataMasked": "available_credit=100.00 CAD (configured), transaction_amount=104.00 CAD", + "expectedHttpStatus": 200, + "automationFeasibility": "MEDIUM" + }, + { + "type": "negative", + "title": "Initiate transaction returns 402 INSUFFICIENT_FUNDS with available_credit", + "description": "Verifies transactions are declined with the documented status and error code when available credit is insufficient, and that the response includes available_credit for user guidance and UI display. Automation: HIGH — pure API assertions with controlled balance.", + "testId": "TC-TXN-03", + "testDescription": "As a cardholder, when I attempt a transaction above my available credit (and not within any allowed buffer case), the API must decline with 402 INSUFFICIENT_FUNDS and include available_credit.", + "prerequisites": "1) Authenticated user has a valid Bearer token.\n2) account_id belongs to the authenticated user.\n3) Test environment can set or know available_credit for the account.\n4) Card status is Active or Frozen (not triggering CARD_INACTIVE).", + "stepsToPerform": "1) Retrieve account summary for the test account and observe available_credit value.\n2) Configure available_credit to a controlled small value (e.g., 50.00 CAD) via test setup and observe confirmation.\n3) Prepare a transaction payload with transaction_amount=200.00, merchant_name=\"Test Electronics\", merchant_id=\"TESTELEC0001\", mcc_code=\"5732\", currency_code=\"CAD\", transaction_type=\"PURCHASE\" and observe required fields are present.\n4) Send POST /v2/accounts/{account_id}/transactions and observe the API responds.\n5) Observe HTTP status code is 402.\n6) Observe response body contains error=\"INSUFFICIENT_FUNDS\".\n7) Observe response body contains available_credit and that it matches the configured/known value (50.00 CAD).\n8) Verify abuse-path: resend request with slightly different merchant_id (e.g., \"TESTELEC0002\") and observe it still returns 402 with available_credit (no bypass).\n9) Cleanup: revert any test credit configuration and observe account state reset.", + "expectedResult": "1) API returns HTTP 402.\n2) Response includes error: INSUFFICIENT_FUNDS.\n3) Response includes available_credit (e.g., 50.00) to inform the client/UI.", + "sourceCitation": { + "location": "Section 5.1 Initiate a Web Payment HTTP codes, page 9", + "excerpt": "402 Insufficient funds (ISO 8583: 51) error: INSUFFICIENT_FUNDS, available_credit" + }, + "apiEndpoint": "POST /v2/accounts/{account_id}/transactions", + "httpMethod": "POST", + "requestBodyExampleMasked": "{\"transaction_amount\":200.00,\"merchant_name\":\"Test Electronics\",\"merchant_id\":\"TESTELEC0001\",\"mcc_code\":\"5732\",\"currency_code\":\"CAD\",\"transaction_type\":\"PURCHASE\"}", + "expectedHttpStatus": 402, + "errorCodeExpected": "INSUFFICIENT_FUNDS", + "automationFeasibility": "HIGH" + }, + { + "type": "negative", + "title": "Initiate transaction returns 403 CARD_INACTIVE when card not Active or Frozen", + "description": "Verifies the transaction endpoint blocks authorizations when the card is in a disallowed status and returns the documented error and card_status, preventing transactions on blocked/closed cards. Automation: MEDIUM — requires ability to set card status to a disallowed state in test data.", + "testId": "TC-TXN-04", + "testDescription": "As a cardholder, if my card is not in Active or Frozen status (e.g., Blocked), initiating a web transaction should be rejected with 403 CARD_INACTIVE and include card_status in the response.", + "prerequisites": "1) Authenticated user has a valid Bearer token.\n2) account_id belongs to the authenticated user.\n3) Test environment can set the card status to a state that is not Active or Frozen (e.g., Blocked or Closed) for the account.\n4) Account has any available_credit (not relevant if card status blocks first).", + "stepsToPerform": "1) Identify the card associated with the test account (via internal test data or prior provisioning) and observe card_id is available.\n2) Set the card to a disallowed status (e.g., Blocked) using test setup tooling and observe status change confirmation.\n3) Prepare a transaction payload with transaction_amount=10.00, merchant_name=\"Test Merchant\", merchant_id=\"TESTM0001\", mcc_code=\"5999\", currency_code=\"CAD\", transaction_type=\"PURCHASE\" and observe required fields are present.\n4) Send POST /v2/accounts/{account_id}/transactions and observe the API responds.\n5) Observe HTTP status code is 403.\n6) Observe response contains error=\"CARD_INACTIVE\".\n7) Observe response contains card_status and it equals the configured disallowed state (e.g., \"Blocked\").\n8) Verify abuse-path: attempt the same transaction with a lower amount (1.00) and observe it is still rejected with CARD_INACTIVE (status-based control not bypassable by amount).\n9) Cleanup: restore the card status to Active for subsequent tests and observe status is reset.", + "expectedResult": "1) API returns HTTP 403.\n2) Response includes error: CARD_INACTIVE.\n3) Response includes card_status reflecting the current (disallowed) status.", + "sourceCitation": { + "location": "Section 5.1 Initiate a Web Payment HTTP codes, page 9", + "excerpt": "403 Card not Active or Frozen error: CARD_INACTIVE, card_status" + }, + "apiEndpoint": "POST /v2/accounts/{account_id}/transactions", + "httpMethod": "POST", + "expectedHttpStatus": 403, + "errorCodeExpected": "CARD_INACTIVE", + "automationFeasibility": "MEDIUM" + }, + { + "type": "boundary", + "title": "Initiate transaction amount boundary: reject transaction_amount <= 0 with 422 INVALID_AMOUNT", + "description": "Validates the transaction_amount boundary rule rejects zero and negative values with the documented status and error code, preventing invalid financial postings. Automation: HIGH — pure API assertions.", + "testId": "TC-TXN-05", + "testDescription": "As a cardholder initiating a transaction, the API must reject transaction_amount values at and below zero, and accept a minimal positive amount, exercising the boundary and just-past-boundary behavior.", + "prerequisites": "1) Authenticated user has a valid Bearer token.\n2) account_id belongs to the authenticated user.\n3) Card status is Active or Frozen.\n4) Account has sufficient available credit for a small positive transaction (e.g., 0.01 CAD).", + "stepsToPerform": "1) Prepare a base transaction payload with merchant_name=\"Boundary Merchant\", merchant_id=\"BOUND0001\", mcc_code=\"5999\", currency_code=\"CAD\", transaction_type=\"PURCHASE\" and observe required non-amount fields are valid.\n2) Set transaction_amount=0.00 and send POST /v2/accounts/{account_id}/transactions; observe the API response is received.\n3) Observe HTTP status code is 422.\n4) Observe response contains error=\"INVALID_AMOUNT\".\n5) Set transaction_amount=-0.01 with the same base payload and send POST /v2/accounts/{account_id}/transactions; observe the API response is received.\n6) Observe HTTP status code is 422 and response contains error=\"INVALID_AMOUNT\".\n7) Set transaction_amount=0.01 (just past boundary) and send POST /v2/accounts/{account_id}/transactions; observe the API response is received.\n8) Observe the response is not HTTP 422 INVALID_AMOUNT (expected to proceed to another defined outcome, typically HTTP 200 if credit is sufficient and card is eligible).\n9) If approved, capture transaction_id and observe it is non-empty for auditability/cleanup.\n10) Cleanup: if a transaction was created at 0.01, record transaction_id for later reconciliation (do not assert voiding behavior since not specified).", + "expectedResult": "1) transaction_amount=0.00 returns HTTP 422 with error: INVALID_AMOUNT.\n2) transaction_amount=-0.01 returns HTTP 422 with error: INVALID_AMOUNT.\n3) transaction_amount=0.01 does not return 422 INVALID_AMOUNT (boundary correctly enforced).", + "sourceCitation": { + "location": "Section 5.1 Initiate a Web Payment HTTP codes, page 9", + "excerpt": "422 transaction_amount <= 0 (ISO 8583: 13) error: INVALID_AMOUNT" + }, + "apiEndpoint": "POST /v2/accounts/{account_id}/transactions", + "httpMethod": "POST", + "boundaryValues": "At boundary: 0.00; just below: -0.01; just above: 0.01", + "expectedHttpStatus": "422 for <=0.00; not 422 for 0.01", + "automationFeasibility": "HIGH" + }, + { + "type": "negative", + "title": "Initiate transaction requires exchange_rate when currency_code != CAD", + "description": "Verifies foreign-currency transaction requests must include exchange_rate when currency_code is not CAD, preventing ambiguous currency conversion and billing. Automation: HIGH — pure API assertions.", + "testId": "TC-TXN-06", + "testDescription": "As a cardholder initiating a foreign-currency transaction, when currency_code is not CAD and exchange_rate is omitted, the request must be rejected; when exchange_rate is provided, the request should not be rejected for missing exchange_rate.", + "prerequisites": "1) Authenticated user has a valid Bearer token.\n2) account_id belongs to the authenticated user.\n3) Card status is Active or Frozen.\n4) Account has sufficient available credit for the transaction when properly specified.", + "stepsToPerform": "1) Prepare a base transaction payload with transaction_amount=10.00, merchant_name=\"FX Test Merchant\", merchant_id=\"FXMERCH0001\", mcc_code=\"5999\", currency_code=\"USD\", transaction_type=\"PURCHASE\" and observe currency_code != CAD.\n2) Ensure exchange_rate is omitted from the request body and observe it is not present.\n3) Send POST /v2/accounts/{account_id}/transactions and observe the API response is received.\n4) Observe the request is rejected (non-200) and record the HTTP status and any error/field/message returned.\n5) Add exchange_rate=1.350000 (6 decimal places) to the same payload and observe the field is present.\n6) Send POST /v2/accounts/{account_id}/transactions with exchange_rate included and observe the API response is received.\n7) Observe the response is not rejected for missing exchange_rate (i.e., does not reproduce the missing-field failure from step 4).\n8) If approved, capture transaction_id and observe it is non-empty.\n9) Verify abuse-path: attempt USD transaction again with exchange_rate omitted and observe it is still rejected (no bypass via cached values).\n10) Cleanup: record any created transaction_id for later reconciliation (do not assert foreign fee calculation fields because they are specified in Section 5.3, not in this outline).", + "expectedResult": "1) When currency_code != CAD and exchange_rate is omitted, the request is rejected (does not succeed).\n2) When currency_code != CAD and exchange_rate is provided (e.g., 1.350000), the request is not rejected for missing exchange_rate.\n3) The requirement is enforced consistently across repeated attempts (prevents ambiguous FX transactions).", + "sourceCitation": { + "location": "Section 5.1 Initiate a Web Payment field table, page 9", + "excerpt": "exchange_rate Decimal Optional Required if currency_code != CAD; Decimal(8,6)" + }, + "apiEndpoint": "POST /v2/accounts/{account_id}/transactions", + "httpMethod": "POST", + "requestBodyExampleMasked": "{\"transaction_amount\":10.00,\"merchant_name\":\"FX Test Merchant\",\"merchant_id\":\"FXMERCH0001\",\"mcc_code\":\"5999\",\"currency_code\":\"USD\",\"transaction_type\":\"PURCHASE\",\"exchange_rate\":1.350000}", + "automationFeasibility": "HIGH" + }, + { + "type": "resilience", + "title": "Transaction frequency limit: >10 transactions in 60 min returns 429 FREQ_EXCEEDED and mfa_required true", + "description": "Verifies the anti-abuse transaction frequency control by ensuring the 11th transaction within a 60-minute window is rejected with the specified HTTP status and error payload, prompting MFA. Automation: HIGH — pure API assertions.", + "testId": "TC-TXNFREQ-01", + "testDescription": "As an authenticated cardholder initiating multiple web transactions, when I exceed the allowed transaction frequency, the API must block further authorizations and explicitly indicate MFA is required using the documented error response.", + "prerequisites": "1) Authenticated user with a valid Bearer token in Authorization header.\n2) An account_id UUID that belongs to the authenticated user.\n3) Card/account status is eligible for transactions (not triggering other errors like CARD_INACTIVE or INSUFFICIENT_FUNDS).\n4) Test runner can issue 11 POST requests within a controlled time window ≤ 60 minutes.\n5) Ensure no prior transactions from this account in the last 60 minutes (or use a fresh test account) to avoid pre-consuming the quota.", + "stepsToPerform": "1) Set the test clock reference (t0) and record current UTC timestamp; outcome: t0 captured for the 60-minute window measurement.\n2) Send POST /v2/accounts/{account_id}/transactions with body (masked): {\"transaction_amount\":\"1.00\",\"merchant_name\":\"LoadTest Shop 01\",\"merchant_id\":\"LTSHOP01\",\"mcc_code\":\"5411\",\"currency_code\":\"CAD\",\"transaction_type\":\"PURCHASE\",\"description\":\"freq test #1\"}; outcome: HTTP 200 returned (approved path).\n3) Repeat Step 2 for transactions #2 through #9, varying merchant_id (LT SHOP 02..09) to keep requests distinct; outcome: each request returns HTTP 200.\n4) Send transaction #10 within 60 minutes of t0 with merchant_id \"LT SHOP 10\"; outcome: HTTP 200 returned.\n5) Immediately send transaction #11 within 60 minutes of t0 with merchant_id \"LT SHOP 11\"; outcome: HTTP 429 returned.\n6) Inspect the HTTP 429 response body; outcome: response contains field error with value \"FREQ_EXCEEDED\".\n7) Inspect the same response body for MFA prompt flag; outcome: response contains \"mfa_required\" and its value is true.\n8) Confirm the rejection is specifically rate/frequency related (abuse-path ruled out); outcome: response is not 401/403/402/422 and includes the documented frequency error fields.\n9) Optionally retry the same request once more within the same 60-minute window; outcome: still blocked with HTTP 429 and error \"FREQ_EXCEEDED\" (demonstrates the limit remains enforced during the window).", + "expectedResult": "1) The 11th transaction attempt within the 60-minute window returns HTTP 429.\n2) The 429 response body contains exactly: error: FREQ_EXCEEDED.\n3) The 429 response body contains mfa_required: true.\n4) The request is rejected for frequency control (not misclassified as authentication/authorization/validation/insufficient funds), ruling out an abuse path of bypassing the limit by spamming distinct merchants.", + "apiEndpoint": "POST /v2/accounts/{account_id}/transactions", + "httpMethod": "POST", + "expectedHttpStatus": 429, + "errorCodeExpected": "FREQ_EXCEEDED", + "testDataMasked": "account_id: 11111111-2222-3333-4444-555555555555; transaction_amount: 1.00 CAD; mcc_code: 5411; merchant_name: LoadTest Shop", + "sourceCitation": { + "location": "Section 5.1 Initiate a Web Payment HTTP codes, page 9", + "excerpt": "429 >10 transactions in 60 min (ISO 8583: 65) error: FREQ_EXCEEDED, mfa_required: true" + } + }, + { + "type": "boundary", + "title": "Transaction frequency boundary: exactly 10 transactions within 60 min does not trigger FREQ_EXCEEDED", + "description": "Validates the boundary condition of the transaction frequency control by ensuring exactly 10 transactions within 60 minutes are not rejected as frequency exceeded. Automation: HIGH — pure API assertions.", + "testId": "TC-TXNFREQ-02", + "testDescription": "As an authenticated cardholder, I should be able to complete up to the allowed number of transactions within a 60-minute window without receiving the FREQ_EXCEEDED error, since the documented trigger is strictly for counts greater than 10.", + "prerequisites": "1) Authenticated user with a valid Bearer token in Authorization header.\n2) An account_id UUID that belongs to the authenticated user.\n3) Account has sufficient available credit and is in a valid state for PURCHASE transactions.\n4) Ensure no prior transactions from this account in the last 60 minutes (or use a fresh test account) so the first request is counted as #1.\n5) Ability to send exactly 10 requests within a controlled time window ≤ 60 minutes.", + "stepsToPerform": "1) Record current UTC timestamp as t0; outcome: the start time for the 60-minute window is captured.\n2) Send POST /v2/accounts/{account_id}/transactions for transaction #1: {\"transaction_amount\":\"1.00\",\"merchant_name\":\"Boundary Shop 01\",\"merchant_id\":\"BDSHOP01\",\"mcc_code\":\"5411\",\"currency_code\":\"CAD\",\"transaction_type\":\"PURCHASE\"}; outcome: HTTP 200.\n3) Send transaction #2 within 60 minutes of t0 with merchant_id \"BDSHOP02\"; outcome: HTTP 200.\n4) Send transaction #3 within 60 minutes of t0; outcome: HTTP 200.\n5) Send transaction #4 within 60 minutes of t0; outcome: HTTP 200.\n6) Send transaction #5 within 60 minutes of t0; outcome: HTTP 200.\n7) Send transaction #6 within 60 minutes of t0; outcome: HTTP 200.\n8) Send transaction #7 within 60 minutes of t0; outcome: HTTP 200.\n9) Send transaction #8 within 60 minutes of t0; outcome: HTTP 200.\n10) Send transaction #9 within 60 minutes of t0; outcome: HTTP 200.\n11) Send transaction #10 within 60 minutes of t0; outcome: HTTP 200 and response is not HTTP 429.\n12) Inspect the 10th response body; outcome: it does not contain error: FREQ_EXCEEDED (i.e., it is an approved/posted response, per the 200 branch).", + "expectedResult": "1) All 10 transactions sent within 60 minutes return HTTP 200 (no HTTP 429).\n2) None of the 10 responses contain error: FREQ_EXCEEDED.\n3) The boundary behavior is consistent with the documented trigger condition \">10 transactions in 60 min\".\n4) Abuse path ruled out: legitimate high activity at the allowed threshold is not incorrectly blocked as frequency exceeded.", + "apiEndpoint": "POST /v2/accounts/{account_id}/transactions", + "httpMethod": "POST", + "expectedHttpStatus": 200, + "boundaryValues": "10 transactions in 60 minutes (at boundary); do not execute an 11th request in this test case", + "sourceCitation": { + "location": "Section 5.1 Initiate a Web Payment HTTP codes, page 9", + "excerpt": "429 >10 transactions in 60 min (ISO 8583: 65) error: FREQ_EXCEEDED" + } + }, + { + "type": "functional", + "title": "List transactions default from_date is billing cycle start", + "description": "Ensures transaction listing uses the documented default filter start date when from_date is omitted, preventing unexpected history scope and supporting statement/billing-cycle views. Automation: MEDIUM — requires knowing billing cycle start reference for the account.", + "testId": "TC-TXNLIST-01", + "testDescription": "As a cardholder viewing transaction history, when I do not provide from_date, the system should automatically list transactions starting from the billing cycle start date by default.", + "prerequisites": "1) Authenticated user with a valid Bearer token.\n2) account_id UUID owned by the authenticated user.\n3) Known billing cycle start date for the account for the current cycle (obtained from billing system configuration/test fixture) and at least one transaction posted on/after that date.\n4) At least one older transaction exists strictly before the current billing cycle start date (seeded test data) to validate it is not included by default.\n5) Ability to query the transactions endpoint without from_date.", + "stepsToPerform": "1) Seed/confirm two transactions for the account: T_old dated before billing cycle start, and T_new dated on/after billing cycle start; outcome: test data exists and their dates are recorded.\n2) Call GET /v2/accounts/{account_id}/transactions with no from_date and no to_date; outcome: HTTP 200 returned.\n3) Verify response contains pagination keys; outcome: response includes transactions[] and page-related metadata (observable fields present).\n4) Scan transactions[] for T_new identifier/date; outcome: T_new is present in the returned list.\n5) Scan transactions[] for T_old identifier/date; outcome: T_old is not present in the returned list.\n6) Re-issue GET /v2/accounts/{account_id}/transactions explicitly setting from_date to the known billing cycle start date; outcome: HTTP 200 returned.\n7) Compare the two result sets (omitted from_date vs explicit from_date=billing cycle start); outcome: the returned transactions are consistent in inclusion of T_new and exclusion of T_old.\n8) Record the request/response for auditability of the defaulting behavior; outcome: evidence captured showing omission of from_date still yields billing-cycle-scoped results.", + "expectedResult": "1) GET transactions without from_date returns HTTP 200 and a transactions[] list.\n2) Transactions strictly before the billing cycle start are excluded when from_date is omitted.\n3) Transactions on/after the billing cycle start are included when from_date is omitted.\n4) The default behavior aligns with the documented default for from_date, reducing risk of exposing older-than-expected history.", + "apiEndpoint": "GET /v2/accounts/{account_id}/transactions", + "httpMethod": "GET", + "testDataMasked": "account_id: 11111111-2222-3333-4444-555555555555; billing cycle start (fixture): 2026-04-01; T_old date: 2026-03-15; T_new date: 2026-04-10", + "sourceCitation": { + "location": "Section 5.2 List Transactions field table, page 10", + "excerpt": "from_date Date Optional ISO 8601; default: billing cycle start" + } + }, + { + "type": "negative", + "title": "List transactions rejects invalid date range with 400 INVALID_DATE_RANGE when to_date < from_date", + "description": "Confirms server-side validation rejects an inverted date range to prevent ambiguous queries and data leakage. Automation: HIGH — pure API assertions.", + "testId": "TC-TXNLIST-02", + "testDescription": "As a cardholder querying my transactions, if I provide a to_date earlier than from_date, the API must reject the request with the documented HTTP code and error identifier.", + "prerequisites": "1) Authenticated user with a valid Bearer token.\n2) account_id UUID owned by the authenticated user.\n3) Ability to call the transactions listing endpoint with query parameters.\n4) Use ISO 8601 date strings for from_date and to_date.\n5) No special environment overrides that auto-correct invalid ranges.", + "stepsToPerform": "1) Prepare from_date as 2026-04-10 and to_date as 2026-04-09; outcome: test inputs clearly satisfy to_date < from_date.\n2) Send GET /v2/accounts/{account_id}/transactions?from_date=2026-04-10&to_date=2026-04-09; outcome: HTTP response received.\n3) Verify HTTP status code; outcome: HTTP 400.\n4) Parse response body; outcome: response contains an error field.\n5) Validate error code value; outcome: error equals \"INVALID_DATE_RANGE\".\n6) Confirm the API does not return transactions[] on this error response; outcome: no paginated data list is returned (only error payload).\n7) Send a control request with a valid range (from_date=2026-04-09, to_date=2026-04-10); outcome: HTTP 200 returned.\n8) Compare outcomes to ensure only inverted range triggers the specific validation; outcome: invalid range yields 400 INVALID_DATE_RANGE, valid range does not.", + "expectedResult": "1) Request with to_date < from_date returns HTTP 400.\n2) Response body contains exactly: error: INVALID_DATE_RANGE.\n3) No transaction list payload is returned for the invalid range.\n4) Abuse path ruled out: client cannot bypass server validation by sending inverted date filters to extract unintended data.", + "apiEndpoint": "GET /v2/accounts/{account_id}/transactions", + "httpMethod": "GET", + "expectedHttpStatus": 400, + "errorCodeExpected": "INVALID_DATE_RANGE", + "boundaryValues": "to_date just one day earlier than from_date (2026-04-09 < 2026-04-10)", + "sourceCitation": { + "location": "Section 5.2 List Transactions HTTP codes, page 10", + "excerpt": "400 to_date < from_date error: INVALID_DATE_RANGE" + } + }, + { + "type": "boundary", + "title": "List transactions enforces per_page max 100 (boundary)", + "description": "Verifies pagination constraint on per_page to prevent excessive payloads and ensure consistent API behavior at and beyond the documented maximum. Automation: HIGH — pure API assertions.", + "testId": "TC-TXNLIST-03", + "testDescription": "As a cardholder retrieving my transaction history, I can request up to 100 items per page; requests at the maximum should succeed while requests beyond should not be accepted per the documented max.", + "prerequisites": "1) Authenticated user with a valid Bearer token.\n2) account_id UUID owned by the authenticated user.\n3) Account has at least 120 transactions in the accessible range (seeded) to observe pagination behavior with per_page=100.\n4) Ability to invoke GET endpoint with per_page query param.\n5) No gateway/proxy altering query parameters.", + "stepsToPerform": "1) Ensure seeded dataset has ≥120 transactions for the account within the same billing cycle; outcome: data volume supports per_page testing.\n2) Call GET /v2/accounts/{account_id}/transactions?per_page=100&page=1; outcome: HTTP 200 returned.\n3) Count returned transactions[] length; outcome: length is 100 (or equals total_count if fewer than 100, but this test assumes ≥120).\n4) Verify response includes page and total_pages; outcome: pagination metadata present.\n5) Call GET /v2/accounts/{account_id}/transactions?per_page=100&page=2; outcome: HTTP 200 returned.\n6) Count returned transactions[] length for page 2; outcome: length is 20 (given 120 seeded) or remaining count consistent with total_count.\n7) Call GET /v2/accounts/{account_id}/transactions?per_page=101&page=1; outcome: HTTP response received.\n8) Validate that the response does not return a successful paginated list with 101 items; outcome: request is not processed as a normal 200 with >100 results, demonstrating enforcement of max 100.\n9) Capture the raw response for per_page=101 for defect triage; outcome: evidence shows the system enforced or constrained the parameter rather than returning >100 items.", + "expectedResult": "1) per_page=100 returns HTTP 200 and returns up to 100 items in transactions[].\n2) Requesting page 2 with per_page=100 returns remaining items consistent with total_count/total_pages.\n3) per_page=101 is not accepted as a normal successful response returning >100 items; the service enforces the documented max of 100.\n4) Abuse path ruled out: client cannot force oversized pages (>100) to increase data exfiltration per request.", + "apiEndpoint": "GET /v2/accounts/{account_id}/transactions", + "httpMethod": "GET", + "boundaryValues": "per_page=100 (at max) and per_page=101 (just past max)", + "sourceCitation": { + "location": "Section 5.2 List Transactions field table, page 10", + "excerpt": "per_page Integer Optional Default 25; max 100" + } + }, + { + "type": "security", + "title": "List transactions returns 403 FORBIDDEN when account not owned by user", + "description": "Validates owner-only access control for transaction history to prevent unauthorized disclosure of financial records. Automation: HIGH — pure API assertions.", + "testId": "TC-TXNLIST-04", + "testDescription": "As a logged-in user, when I attempt to list transactions for an account_id that I do not own, the API must deny access with the documented HTTP status and error code.", + "prerequisites": "1) Two distinct test users exist: UserA and UserB.\n2) UserA has a valid Bearer token.\n3) account_id_B belongs to UserB and is not owned by UserA.\n4) Ability to call GET /v2/accounts/{account_id}/transactions with UserA's token.\n5) Ensure account_id_B is valid (exists) to specifically test authorization, not NOT_FOUND behavior.", + "stepsToPerform": "1) Authenticate as UserA and obtain a valid Bearer token; outcome: Authorization token available.\n2) Confirm account_id_B is owned by UserB (setup validation); outcome: ownership mapping is confirmed from test fixture.\n3) Send GET /v2/accounts/{account_id_B}/transactions using UserA's Authorization header; outcome: HTTP response received.\n4) Verify HTTP status code; outcome: HTTP 403.\n5) Parse response body; outcome: response includes an error field.\n6) Verify error code value; outcome: error equals \"FORBIDDEN\".\n7) Send a control request: GET /v2/accounts/{account_id_A}/transactions using UserA token; outcome: HTTP 200 returned.\n8) Compare outcomes; outcome: only non-owned account access returns 403 FORBIDDEN, confirming authorization enforcement.\n9) Record that no transaction data is leaked in the 403 response; outcome: response contains no transactions[] payload.", + "expectedResult": "1) Listing transactions for a non-owned account returns HTTP 403.\n2) Response body contains exactly: error: FORBIDDEN.\n3) No transaction history data is returned in the forbidden response.\n4) Abuse path ruled out: a valid token for one user cannot be used to enumerate or view another user's transactions by guessing account_id.", + "apiEndpoint": "GET /v2/accounts/{account_id}/transactions", + "httpMethod": "GET", + "expectedHttpStatus": 403, + "errorCodeExpected": "FORBIDDEN", + "rolesAndAccess": "Role under test: authenticated cardholder (UserA) attempting cross-account access", + "sourceCitation": { + "location": "Section 5.2 List Transactions HTTP codes, page 10", + "excerpt": "403 Account not owned by user error: FORBIDDEN" + } + }, + { + "type": "functional", + "title": "Foreign transaction fee calculation applies 1.03 multiplier to (amount × exchange_rate)", + "description": "Validates the foreign currency conversion and 3% fee multiplier formula to prevent incorrect billing for FX purchases. Automation: HIGH — deterministic calculation validation via API response fields.", + "testId": "TC-FX-01", + "testDescription": "As a cardholder making a non-CAD transaction, the platform must compute the CAD total using the documented formula and return a CAD amount consistent with (transaction_amount × exchange_rate) × 1.03.", + "prerequisites": "1) Authenticated user with valid Bearer token.\n2) account_id UUID owned by the authenticated user with sufficient available credit.\n3) Ability to initiate a transaction with currency_code != CAD and provide exchange_rate.\n4) Response payload includes the computed CAD totals/amounts required to validate the formula (test environment must expose the computed Total_CAD field used for posting; if represented as posted amount, use that field).\n5) Ensure transaction frequency limit is not near the threshold (>10/60min) to avoid 429 interference.", + "stepsToPerform": "1) Choose concrete inputs: transaction_amount=100.00 (USD), exchange_rate=1.250000; outcome: base conversion amount = 125.00 CAD computed for expectation.\n2) Compute expected Total_CAD = (100.00 × 1.250000) × 1.03 = 128.75 CAD; outcome: expected value recorded as 128.75.\n3) Send POST /v2/accounts/{account_id}/transactions with body: {\"transaction_amount\":\"100.00\",\"merchant_name\":\"FX Test Merchant\",\"merchant_id\":\"FXM123\",\"mcc_code\":\"5812\",\"currency_code\":\"USD\",\"exchange_rate\":\"1.250000\",\"transaction_type\":\"PURCHASE\",\"description\":\"FX formula test\"}; outcome: HTTP 200 returned.\n4) Parse the success response and identify the field representing the CAD posted total for the transaction (as provided by the API response for foreign transactions); outcome: actual_total_cad extracted.\n5) Compare actual_total_cad to expected 128.75 CAD; outcome: values match exactly to 2 decimals (128.75).\n6) Verify transaction_id is present; outcome: transaction_id returned, proving the transaction was processed.\n7) Fetch the transaction via GET /v2/accounts/{account_id}/transactions (filter by recent/page=1); outcome: transaction appears in history.\n8) Validate the listed transaction reflects the same CAD total used for posting; outcome: history shows the same computed total for this transaction.\n9) Cleanup: if the environment supports reversal/void, remove the test transaction; otherwise mark test account for reset; outcome: cleanup action recorded.", + "expectedResult": "1) The foreign transaction is accepted (HTTP 200) and returns a transaction_id.\n2) The CAD total for the transaction equals 128.75 CAD for inputs transaction_amount=100.00 and exchange_rate=1.250000, matching: Total_CAD = (transaction_amount × exchange_rate) × 1.03.\n3) Transaction history reflects the same computed CAD total for the posted transaction.\n4) Abuse path ruled out: client cannot influence fee omission by setting currency_code != CAD without correct total calculation being applied server-side.", + "apiEndpoint": "POST /v2/accounts/{account_id}/transactions", + "httpMethod": "POST", + "calculationExpected": "Total_CAD = (100.00 × 1.250000) × 1.03 = 128.75 CAD", + "testDataMasked": "account_id: 11111111-2222-3333-4444-555555555555; merchant_id: FXM123; currency_code: USD; exchange_rate: 1.250000", + "sourceCitation": { + "location": "Section 5.3 Foreign Transaction Fee Calculation, page 10", + "excerpt": "Total_CAD = (transaction_amount × exchange_rate) × 1.03" + } + }, + { + "type": "functional", + "title": "Foreign fee is itemised separately as foreign_fee_amount in response", + "description": "Ensures FX fee transparency by verifying the 3% foreign transaction fee is returned as a distinct itemized field, enabling accurate statement display and dispute handling. Automation: HIGH — API field presence/value assertion.", + "testId": "TC-FX-02", + "testDescription": "As a cardholder reviewing a non-CAD purchase, I should see the foreign transaction fee itemized separately as foreign_fee_amount in the API response so the portal can display the fee distinctly from the base conversion.", + "prerequisites": "1) Authenticated user with valid Bearer token.\n2) account_id UUID owned by the authenticated user with sufficient credit.\n3) Ability to initiate a foreign currency transaction (currency_code ≠ CAD) with exchange_rate provided.\n4) The transaction initiation response for foreign transactions includes foreign_fee_amount.\n5) Ensure frequency limit is not exceeded during test execution.", + "stepsToPerform": "1) Choose concrete inputs: transaction_amount=50.00 (EUR), exchange_rate=1.450000; outcome: base converted amount = 72.50 CAD.\n2) Compute expected fee: 72.50 × 0.03 = 2.175 → expected foreign fee amount recorded as 2.18 if rounded to 2 decimals in response (record both raw 2.175 and 2-decimal expectation per response format); outcome: expected fee computed.\n3) Send POST /v2/accounts/{account_id}/transactions with body: {\"transaction_amount\":\"50.00\",\"merchant_name\":\"FX Fee Merchant\",\"merchant_id\":\"FXFEE50\",\"mcc_code\":\"5999\",\"currency_code\":\"EUR\",\"exchange_rate\":\"1.450000\",\"transaction_type\":\"PURCHASE\",\"description\":\"FX fee itemization test\"}; outcome: HTTP 200 returned.\n4) Parse response body; outcome: response includes a field named foreign_fee_amount.\n5) Verify foreign_fee_amount is a positive numeric amount; outcome: value > 0.\n6) Verify foreign_fee_amount corresponds to 3% of (transaction_amount × exchange_rate) for the given inputs within 0.01 tolerance (2-decimal currency); outcome: value matches expected (e.g., 2.18).\n7) Verify the response still contains the overall transaction identifiers; outcome: transaction_id present.\n8) Retrieve the transaction in GET /v2/accounts/{account_id}/transactions; outcome: transaction entry is present for audit/troubleshooting.\n9) Cleanup: mark transaction for removal/reset in test environment if needed; outcome: cleanup logged.", + "expectedResult": "1) The foreign transaction initiation returns HTTP 200 and includes transaction_id.\n2) Response includes the itemized field foreign_fee_amount.\n3) foreign_fee_amount is consistent with a 3% fee on the converted amount for the transaction within a ±$0.01 tolerance appropriate for 2-decimal currency.\n4) Abuse path ruled out: fee is not hidden/merged ambiguously; the API exposes a distinct fee field as specified.", + "apiEndpoint": "POST /v2/accounts/{account_id}/transactions", + "httpMethod": "POST", + "tolerance": "±0.01", + "testDataMasked": "account_id: 11111111-2222-3333-4444-555555555555; currency_code: EUR; exchange_rate: 1.450000; merchant_id: FXFEE50", + "sourceCitation": { + "location": "Section 5.3 Foreign Transaction Fee Calculation, page 10", + "excerpt": "The 3% foreign transaction fee is itemised separately in the response as foreign_fee_amount." + } + }, + { + "type": "functional", + "title": "Get account summary success returns balances, credit_limit, status, billing cycle, points_balance", + "description": "Verifies the dashboard summary API returns the documented core account fields required for showing balance, available credit, account status, billing cycle end, and points. Automation: HIGH — pure API contract assertions.", + "testId": "TC-SUM-01", + "testDescription": "As a logged-in cardholder, when I open the dashboard, the system should return my current balance, available credit, credit limit, account status, billing cycle end date, and points balance via the account summary endpoint.", + "prerequisites": "1) Authenticated user with valid Bearer token.\n2) account_id UUID owned by the authenticated user.\n3) Account exists and is active in test environment.\n4) Account has known values for current_balance/available_credit/credit_limit/points_balance (seeded or retrievable) for verification.\n5) Ability to call GET /v2/accounts/{account_id}/summary.", + "stepsToPerform": "1) Authenticate and obtain a valid Bearer token; outcome: token ready for Authorization header.\n2) Send GET /v2/accounts/{account_id}/summary without query params; outcome: HTTP 200 returned.\n3) Validate response contains current_balance; outcome: field present and parseable as a monetary amount.\n4) Validate response contains available_credit; outcome: field present and parseable.\n5) Validate response contains credit_limit; outcome: field present.\n6) Validate response contains account_status; outcome: field present.\n7) Validate response contains billing_cycle_end; outcome: field present and parseable as a date.\n8) Validate response contains points_balance; outcome: field present and parseable as integer/number.\n9) Record the response schema for regression (no extra required assertions); outcome: evidence captured of required fields being returned.", + "expectedResult": "1) Endpoint returns HTTP 200.\n2) Response includes: current_balance, available_credit, credit_limit, account_status, billing_cycle_end, points_balance.\n3) All required fields are present and non-null in a normal successful response.\n4) Abuse path ruled out: only authenticated requests with a valid token can retrieve the summary (implicit, validated by prerequisite token use).", + "apiEndpoint": "GET /v2/accounts/{account_id}/summary", + "httpMethod": "GET", + "expectedHttpStatus": 200, + "sourceCitation": { + "location": "Section 6.1 Get Account Summary HTTP codes, page 11", + "excerpt": "200 Success current_balance, available_credit, credit_limit, account_status, billing_cycle_end, points_balance" + } + }, + { + "type": "functional", + "title": "Get account summary include_rewards defaults to false when omitted", + "description": "Confirms the include_rewards query parameter default is applied when omitted, preventing unintended rewards inclusion and ensuring predictable dashboard behavior. Automation: MEDIUM — requires observing rewards inclusion behavior in response.", + "testId": "TC-SUM-02", + "testDescription": "As a cardholder, when I do not specify include_rewards on the summary endpoint, the system should apply the documented default of false.", + "prerequisites": "1) Authenticated user with valid Bearer token.\n2) account_id UUID owned by the authenticated user.\n3) The environment supports observing whether rewards are included/expanded when include_rewards=true versus omitted (e.g., points_balance presence/structure differences as implemented).\n4) Points balance exists for the account (non-zero preferred) to make differences observable.\n5) Ability to call the summary endpoint with and without include_rewards param.", + "stepsToPerform": "1) Authenticate and obtain a valid Bearer token; outcome: token available.\n2) Call GET /v2/accounts/{account_id}/summary (omit include_rewards); outcome: HTTP 200 returned.\n3) Capture response payload as ResponseA; outcome: ResponseA stored.\n4) Call GET /v2/accounts/{account_id}/summary?include_rewards=false; outcome: HTTP 200 returned.\n5) Capture response payload as ResponseB; outcome: ResponseB stored.\n6) Compare ResponseA and ResponseB for rewards-related sections/fields controlled by include_rewards; outcome: they are functionally equivalent with respect to rewards inclusion.\n7) Call GET /v2/accounts/{account_id}/summary?include_rewards=true; outcome: HTTP 200 returned.\n8) Compare ResponseC (include_rewards=true) against ResponseA; outcome: ResponseC shows rewards included per implementation, demonstrating the omitted parameter behaved as false.\n9) Persist evidence (request URLs + responses) for review; outcome: proof that omission maps to false.", + "expectedResult": "1) Omitting include_rewards yields a successful HTTP 200 response.\n2) Response when include_rewards is omitted matches the behavior of include_rewards=false (default applied).\n3) The system honors the query param such that include_rewards=true is distinguishable from the omitted/default behavior.\n4) Abuse path ruled out: clients cannot force rewards inclusion without explicitly setting include_rewards=true if the implementation restricts additional rewards details to that flag.", + "apiEndpoint": "GET /v2/accounts/{account_id}/summary", + "httpMethod": "GET", + "sourceCitation": { + "location": "Section 6.1 Get Account Summary field table, page 11", + "excerpt": "include_rewards Boolean Optional Query param; default false" + } + }, + { + "type": "security", + "title": "Get account summary enforces owner-only access returning 403 FORBIDDEN", + "description": "Verifies access control on account summary so a user cannot read balances/credit details for an account they do not own. This blocks horizontal privilege escalation via account_id enumeration. Automation: HIGH — pure API assertions.", + "testId": "TC-SUM-03", + "testDescription": "As an authenticated cardholder (User B), when I attempt to retrieve the account summary for another cardholder’s account_id (User A), the API must deny access with HTTP 403 and the documented error code.", + "prerequisites": "1) Two active portal users exist: UserA (owner of account_id_A) and UserB (non-owner).\n2) account_id_A is a valid UUID belonging to UserA and is retrievable/known for test purposes.\n3) UserB has a valid JWT Bearer access token (unexpired) for Authorization header.\n4) API base URL is reachable: https://api.aegiscard.com/v2.", + "stepsToPerform": "1) Authenticate as UserB and obtain a valid access token for the Authorization header; observe that authentication succeeds (token available for subsequent calls).\n2) Construct the request URL GET https://api.aegiscard.com/v2/accounts/{account_id_A}/summary using UserA’s account_id_A; observe URL contains account_id_A.\n3) Send the GET request with header Authorization: Bearer ; observe the request is accepted by the gateway and a response is returned.\n4) Observe the HTTP status code in the response; verify it is 403.\n5) Parse the JSON response body; observe an error field is present.\n6) Verify the error value equals FORBIDDEN; observe exact match.\n7) Verify the response body does not contain any of the success keys for summary (e.g., current_balance, available_credit, credit_limit); observe they are absent.\n8) Repeat the same GET request but append include_rewards=false; observe response remains 403 with error FORBIDDEN.\n9) (Abuse-path check) Attempt the same request with a different random UUID (synthetic) in place of account_id_A while still using UserB token; observe the API does not return a 200 with summary data for a non-owned account and maintains owner-only enforcement (403 with FORBIDDEN when applicable).", + "expectedResult": "1) API returns HTTP 403.\n2) Response body includes error: FORBIDDEN.\n3) No account summary data fields (current_balance, available_credit, credit_limit, etc.) are returned for a non-owned account, preventing data leakage.", + "apiEndpoint": "GET /v2/accounts/{account_id}/summary", + "httpMethod": "GET", + "requestHeaders": "Authorization: Bearer ", + "expectedHttpStatus": 403, + "errorCodeExpected": "FORBIDDEN", + "rolesAndAccess": "Role: authenticated cardholder (non-owner) attempting access to another user’s account_id", + "sourceCitation": { + "location": "Section 6.1 Get Account Summary HTTP codes, page 11", + "excerpt": "403 Token does not own this account error: FORBIDDEN" + } + }, + { + "type": "state-transition", + "title": "Freeze card: PATCH status=Frozen succeeds with valid confirm_otp", + "description": "Verifies a card can be frozen by the authenticated owner using the only supported user-changeable status value and a valid OTP. This protects customers by enabling immediate self-service card lock. Automation: HIGH — API assertions with test OTP fixture.", + "testId": "TC-CARDSTAT-01", + "testDescription": "As an authenticated cardholder who owns a card currently in Active status, when I PATCH the card status to Frozen with a valid 6-digit confirm_otp, the service must return 200 with the new_status set to Frozen.", + "prerequisites": "1) A test user (CardOwner) exists with a valid Bearer access token (unexpired).\n2) CardOwner owns card_id_1 (UUID) and the card is currently in status Active.\n3) A valid 6-digit OTP value (confirm_otp_valid) has been issued to CardOwner through the system’s OTP channel and is not expired.\n4) Test environment supports observing the card status via subsequent PATCH response fields (card_id, new_status, updated_at).", + "stepsToPerform": "1) Authenticate as CardOwner and obtain a valid access token; observe token available.\n2) Confirm starting state: card_id_1 current status is Active (via existing test fixture/state setup); observe recorded starting status.\n3) Prepare request PATCH https://api.aegiscard.com/v2/cards/{card_id_1}/status; observe path contains card_id_1.\n4) Set request headers Authorization: Bearer ; Content-Type: application/json; observe headers set.\n5) Set request body with status=\"Frozen\", confirm_otp=confirm_otp_valid, and reason=\"Freeze via test\" (<=255 chars); observe payload prepared.\n6) Send the PATCH request; observe a response is returned.\n7) Verify HTTP status code is 200; observe success.\n8) Verify response body contains card_id equal to card_id_1; observe exact match.\n9) Verify response body new_status equals \"Frozen\"; observe expected state transition.\n10) Verify response body includes updated_at and it is populated (non-empty); observe timestamp present.", + "expectedResult": "1) API returns HTTP 200.\n2) Response body contains card_id, new_status=\"Frozen\", and updated_at.\n3) Card is now in Frozen state as indicated by new_status, confirming successful Active → Frozen transition.", + "apiEndpoint": "PATCH /v2/cards/{card_id}/status", + "httpMethod": "PATCH", + "requestHeaders": "Authorization: Bearer , Content-Type: application/json", + "requestBodyExampleMasked": "{ \"status\": \"Frozen\", \"reason\": \"Freeze via test\", \"confirm_otp\": \"123456\" }", + "expectedHttpStatus": 200, + "validationRulesCovered": "status enum {Active, Frozen}; confirm_otp required (6-digit OTP)", + "rolesAndAccess": "Role: authenticated cardholder (owner of card_id_1)", + "sourceCitation": { + "location": "Section 6.2 Update Card Status, page 11", + "excerpt": "status Enum ✓ Required {Active, Frozen} — only these two user-changeable states" + } + }, + { + "type": "state-transition", + "title": "Unfreeze card: PATCH status=Active succeeds with valid confirm_otp", + "description": "Verifies a frozen card can be unfrozen back to Active by the authenticated owner using a valid OTP, ensuring legitimate restoration of card usability. Automation: HIGH — API assertions with OTP fixture.", + "testId": "TC-CARDSTAT-02", + "testDescription": "As an authenticated cardholder who owns a card currently in Frozen status, when I PATCH the card status to Active with a valid 6-digit confirm_otp, the service must return 200 with new_status set to Active and include updated_at.", + "prerequisites": "1) A test user (CardOwner) exists with a valid Bearer access token (unexpired).\n2) CardOwner owns card_id_2 (UUID) and the card is currently in status Frozen.\n3) A valid 6-digit OTP value (confirm_otp_valid) has been issued to CardOwner through the system’s OTP channel and is not expired.\n4) The API is reachable at https://api.aegiscard.com/v2.", + "stepsToPerform": "1) Authenticate as CardOwner and obtain a valid access token; observe token available.\n2) Confirm starting state: card_id_2 current status is Frozen (via fixture/state setup); observe recorded starting status.\n3) Prepare request PATCH https://api.aegiscard.com/v2/cards/{card_id_2}/status; observe path contains card_id_2.\n4) Set request headers Authorization: Bearer ; Content-Type: application/json; observe headers set.\n5) Set request body with status=\"Active\", confirm_otp=confirm_otp_valid, and reason=\"Unfreeze via test\" (<=255 chars); observe payload prepared.\n6) Send the PATCH request; observe a response is returned.\n7) Verify HTTP status code is 200; observe success.\n8) Verify response body includes card_id equal to card_id_2; observe exact match.\n9) Verify response body new_status equals \"Active\"; observe Frozen → Active transition.\n10) Verify response body includes updated_at and it is populated (non-empty); observe timestamp present.", + "expectedResult": "1) API returns HTTP 200.\n2) Response body contains card_id, new_status=\"Active\", updated_at.\n3) Card is restored to Active state as indicated by new_status, enabling normal usage.", + "apiEndpoint": "PATCH /v2/cards/{card_id}/status", + "httpMethod": "PATCH", + "requestHeaders": "Authorization: Bearer , Content-Type: application/json", + "requestBodyExampleMasked": "{ \"status\": \"Active\", \"reason\": \"Unfreeze via test\", \"confirm_otp\": \"123456\" }", + "expectedHttpStatus": 200, + "validationRulesCovered": "200 response returns card_id, new_status, updated_at", + "rolesAndAccess": "Role: authenticated cardholder (owner of card_id_2)", + "sourceCitation": { + "location": "Section 6.2 Update Card Status HTTP codes, page 11", + "excerpt": "200 Status updated successfully card_id, new_status, updated_at" + } + }, + { + "type": "negative", + "title": "Card status update rejects invalid transition with 400 INVALID_TRANSITION and allowed_transitions", + "description": "Verifies the card status API rejects an invalid state change attempt and returns the specified error code plus allowed_transitions, preventing unsupported/unsafe status changes. Automation: HIGH — API assertions.", + "testId": "TC-CARDSTAT-03", + "testDescription": "As an authenticated cardholder, when I attempt an invalid card status transition, the API must respond with HTTP 400 and include error INVALID_TRANSITION and allowed_transitions to guide the client.", + "prerequisites": "1) A test user (CardOwner) exists with a valid Bearer access token (unexpired).\n2) CardOwner owns card_id_3 (UUID) and the card is in a state where the requested transition is invalid per system rules (preconfigured fixture).\n3) A valid 6-digit OTP value (confirm_otp_valid) has been issued to CardOwner and is not expired.\n4) Test harness can capture and assert response JSON fields error and allowed_transitions.", + "stepsToPerform": "1) Authenticate as CardOwner and obtain a valid access token; observe token available.\n2) Record the starting card status for card_id_3 from test fixture (e.g., a non-user-changeable state); observe starting status captured.\n3) Prepare request PATCH https://api.aegiscard.com/v2/cards/{card_id_3}/status; observe path contains card_id_3.\n4) Set headers Authorization: Bearer ; Content-Type: application/json; observe headers set.\n5) Create request body attempting the invalid transition (e.g., status=\"Frozen\" from the current fixture state), include confirm_otp=confirm_otp_valid and reason=\"Attempt invalid transition\"; observe payload prepared.\n6) Send the PATCH request; observe a response is returned.\n7) Verify HTTP status code is 400; observe client error.\n8) Verify response body error equals \"INVALID_TRANSITION\"; observe exact match.\n9) Verify response body includes allowed_transitions and it is present (non-empty value); observe field exists.\n10) Verify response body does not include success fields card_id/new_status/updated_at as a successful update; observe success payload is absent.", + "expectedResult": "1) API returns HTTP 400.\n2) Response body includes error: INVALID_TRANSITION.\n3) Response body includes allowed_transitions, enabling the client to display permissible next states and preventing the illegal state change.", + "apiEndpoint": "PATCH /v2/cards/{card_id}/status", + "httpMethod": "PATCH", + "requestHeaders": "Authorization: Bearer , Content-Type: application/json", + "requestBodyExampleMasked": "{ \"status\": \"Frozen\", \"reason\": \"Attempt invalid transition\", \"confirm_otp\": \"123456\" }", + "expectedHttpStatus": 400, + "errorCodeExpected": "INVALID_TRANSITION", + "validationRulesCovered": "400 Invalid status transition attempted error: INVALID_TRANSITION, allowed_transitions", + "rolesAndAccess": "Role: authenticated cardholder (owner of card_id_3)", + "sourceCitation": { + "location": "Section 6.2 Update Card Status HTTP codes, page 11", + "excerpt": "400 Invalid status transition attempted error: INVALID_TRANSITION, allowed_transitions" + } + }, + { + "type": "security", + "title": "Card status update fails with 401 OTP_FAILED and returns attempts_remaining", + "description": "Verifies OTP enforcement on card freeze/unfreeze: an invalid or expired OTP must not change card status and must return attempts_remaining to throttle brute-force attempts. Automation: HIGH — API assertions.", + "testId": "TC-CARDSTAT-04", + "testDescription": "As an authenticated cardholder attempting to freeze/unfreeze a card, when I provide an invalid or expired confirm_otp, the API must respond with HTTP 401 and error OTP_FAILED including attempts_remaining.", + "prerequisites": "1) A test user (CardOwner) exists with a valid Bearer access token (unexpired).\n2) CardOwner owns card_id_4 (UUID) and the card is in a user-changeable state (Active or Frozen) to ensure OTP is the only failing condition.\n3) An invalid or expired 6-digit OTP value is available for testing (e.g., \"000000\"), guaranteed not to validate.\n4) Test harness can assert that card status did not change after the call (via subsequent valid-status update or fixture observation).", + "stepsToPerform": "1) Authenticate as CardOwner and obtain a valid access token; observe token available.\n2) Record the starting status of card_id_4 (Active or Frozen) from fixture/setup; observe status captured.\n3) Prepare PATCH https://api.aegiscard.com/v2/cards/{card_id_4}/status; observe path contains card_id_4.\n4) Set headers Authorization: Bearer ; Content-Type: application/json; observe headers set.\n5) Create request body with a valid target status (e.g., status=\"Frozen\" if starting Active, else status=\"Active\") and confirm_otp=\"000000\"; observe invalid OTP set.\n6) Send the PATCH request; observe a response is returned.\n7) Verify HTTP status code is 401; observe unauthorized due to OTP failure.\n8) Verify response body error equals \"OTP_FAILED\"; observe exact match.\n9) Verify response body includes attempts_remaining and it is a populated value; observe field exists.\n10) Re-check card status for card_id_4 using a controlled follow-up (e.g., repeat PATCH with a known-valid OTP or consult fixture/state read if available) to confirm the earlier request did not change the status; observe status unchanged from starting value until a valid OTP is used.", + "expectedResult": "1) API returns HTTP 401.\n2) Response body includes error: OTP_FAILED and attempts_remaining.\n3) Card status is not updated when confirm_otp is invalid/expired, preventing OTP brute-force abuse from changing card state.", + "apiEndpoint": "PATCH /v2/cards/{card_id}/status", + "httpMethod": "PATCH", + "requestHeaders": "Authorization: Bearer , Content-Type: application/json", + "requestBodyExampleMasked": "{ \"status\": \"Frozen\", \"confirm_otp\": \"000000\" }", + "expectedHttpStatus": 401, + "errorCodeExpected": "OTP_FAILED", + "securityScenario": "OTP invalid/expired; assert attempts_remaining returned to limit repeated guessing", + "sourceCitation": { + "location": "Section 6.2 Update Card Status HTTP codes, page 11", + "excerpt": "401 OTP invalid or expired error: OTP_FAILED, attempts_remaining" + } + }, + { + "type": "functional", + "title": "Report card lost/stolen blocks card and schedules replacement (irreversible)", + "description": "Verifies reporting a card as lost or stolen immediately blocks the card and schedules a replacement, and that the action is irreversible to reduce fraud exposure. Automation: MEDIUM — API assertions; irreversibility may require environment reset.", + "testId": "TC-LOST-01", + "testDescription": "As an authenticated cardholder, when I report my card as LOST or STOLEN, the system must immediately block the card and trigger the re-issue workflow, returning the replacement scheduling details and treating the action as irreversible.", + "prerequisites": "1) A test user (CardOwner) exists with a valid Bearer access token (unexpired).\n2) CardOwner owns card_id_5 (UUID) and the card is not already in Blocked or Closed state.\n3) Test environment supports observing the report-lost response fields (blocked_card_id, new_card_eta, case_number).\n4) If irreversibility needs verification, environment supports attempting a subsequent operation that would represent reversal (e.g., freeze/unfreeze) to confirm it cannot restore the state (without asserting a specific error code not documented).", + "stepsToPerform": "1) Authenticate as CardOwner and obtain a valid access token; observe token available.\n2) Confirm card_id_5 is not already Blocked or Closed (fixture/state setup); observe starting state recorded.\n3) Prepare POST https://api.aegiscard.com/v2/cards/{card_id_5}/report-lost; observe path contains card_id_5.\n4) Set headers Authorization: Bearer ; Content-Type: application/json; observe headers set.\n5) Create request body with loss_type=\"LOST\"; do not include optional fields; observe payload prepared.\n6) Send the POST request; observe response is returned.\n7) Verify HTTP status code is 200; observe success.\n8) Verify response body contains blocked_card_id, new_card_eta, and case_number; observe all fields present.\n9) Verify blocked_card_id is populated and corresponds to the blocked card reference (non-empty); observe value present.\n10) (Irreversibility check) Attempt a follow-up action that would normally change card state back to usable (e.g., PATCH /v2/cards/{card_id_5}/status with status=\"Active\" and a valid confirm_otp, if available); observe the card is not restored to a normal usable state (do not assert a specific status/error not documented).", + "expectedResult": "1) API returns HTTP 200.\n2) Response includes blocked_card_id, new_card_eta, case_number indicating the card is blocked and replacement is scheduled.\n3) Post-condition: the lost/stolen report is treated as irreversible (the system does not allow returning the same card to normal use), requiring cleanup via test data reset for subsequent runs.", + "apiEndpoint": "POST /v2/cards/{card_id}/report-lost", + "httpMethod": "POST", + "requestHeaders": "Authorization: Bearer , Content-Type: application/json", + "requestBodyExampleMasked": "{ \"loss_type\": \"LOST\" }", + "expectedHttpStatus": 200, + "validationRulesCovered": "loss_type enum {LOST, STOLEN}; irreversible behavior; replacement scheduled", + "sourceCitation": { + "location": "Section 6.3 Report Card Lost or Stolen, page 12", + "excerpt": "Immediately blocks card and triggers re-issue workflow (REQ-007). Irreversible." + } + }, + { + "type": "negative", + "title": "Report lost/stolen returns 409 ALREADY_BLOCKED if card already Blocked or Closed", + "description": "Verifies duplicate/invalid lost-stolen reporting is prevented when the card is already Blocked or Closed, avoiding redundant re-issue workflows and case duplication. Automation: HIGH — API assertions with pre-blocked fixture.", + "testId": "TC-LOST-02", + "testDescription": "As an authenticated cardholder, when I attempt to report a card lost/stolen that is already in Blocked or Closed state, the API must return HTTP 409 with error ALREADY_BLOCKED.", + "prerequisites": "1) A test user (CardOwner) exists with a valid Bearer access token (unexpired).\n2) CardOwner owns card_id_6 (UUID) and card_id_6 is already in Blocked or Closed state (fixture).\n3) API base URL is reachable: https://api.aegiscard.com/v2.\n4) Test harness can assert response error code.", + "stepsToPerform": "1) Authenticate as CardOwner and obtain a valid access token; observe token available.\n2) Verify via fixture/setup that card_id_6 state is Blocked or Closed; observe state recorded.\n3) Prepare POST https://api.aegiscard.com/v2/cards/{card_id_6}/report-lost; observe correct path.\n4) Set headers Authorization: Bearer ; Content-Type: application/json; observe headers set.\n5) Create request body with loss_type=\"STOLEN\"; observe payload prepared.\n6) Send the POST request; observe a response is returned.\n7) Verify HTTP status code is 409; observe conflict.\n8) Verify response body includes error equal to \"ALREADY_BLOCKED\"; observe exact match.\n9) Verify response body does not include success keys (blocked_card_id, new_card_eta, case_number); observe absence.\n10) Retry the same request once more to ensure consistent behavior; observe it remains 409 with error ALREADY_BLOCKED.", + "expectedResult": "1) API returns HTTP 409.\n2) Response body includes error: ALREADY_BLOCKED.\n3) No replacement scheduling fields are returned for an already Blocked/Closed card, preventing duplicate re-issue initiation.", + "apiEndpoint": "POST /v2/cards/{card_id}/report-lost", + "httpMethod": "POST", + "requestHeaders": "Authorization: Bearer , Content-Type: application/json", + "requestBodyExampleMasked": "{ \"loss_type\": \"STOLEN\" }", + "expectedHttpStatus": 409, + "errorCodeExpected": "ALREADY_BLOCKED", + "sourceCitation": { + "location": "Section 6.3 Report Card Lost or Stolen HTTP codes, page 12", + "excerpt": "409 Card already in Blocked or Closed state error: ALREADY_BLOCKED" + } + }, + { + "type": "boundary", + "title": "Report lost/stolen accepts last_known_use in ISO 8601 UTC (optional)", + "description": "Verifies the optional last_known_use field is accepted when provided in ISO 8601 UTC format to support fraud investigation timelines. Automation: HIGH — API assertions.", + "testId": "TC-LOST-03", + "testDescription": "As an authenticated cardholder reporting a card lost, when I include last_known_use in ISO 8601 UTC format, the API must accept the request and process the loss report successfully.", + "prerequisites": "1) A test user (CardOwner) exists with a valid Bearer access token (unexpired).\n2) CardOwner owns card_id_7 (UUID) and the card is not already Blocked or Closed.\n3) A valid ISO 8601 UTC datetime string is prepared, e.g., \"2026-04-01T13:45:30Z\".\n4) API base URL is reachable: https://api.aegiscard.com/v2.", + "stepsToPerform": "1) Authenticate as CardOwner and obtain a valid access token; observe token available.\n2) Confirm card_id_7 is eligible to be reported (not Blocked/Closed) via fixture; observe status recorded.\n3) Prepare POST https://api.aegiscard.com/v2/cards/{card_id_7}/report-lost; observe correct path.\n4) Set headers Authorization: Bearer ; Content-Type: application/json; observe headers set.\n5) Create request body with loss_type=\"LOST\" and last_known_use=\"2026-04-01T13:45:30Z\"; observe ISO 8601 UTC value present.\n6) Send the POST request; observe a response is returned.\n7) Verify HTTP status code is 200; observe success.\n8) Verify response body includes blocked_card_id; observe value present.\n9) Verify response body includes new_card_eta and case_number; observe both present.\n10) Record the request/response in test evidence noting last_known_use was accepted (no validation error returned); observe successful processing with last_known_use provided.", + "expectedResult": "1) API returns HTTP 200 when last_known_use is provided in ISO 8601 UTC format.\n2) Response includes blocked_card_id, new_card_eta, case_number, confirming the lost report was processed.\n3) No validation error is returned due to presence of last_known_use in the specified format.", + "apiEndpoint": "POST /v2/cards/{card_id}/report-lost", + "httpMethod": "POST", + "requestHeaders": "Authorization: Bearer , Content-Type: application/json", + "requestBodyExampleMasked": "{ \"loss_type\": \"LOST\", \"last_known_use\": \"2026-04-01T13:45:30Z\" }", + "expectedHttpStatus": 200, + "boundaryValues": "Datetime format: ISO 8601 UTC (Z-suffix) example used", + "sourceCitation": { + "location": "Section 6.3 Report Card Lost or Stolen field table, page 12", + "excerpt": "last_known_use DateTime Optional ISO 8601 UTC; for fraud investigation" + } + }, + { + "type": "functional", + "title": "Set virtual PIN succeeds when new_pin is 4 digits, confirm_pin matches, and session_otp provided", + "description": "Verifies the virtual PIN can be set when inputs meet format rules and OTP is provided, and that the PIN is transmitted encrypted as required. This protects card access by enforcing PIN complexity/format and secure transport. Automation: MEDIUM — requires test harness capable of RSA-OAEP encryption.", + "testId": "TC-PIN-01", + "testDescription": "As an authenticated cardholder, when I set a new virtual PIN using a 4-digit numeric value (encrypted with RSA-OAEP), provide matching confirm_pin, and include a valid 6-digit session_otp, the API must return success.", + "prerequisites": "1) A test user (CardOwner) exists with a valid Bearer access token (unexpired).\n2) CardOwner owns card_id_8 (UUID) and the card is not in Blocked state.\n3) A valid 6-digit OTP (session_otp_valid) has been obtained from the OTP request flow referenced by the SRS (\"/v2/auth/otp/request\").\n4) Test harness can encrypt the 4-digit PIN using RSA-OAEP per system requirement before sending new_pin/confirm_pin.", + "stepsToPerform": "1) Authenticate as CardOwner and obtain a valid access token; observe token available.\n2) Obtain a valid session_otp for the user via the referenced OTP issuance flow; observe session_otp_valid is 6 digits.\n3) Choose a synthetic PIN value \"1234\"; observe it is exactly 4 numeric digits.\n4) Encrypt \"1234\" using RSA-OAEP as required by the integration; observe ciphertext string produced (do not log plaintext in test evidence).\n5) Prepare PUT https://api.aegiscard.com/v2/cards/{card_id_8}/pin; observe correct path.\n6) Set headers Authorization: Bearer ; Content-Type: application/json; observe headers set.\n7) Create request body with new_pin=, confirm_pin=, session_otp=session_otp_valid; observe confirm matches new_pin.\n8) Send the PUT request; observe a response is returned.\n9) Verify HTTP status code is 200; observe success.\n10) Verify response body contains success: true and updated_at; observe both present and success is true.", + "expectedResult": "1) API returns HTTP 200.\n2) Response body contains success: true and updated_at.\n3) PIN update is accepted only when new_pin is exactly 4 numeric digits (sent encrypted RSA-OAEP), confirm_pin matches, and session_otp is provided, reducing risk of weak/incorrect PIN setting and protecting transport security.", + "apiEndpoint": "PUT /v2/cards/{card_id}/pin", + "httpMethod": "PUT", + "requestHeaders": "Authorization: Bearer , Content-Type: application/json", + "requestBodyExampleMasked": "{ \"new_pin\": \"\", \"confirm_pin\": \"\", \"session_otp\": \"654321\" }", + "expectedHttpStatus": 200, + "validationRulesCovered": "new_pin exactly 4 numeric digits; transmitted encrypted (RSA-OAEP); confirm_pin matches; session_otp required", + "sourceCitation": { + "location": "Section 6.4 Set Web-Based PIN (Virtual PIN), page 12", + "excerpt": "Exactly 4 numeric digits; transmitted encrypted (RSA-OAEP)" + } + }, + { + "type": "negative", + "title": "Set virtual PIN returns 400 PIN_MISMATCH when confirm_pin does not match new_pin", + "description": "Verifies the service rejects PIN set attempts when confirm_pin does not match new_pin and returns the specified error code, preventing accidental/incorrect PIN assignment. Automation: MEDIUM — requires RSA-OAEP encryption in test harness.", + "testId": "TC-PIN-02", + "testDescription": "As an authenticated cardholder, when I attempt to set a virtual PIN with confirm_pin different from new_pin (while still providing session_otp), the API must return HTTP 400 with error PIN_MISMATCH.", + "prerequisites": "1) A test user (CardOwner) exists with a valid Bearer access token (unexpired).\n2) CardOwner owns card_id_9 (UUID) and the card is not in Blocked state.\n3) A valid 6-digit session_otp (session_otp_valid) is available from the OTP request flow referenced by the SRS.\n4) Test harness can encrypt PIN values using RSA-OAEP prior to sending.", + "stepsToPerform": "1) Authenticate as CardOwner and obtain a valid access token; observe token available.\n2) Obtain/prepare a valid 6-digit session_otp_valid; observe it is present.\n3) Choose two different 4-digit numeric PINs: new_pin_plain=\"1234\" and confirm_pin_plain=\"4321\"; observe they differ.\n4) Encrypt both values using RSA-OAEP; observe two different ciphertext values produced.\n5) Prepare PUT https://api.aegiscard.com/v2/cards/{card_id_9}/pin; observe correct path.\n6) Set headers Authorization: Bearer ; Content-Type: application/json; observe headers set.\n7) Create request body with new_pin=, confirm_pin=, session_otp=session_otp_valid; observe mismatch condition.\n8) Send the PUT request; observe a response is returned.\n9) Verify HTTP status code is 400; observe validation error.\n10) Verify response body error equals \"PIN_MISMATCH\"; observe exact match.", + "expectedResult": "1) API returns HTTP 400.\n2) Response body error is PIN_MISMATCH.\n3) No success indicators (success: true, updated_at) are returned when confirm_pin does not match new_pin, preventing incorrect PIN setup.", + "apiEndpoint": "PUT /v2/cards/{card_id}/pin", + "httpMethod": "PUT", + "requestHeaders": "Authorization: Bearer , Content-Type: application/json", + "requestBodyExampleMasked": "{ \"new_pin\": \"\", \"confirm_pin\": \"\", \"session_otp\": \"654321\" }", + "expectedHttpStatus": 400, + "errorCodeExpected": "PIN_MISMATCH", + "validationRulesCovered": "400 PIN mismatch or not 4 digits error: PIN_MISMATCH or PIN_FORMAT", + "sourceCitation": { + "location": "Section 6.4 Set Web-Based PIN HTTP codes, page 12", + "excerpt": "400 PIN mismatch or not 4 digits error: PIN_MISMATCH or PIN_FORMAT" + } + }, + { + "type": "boundary", + "title": "Set virtual PIN returns 400 PIN_FORMAT when new_pin is not exactly 4 numeric digits", + "description": "Validates that the Set Web-Based PIN API enforces the exact PIN length/character rule and rejects non-compliant values with the specified error. This prevents weak/invalid PINs from being set and ensures consistent downstream PIN handling. Automation: HIGH — pure API assertions.", + "testId": "TC-PIN-03", + "testDescription": "As an authenticated cardholder with a non-blocked card, when I attempt to set/reset my virtual PIN using a session OTP but provide a new_pin that is not exactly 4 numeric digits, the system must reject the request with HTTP 400 and error code PIN_FORMAT.", + "prerequisites": "1) Valid authenticated session for a cardholder (Authorization: Bearer ).\n2) Test card_id exists and belongs to the authenticated user; card is NOT in Blocked state.\n3) Obtain a valid 6-digit session_otp from /v2/auth/otp/request (per field requirement: \"6-digit OTP from /v2/auth/otp/request\").\n4) Ability to encrypt new_pin using RSA-OAEP as required for transmission.", + "stepsToPerform": "1) Prepare test card_id = \"11111111-2222-3333-4444-555555555555\" that is owned by the authenticated user; observe in test data setup that card is not Blocked.\n2) Request an OTP using the referenced flow (call /v2/auth/otp/request) and capture a valid session_otp (example: \"123456\"); observe OTP is issued/available for use.\n3) Generate RSA-OAEP encrypted payload for an invalid new_pin of length 3 digits (example plaintext \"123\" -> encrypted \"\"); observe ciphertext is produced.\n4) Call PUT https://api.aegiscard.com/v2/cards/{card_id}/pin with body: {\"new_pin\":\"\",\"confirm_pin\":\"\",\"session_otp\":\"123456\"}; observe HTTP response.\n5) Verify response is HTTP 400; observe response body contains error code \"PIN_FORMAT\".\n6) Generate RSA-OAEP encrypted payload for an invalid new_pin of length 5 digits (example plaintext \"12345\" -> encrypted \"\"); observe ciphertext is produced.\n7) Call PUT /v2/cards/{card_id}/pin with {\"new_pin\":\"\",\"confirm_pin\":\"\",\"session_otp\":\"123456\"}; observe HTTP response.\n8) Verify response is HTTP 400 with error \"PIN_FORMAT\".\n9) Generate RSA-OAEP encrypted payload for an invalid new_pin containing non-numeric characters (example plaintext \"12A4\" -> encrypted \"\"); observe ciphertext is produced.\n10) Call PUT /v2/cards/{card_id}/pin with {\"new_pin\":\"\",\"confirm_pin\":\"\",\"session_otp\":\"123456\"}; observe HTTP response.\n11) Verify response is HTTP 400 with error \"PIN_FORMAT\" and confirm no success indicator (e.g., do not see \"success: true\").", + "expectedResult": "1) For each invalid new_pin case (3 digits, 5 digits, or non-numeric), the API returns HTTP 400.\n2) Response body contains the exact error code \"PIN_FORMAT\" (not PIN_MISMATCH).\n3) PIN is not set/updated (no \"success: true\" returned).", + "apiEndpoint": "https://api.aegiscard.com/v2/cards/{card_id}/pin", + "httpMethod": "PUT", + "requestHeaders": "Authorization: Bearer , Content-Type: application/json", + "requestBodyExampleMasked": "{\"new_pin\":\"\",\"confirm_pin\":\"\",\"session_otp\":\"123456\"}", + "expectedHttpStatus": 400, + "errorCodeExpected": "PIN_FORMAT", + "boundaryValues": "Invalid: \"123\" (len=3), \"12345\" (len=5), \"12A4\" (non-numeric); rule requires exactly 4 numeric digits", + "sourceCitation": { + "location": "Section 6.4 Set Web-Based PIN (Virtual PIN) field table, page 12", + "excerpt": "Exactly 4 numeric digits; transmitted encrypted (RSA-OAEP)" + } + }, + { + "type": "negative", + "title": "Set virtual PIN returns 403 CARD_BLOCKED when card is in Blocked state", + "description": "Ensures the Set Web-Based PIN API blocks PIN changes when the card is already Blocked, returning the specified authorization/business error. This prevents actions on blocked instruments and reduces fraud risk. Automation: HIGH — pure API assertions.", + "testId": "TC-PIN-04", + "testDescription": "As an authenticated cardholder, when I attempt to set/reset the virtual PIN for a card that is in Blocked state (even with valid OTP and matching PIN values), the system must reject the request with HTTP 403 and error code CARD_BLOCKED.", + "prerequisites": "1) Valid authenticated session for a cardholder (Authorization: Bearer ).\n2) card_id exists and belongs to the authenticated user.\n3) Card is in Blocked state before the test starts.\n4) Obtain a valid 6-digit session_otp from /v2/auth/otp/request.\n5) Ability to encrypt new_pin using RSA-OAEP.", + "stepsToPerform": "1) Select a test card_id = \"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\" owned by the authenticated user and ensure its state is Blocked; observe card status in test setup (Blocked).\n2) Request an OTP using /v2/auth/otp/request and capture a valid session_otp (example: \"123456\"); observe OTP is issued.\n3) Create a valid 4-digit PIN plaintext \"1234\" and encrypt with RSA-OAEP to \"\"; observe ciphertext is produced.\n4) Call PUT https://api.aegiscard.com/v2/cards/{card_id}/pin with body {\"new_pin\":\"\",\"confirm_pin\":\"\",\"session_otp\":\"123456\"}; observe HTTP response.\n5) Verify HTTP status is 403; observe response body error code.\n6) Assert error code equals \"CARD_BLOCKED\" exactly.\n7) Re-try the same request with a fresh OTP (request a new session_otp and resend same encrypted PIN); observe HTTP response.\n8) Verify the response remains 403 with \"CARD_BLOCKED\" (ensures blocked-state rule dominates over OTP correctness).\n9) Confirm response does not include success payload (e.g., no \"success: true, updated_at\").", + "expectedResult": "1) API returns HTTP 403.\n2) Response body contains exact error code \"CARD_BLOCKED\".\n3) PIN is not set/updated (no success response payload returned).", + "apiEndpoint": "https://api.aegiscard.com/v2/cards/{card_id}/pin", + "httpMethod": "PUT", + "requestHeaders": "Authorization: Bearer , Content-Type: application/json", + "requestBodyExampleMasked": "{\"new_pin\":\"\",\"confirm_pin\":\"\",\"session_otp\":\"123456\"}", + "expectedHttpStatus": 403, + "errorCodeExpected": "CARD_BLOCKED", + "sourceCitation": { + "location": "Section 6.4 Set Web-Based PIN (Virtual PIN) HTTP codes, page 12", + "excerpt": "403 Card in Blocked state error: CARD_BLOCKED" + } + }, + { + "type": "functional", + "title": "Retrieve statement default format JSON and returns required statement fields (200)", + "description": "Verifies the statement retrieval endpoint defaults to JSON when format is omitted and returns the required statement fields for billing transparency. Automation: HIGH — pure API assertions.", + "testId": "TC-STMT-01", + "testDescription": "As an authenticated cardholder, when I retrieve an existing generated statement without providing the format parameter, I should receive a 200 response with a JSON payload containing all required statement fields.", + "prerequisites": "1) Valid authenticated session for a cardholder (Authorization: Bearer ).\n2) account_id exists and belongs to the authenticated user.\n3) A generated statement exists for the account with statement_id = \"99999999-8888-7777-6666-555555555555\".\n4) Test environment can distinguish JSON response (Content-Type application/json) from PDF responses.", + "stepsToPerform": "1) Identify account_id = \"22222222-3333-4444-5555-666666666666\" owned by the authenticated user; observe ownership is valid in test data.\n2) Identify an existing generated statement_id = \"99999999-8888-7777-6666-555555555555\" for that account; observe it is marked generated/available in test data.\n3) Send GET https://api.aegiscard.com/v2/accounts/{account_id}/statements/{statement_id} WITHOUT a format query parameter; observe HTTP status and headers.\n4) Verify HTTP status is 200; observe Content-Type header indicates JSON (e.g., application/json).\n5) Parse response body as JSON; observe it is valid JSON.\n6) Verify the presence of \"statement_date\" and \"total_spend\" fields in the response body.\n7) Verify the presence of \"adb\" and \"interest_charged\" fields in the response body.\n8) Verify the presence of \"late_fee\" and \"rewards_earned\" fields in the response body.\n9) Verify the presence of \"minimum_payment_due\" and \"due_date\" fields in the response body.\n10) Confirm no binary PDF payload is returned (e.g., response body is structured JSON and not a PDF stream).", + "expectedResult": "1) Response is HTTP 200.\n2) Default response format is JSON when format is omitted.\n3) JSON payload contains the required keys: statement_date, total_spend, adb, interest_charged, late_fee, rewards_earned, minimum_payment_due, due_date.", + "apiEndpoint": "https://api.aegiscard.com/v2/accounts/{account_id}/statements/{statement_id}", + "httpMethod": "GET", + "requestHeaders": "Authorization: Bearer , Accept: application/json", + "expectedHttpStatus": 200, + "validationRulesCovered": "format Enum Optional {JSON, PDF}; default JSON; required response body keys for 200", + "sourceCitation": { + "location": "Section 7.1 Statement & Billing Cycle API, page 13", + "excerpt": "format Enum Optional {JSON, PDF}; default JSON" + } + }, + { + "type": "functional", + "title": "Retrieve statement in PDF format when format=PDF", + "description": "Ensures the statement retrieval endpoint supports PDF output when requested, enabling downloadable statements for customers. Automation: MEDIUM — API assertions plus binary/PDF header validation.", + "testId": "TC-STMT-02", + "testDescription": "As an authenticated cardholder, when I retrieve an existing generated statement with format=PDF, I should receive a 200 response containing a PDF payload instead of JSON.", + "prerequisites": "1) Valid authenticated session for a cardholder (Authorization: Bearer ).\n2) account_id exists and belongs to the authenticated user.\n3) A generated statement exists for the account with statement_id = \"99999999-8888-7777-6666-555555555555\".\n4) Test client can validate PDF response characteristics (Content-Type and PDF file signature).", + "stepsToPerform": "1) Identify account_id = \"22222222-3333-4444-5555-666666666666\" owned by the authenticated user; observe ownership is valid in test data.\n2) Identify generated statement_id = \"99999999-8888-7777-6666-555555555555\"; observe it is available.\n3) Send GET https://api.aegiscard.com/v2/accounts/{account_id}/statements/{statement_id}?format=PDF; observe HTTP status and headers.\n4) Verify HTTP status is 200.\n5) Verify Content-Type header indicates a PDF response (e.g., application/pdf); observe header value.\n6) Inspect the first bytes of the response body and verify it begins with the PDF signature \"%PDF\"; observe signature match.\n7) Verify the response body is not parseable as JSON (e.g., JSON parsing fails); observe parse failure.\n8) Save response to a file named \"statement_test.pdf\"; observe file is created and non-empty.\n9) Open/validate the PDF via a PDF parser in automation and confirm it is a valid PDF document structure; observe parser succeeds.", + "expectedResult": "1) Response is HTTP 200.\n2) Response is delivered in PDF format when format=PDF (Content-Type PDF and body starts with \"%PDF\").\n3) Response is not a JSON statement payload (binary/PDF content returned).", + "apiEndpoint": "https://api.aegiscard.com/v2/accounts/{account_id}/statements/{statement_id}?format=PDF", + "httpMethod": "GET", + "requestHeaders": "Authorization: Bearer , Accept: application/pdf", + "expectedHttpStatus": 200, + "validationRulesCovered": "format Enum Optional {JSON, PDF}", + "sourceCitation": { + "location": "Section 7.1 Statement & Billing Cycle API, page 13", + "excerpt": "format Enum Optional {JSON, PDF}; default JSON" + } + }, + { + "type": "negative", + "title": "Retrieve statement returns 404 NOT_FOUND when statement not generated or missing", + "description": "Validates correct error handling when a statement is not available (missing or not yet generated), preventing misleading data exposure and enabling proper client messaging. Automation: HIGH — pure API assertions.", + "testId": "TC-STMT-03", + "testDescription": "As an authenticated cardholder, when I retrieve a statement_id that does not exist or is not yet generated for my account, the API must return HTTP 404 with error code NOT_FOUND.", + "prerequisites": "1) Valid authenticated session for a cardholder (Authorization: Bearer ).\n2) account_id exists and belongs to the authenticated user.\n3) statement_id used for the test is confirmed to be missing/not generated in the environment (e.g., a random UUID not present).", + "stepsToPerform": "1) Identify account_id = \"22222222-3333-4444-5555-666666666666\" owned by the authenticated user; observe ownership is valid.\n2) Choose a non-existent statement_id = \"00000000-1111-2222-3333-444444444444\"; observe it is not present in statements list/test data.\n3) Send GET https://api.aegiscard.com/v2/accounts/{account_id}/statements/{statement_id} with format omitted; observe HTTP response.\n4) Verify HTTP status is 404.\n5) Verify response body contains error code \"NOT_FOUND\".\n6) Re-send request with explicit format=JSON; observe HTTP response.\n7) Verify HTTP status remains 404 and error code remains \"NOT_FOUND\".\n8) Re-send request with format=PDF; observe HTTP response.\n9) Verify HTTP status remains 404 and response still indicates error \"NOT_FOUND\" (not a PDF payload).", + "expectedResult": "1) API returns HTTP 404.\n2) Response body contains exact error code \"NOT_FOUND\".\n3) No statement payload is returned (neither JSON fields nor PDF content).", + "apiEndpoint": "https://api.aegiscard.com/v2/accounts/{account_id}/statements/{statement_id}", + "httpMethod": "GET", + "requestHeaders": "Authorization: Bearer , Accept: application/json", + "expectedHttpStatus": 404, + "errorCodeExpected": "NOT_FOUND", + "sourceCitation": { + "location": "Section 7.1 Statement & Billing Cycle API HTTP codes, page 13", + "excerpt": "404 Statement not found or not yet generated error: NOT_FOUND" + } + }, + { + "type": "functional", + "title": "Interest computed using ADB formula with Days_in_Billing_Cycle and APR", + "description": "Validates interest charged is computed exactly per the ADB formula using APR and Days_in_Billing_Cycle, ensuring accurate regulated billing. Automation: MEDIUM — requires deterministic billing inputs and statement retrieval.", + "testId": "TC-INT-01", + "testDescription": "As a cardholder, when a billing cycle is computed for a known ADB, APR, and Days_in_Billing_Cycle, the interest_charged must equal the formula result: (ADB × APR / 365) × Days_in_Billing_Cycle.", + "prerequisites": "1) Billing computation test fixture/environment supports creating a statement with known ADB and APR inputs (or a seeded statement record).\n2) account_id belongs to the authenticated user.\n3) A generated statement_id exists whose underlying values are known: ADB=1000.00, APR=0.1999 (19.99%), Days_in_Billing_Cycle=30.\n4) Valid authenticated session for statement retrieval (Authorization: Bearer ).", + "stepsToPerform": "1) Seed or select a test statement for account_id = \"33333333-4444-5555-6666-777777777777\" with known billing inputs: adb=1000.00, apr=0.1999, days_in_billing_cycle=30; observe these fixture values are recorded.\n2) Compute expected interest using the specified formula: (1000.00 × 0.1999 / 365) × 30 = 16.4301369863; observe computed expected value.\n3) Round/format expected value to 2 decimals for comparison as used in statement currency fields: expected_interest_charged = 16.43; observe expected rounded value.\n4) Call GET https://api.aegiscard.com/v2/accounts/{account_id}/statements/{statement_id} (default JSON); observe HTTP 200.\n5) Extract the \"adb\" field from response and verify it equals 1000.00; observe match.\n6) Extract \"interest_charged\" from response; observe returned value.\n7) Compare returned interest_charged to expected 16.43 exactly to 2 decimal places; observe match.\n8) Verify the statement response includes \"statement_date\" and \"due_date\" (ensures it is a full statement payload); observe fields present.\n9) Record the test evidence (request/response and calculation sheet) for auditability; observe artifacts saved in test run output.", + "expectedResult": "1) Statement retrieval returns HTTP 200 with JSON payload.\n2) Returned adb equals the seeded/known ADB value (1000.00).\n3) Returned interest_charged equals the computed value per formula: 16.43 for ADB=1000.00, APR=0.1999, Days_in_Billing_Cycle=30.", + "apiEndpoint": "https://api.aegiscard.com/v2/accounts/{account_id}/statements/{statement_id}", + "httpMethod": "GET", + "expectedHttpStatus": 200, + "calculationExpected": "Interest = (1000.00 × 0.1999 / 365) × 30 = 16.43", + "testDataMasked": "account_id=33333333-4444-5555-6666-777777777777, statement_id=abababab-cdcd-efef-0101-121212121212", + "sourceCitation": { + "location": "Section 7.2 Billing Calculation Rules, page 13", + "excerpt": "Interest = (ADB × APR / 365) × Days_in_Billing_Cycle" + } + }, + { + "type": "boundary", + "title": "Interest calculation handles different Days_in_Billing_Cycle values (e.g., 28/30/31) per formula", + "description": "Validates interest scales with Days_in_Billing_Cycle at common month-length boundaries, preventing under/overcharging. Automation: MEDIUM — requires deterministic billing fixtures for different cycle lengths.", + "testId": "TC-INT-02", + "testDescription": "As a cardholder, for the same ADB and APR, interest_charged must change proportionally when Days_in_Billing_Cycle is 28, 30, and 31 as defined by the ADB interest formula.", + "prerequisites": "1) Billing computation test fixture/environment supports generating three statements with controlled Days_in_Billing_Cycle values while keeping ADB and APR constant.\n2) account_id belongs to authenticated user.\n3) Three generated statements exist (or can be generated) with constant adb=2500.00 and apr=0.2499, and Days_in_Billing_Cycle values of 28, 30, 31.\n4) Valid authenticated session for statement retrieval.", + "stepsToPerform": "1) Seed/select statement S28 with adb=2500.00, apr=0.2499, days_in_billing_cycle=28; observe fixture values.\n2) Compute expected interest for 28 days: (2500.00 × 0.2499 / 365) × 28 = 47.9104109589 -> expected 47.91; observe expected value.\n3) Retrieve S28 via GET /v2/accounts/{account_id}/statements/{S28_id}; observe HTTP 200 and extract interest_charged.\n4) Verify S28 interest_charged equals 47.91 (2 decimals); observe match.\n5) Seed/select statement S30 with same adb/apr and days_in_billing_cycle=30; compute expected: (2500.00 × 0.2499 / 365) × 30 = 51.3325839041 -> 51.33; observe expected value.\n6) Retrieve S30 and verify interest_charged equals 51.33; observe match.\n7) Seed/select statement S31 with same adb/apr and days_in_billing_cycle=31; compute expected: (2500.00 × 0.2499 / 365) × 31 = 53.0436703767 -> 53.04; observe expected value.\n8) Retrieve S31 and verify interest_charged equals 53.04; observe match.\n9) Verify monotonic boundary behavior: S28 < S30 < S31 for interest_charged; observe ordering holds.", + "expectedResult": "1) Each statement retrieval returns HTTP 200.\n2) interest_charged matches the formula output for each boundary cycle length: 28d=47.91, 30d=51.33, 31d=53.04 (for ADB=2500.00, APR=0.2499).\n3) Interest increases as Days_in_Billing_Cycle increases while ADB and APR remain constant.", + "apiEndpoint": "https://api.aegiscard.com/v2/accounts/{account_id}/statements/{statement_id}", + "httpMethod": "GET", + "expectedHttpStatus": 200, + "boundaryValues": "Days_in_Billing_Cycle=28, 30, 31", + "calculationExpected": "28d: 47.91; 30d: 51.33; 31d: 53.04 (ADB=2500.00, APR=0.2499)", + "sourceCitation": { + "location": "Section 7.2 Billing Calculation Rules, page 13", + "excerpt": "Interest = (ADB × APR / 365) × Days_in_Billing_Cycle" + } + }, + { + "type": "functional", + "title": "Grace period (21 days) applied only when prev_statement_balance_paid_in_full=true", + "description": "Validates the grace period eligibility rule is strictly gated by prev_statement_balance_paid_in_full, preventing unearned interest-free periods. Automation: MEDIUM — requires billing fixture flags and due-date/grace representation in statement metadata.", + "testId": "TC-GRACE-01", + "testDescription": "As a cardholder, I should only receive a 21-day grace period when the previous statement balance was paid in full; if not paid in full, the grace period must not be applied.", + "prerequisites": "1) Billing test fixture/environment can generate two statements with the same cycle settings but different flag values for prev_statement_balance_paid_in_full.\n2) account_id belongs to authenticated user.\n3) Statement A exists with prev_statement_balance_paid_in_full=true and Statement B exists with prev_statement_balance_paid_in_full=false (or can be generated).\n4) A way to observe grace period application in generated billing outputs used by the system under test (e.g., statement metadata/derived due date window in fixture logs or billing computation output used to build statement).", + "stepsToPerform": "1) Seed/select Statement A for account_id = \"44444444-5555-6666-7777-888888888888\" with prev_statement_balance_paid_in_full=true; observe fixture flag value.\n2) Retrieve Statement A via GET /v2/accounts/{account_id}/statements/{A_id}; observe HTTP 200.\n3) Capture statement_date and due_date from Statement A response; observe both fields present.\n4) From billing computation output/fixture evidence for Statement A, verify grace period was set to 21 days due to prev_statement_balance_paid_in_full=true; observe recorded grace period = 21.\n5) Seed/select Statement B with prev_statement_balance_paid_in_full=false; observe fixture flag value.\n6) Retrieve Statement B via GET /v2/accounts/{account_id}/statements/{B_id}; observe HTTP 200.\n7) Capture statement_date and due_date from Statement B response; observe both fields present.\n8) From billing computation output/fixture evidence for Statement B, verify grace period was NOT applied because prev_statement_balance_paid_in_full=false; observe grace period not set to 21.\n9) Attach calculation/fixture evidence to test run artifacts for both cases; observe artifacts saved.", + "expectedResult": "1) When prev_statement_balance_paid_in_full=true, grace period is applied as 21 days.\n2) When prev_statement_balance_paid_in_full=false, grace period is not applied.\n3) Both statements are retrievable with HTTP 200 and include statement_date and due_date fields.", + "apiEndpoint": "https://api.aegiscard.com/v2/accounts/{account_id}/statements/{statement_id}", + "httpMethod": "GET", + "expectedHttpStatus": 200, + "validationRulesCovered": "Grace period (21 days) only if prev_statement_balance_paid_in_full = true", + "sourceCitation": { + "location": "Section 7.2 Billing Calculation Rules, page 13", + "excerpt": "Grace period (21 days) only if prev_statement_balance_paid_in_full = true" + } + }, + { + "type": "functional", + "title": "Late fee charged when payment_received_date is greater than due_date + 2 days", + "description": "Validates late fee is assessed only when payment is received strictly after the 2-day buffer beyond due_date, ensuring correct fee charging. Automation: MEDIUM — requires billing fixture with controlled due_date and payment_received_date.", + "testId": "TC-LATEFEE-01", + "testDescription": "As a cardholder, if my payment is received after due_date plus 2 days, the statement should include a late_fee of $35.00 as defined by the late fee trigger rule.", + "prerequisites": "1) Billing test fixture/environment can create a statement with controlled due_date and payment_received_date.\n2) account_id belongs to authenticated user.\n3) A statement exists with due_date=2026-04-10 and payment_received_date=2026-04-13 (i.e., > due_date + 2 days).\n4) Valid authenticated session for statement retrieval.", + "stepsToPerform": "1) Seed/select a test statement for account_id = \"55555555-6666-7777-8888-999999999999\" with due_date = \"2026-04-10\"; observe fixture due_date.\n2) Set/confirm payment_received_date = \"2026-04-13\" for the cycle; observe this is greater than due_date + 2 days (2026-04-12).\n3) Compute boundary check: due_date + 2 days = \"2026-04-12\" and payment_received_date \"2026-04-13\" is strictly greater; observe condition holds.\n4) Retrieve the statement via GET /v2/accounts/{account_id}/statements/{statement_id}; observe HTTP 200.\n5) Extract \"due_date\" from response and verify it equals \"2026-04-10\"; observe match.\n6) Extract \"late_fee\" from response; observe returned value.\n7) Verify late_fee equals 35.00 (or \"$35.00\" depending on numeric formatting); observe exact fee amount.\n8) Save response payload as evidence; observe artifacts stored.\n9) Confirm that late_fee is present as a distinct field in the statement response (not inferred); observe field exists.", + "expectedResult": "1) Statement retrieval returns HTTP 200.\n2) When payment_received_date > due_date + 2 days, late_fee equals $35.00.\n3) Statement includes due_date and late_fee fields in the response payload.", + "apiEndpoint": "https://api.aegiscard.com/v2/accounts/{account_id}/statements/{statement_id}", + "httpMethod": "GET", + "expectedHttpStatus": 200, + "calculationExpected": "due_date 2026-04-10; due_date+2 days=2026-04-12; payment_received_date 2026-04-13 => late_fee $35.00", + "sourceCitation": { + "location": "Section 7.2 Billing Calculation Rules, page 13", + "excerpt": "late_fee = $35.00 if payment_received_date > due_date + 2 days" + } + }, + { + "type": "boundary", + "title": "Late fee boundary: no late fee when payment_received_date equals due_date + 2 days", + "description": "Validates the strict 'greater than' boundary for late fee assessment, ensuring customers are not charged when payment arrives exactly on the last allowed buffer day. Automation: MEDIUM — requires billing fixture with controlled dates.", + "testId": "TC-LATEFEE-02", + "testDescription": "As a cardholder, when payment_received_date is exactly equal to due_date + 2 days, the late fee trigger condition is not met and no $35.00 late_fee should be applied.", + "prerequisites": "1) Billing test fixture/environment can create a statement with controlled due_date and payment_received_date.\n2) account_id belongs to authenticated user.\n3) A statement exists with due_date=2026-04-10 and payment_received_date=2026-04-12 (exactly due_date + 2 days).\n4) Valid authenticated session for statement retrieval.", + "stepsToPerform": "1) Seed/select a test statement for account_id = \"66666666-7777-8888-9999-000000000000\" with due_date = \"2026-04-10\"; observe fixture due_date.\n2) Set/confirm payment_received_date = \"2026-04-12\"; observe it equals due_date + 2 days.\n3) Compute boundary check: due_date + 2 days = \"2026-04-12\" and payment_received_date is equal (not greater); observe equality.\n4) Retrieve the statement via GET /v2/accounts/{account_id}/statements/{statement_id}; observe HTTP 200.\n5) Extract \"due_date\" from response and verify it equals \"2026-04-10\"; observe match.\n6) Extract \"late_fee\" from response; observe returned value.\n7) Verify late_fee is not charged: assert late_fee equals 0.00 or is absent/null according to system’s statement representation in the test environment; observe it is not $35.00.\n8) Ensure that the response does not show late_fee as 35.00; observe negative assertion passes.\n9) Save response payload as evidence of boundary behavior; observe artifacts stored.", + "expectedResult": "1) Statement retrieval returns HTTP 200.\n2) When payment_received_date equals due_date + 2 days, late_fee is not applied (specifically, it must not be $35.00).\n3) Boundary condition is handled using strict '>' comparison as specified.", + "apiEndpoint": "https://api.aegiscard.com/v2/accounts/{account_id}/statements/{statement_id}", + "httpMethod": "GET", + "expectedHttpStatus": 200, + "boundaryValues": "payment_received_date = due_date + 2 days (exact equality boundary)", + "sourceCitation": { + "location": "Section 7.2 Billing Calculation Rules, page 13", + "excerpt": "payment_received_date > due_date + 2 days" + } + }, + { + "type": "functional", + "title": "Rewards accrual: Travel MCC earns floor(amount × 3) points", + "description": "Verifies that rewards points for Travel MCC transactions are calculated using the specified multiplier and floor() rounding to prevent over-crediting points. Automation: HIGH — API-driven with deterministic calculation assertions.", + "testId": "TC-REW-01", + "testDescription": "As a cardholder, when I make a Travel-category purchase, the statement’s rewards_earned must reflect points computed as floor(transaction_amount × 3) for that transaction, ensuring the platform applies the Travel multiplier exactly as defined.", + "prerequisites": "1) Test user is authenticated and has a valid Bearer access token.\n2) A test account_id exists and is owned by the authenticated user.\n3) A statement_id for the account exists for a billing cycle that includes the test transaction(s).\n4) At least one posted transaction in the billing cycle is categorized as Travel MCC (per product MCC mapping) with transaction_amount = 123.45 (CAD).\n5) Test harness can retrieve the statement via GET /v2/accounts/{account_id}/statements/{statement_id} in JSON format.", + "stepsToPerform": "1) Send GET https://api.aegiscard.com/v2/accounts/{account_id}/statements/{statement_id}?format=JSON with Authorization: Bearer ; observe HTTP 200.\n2) In the response body, locate rewards_earned; observe it is present.\n3) From test data setup (posted transaction list for the statement period), identify the Travel MCC transaction with transaction_amount = 123.45; observe the amount value used for calculation.\n4) Compute expected travel points = floor(123.45 × 3) = floor(370.35) = 370; record expected value.\n5) If the cycle contains only this single rewards-eligible transaction, compare response rewards_earned to 370; observe equality.\n6) If the cycle contains additional transactions, compute their expected points per REQ-012 (Travel vs All other) using floor() and sum; observe the computed sum.\n7) Compare the computed total expected points to response rewards_earned; observe equality.\n8) Re-run step 1 immediately to ensure consistent calculation (no non-deterministic rounding drift); observe rewards_earned unchanged.\n9) Capture request/response artifacts (request ID, response JSON) for audit of calculation evidence; observe stored artifacts are accessible to the test run.", + "expectedResult": "1) Statement retrieval returns HTTP 200 and includes rewards_earned.\n2) For the Travel MCC transaction amount 123.45, points contribution equals floor(123.45 × 3) = 370 (no fractional or rounded-up points).\n3) Statement rewards_earned equals the sum of per-transaction points computed using the Travel rule where applicable and floor() rounding.", + "sourceCitation": { + "location": "Section 7.2 Billing Calculation Rules, page 13", + "excerpt": "Travel MCC: points += floor(amount × 3)" + } + }, + { + "type": "boundary", + "title": "Rewards rounding always floor(): verify no round/ceil applied", + "description": "Validates that rewards calculations always use floor() and never round or ceil(), preventing inflation of points at fractional boundaries. Automation: HIGH — deterministic math checks via statement API.", + "testId": "TC-REW-02", + "testDescription": "As a cardholder, when my purchase amount produces fractional points, the platform must always drop the fractional portion (floor) rather than rounding to nearest or up, so that points are not overstated.", + "prerequisites": "1) Test user is authenticated and has a valid Bearer access token.\n2) A test account_id exists and is owned by the authenticated user.\n3) A statement_id exists for a billing cycle that includes exactly two posted transactions created for this test:\n a) One Travel MCC transaction with transaction_amount = 0.34 (CAD).\n b) One non-Travel transaction with transaction_amount = 1.99 (CAD).\n4) The billing cycle statement for statement_id is generated and retrievable in JSON.\n5) Test harness can compute expected points independently.", + "stepsToPerform": "1) Send GET https://api.aegiscard.com/v2/accounts/{account_id}/statements/{statement_id}?format=JSON with Authorization: Bearer ; observe HTTP 200.\n2) In the statement JSON, locate rewards_earned; observe it is present and numeric.\n3) For the Travel MCC transaction amount 0.34, compute points_travel_expected = floor(0.34 × 3) = floor(1.02) = 1; record expected.\n4) For the non-Travel transaction amount 1.99, compute points_other_expected = floor(1.99 × 1) = floor(1.99) = 1; record expected.\n5) Compute total_expected_points = 1 + 1 = 2; record expected.\n6) Compare statement rewards_earned to total_expected_points; observe equality.\n7) Explicitly assert that rewards_earned is NOT 3 (which would indicate rounding/ceiling of 1.02→2 or 1.99→2 in any combination); observe it is not 3.\n8) Repeat GET in step 1 and confirm the same rewards_earned value is returned; observe consistency.\n9) Store evidence (calculation worksheet + response payload) with the test run ID; observe artifacts persisted for review.", + "expectedResult": "1) Statement API returns HTTP 200 with rewards_earned present.\n2) rewards_earned equals 2 (floor-based), matching floor(0.34×3)=1 and floor(1.99×1)=1.\n3) rewards_earned does not reflect any round/ceil behavior (e.g., not 3).", + "sourceCitation": { + "location": "Section 7.2/REQ-013, page 14", + "excerpt": "Always floor() — never round or ceil()" + } + }, + { + "type": "functional", + "title": "Statement accuracy: sum(transaction_amount[]) equals total_spend within ±$0.01", + "description": "Ensures the statement’s total_spend matches the sum of line-item transaction_amount values within the defined tolerance, preventing reconciliation and customer dispute issues. Automation: HIGH — API computation and tolerance assertion.", + "testId": "TC-STMTACC-01", + "testDescription": "As a cardholder, my statement’s total_spend must reconcile to the sum of the transactions displayed for that statement period within ±$0.01, so totals are accurate despite minor representation/rounding artifacts.", + "prerequisites": "1) Test user is authenticated and has a valid Bearer access token.\n2) A test account_id exists and is owned by the authenticated user.\n3) A statement_id exists for a billing cycle where the statement JSON exposes total_spend.\n4) The transactions that belong to the statement period are available to the test harness (either via statement content or via GET /v2/accounts/{account_id}/transactions filtered to the period).\n5) The prepared dataset sums to total_spend with a difference of exactly $0.01 or less (e.g., three transactions: 10.00, 20.00, 30.00 => sum = 60.00; expected total_spend = 60.00).", + "stepsToPerform": "1) Send GET https://api.aegiscard.com/v2/accounts/{account_id}/statements/{statement_id}?format=JSON with Authorization: Bearer ; observe HTTP 200.\n2) In the response, read total_spend; observe it is present and numeric.\n3) Retrieve the list of transactions for the statement period using GET https://api.aegiscard.com/v2/accounts/{account_id}/transactions?from_date=&to_date= (or use the statement’s transaction list if included); observe HTTP 200 and transactions[] present.\n4) Extract each transaction_amount from transactions[] that falls within the statement period; observe amounts are Decimal(10,2) formatted.\n5) Compute sum_transaction_amounts = Σ(transaction_amount); record computed sum.\n6) Compute delta = |sum_transaction_amounts − total_spend|; record delta.\n7) Assert delta ≤ 0.01; observe pass.\n8) Persist calculation evidence (transaction list used, computed sum, delta, statement payload) with the test run; observe artifacts retrievable.\n9) Re-run statement GET (step 1) to confirm total_spend is stable during the test; observe unchanged total_spend for the same statement_id.", + "expectedResult": "1) Statement retrieval returns HTTP 200 and includes total_spend.\n2) The absolute difference |sum(transaction_amount[]) − total_spend| is ≤ $0.01.\n3) Evidence artifacts show the exact transaction_amount inputs and computed delta meeting the tolerance.", + "sourceCitation": { + "location": "Section 7.2/REQ-015, page 14", + "excerpt": "sum(transaction_amount[]) == total_spend within tolerance ±$0.01" + } + }, + { + "type": "boundary", + "title": "Statement accuracy boundary: totals differ by $0.02 should violate ±$0.01 tolerance", + "description": "Validates that the platform’s statement accuracy tolerance is enforced strictly and that a $0.02 discrepancy is detected as outside the allowed ±$0.01 window. Automation: MEDIUM — requires controlled data or a test double to create the mismatch.", + "testId": "TC-STMTACC-02", + "testDescription": "As a risk/control check, when the sum of transaction_amount values differs from total_spend by $0.02, the system must not be considered compliant with the stated tolerance rule, ensuring reconciliation issues are detectable.", + "prerequisites": "1) Test user is authenticated and has a valid Bearer access token.\n2) A test account_id exists and is owned by the authenticated user.\n3) A statement_id exists where the statement totals can be validated.\n4) A controlled test dataset exists (via seeded DB fixture or billing computation test mode) such that sum(transaction_amount[]) and total_spend differ by exactly $0.02 (e.g., sum = 100.00, total_spend = 100.02).\n5) Test harness can compute delta and record a test failure when delta > 0.01.", + "stepsToPerform": "1) Send GET https://api.aegiscard.com/v2/accounts/{account_id}/statements/{statement_id}?format=JSON with Authorization: Bearer ; observe HTTP 200.\n2) Read total_spend from the statement JSON; observe the value (e.g., 100.02).\n3) Retrieve statement-period transactions via GET https://api.aegiscard.com/v2/accounts/{account_id}/transactions?from_date=&to_date=; observe HTTP 200 and transactions[] present.\n4) Extract transaction_amount from all statement-period transactions; observe extracted list matches the seeded/controlled dataset.\n5) Compute sum_transaction_amounts; observe it equals the controlled sum (e.g., 100.00).\n6) Compute delta = |sum_transaction_amounts − total_spend|; observe delta = 0.02.\n7) Assert delta ≤ 0.01; observe this assertion FAILS (test should mark non-compliance).\n8) Log the discrepancy details (statement_id, computed sum, total_spend, delta) to the test report; observe the report captures exact numeric evidence.\n9) Confirm the failure condition is strictly due to tolerance (delta > 0.01) and not due to missing transactions in the dataset; observe transaction count matches expected fixture.", + "expectedResult": "1) The computed delta equals $0.02, exceeding the allowed tolerance.\n2) The test verdict is FAIL because |sum(transaction_amount[]) − total_spend| > $0.01.\n3) Test evidence includes the statement total_spend and the exact transaction_amount list used to compute the $0.02 discrepancy.", + "sourceCitation": { + "location": "Section 7.2/REQ-015, page 14", + "excerpt": "within tolerance ±$0.01" + } + }, + { + "type": "functional", + "title": "Make immediate payment (scheduled_date omitted) returns payment_id and new_balance_estimate", + "description": "Verifies immediate payment behavior when scheduled_date is omitted and ensures the response includes payment_id and new_balance_estimate for customer confirmation and reconciliation. Automation: HIGH — pure API assertions; includes abuse-path check for missing CSRF on state-changing call.", + "testId": "TC-PAY-01", + "testDescription": "As a cardholder, when I submit a payment without specifying scheduled_date, the system should treat it as immediate and return identifiers and balance estimate so I can confirm the payment was accepted.", + "prerequisites": "1) Test user is authenticated and has a valid Bearer access token.\n2) A valid CSRF token value is available for the session to send in X-CSRF-Token (state-changing endpoint requirement).\n3) account_id exists and is owned by the authenticated user.\n4) bank_account_id is pre-linked and active for the user (from /v2/bank-accounts).\n5) Current total_balance is known and payment_amount chosen does not exceed max = total_balance and is ≥ $1.00.", + "stepsToPerform": "1) (Abuse-path check) Send POST https://api.aegiscard.com/v2/accounts/{account_id}/payments WITHOUT X-CSRF-Token, body with payment_amount=25.00, payment_type=CUSTOM, bank_account_id=; observe request is rejected due to missing CSRF (record response for security evidence).\n2) Send POST https://api.aegiscard.com/v2/accounts/{account_id}/payments WITH headers Authorization: Bearer and X-CSRF-Token: ; omit scheduled_date in JSON body; observe HTTP 200.\n3) In the HTTP 200 response, verify payment_id is present and non-empty; observe field exists.\n4) Verify new_balance_estimate is present and numeric; observe field exists.\n5) Verify scheduled_date field is present but null/omitted per implementation response contract; observe no future date was scheduled.\n6) Immediately re-GET account summary (GET /v2/accounts/{account_id}/summary) to capture current_balance for comparison context; observe HTTP 200 and current_balance present.\n7) Record the payment_id and response payload to the test report; observe artifacts saved.\n8) Repeat the payment POST with the same inputs but a different payment_amount (e.g., 26.00) to ensure response still includes required fields; observe HTTP 200 and required keys present.\n9) Ensure no raw bank details are echoed back beyond identifiers (only payment_id, scheduled_date, new_balance_estimate per response body keys); observe response does not include bank routing/account numbers.", + "expectedResult": "1) When scheduled_date is omitted, the payment request is accepted (HTTP 200) and the response includes payment_id and new_balance_estimate.\n2) The response includes scheduled_date only as part of the response contract but does not represent a future scheduled date when omitted.\n3) Abuse-path evidence: the state-changing endpoint rejects a request missing X-CSRF-Token (captured as a security control check).", + "sourceCitation": { + "location": "Section 7.3 Make a Payment, page 14", + "excerpt": "scheduled_date optional (future date) or immediate if omitted" + } + }, + { + "type": "functional", + "title": "Make scheduled payment with future scheduled_date returns scheduled_date in response", + "description": "Confirms that providing a future ISO 8601 scheduled_date creates a scheduled payment and that the API echoes scheduled_date back in the response for user confirmation. Automation: HIGH — API-driven; includes CSRF header usage for state-changing call.", + "testId": "TC-PAY-02", + "testDescription": "As a cardholder, when I schedule a payment for a future date, the system must accept it and return the scheduled_date in the response so I can verify the payment timing.", + "prerequisites": "1) Test user is authenticated and has a valid Bearer access token.\n2) Valid CSRF token is available to send in X-CSRF-Token.\n3) account_id exists and is owned by the authenticated user.\n4) bank_account_id is pre-linked and active.\n5) Choose a future scheduled_date in ISO 8601 date format (e.g., 2026-05-15) and payment_amount=50.00 (≥1.00 and ≤ total_balance).", + "stepsToPerform": "1) Send POST https://api.aegiscard.com/v2/accounts/{account_id}/payments with Authorization: Bearer and X-CSRF-Token: , body: {payment_amount:50.00, payment_type:\"CUSTOM\", bank_account_id:\"\", scheduled_date:\"2026-05-15\"}; observe HTTP 200.\n2) Verify payment_id is present in the response; observe non-empty value.\n3) Verify scheduled_date is present in the response; observe value equals \"2026-05-15\".\n4) Verify new_balance_estimate is present and numeric; observe field exists.\n5) Re-submit the same request but with a different future date (e.g., 2026-05-16) to validate acceptance for another future date; observe HTTP 200.\n6) Verify the second response scheduled_date equals \"2026-05-16\"; observe exact match.\n7) Record both payment_id values and response payloads as evidence; observe artifacts saved.\n8) (Abuse-path check) Attempt the scheduled payment request without Authorization header; observe request is rejected (record response).\n9) Ensure response does not include bank account sensitive details (only the response body keys); observe no sensitive bank fields are returned.", + "expectedResult": "1) Scheduled payment request with a future scheduled_date returns HTTP 200.\n2) Response includes payment_id and echoes the same scheduled_date value provided in the request.\n3) Response includes new_balance_estimate as a numeric value.", + "sourceCitation": { + "location": "Section 7.3 Make a Payment field table, page 14", + "excerpt": "scheduled_date Date Optional ISO 8601; future date; omit for immediate" + } + }, + { + "type": "boundary", + "title": "Payment amount boundary: min $1.00 enforced for Decimal(10,2)", + "description": "Validates payment_amount minimum boundary at $1.00 and just below it, ensuring enforcement of Decimal(10,2) minimum to prevent zero/low-value payment abuse and downstream processing errors. Automation: HIGH — API boundary assertions.", + "testId": "TC-PAY-03", + "testDescription": "As the platform, I must accept payment_amount at the minimum ($1.00) and reject values below the minimum, to enforce payment constraints consistently.", + "prerequisites": "1) Test user is authenticated and has a valid Bearer access token.\n2) Valid CSRF token is available for X-CSRF-Token.\n3) account_id exists and is owned by the authenticated user.\n4) bank_account_id is pre-linked and active.\n5) total_balance is at least $10.00 so that both $1.00 and $0.99 are ≤ total_balance.", + "stepsToPerform": "1) Send POST https://api.aegiscard.com/v2/accounts/{account_id}/payments with headers Authorization: Bearer , X-CSRF-Token: ; body: {payment_amount:1.00, payment_type:\"CUSTOM\", bank_account_id:\"\"}; observe HTTP 200.\n2) Verify response contains payment_id; observe non-empty.\n3) Verify response contains new_balance_estimate; observe numeric.\n4) Send POST /payments with payment_amount:0.99 (just below minimum), same payment_type and bank_account_id, with Authorization and X-CSRF-Token; observe request is rejected (non-200).\n5) Capture the error response payload for the 0.99 attempt; observe error indicator is present.\n6) Re-try with payment_amount:1.00 again to confirm acceptance is stable at boundary; observe HTTP 200.\n7) Confirm each accepted response includes response body keys payment_id and new_balance_estimate; observe present.\n8) Confirm the rejected 0.99 attempt does not create a payment_id in the response; observe absent.\n9) Store all three request/response pairs as boundary evidence; observe artifacts retrievable.", + "expectedResult": "1) payment_amount = $1.00 is accepted (HTTP 200) and returns payment_id and new_balance_estimate.\n2) payment_amount = $0.99 is rejected (non-200) demonstrating enforcement of min $1.00.\n3) The rejected response does not include a successful payment_id issuance.", + "sourceCitation": { + "location": "Section 7.3 Make a Payment field table, page 14", + "excerpt": "payment_amount Decimal ✓ Required Decimal(10,2); min $1.00; max = total_balance" + } + }, + { + "type": "negative", + "title": "Payment below minimum_payment_due returns 400 BELOW_MINIMUM with minimum_payment_due", + "description": "Ensures payments below minimum_payment_due are rejected with the exact error code and include minimum_payment_due in the response, preventing underpayment acceptance and supporting clear customer remediation. Automation: HIGH — API negative assertion with exact error contract; includes money-transfer abuse-path coverage.", + "testId": "TC-PAY-04", + "testDescription": "As a cardholder attempting to pay less than my minimum_payment_due, I should receive a clear 400 error with code BELOW_MINIMUM and the minimum_payment_due amount, so I know what to pay.", + "prerequisites": "1) Test user is authenticated and has a valid Bearer access token.\n2) Valid CSRF token is available for X-CSRF-Token.\n3) account_id exists and is owned by the authenticated user.\n4) bank_account_id is pre-linked and active.\n5) The current statement (or account state) has a known minimum_payment_due value available from GET statement (e.g., minimum_payment_due = 25.00) and total_balance ≥ 25.00.", + "stepsToPerform": "1) Send GET https://api.aegiscard.com/v2/accounts/{account_id}/statements/{statement_id}?format=JSON with Authorization: Bearer ; observe HTTP 200.\n2) Read minimum_payment_due from the statement response; observe it is present (e.g., 25.00).\n3) Prepare a payment_amount strictly less than minimum_payment_due (e.g., 24.99); record the chosen amount.\n4) Send POST https://api.aegiscard.com/v2/accounts/{account_id}/payments with Authorization: Bearer , X-CSRF-Token: ; body: {payment_amount:24.99, payment_type:\"CUSTOM\", bank_account_id:\"\"}; observe HTTP 400.\n5) Verify response body includes error: \"BELOW_MINIMUM\"; observe exact match.\n6) Verify response body includes minimum_payment_due and that it equals the value from step 2 (e.g., 25.00); observe exact match.\n7) Confirm response does not include payment_id (no queued payment created); observe absent.\n8) (Abuse-path check) Attempt the same below-minimum payment with a different bank_account_id that is not linked; observe the system does not incorrectly bypass BELOW_MINIMUM validation (record response for investigation if behavior differs).\n9) Persist request/response evidence and the retrieved minimum_payment_due used; observe artifacts saved.", + "expectedResult": "1) Payment attempt where payment_amount < minimum_payment_due returns HTTP 400.\n2) Response body contains error: BELOW_MINIMUM and includes minimum_payment_due.\n3) No payment_id is issued for the rejected request (no queued payment created).", + "sourceCitation": { + "location": "Section 7.3 Make a Payment HTTP codes, page 14", + "excerpt": "payment_amount < minimum_payment_due error: BELOW_MINIMUM, minimum_payment_due" + } + }, + { + "type": "security", + "title": "PAN displayed in browser only as masked format **** **** **** 1234", + "description": "Validates PCI data masking in the web portal UI by ensuring PAN is only rendered as the required masked format, preventing exposure of cardholder data in the DOM. Automation: MEDIUM — requires UI/DOM inspection via browser automation.", + "testId": "TC-PAN-01", + "testDescription": "As a portal user viewing card details, I must only ever see the masked PAN format (last 4 digits) in the browser, ensuring the UI never displays full PAN and remains PCI compliant.", + "prerequisites": "1) Test user can sign in to https://portal.aegiscard.com and has an account with an issued card.\n2) Test card has a known last4 (synthetic) such as 4242; expected mask is \"**** **** **** 4242\".\n3) Browser automation has access to DevTools/DOM inspection or can capture rendered text from the card details component.\n4) Test environment is configured to serve card details to the portal (card summary visible on dashboard).\n5) No browser extensions that alter page content are enabled for the test profile.", + "stepsToPerform": "1) Open https://portal.aegiscard.com and log in as the test user; observe navigation to the dashboard succeeds.\n2) Navigate to the card details area where the card number is displayed (e.g., dashboard card widget); observe a card number is shown.\n3) Capture the visible card number text as rendered; observe it matches the pattern \"**** **** **** 4242\" (spaces/grouping as shown).\n4) Right-click and inspect the element (DOM) containing the card number; observe the DOM text content is masked (no additional digits).\n5) Search the DOM (Ctrl+F in Elements) for any contiguous 12–19 digit sequences matching a PAN-like pattern; observe none found.\n6) Open the page source / React component rendered HTML snapshot; observe no unmasked PAN present.\n7) Refresh the page and repeat the visual capture of the masked card number; observe it remains masked.\n8) Perform a UI action that re-renders the widget (e.g., toggle between accounts/cards if available); observe any displayed PAN remains masked.\n9) Save screenshots and DOM dumps as evidence; observe artifacts are stored with the test run ID.", + "expectedResult": "1) The PAN displayed in the browser is only in masked format exactly like \"**** **** **** 4242\".\n2) The DOM does not contain a full/unmasked PAN value (no PAN-like digit sequence appears in rendered DOM text/HTML snapshot).\n3) Masking persists across refresh and re-render actions (no transient exposure).", + "sourceCitation": { + "location": "Section 8.1 Security & Compliance Requirements, page 15", + "excerpt": "PAN displayed in browser as **** **** **** 1234 only." + } + }, + { + "type": "security", + "title": "Full PAN never transmitted to frontend (verify API/portal payloads exclude raw PAN)", + "description": "Ensures the backend never transmits full PAN to the frontend by verifying API responses and portal network payloads do not include raw PAN fields/values, reducing PCI scope and preventing data leakage. Automation: HIGH — network capture assertions (API + browser DevTools export).", + "testId": "TC-PAN-02", + "testDescription": "As a security control, the platform must not transmit full PAN values to the browser; only masked values may be provided. This test verifies that API responses consumed by the portal exclude raw PAN and that network payloads do not contain unmasked PAN data.", + "prerequisites": "1) Test user can authenticate to the portal and has an issued card with known last4 = 4242 (synthetic).\n2) Ability to capture browser network traffic (HAR export) for portal session.\n3) Ability to call relevant API endpoints directly with Bearer token (e.g., account summary and statement retrieval) and inspect raw JSON.\n4) Test environment has deterministic masked card number representation available somewhere (e.g., card_number_masked in decision response or UI widget).\n5) A regex/pattern check is available in the test harness to detect PAN-like sequences (12–19 digits) in payloads.", + "stepsToPerform": "1) Log in to https://portal.aegiscard.com as the test user; observe dashboard loads.\n2) Start recording Network traffic in browser DevTools; observe recording enabled.\n3) Navigate to card/account areas that trigger API calls returning card/account data (e.g., account summary view); observe network requests are captured.\n4) Export the session’s network log as HAR; observe HAR file is generated.\n5) Scan HAR response bodies for PAN-like numeric sequences (12–19 digits) and for common raw PAN keys (e.g., \"pan\", \"card_number\"); observe none present.\n6) Separately call GET https://api.aegiscard.com/v2/accounts/{account_id}/summary with Authorization: Bearer ; observe HTTP 200 and parse JSON.\n7) Assert the summary JSON does not include any raw PAN field/value (no \"pan\" or unmasked card number); observe absent.\n8) Call GET https://api.aegiscard.com/v2/accounts/{account_id}/statements/{statement_id}?format=JSON; observe HTTP 200 and parse JSON.\n9) Assert statement JSON does not include raw PAN anywhere (including nested fields if any); observe absent and store evidence.", + "expectedResult": "1) HAR/network payloads from the portal session contain no full PAN values and no PAN-like digit sequences.\n2) API responses inspected (account summary, statement retrieval) do not include raw PAN fields/values.\n3) Only masked representations (e.g., last 4) are visible to the frontend; raw PAN is not transmitted.", + "sourceCitation": { + "location": "Section 8.1 Security & Compliance Requirements, page 15", + "excerpt": "Full PAN never transmitted to frontend." + } + }, + { + "type": "security", + "title": "All web forms transmit over TLS 1.3 (PCI-DSS L1 requirement)", + "description": "Verifies that form submissions from the portal are transported using TLS 1.3 to meet the PCI-DSS L1 requirement and reduce risk of interception/downgrade. Automation: MEDIUM — requires TLS handshake inspection plus functional form submission.", + "testId": "TC-PCI-01", + "testDescription": "As a portal user submitting any web form, the browser must negotiate TLS 1.3 and transmit the form request over HTTPS using TLS 1.3 (minimum).", + "prerequisites": "1) Test environment exposes https://portal.aegiscard.com and its backing API.\n2) Tester has a valid portal test user (e.g., test+tls13@example.com).\n3) A network inspection tool is available (e.g., Chrome DevTools Security panel and/or openssl s_client, and a proxy that can record TLS version without breaking TLS).\n4) Ability to submit at least one portal form that triggers an HTTPS request (e.g., login form calling POST /v2/auth/login).", + "stepsToPerform": "1) Open a clean browser profile (no extensions) and navigate to https://portal.aegiscard.com; observe the connection is HTTPS and loads successfully.\n2) In browser DevTools, open the Security tab for the portal page; observe the negotiated protocol/cipher details are shown.\n3) Confirm the negotiated TLS version is TLS 1.3; record evidence (screenshot/export).\n4) Open DevTools Network tab and prepare to capture requests; observe no mixed-content HTTP resources are loaded for the page.\n5) Use the portal login form and submit synthetic credentials (e.g., email: test+tls13@example.com, password: ************); observe a network request is made to the API.\n6) Click the login request and verify the Request URL uses https://api.aegiscard.com (HTTPS); observe it is not http://.\n7) Using a TLS-capable client (e.g., openssl s_client -connect api.aegiscard.com:443 -tls1_2), attempt to force TLS 1.2 handshake; observe handshake is rejected or cannot be negotiated (evidence captured).\n8) Using a TLS-capable client (e.g., openssl s_client -connect api.aegiscard.com:443 -tls1_3), connect using TLS 1.3; observe the handshake succeeds and reports TLSv1.3.\n9) Repeat Step 8 for portal.aegiscard.com:443; observe the portal also negotiates TLSv1.3.\n10) Save artifacts (screenshots/command output) demonstrating portal and API form-related traffic negotiated TLS 1.3.", + "expectedResult": "1) Portal and API endpoints used by web forms negotiate TLSv1.3 and do not downgrade below TLS 1.3.\n2) Form submission requests are sent only to https:// URLs (no http://), with no mixed content.\n3) A forced TLS 1.2 handshake attempt fails while a TLS 1.3 handshake succeeds (downgrade/legacy-protocol abuse path is prevented).", + "sourceCitation": { + "location": "Section 8.1 Security & Compliance Requirements (NFR-01 PCI-DSS L1), page 15", + "excerpt": "All web forms transmit over TLS 1.3." + } + }, + { + "type": "security", + "title": "Card fields use iframe tokenisation and no raw PAN in DOM", + "description": "Verifies that any card entry UI embeds card fields via iframe tokenisation and prevents raw PAN exposure in the DOM, reducing PCI scope and client-side data leakage. Automation: MEDIUM — DOM and network inspection required.", + "testId": "TC-PCI-02", + "testDescription": "As a portal user entering card data in any payment method/card entry form, the UI must use an iframe-based tokenisation component and must not place raw PAN anywhere in the DOM.", + "prerequisites": "1) A portal page exists in the environment where card details could be entered (e.g., add payment card / card verification flow using Stripe Elements or equivalent).\n2) Tester has a valid portal test user session.\n3) Test card PAN is synthetic (e.g., \"**** **** **** 4242\" for display expectations; do not use real PAN).\n4) Browser DevTools access to Elements/Network panels (and optional DOM search).", + "stepsToPerform": "1) Log in to https://portal.aegiscard.com with the test user; observe the portal loads successfully.\n2) Navigate to the feature/page that collects card details (card number, expiry, CVC) if present; observe the form renders card input controls.\n3) In DevTools Elements panel, inspect the card input area; observe that card entry fields are rendered inside one or more