From 82b8bf99f9b5a595283b571d8b0b33f0e4b72292 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=BDubom=C3=ADr=20Samotn=C3=BD?= Date: Thu, 6 Aug 2026 11:05:38 +0200 Subject: [PATCH 1/3] feat: add merchant api mode as default --- .github/workflows/ci.yml | 5 +- CHANGELOG.md | 34 ++++ README.md | 166 ++++++++++--------- TEST_SETUP.md | 128 +++++---------- examples/basic-usage.ts | 9 +- package.json | 6 +- src/RaiAcceptService.ts | 15 +- src/api/RaiAcceptAPIApi.ts | 298 ++++++++++----------------------- src/index.ts | 8 + src/models/ErrorResponse.ts | 14 +- src/types/IntegrationMode.ts | 60 +++++++ tests/README.md | 154 ++++-------------- tests/integration.test.js | 308 +++++++++++++++++------------------ tests/routing.test.js | 109 +++++++++++++ 14 files changed, 629 insertions(+), 685 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/types/IntegrationMode.ts create mode 100644 tests/routing.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ac0b5d..41303f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,10 +56,9 @@ jobs: - name: Check if credentials are available id: check-creds run: | - if [ -z "${{ secrets.RAIACCEPT_TEST_USERNAME }}" ] || [ -z "${{ secrets.RAIACCEPT_TEST_PASSWORD }}" ] || [ -z "${{ secrets.RAIACCEPT_TEST_CERT_BASE64 }}" ] || [ -z "${{ secrets.RAIACCEPT_TEST_KEY_BASE64 }}" ]; then + if [ -z "${{ secrets.RAIACCEPT_TEST_USERNAME }}" ] || [ -z "${{ secrets.RAIACCEPT_TEST_PASSWORD }}" ]; then echo "skip=true" >> $GITHUB_OUTPUT - echo "⚠️ Integration tests skipped: GitHub Secrets not configured" - echo "See .github/SETUP_CI.md for setup instructions" + echo "Integration tests skipped: RAIACCEPT_TEST_USERNAME/RAIACCEPT_TEST_PASSWORD not configured" else echo "skip=false" >> $GITHUB_OUTPUT fi diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cb2ddc7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +## 0.10.0 + +### Added + +- **Merchant integration mode** (default): auth on `https://auth.raiaccept.com`, API on `https://trapi.raiaccept.com`, no mTLS. +- **Partner integration mode**: mTLS cert/key; auth and API on `https://api.raiaccept.com`. Auto-detected when both cert and key are provided; override with `{ authMode: 'partner' }` or `{ authMode: 'merchant' }`. +- Exported `AuthMode`, `RaiAcceptClientConfig`, `DEFAULT_AUTH_MODE`, `RAIACCEPT_URLS`, `resolveAuthMode`, and `assertTlsCredentialsPair`. +- Unit tests for mode-aware URL routing and mTLS behavior. +- Separate merchant and partner integration test suites (shared username/password credentials). + +### Changed + +- **Breaking:** Default auth mode is now `merchant`. Partner mode applies when both cert and key are provided (same as 0.9.x), or via `{ authMode: 'partner' }`. +- Partial TLS config (only cert or only key) throws `InvalidArgumentException` at construction time. +- `RaiAcceptService` and `RaiAcceptAPIApi` constructors accept an optional fourth argument `config?: RaiAcceptClientConfig`. +- `ErrorResponse` model accepts both partner (`message`, `code`, `details`) and trapi (`traceId`, `timestamp`, `status`, `errors`) error shapes. +- API error parsing now includes HTTP 401 and 403 in addition to 400. + +### Migration from 0.9.x (partner integrations) + +```typescript +// 0.9.x — implicit partner via cert + key +const service = new RaiAcceptService(httpClient, cert, key); + +// 0.10.x — same call auto-detects partner; explicit flag optional +const service = new RaiAcceptService(httpClient, cert, key); +// or: new RaiAcceptService(httpClient, cert, key, { authMode: 'partner' }); +``` + +## 0.9.5 + +- Partner-only SDK using `api.raiaccept.com` for auth and API with mTLS. diff --git a/README.md b/README.md index d7af1ef..9dbb843 100644 --- a/README.md +++ b/README.md @@ -13,93 +13,110 @@ TypeScript/JavaScript SDK for RaiAccept payment gateway API. npm install @smartbase-js/raiaccept-api-client ``` -## Usage +## Integration modes + +| | Merchant (default) | Partner | +|--|-------------------|---------| +| Auth | `https://auth.raiaccept.com/auth/api/*` | `https://api.raiaccept.com/auth/api/*` | +| API | `https://trapi.raiaccept.com` | `https://api.raiaccept.com` | +| mTLS | Not used | Required (cert + key) | + +When both `cert` and `key` are passed to the constructor, partner mode is selected automatically. Use `{ authMode: 'merchant' }` to force merchant mode despite cert/key (e.g. testing), or `{ authMode: 'partner' }` for explicit opt-in. Providing only cert or only key throws `InvalidArgumentException` at construction time. + +## Merchant integration (default) + +For direct merchant integrations — no mTLS required. ```typescript -import { RaiAcceptService } from '@smartbase-js/raiaccept-api-client'; +import { RaiAcceptService, HttpClient } from '@smartbase-js/raiaccept-api-client'; -// Create service instance -const service = new RaiAcceptService(); +const httpClient = new HttpClient({ logger: console }); +const service = new RaiAcceptService(httpClient); + +const integrationContext = { + type: 'CODE', + data: { + name: 'YourShop', + version: '1.0', + vendor: 'YourVendor', + }, +}; -// Authenticate with your credentials const authResult = await service.retrieveAccessTokenWithCredentials( - 'your-username', // Replace with your actual username - 'your-password', // Replace with your actual password - cert, // Client certificate for mTLS - key // Client private key for mTLS + 'your-username', + 'your-password', + integrationContext ); const accessToken = authResult?.accessToken; -const response = await service.createOrderEntry(accessToken, orderRequest); +// API calls use trapi.raiaccept.com with Bearer token only +const orderResponse = await service.createOrderEntry(accessToken, orderRequest); ``` -### Create Payment +### Token refresh and logout (merchant) + +```typescript +const refreshed = await service.tokenRefresh(authResult.refreshToken, integrationContext); +await service.tokenLogout(authResult.refreshToken); +``` + +## Partner integration + +For platform/partner integrations (e.g. Shopify apps) — requires mTLS client certificate. ```typescript import { RaiAcceptService, HttpClient } from '@smartbase-js/raiaccept-api-client'; +import { readFileSync } from 'fs'; -// Initialize HTTP client (optional, for logging) -const httpClient = new HttpClient({ - logger: console, // Optional: for debugging -}); +const cert = readFileSync('/path/to/client.crt', 'utf-8'); +const key = readFileSync('/path/to/client.key', 'utf-8'); -// Initialize the unified SDK client -const service = new RaiAcceptService(httpClient); +const httpClient = new HttpClient({ logger: console }); +const service = new RaiAcceptService(httpClient, cert, key); -// Authenticate const authResult = await service.retrieveAccessTokenWithCredentials( - 'your-username', - 'your-password', - cert, // Client certificate for mTLS - key // Client private key for mTLS + 'merchant-username', + 'merchant-password', + integrationContext ); -const accessToken = authResult?.accessToken; +``` -// Create an order and payment session (two-step process) +## Create payment (both modes) + +```typescript const orderRequest = { invoice: { amount: 100.00, currency: 'EUR', description: 'Test payment', merchantOrderReference: 'ORDER-123', - items: [ - { - description: 'Product 1', - numberOfItems: 1, - price: 100.00 - } - ] + items: [{ description: 'Product 1', numberOfItems: 1, price: 100.00 }], }, urls: { successUrl: 'https://example.com/success', failUrl: 'https://example.com/fail', cancelUrl: 'https://example.com/cancel', - notificationUrl: 'https://example.com/webhook' + notificationUrl: 'https://example.com/webhook', }, consumer: { email: 'customer@example.com', firstName: 'John', lastName: 'Doe', - phone: '+1234567890' + phone: '+1234567890', }, paymentMethodPreference: 'CARD', - linkId: 'unique-link-id' + linkId: 'unique-link-id', }; -// Step 1: Create order entry const orderResponse = await service.createOrderEntry(accessToken, orderRequest); -const orderIdentification = orderResponse.object.getOrderIdentification(); -console.log('Order created:', orderIdentification); +const orderId = orderResponse.object.getOrderIdentification(); -// Step 2: Create payment session for the order const paymentSessionResponse = await service.createPaymentSession( accessToken, orderRequest, - orderIdentification + orderId ); - -const paymentRedirectURL = paymentSessionResponse.object?.paymentRedirectURL; -console.log('Payment session created. Redirect customer to:', paymentRedirectURL); +console.log('Redirect to:', paymentSessionResponse.object?.paymentRedirectURL); ``` ## API Reference @@ -107,67 +124,56 @@ console.log('Payment session created. Redirect customer to:', paymentRedirectURL ### Initialization ```typescript -import { RaiAcceptService, HttpClient } from '@smartbase-js/raiaccept-api-client'; +// Merchant (default) +new RaiAcceptService(httpClient); -// With HTTP client (recommended for logging) -const httpClient = new HttpClient({ logger: console }); -const client = new RaiAcceptService(httpClient); +// Partner (auto-detected when cert + key provided) +new RaiAcceptService(httpClient, cert, key); -// Without HTTP client (uses default) -const client = new RaiAcceptService(); +// Explicit override +new RaiAcceptService(httpClient, cert, key, { authMode: 'partner' }); +new RaiAcceptService(httpClient, cert, key, { authMode: 'merchant' }); ``` ### Authentication -```typescript -const authResult = await client.retrieveAccessTokenWithCredentials( - username, - password, - cert, // Client certificate for mTLS - key // Client private key for mTLS -); -const accessToken = authResult?.accessToken; -// Also available: authResult.refreshToken, authResult.accessTokenExpiresIn, authResult.refreshTokenExpiresIn -``` +- `retrieveAccessTokenWithCredentials(username, password, integrationContext)` +- `tokenRefresh(refreshToken, integrationContext)` +- `tokenLogout(refreshToken)` -### Order Operations +### Order operations -- `client.createOrderEntry(accessToken, orderRequest)` - Create a new order -- `client.createPaymentSession(accessToken, sessionRequest, externalOrderId)` - Create payment session -- `client.getOrderDetails(accessToken, orderId)` - Get order details -- `client.getOrderTransactions(accessToken, orderId)` - Get order transactions +- `createOrderEntry(accessToken, orderRequest)` +- `createPaymentSession(accessToken, sessionRequest, externalOrderId)` +- `getOrderDetails(accessToken, orderId)` +- `getOrderTransactions(accessToken, orderId)` -### Transaction Operations +### Transaction operations -- `client.getTransactionDetails(accessToken, orderId, transactionId)` - Get transaction details -- `client.refund(accessToken, orderId, transactionId, refundRequest)` - Process a refund +- `getTransactionDetails(accessToken, orderId, transactionId)` +- `refund(accessToken, orderId, transactionId, refundRequest)` -### Utility Functions +## Migration from 0.9.x -- `RaiAcceptService.transliterate(string)` - Transliterate non-Latin characters -- `RaiAcceptService.transliterateAndLimitLength(string, limit)` - Transliterate and limit length -- `RaiAcceptService.cleanPhoneNumber(phoneNumber)` - Clean phone number format -- `RaiAcceptService.getCountryIso3(countryCode)` - Convert 2-letter to 3-letter country code +Version 0.10.0 defaults to **merchant mode**. Partner integrations with cert + key work as in 0.9.x — mode is auto-detected. You may still pass `{ authMode: 'partner' }` explicitly. -## TypeScript Support +```typescript +new RaiAcceptService(httpClient, cert, key); +``` -This SDK is written in TypeScript and includes full type definitions. All types are exported and available for use in your TypeScript projects. +See [CHANGELOG.md](./CHANGELOG.md) for details. ## Testing -Run the test suite: - ```bash -# Run unit tests (mocked) npm run unit-tests - -# Run integration tests (real API calls!) npm run integration-tests +npm run integration-tests:merchant +npm run integration-tests:partner ``` -For more details, see [TEST_SETUP.md](./TEST_SETUP.md) and [tests/README.md](./tests/README.md). +See [TEST_SETUP.md](./TEST_SETUP.md) and [tests/README.md](./tests/README.md). ## License OSL-3.0 - diff --git a/TEST_SETUP.md b/TEST_SETUP.md index f4dd639..2011e12 100644 --- a/TEST_SETUP.md +++ b/TEST_SETUP.md @@ -1,108 +1,62 @@ # RaiAccept API Client - Test Setup -This document describes the self-contained test structure for the RaiAccept JavaScript SDK. +This document describes the test structure for the RaiAccept JavaScript SDK. -## Overview +## Quick Start + +```bash +npm install +npm run unit-tests +npm run integration-tests +``` -The test suite uses **Vitest** as the testing framework, providing fast and reliable testing for this ES module-based SDK. +## Environment variables -## File Structure +### Shared (merchant + partner integration tests) -``` -raiaccept_api_client/ -├── vitest.config.js # Vitest configuration for unit tests -├── vitest.integration.config.js # Vitest configuration for integration tests -├── package.json # Test scripts and dependencies -├── tests/ -│ ├── setup.js # Global test setup and mocks (unit tests only) -│ ├── unit.test.js # Unit tests with mocked dependencies -│ ├── integration.test.js # Integration tests with real API calls -│ └── README.md # Test documentation -└── TEST_SETUP.md # This file +```bash +RAIACCEPT_TEST_USERNAME=your_username +RAIACCEPT_TEST_PASSWORD=your_password ``` -## Quick Start +The same merchant credentials work for both integration modes. -1. **Install dependencies:** - ```bash - npm install - ``` - -2. **Run tests:** - ```bash - # Run unit tests (mocked) - npm run unit-tests - - # Run integration tests (real API calls) - npm run integration-tests - ``` - -## Test Configuration - -### Vitest Config (`vitest.config.js`) -- Uses Node.js environment for server-side testing -- Enables global test functions (`describe`, `it`, `expect`) -- Configures coverage with 80% thresholds -- Includes global setup and mock clearing - -### Global Setup (`tests/setup.js`) -- Mocks axios globally to prevent real HTTP calls -- Clears all mocks before each test - -### Package.json Scripts -```json -{ - "scripts": { - "unit-tests": "vitest run tests/unit.test.js", - "integration-tests": "vitest run --config vitest.integration.config.js" - } -} -``` +### Partner mode only (mTLS) -**Most Important Commands:** -- `npm run unit-tests` - Run unit tests with mocked dependencies -- `npm run integration-tests` - Run integration tests with real API calls +Required for `partner mode integration` tests; not needed for merchant mode. -## Current Test Coverage +```bash +RAIACCEPT_CERT_PATH=/path/to/client.crt +RAIACCEPT_KEY_PATH=/path/to/client.key +# or +RAIACCEPT_CERT_BASE64= +RAIACCEPT_KEY_BASE64= +``` -### Unit Tests (`unit.test.js`) -- ✅ `RaiAcceptService.transliterate()` - Comprehensive character transliteration tests - - Greek, Cyrillic, Arabic, Hebrew characters - - Mixed scripts and edge cases - - Null/undefined handling - - Whitespace normalization - - Special character removal +## Test scripts -### Integration Tests (`integration.test.js`) -- ✅ Complete payment flow with real API calls - - Authentication - - Order creation - - Payment session creation - - Order details retrieval - - Transaction validation +| Command | Description | +|---------|-------------| +| `npm run unit-tests` | Mocked unit + routing tests | +| `npm run integration-tests` | Live API: merchant + partner flows | +| `npm run integration-tests:merchant` | Live API: merchant mode only (no mTLS) | +| `npm run integration-tests:partner` | Live API: partner mode only (mTLS) | -## Future Extensions +## Test coverage -This foundation can be extended with: +### Unit tests -1. **HTTP Client Tests** - Mock axios responses for API testing -2. **Integration Tests** - Real API calls with credentials -3. **Model Tests** - Serialization/deserialization testing -4. **Error Handling Tests** - Exception and edge case testing -5. **Performance Tests** - Benchmarking critical paths +- `tests/unit.test.js` — transliteration utilities +- `tests/routing.test.js` — auth mode URL routing and mTLS attachment (mocked) -## Running Tests in CI/CD +### Integration tests -For automated testing in CI/CD pipelines: +- `tests/integration.test.js` — two suites: + - **merchant mode**: auth on `auth.raiaccept.com`, API on `trapi.raiaccept.com`, no mTLS + - **partner mode**: auth + API on `api.raiaccept.com` with mTLS -```yaml -# GitHub Actions example -- name: Install dependencies - run: npm ci +Both run: auth → create order → payment session → order details → transactions → refresh → logout. -- name: Run unit tests - run: npm run unit-tests +## CI -- name: Run integration tests - run: npm run integration-tests -``` +CI is configured in [`.github/workflows/ci.yml`](.github/workflows/ci.yml). diff --git a/examples/basic-usage.ts b/examples/basic-usage.ts index 5ff703a..b8a4d3f 100644 --- a/examples/basic-usage.ts +++ b/examples/basic-usage.ts @@ -10,12 +10,11 @@ async function main(): Promise { try { console.log('Creating service and authenticating...'); - // Load mTLS cert and key (from env, files, or secure storage) - const cert = process.env.RAIACCEPT_CERT || ''; // Replace with your cert - const key = process.env.RAIACCEPT_KEY || ''; // Replace with your key + // Create service instance (merchant mode — default, no mTLS) + const client = new RaiAcceptService(new HttpClient()); - // Create service instance with cert and key - const client = new RaiAcceptService(null, cert, key); + // For partner mode with mTLS, use: + // const client = new RaiAcceptService(new HttpClient(), cert, key, { authMode: 'partner' }); // Authenticate with your credentials const integrationContext = { diff --git a/package.json b/package.json index 90b8e30..e41a05e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@smartbase-js/raiaccept-api-client", - "version": "0.9.5", + "version": "0.10.0", "description": "TypeScript SDK for RaiAccept payment gateway API", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -17,8 +17,10 @@ "scripts": { "build": "tsc", "build:watch": "tsc --watch", - "unit-tests": "vitest run tests/unit.test.js", + "unit-tests": "vitest run tests/unit.test.js tests/routing.test.js", "integration-tests": "vitest run --config vitest.integration.config.js", + "integration-tests:merchant": "vitest run --config vitest.integration.config.js -t \"merchant mode integration\"", + "integration-tests:partner": "vitest run --config vitest.integration.config.js -t \"partner mode integration\"", "prepublishOnly": "npm run build", "publish:public": "npm publish --access public" }, diff --git a/src/RaiAcceptService.ts b/src/RaiAcceptService.ts index 1be9d64..9c3cdb0 100644 --- a/src/RaiAcceptService.ts +++ b/src/RaiAcceptService.ts @@ -10,6 +10,7 @@ import { RefundResponse } from './models/RefundResponse.js'; import { AuthApiLoginOutput } from './models/AuthApiLoginOutput.js'; import type { IntegrationContext } from './models/AuthApiLoginInput.js'; import { AuthApiRefreshOutput } from './models/AuthApiRefreshOutput.js'; +import type { RaiAcceptClientConfig } from './types/IntegrationMode.js'; /** * RaiAcceptService @@ -31,11 +32,17 @@ export class RaiAcceptService { /** * Create a new RaiAcceptService instance * @param httpClient - HTTP client instance (optional) - * @param cert - Client certificate for mTLS (optional, required for retrieveAccessTokenWithCredentials) - * @param key - Client private key for mTLS (optional, required for retrieveAccessTokenWithCredentials) + * @param cert - Client certificate for mTLS (required for partner mode) + * @param key - Client private key for mTLS (required for partner mode) + * @param config - Client configuration; authMode defaults to merchant, or partner when cert and key are both provided */ - constructor(httpClient: HttpClient | null = null, cert?: string | Buffer, key?: string | Buffer) { - this.apiClient = new RaiAcceptAPIApi(httpClient, cert, key); + constructor( + httpClient: HttpClient | null = null, + cert?: string | Buffer, + key?: string | Buffer, + config?: RaiAcceptClientConfig + ) { + this.apiClient = new RaiAcceptAPIApi(httpClient, cert, key, config); this.cert = cert; this.key = key; } diff --git a/src/api/RaiAcceptAPIApi.ts b/src/api/RaiAcceptAPIApi.ts index 9f2b64b..89649ce 100644 --- a/src/api/RaiAcceptAPIApi.ts +++ b/src/api/RaiAcceptAPIApi.ts @@ -15,19 +15,29 @@ import { RefundResponse } from '../models/RefundResponse.js'; import { ErrorResponse } from '../models/ErrorResponse.js'; import { HttpClient, HttpRequest, HttpResponse } from '../HttpClient.js'; import { CreateOrderEntryRequest } from '../models/CreateOrderEntryRequest.js'; +import { + type AuthMode, + type RaiAcceptClientConfig, + RAIACCEPT_URLS, + resolveAuthMode, +} from '../types/IntegrationMode.js'; export interface ApiResponse { object: T | null; response: HttpResponse; } +type TlsOptions = Pick; + /** * RaiAcceptAPIApi * Main API client for RaiAccept payment gateway */ export class RaiAcceptAPIApi { - static AUTH_URL = 'https://api.raiaccept.com'; - static API_URL = 'https://api.raiaccept.com'; + /** @deprecated Use mode-specific URLs via authMode config. Partner auth URL. */ + static AUTH_URL = RAIACCEPT_URLS.partner.auth; + /** @deprecated Use mode-specific URLs via authMode config. Partner API URL. */ + static API_URL = RAIACCEPT_URLS.partner.api; static ACCEPTED_LANGUAGES = [ 'en', 'de', 'fr', 'cs', 'sk', 'sr', 'al', 'ro', 'pl', 'hr' @@ -36,17 +46,54 @@ export class RaiAcceptAPIApi { private client: HttpClient; private cert?: string | Buffer; private key?: string | Buffer; + private authMode: AuthMode; /** * Create a new RaiAcceptAPIApi instance * @param client - HTTP client instance (optional) - * @param cert - Client certificate for mTLS - * @param key - Client private key for mTLS + * @param cert - Client certificate for mTLS (required for partner mode) + * @param key - Client private key for mTLS (required for partner mode) + * @param config - Client configuration; authMode defaults to merchant, or partner when cert and key are both provided */ - constructor(client: HttpClient | null = null, cert?: string | Buffer, key?: string | Buffer) { + constructor( + client: HttpClient | null = null, + cert?: string | Buffer, + key?: string | Buffer, + config?: RaiAcceptClientConfig + ) { this.client = client || new HttpClient(); this.cert = cert; this.key = key; + this.authMode = resolveAuthMode(cert, key, config); + } + + getAuthMode(): AuthMode { + return this.authMode; + } + + private get authBaseUrl(): string { + return RAIACCEPT_URLS[this.authMode].auth; + } + + private get apiBaseUrl(): string { + return RAIACCEPT_URLS[this.authMode].api; + } + + private requirePartnerTls(context: string): TlsOptions { + if (this.authMode !== 'partner') { + return {}; + } + if (!this.cert) { + throw new InvalidArgumentException( + `Missing the required parameter $cert when calling ${context} (provide in constructor with authMode: 'partner')` + ); + } + if (!this.key) { + throw new InvalidArgumentException( + `Missing the required parameter $key when calling ${context} (provide in constructor with authMode: 'partner')` + ); + } + return { cert: this.cert, key: this.key }; } getAcceptedLanguages(): string[] { @@ -55,11 +102,6 @@ export class RaiAcceptAPIApi { /** * Process API request - * @param request - Request configuration - * @param targetClass - Target model class for response - * @param errorClass - Error model class - * @param omitLogging - Whether to omit logging - * @returns Response with object and raw response */ async processRequest( request: HttpRequest, @@ -90,44 +132,33 @@ export class RaiAcceptAPIApi { response: response, }; } catch (error) { - if (error instanceof ApiException) { - if (error.getCode() === 400 && errorClass) { - const data = ObjectSerializer.deserialize( - JSON.parse(error.getResponseBody() || '{}'), - errorClass - ); - error.setResponseObject(data); + if (error instanceof ApiException && errorClass) { + const statusCode = error.getCode(); + if (statusCode === 400 || statusCode === 401 || statusCode === 403) { + try { + const data = ObjectSerializer.deserialize( + JSON.parse(error.getResponseBody() || '{}'), + errorClass + ); + error.setResponseObject(data); + } catch { + // Leave responseObject unset if body is not parseable + } } } throw error; } } - - /** - * Authenticate with username and password - * @param username - Username - * @param password - Password - * @param integrationContext - Integration context (type, name, version, vendor) - must be provided by the caller - * @returns Authentication response - */ async token( username: string, password: string, integrationContext: IntegrationContext ): Promise> { const request = this.tokenRequest(username, password, integrationContext); - return this.processRequest(request, AuthApiLoginOutput, ErrorResponse, true); } - /** - * Create token request - * @param username - Username - * @param password - Password - * @param integrationContext - Integration context (type, name, version, vendor) - must be provided by the caller - * @returns Request object - */ tokenRequest( username: string, password: string, @@ -142,12 +173,6 @@ export class RaiAcceptAPIApi { if (!integrationContext) { throw new InvalidArgumentException('Missing the required parameter $integrationContext when calling tokenRequest'); } - if (!this.cert) { - throw new InvalidArgumentException('Missing the required parameter $cert when calling token (provide in constructor)'); - } - if (!this.key) { - throw new InvalidArgumentException('Missing the required parameter $key when calling token (provide in constructor)'); - } const loginInput = new AuthApiLoginInput(); loginInput.username = username; @@ -161,20 +186,13 @@ export class RaiAcceptAPIApi { return { method: 'POST', - url: `${RaiAcceptAPIApi.AUTH_URL}/auth/api/login`, + url: `${this.authBaseUrl}/auth/api/login`, headers: headers, body: httpBody, - cert: this.cert, - key: this.key, + ...this.requirePartnerTls('token'), } as HttpRequest; } - /** - * Refresh access token using refresh token - * @param refreshToken - Refresh token - * @param integrationContext - Integration context (type, name, version, vendor) - must be provided by the caller - * @returns Authentication response with new access token and expiration - */ async tokenRefresh( refreshToken: string, integrationContext: IntegrationContext @@ -183,12 +201,6 @@ export class RaiAcceptAPIApi { return this.processRequest(request, AuthApiRefreshOutput, ErrorResponse, true); } - /** - * Create token refresh request - * @param refreshToken - Refresh token - * @param integrationContext - Integration context (type, name, version, vendor) - must be provided by the caller - * @returns Request object - */ tokenRefreshRequest( refreshToken: string, integrationContext: IntegrationContext @@ -199,12 +211,6 @@ export class RaiAcceptAPIApi { if (!integrationContext) { throw new InvalidArgumentException('Missing the required parameter $integrationContext when calling tokenRefreshRequest'); } - if (!this.cert) { - throw new InvalidArgumentException('Missing the required parameter $cert when calling tokenRefresh (provide in constructor)'); - } - if (!this.key) { - throw new InvalidArgumentException('Missing the required parameter $key when calling tokenRefresh (provide in constructor)'); - } const refreshInput = new AuthApiRefreshInput(); refreshInput.refreshToken = refreshToken; @@ -217,19 +223,13 @@ export class RaiAcceptAPIApi { return { method: 'POST', - url: `${RaiAcceptAPIApi.AUTH_URL}/auth/api/refresh`, + url: `${this.authBaseUrl}/auth/api/refresh`, headers: headers, body: httpBody, - cert: this.cert, - key: this.key, + ...this.requirePartnerTls('tokenRefresh'), } as HttpRequest; } - /** - * Logout with token - * @param token - Token to logout - * @returns True if logout successful (HTTP 200), false otherwise - */ async tokenLogout(token: string): Promise { const request = this.tokenLogoutRequest(token); @@ -242,21 +242,10 @@ export class RaiAcceptAPIApi { } } - /** - * Create token logout request - * @param token - Token to logout - * @returns Request object - */ tokenLogoutRequest(token: string): HttpRequest { if (!token) { throw new InvalidArgumentException('Missing the required parameter $token when calling tokenLogoutRequest'); } - if (!this.cert) { - throw new InvalidArgumentException('Missing the required parameter $cert when calling tokenLogout (provide in constructor)'); - } - if (!this.key) { - throw new InvalidArgumentException('Missing the required parameter $key when calling tokenLogout (provide in constructor)'); - } const logoutInput = new AuthApiLogoutInput(); logoutInput.refreshToken = token; @@ -268,20 +257,13 @@ export class RaiAcceptAPIApi { return { method: 'POST', - url: `${RaiAcceptAPIApi.AUTH_URL}/auth/api/logout`, + url: `${this.authBaseUrl}/auth/api/logout`, headers: headers, body: httpBody, - cert: this.cert, - key: this.key, + ...this.requirePartnerTls('tokenLogout'), } as HttpRequest; } - /** - * Create order entry - * @param accessToken - Access token - * @param createOrderRequest - Order request object - * @returns Order response - */ async createOrderEntry( accessToken: string, createOrderRequest: CreateOrderEntryRequest @@ -290,12 +272,6 @@ export class RaiAcceptAPIApi { return this.processRequest(request, CreateOrderEntryResponse, ErrorResponse); } - /** - * Create order entry request - * @param accessToken - Access token - * @param createOrderRequest - Order request object - * @returns Request object - */ createOrderEntryRequest(accessToken: string, createOrderRequest: CreateOrderEntryRequest): HttpRequest { if (!accessToken) { throw new InvalidArgumentException('Missing the required parameter $accessToken when calling createOrderEntry'); @@ -303,12 +279,6 @@ export class RaiAcceptAPIApi { if (!createOrderRequest) { throw new InvalidArgumentException('Missing the required parameter $createOrderRequest when calling createOrderEntry'); } - if (!this.cert) { - throw new InvalidArgumentException('Missing the required parameter $cert when calling createOrderEntry (provide in constructor)'); - } - if (!this.key) { - throw new InvalidArgumentException('Missing the required parameter $key when calling createOrderEntry (provide in constructor)'); - } const resourcePath = '/orders'; const headers = { @@ -321,21 +291,13 @@ export class RaiAcceptAPIApi { return { method: 'POST', - url: RaiAcceptAPIApi.API_URL + resourcePath, + url: this.apiBaseUrl + resourcePath, headers: headers, body: httpBody, - cert: this.cert, - key: this.key, + ...this.requirePartnerTls('createOrderEntry'), } as HttpRequest; } - /** - * Create payment session - * @param accessToken - Access token - * @param paymentSessionRequest - Payment session request - * @param externalOrderId - External order ID - * @returns Payment session response - */ async createPaymentSession( accessToken: string, paymentSessionRequest: CreateOrderEntryRequest, @@ -345,13 +307,6 @@ export class RaiAcceptAPIApi { return this.processRequest(request, CreatePaymentSessionResponse, ErrorResponse); } - /** - * Create payment session request - * @param accessToken - Access token - * @param paymentSessionRequest - Payment session request - * @param externalOrderId - External order ID - * @returns Request object - */ createPaymentSessionRequest( accessToken: string, paymentSessionRequest: CreateOrderEntryRequest, @@ -366,14 +321,8 @@ export class RaiAcceptAPIApi { if (!paymentSessionRequest) { throw new InvalidArgumentException('Missing the required parameter $paymentSessionRequest when calling createPaymentSession'); } - if (!this.cert) { - throw new InvalidArgumentException('Missing the required parameter $cert when calling createPaymentSession (provide in constructor)'); - } - if (!this.key) { - throw new InvalidArgumentException('Missing the required parameter $key when calling createPaymentSession (provide in constructor)'); - } - const resourcePath = `${RaiAcceptAPIApi.API_URL}/orders/${externalOrderId}/checkout`; + const resourcePath = `${this.apiBaseUrl}/orders/${externalOrderId}/checkout`; const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}`, @@ -387,17 +336,10 @@ export class RaiAcceptAPIApi { url: resourcePath, headers: headers, body: httpBody, - cert: this.cert, - key: this.key, + ...this.requirePartnerTls('createPaymentSession'), } as HttpRequest; } - /** - * Get order details - * @param accessToken - Access token - * @param paymentId - Payment ID - * @returns Order details response - */ async getOrderDetails( accessToken: string, paymentId: string @@ -406,12 +348,6 @@ export class RaiAcceptAPIApi { return this.processRequest(request, GetOrderDetailsResponse, ErrorResponse); } - /** - * Create get order details request - * @param accessToken - Access token - * @param paymentId - Payment ID - * @returns Request object - */ getOrderDetailsRequest(accessToken: string, paymentId: string): HttpRequest { if (!paymentId) { throw new InvalidArgumentException('Missing the required parameter $paymentId when calling getOrderDetailsRequest'); @@ -419,15 +355,9 @@ export class RaiAcceptAPIApi { if (!accessToken) { throw new InvalidArgumentException('Missing the required parameter $accessToken when calling getOrderDetailsRequest'); } - if (!this.cert) { - throw new InvalidArgumentException('Missing the required parameter $cert when calling getOrderDetails (provide in constructor)'); - } - if (!this.key) { - throw new InvalidArgumentException('Missing the required parameter $key when calling getOrderDetails (provide in constructor)'); - } const encodedPaymentId = ObjectSerializer.toPathValue(paymentId); - const resourcePath = `${RaiAcceptAPIApi.API_URL}/orders/${encodedPaymentId}`; + const resourcePath = `${this.apiBaseUrl}/orders/${encodedPaymentId}`; const headers = { 'Content-Type': 'application/json', @@ -438,18 +368,10 @@ export class RaiAcceptAPIApi { method: 'GET', url: resourcePath, headers: headers, - cert: this.cert, - key: this.key, + ...this.requirePartnerTls('getOrderDetails'), } as HttpRequest; } - /** - * Get transaction details - * @param accessToken - Access token - * @param orderId - Order ID - * @param transactionId - Transaction ID - * @returns Transaction details response - */ async getTransactionDetails( accessToken: string, orderId: string, @@ -459,13 +381,6 @@ export class RaiAcceptAPIApi { return this.processRequest(request, GetTransactionDetailsResponse, ErrorResponse); } - /** - * Create get transaction details request - * @param accessToken - Access token - * @param orderId - Order ID - * @param transactionId - Transaction ID - * @returns Request object - */ getTransactionDetailsRequest(accessToken: string, orderId: string, transactionId: string): HttpRequest { if (!orderId) { throw new InvalidArgumentException('Missing the required parameter $orderId when calling getTransactionDetailsRequest'); @@ -476,16 +391,10 @@ export class RaiAcceptAPIApi { if (!accessToken) { throw new InvalidArgumentException('Missing the required parameter $accessToken when calling getTransactionDetailsRequest'); } - if (!this.cert) { - throw new InvalidArgumentException('Missing the required parameter $cert when calling getTransactionDetails (provide in constructor)'); - } - if (!this.key) { - throw new InvalidArgumentException('Missing the required parameter $key when calling getTransactionDetails (provide in constructor)'); - } const encodedOrderId = ObjectSerializer.toPathValue(orderId); const encodedTransactionId = ObjectSerializer.toPathValue(transactionId); - const resourcePath = `${RaiAcceptAPIApi.API_URL}/orders/${encodedOrderId}/transactions/${encodedTransactionId}`; + const resourcePath = `${this.apiBaseUrl}/orders/${encodedOrderId}/transactions/${encodedTransactionId}`; const headers = { 'Content-Type': 'application/json', @@ -496,17 +405,10 @@ export class RaiAcceptAPIApi { method: 'GET', url: resourcePath, headers: headers, - cert: this.cert, - key: this.key, + ...this.requirePartnerTls('getTransactionDetails'), } as HttpRequest; } - /** - * Get order transactions - * @param accessToken - Access token - * @param orderId - Order ID - * @returns Order transactions response - */ async getOrderTransactions( accessToken: string, orderId: string @@ -515,12 +417,6 @@ export class RaiAcceptAPIApi { return this.processRequest(request, GetOrderTransactionsResponse, ErrorResponse); } - /** - * Create get order transactions request - * @param accessToken - Access token - * @param orderId - Order ID - * @returns Request object - */ getOrderTransactionsRequest(accessToken: string, orderId: string): HttpRequest { if (!orderId) { throw new InvalidArgumentException('Missing the required parameter $orderId when calling getOrderTransactionsRequest'); @@ -528,15 +424,9 @@ export class RaiAcceptAPIApi { if (!accessToken) { throw new InvalidArgumentException('Missing the required parameter $accessToken when calling getOrderTransactionsRequest'); } - if (!this.cert) { - throw new InvalidArgumentException('Missing the required parameter $cert when calling getOrderTransactions (provide in constructor)'); - } - if (!this.key) { - throw new InvalidArgumentException('Missing the required parameter $key when calling getOrderTransactions (provide in constructor)'); - } const encodedOrderId = ObjectSerializer.toPathValue(orderId); - const resourcePath = `${RaiAcceptAPIApi.API_URL}/orders/${encodedOrderId}/transactions`; + const resourcePath = `${this.apiBaseUrl}/orders/${encodedOrderId}/transactions`; const headers = { 'Content-Type': 'application/json', @@ -547,19 +437,10 @@ export class RaiAcceptAPIApi { method: 'GET', url: resourcePath, headers: headers, - cert: this.cert, - key: this.key, + ...this.requirePartnerTls('getOrderTransactions'), } as HttpRequest; } - /** - * Process refund - * @param accessToken - Access token - * @param orderId - Order ID - * @param transactionId - Transaction ID - * @param requestObj - Refund request object - * @returns Refund response - */ async refund( accessToken: string, orderId: string, @@ -570,14 +451,6 @@ export class RaiAcceptAPIApi { return this.processRequest(request, RefundResponse, ErrorResponse); } - /** - * Create refund request - * @param accessToken - Access token - * @param orderId - Order ID - * @param transactionId - Transaction ID - * @param requestObj - Refund request object - * @returns Request object - */ getRefundRequest(accessToken: string, orderId: string, transactionId: string, requestObj: any): HttpRequest { if (!orderId) { throw new InvalidArgumentException('Missing the required parameter $orderId when calling getRefundRequest'); @@ -591,16 +464,10 @@ export class RaiAcceptAPIApi { if (!requestObj) { throw new InvalidArgumentException('Missing the required parameter $requestObj when calling getRefundRequest'); } - if (!this.cert) { - throw new InvalidArgumentException('Missing the required parameter $cert when calling refund (provide in constructor)'); - } - if (!this.key) { - throw new InvalidArgumentException('Missing the required parameter $key when calling refund (provide in constructor)'); - } const encodedOrderId = ObjectSerializer.toPathValue(orderId); const encodedTransactionId = ObjectSerializer.toPathValue(transactionId); - const resourcePath = `${RaiAcceptAPIApi.API_URL}/orders/${encodedOrderId}/transactions/${encodedTransactionId}/refund`; + const resourcePath = `${this.apiBaseUrl}/orders/${encodedOrderId}/transactions/${encodedTransactionId}/refund`; const headers = { 'Content-Type': 'application/json', @@ -615,8 +482,7 @@ export class RaiAcceptAPIApi { url: resourcePath, headers: headers, body: httpBody, - cert: this.cert, - key: this.key, + ...this.requirePartnerTls('refund'), } as HttpRequest; } } diff --git a/src/index.ts b/src/index.ts index aa4bf6f..a28dca8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,14 @@ // Core API - Use RaiAcceptService as the unified client export { RaiAcceptService } from './RaiAcceptService.js'; +export { + type AuthMode, + type RaiAcceptClientConfig, + DEFAULT_AUTH_MODE, + RAIACCEPT_URLS, + resolveAuthMode, + assertTlsCredentialsPair, +} from './types/IntegrationMode.js'; export { HttpClient, type Logger, type HttpClientConfig, type HttpRequest, type HttpResponse } from './HttpClient.js'; export { RaiAcceptLogger } from './RaiAcceptLogger.js'; diff --git a/src/models/ErrorResponse.ts b/src/models/ErrorResponse.ts index 304046c..5e4c3dd 100644 --- a/src/models/ErrorResponse.ts +++ b/src/models/ErrorResponse.ts @@ -6,18 +6,30 @@ export class ErrorResponse { message: string = ''; code: string = ''; details: any = null; + traceId: string = ''; + timestamp: string = ''; + status: string = ''; + errors: any[] = []; constructor() { this.message = ''; this.code = ''; this.details = null; + this.traceId = ''; + this.timestamp = ''; + this.status = ''; + this.errors = []; } static fromObject(data: any = {}): ErrorResponse { const instance = new ErrorResponse(); instance.message = data.message || ''; instance.code = data.code || ''; - instance.details = data.details || null; + instance.details = data.details ?? null; + instance.traceId = data.traceId || ''; + instance.timestamp = data.timestamp || ''; + instance.status = data.status || ''; + instance.errors = Array.isArray(data.errors) ? data.errors : []; return instance; } } diff --git a/src/types/IntegrationMode.ts b/src/types/IntegrationMode.ts new file mode 100644 index 0000000..a604d2a --- /dev/null +++ b/src/types/IntegrationMode.ts @@ -0,0 +1,60 @@ +import { InvalidArgumentException } from '../exceptions/InvalidArgumentException.js'; + +export type AuthMode = 'merchant' | 'partner'; + +export type RaiAcceptClientConfig = { + authMode?: AuthMode; +}; + +export const DEFAULT_AUTH_MODE: AuthMode = 'merchant'; + +export const RAIACCEPT_URLS = { + merchant: { + auth: 'https://auth.raiaccept.com', + api: 'https://trapi.raiaccept.com', + }, + partner: { + auth: 'https://api.raiaccept.com', + api: 'https://api.raiaccept.com', + }, +} as const; + +function hasTlsCredential(value?: string | Buffer): boolean { + return value !== undefined && value !== null && value !== ''; +} + +/** + * Fail fast when only one of cert/key is provided — partial mTLS config is always invalid. + */ +export function assertTlsCredentialsPair( + cert?: string | Buffer, + key?: string | Buffer +): void { + const hasCert = hasTlsCredential(cert); + const hasKey = hasTlsCredential(key); + if (hasCert !== hasKey) { + throw new InvalidArgumentException( + 'Invalid TLS configuration: both cert and key must be provided together, or neither for merchant mode.' + ); + } +} + +/** + * Resolve auth mode: explicit config wins; otherwise partner when both cert and key + * are provided, merchant by default. Throws when only one of cert/key is set. + */ +export function resolveAuthMode( + cert?: string | Buffer, + key?: string | Buffer, + config?: RaiAcceptClientConfig +): AuthMode { + assertTlsCredentialsPair(cert, key); + + if (config?.authMode) { + return config.authMode; + } + if (cert && key) { + return 'partner'; + } + return DEFAULT_AUTH_MODE; +} diff --git a/tests/README.md b/tests/README.md index 6b0fbcf..05d8f92 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,142 +1,46 @@ # RaiAccept API Client Tests -This directory contains the test suite for the RaiAccept JavaScript SDK. +## Commands -## Setup - -1. Install dependencies: -```bash -npm install -``` - -2. Run tests: ```bash -# Run unit tests (mocked) -npm run unit-tests - -# Run integration tests (real API calls!) -npm run integration-tests -``` - -## Available Commands - -**Most Important Commands:** -- `npm run unit-tests` - Run unit tests with mocked dependencies (no external API calls) -- `npm run integration-tests` - Run integration tests with real API calls (requires credentials) - -## Test Structure - -``` -tests/ -├── setup.js # Global test setup and mocks (unit tests only) -├── unit.test.js # Unit tests with mocked dependencies -├── integration.test.js # End-to-end integration test for complete payment flow -└── README.md # This file -``` - -## Test Types - -### Unit Tests (`unit.test.js`) -- Test static utility methods of RaiAcceptService -- Use mocked dependencies (axios is globally mocked) -- Focus on business logic without external API calls -- Run with: `npm run unit-tests` - -### Integration Tests (`integration.test.js`) -- Test complete payment flows with real API calls -- Require valid credentials in `.env` file -- Validate end-to-end functionality -- Run with: `npm run integration-tests` - -## Writing Tests - -Tests are written using Vitest with the following conventions: - -- Test files end with `.test.js` -- Use `describe()` blocks to group related tests -- Use `it()` or `test()` for individual test cases -- Use `expect()` for assertions -- Use `vi` for mocking in unit tests (Vitest's equivalent of Jest's `jest`) - -### Example Unit Test Structure - -```javascript -import { describe, it, expect } from 'vitest' -import { RaiAcceptService } from '../src/RaiAcceptService.js' - -describe('RaiAcceptService', () => { - describe('someMethod()', () => { - it('should do something', () => { - // Arrange - const input = 'test' - const expected = 'result' - - // Act - const result = RaiAcceptService.someMethod(input) - - // Assert - expect(result).toBe(expected) - }) - }) -}) +npm run unit-tests # Mocked: transliteration + routing +npm run integration-tests # Live: merchant + partner flows +npm run integration-tests:merchant # Live: merchant only (no mTLS) +npm run integration-tests:partner # Live: partner only (mTLS) ``` -## Mocking - -Axios is globally mocked in `setup.js`. For other dependencies, use Vitest's mocking: - -```javascript -import { vi } from 'vitest' - -const mockDependency = vi.fn() -vi.mock('../src/some-module.js', () => ({ - someFunction: mockDependency -})) -``` - -## Integration Tests - -Integration tests make real API calls to the RaiAccept service to test the complete payment creation flow. They require valid credentials to be set up. - -### Complete Payment Creation Test - -The integration test (`integration.test.js`) covers the full end-to-end payment lifecycle: - -1. **Authentication** (Required): Obtain access token using credentials - **must succeed** -2. **Order Creation** (Required): Create an order entry with consumer, invoice, and URL details - **must succeed** -3. **Payment Session** (Required): Create a payment session for the order - **must succeed** -4. **Order Details** (Required): Retrieve and verify order details using the order ID - **must succeed** -5. **Transaction Validation** (Required): Verify no transactions exist for newly created payment sessions - **must succeed** +## Credentials -**All API calls must succeed with the provided test data.** The complete payment flow should work exactly the same on every run - no optional failures allowed. - -### Setup Credentials - -Create a `.env` file in the project root with your test credentials: +Create a `.env` file in the SDK project root: ```bash +# Shared for both integration modes RAIACCEPT_TEST_USERNAME=your_username RAIACCEPT_TEST_PASSWORD=your_password + +# Partner mode only +RAIACCEPT_CERT_PATH=/path/to/client.crt +RAIACCEPT_KEY_PATH=/path/to/client.key ``` -**Cert/key** use either decoded input from files or base64 encoded strings directly: +## Test files -```bash -RAIACCEPT_CERT_PATH=../local_env/decoded.pem -RAIACCEPT_KEY_PATH=../local_env/decoded.key -# or -RAIACCEPT_TEST_CERT_BASE64= -RAIACCEPT_TEST_KEY_BASE64= -``` +| File | Type | Description | +|------|------|-------------| +| `unit.test.js` | Unit | Transliteration helpers | +| `routing.test.js` | Unit | Auth mode URLs and mTLS (no live API) | +| `integration.test.js` | Integration | Full payment flow for merchant and partner modes | -### Running Tests +## Integration flow (both modes) -**Unit Tests (Mocked):** -```bash -npm run unit-tests -``` +1. Authenticate +2. Create order entry +3. Create payment session +4. Get order details +5. Get order transactions (expect empty for new order) +6. Refresh access token +7. Logout -**Integration Tests (Real API):** -```bash -npm run integration-tests -``` +Merchant mode uses `auth.raiaccept.com` + `trapi.raiaccept.com` without mTLS. + +Partner mode uses `api.raiaccept.com` with mTLS cert/key and `{ authMode: 'partner' }`. diff --git a/tests/integration.test.js b/tests/integration.test.js index 92d43e3..214747f 100644 --- a/tests/integration.test.js +++ b/tests/integration.test.js @@ -35,168 +35,152 @@ function loadCertAndKey() { } } -describe('RaiAcceptService Integration Tests', () => { - describe('Complete Payment Creation Flow', () => { - it('should authenticate, create order entry, and create payment session', async () => { - const username = process.env.RAIACCEPT_TEST_USERNAME || process.env.RAIACCEPT_USERNAME - const password = process.env.RAIACCEPT_TEST_PASSWORD || process.env.RAIACCEPT_PASSWORD - - if (!username || !password) { - throw new Error('Test credentials required: Set RAIACCEPT_TEST_USERNAME/RAIACCEPT_TEST_PASSWORD or RAIACCEPT_USERNAME/RAIACCEPT_PASSWORD environment variables') - } - - const certKey = loadCertAndKey() - if (!certKey) { - throw new Error( - 'Test cert/key required. Use either:\n' + +function loadCredentials() { + const username = process.env.RAIACCEPT_TEST_USERNAME || process.env.RAIACCEPT_USERNAME + const password = process.env.RAIACCEPT_TEST_PASSWORD || process.env.RAIACCEPT_PASSWORD + + if (!username || !password) { + throw new Error( + 'Test credentials required: Set RAIACCEPT_TEST_USERNAME/RAIACCEPT_TEST_PASSWORD or RAIACCEPT_USERNAME/RAIACCEPT_PASSWORD environment variables' + ) + } + + return { + username, + password, + integrationContext: { + type: 'CODE', + data: { + name: 'raiaccept-sdk-integration-test', + version: '1.0.0', + vendor: 'Smartbase s.r.o.', + }, + }, + } +} + +function createService(authMode) { + const httpClient = new HttpClient() + if (authMode === 'partner') { + const certKey = loadCertAndKey() + if (!certKey) { + throw new Error( + 'Partner mode test cert/key required. Use either:\n' + ' - RAIACCEPT_CERT_PATH + RAIACCEPT_KEY_PATH (paths to PEM files)\n' + - ' - RAIACCEPT_CERT_BASE64 + RAIACCEPT_KEY_BASE64 (base64 of full PEM files, e.g. base64 -w 0 cert.pem)' - ) - } - const { cert, key } = certKey - - const httpClient = new HttpClient() - const realService = new RaiAcceptService(httpClient, cert, key) - - // Step 1: Authenticate to get access token - console.log('[Step 1] Authenticating...') - const integrationContext = { - type: 'CODE', - data: { - name: 'raiaccept-shopify-integration-test', - version: '1.0.0', - vendor: 'Smartbase s.r.o.', - }, - } - const authResult = await realService.retrieveAccessTokenWithCredentials(username, password, integrationContext) - const accessToken = authResult?.accessToken - expect(accessToken).toBeTruthy() - expect(typeof accessToken).toBe('string') - expect(accessToken.length).toBeGreaterThan(10) - expect(accessToken).toMatch(/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/) - - // Step 2: Create order entry - console.log('[Step 2] Creating order entry...') - const consumer = Consumer.fromObject({ - email: 'test@example.com', - firstName: 'John', - lastName: 'Doe', - mobilePhone: '+421908123456' - }) - - const invoice = Invoice.fromObject({ - amount: 100.00, - currency: 'USD', - description: 'Test Order', - merchantOrderReference: `test-order-${Date.now()}`, - items: [] - }) - - const urls = Urls.fromObject({ - successUrl: 'https://example.com/success', - failUrl: 'https://example.com/fail', - cancelUrl: 'https://example.com/cancel', - notificationUrl: 'https://example.com/notification' - }) - - const orderRequest = CreateOrderEntryRequest.fromObject({ - consumer: consumer, - invoice: invoice, - urls: urls, - paymentMethodPreference: 'CARD' - }) - - const orderResult = await realService.createOrderEntry(accessToken, orderRequest) - - // Verify order creation response - expect(orderResult).toBeDefined() - expect(orderResult).toHaveProperty('object') - - const orderResponse = orderResult.object - expect(orderResponse).toBeDefined() - expect(orderResponse).toHaveProperty('orderIdentification') - expect(orderResponse.orderIdentification).toBeTruthy() - expect(typeof orderResponse.orderIdentification).toBe('string') - expect(orderResponse).toHaveProperty('createdOn') - expect(orderResponse).toHaveProperty('isProduction') - expect(typeof orderResponse.isProduction).toBe('boolean') - - const orderId = orderResponse.orderIdentification - - // Step 3: Create payment session for the order - console.log('[Step 3] Creating payment session...') - const paymentSessionRequest = orderRequest - - const paymentResult = await realService.createPaymentSession(accessToken, paymentSessionRequest, orderId) - - // Verify payment session response - expect(paymentResult).toBeDefined() - expect(paymentResult).toHaveProperty('object') - - const paymentResponse = paymentResult.object - expect(paymentResponse).toBeDefined() - expect(paymentResponse).toHaveProperty('sessionId') - expect(paymentResponse).toHaveProperty('paymentRedirectURL') - expect(paymentResponse).toHaveProperty('expiresAt') - expect(paymentResponse.sessionId).toBeTruthy() - expect(typeof paymentResponse.sessionId).toBe('string') - expect(paymentResponse.paymentRedirectURL).toBeTruthy() - expect(typeof paymentResponse.paymentRedirectURL).toBe('string') - expect(paymentResponse.paymentRedirectURL).toMatch(/^https?:\/\//) - - // Step 4: Get order details - console.log('[Step 4] Getting order details...') - const orderDetailsResult = await realService.getOrderDetails(accessToken, orderId) - - // Verify order details response - expect(orderDetailsResult).toBeDefined() - expect(orderDetailsResult).toHaveProperty('object') - - const orderDetailsResponse = orderDetailsResult.object - expect(orderDetailsResponse).toBeDefined() - expect(orderDetailsResponse).toHaveProperty('status') - expect(typeof orderDetailsResponse.status).toBe('string') - expect(orderDetailsResponse.consumer).toBeDefined() - expect(orderDetailsResponse.consumer.email).toBe(consumer.email) - expect(orderDetailsResponse.consumer.firstName).toBe(consumer.firstName) - expect(orderDetailsResponse.consumer.lastName).toBe(consumer.lastName) - expect(orderDetailsResponse.invoice).toBeDefined() - expect(orderDetailsResponse.invoice.amount).toBe(invoice.amount) - expect(orderDetailsResponse.invoice.currency).toBe(invoice.currency) - expect(orderDetailsResponse.invoice.merchantOrderReference).toBe(invoice.merchantOrderReference) - - // Step 5: Verify no transactions exist for newly created payment session - console.log('[Step 5] Getting order transactions...') - const transactionsResult = await realService.getOrderTransactions(accessToken, orderId) - - expect(transactionsResult).toBeDefined() - expect(transactionsResult).toHaveProperty('object') - - const transactionsResponse = transactionsResult.object - expect(transactionsResponse).toBeDefined() - expect(transactionsResponse).toHaveProperty('transactions') - expect(Array.isArray(transactionsResponse.transactions)).toBe(true) - - // For a newly created payment session, there should be no transactions yet - expect(transactionsResponse.transactions.length).toBe(0) - - // Step 6: Refresh access token - console.log('[Step 6] Refreshing token...') - const refreshToken = authResult?.refreshToken - expect(refreshToken).toBeTruthy() - const refreshResult = await realService.tokenRefresh(refreshToken, integrationContext) - expect(refreshResult).toBeDefined() - expect(refreshResult).toHaveProperty('object') - const refreshOutput = refreshResult.object - expect(refreshOutput).toBeDefined() - expect(refreshOutput.accessToken).toBeTruthy() - expect(typeof refreshOutput.accessToken).toBe('string') - expect(typeof refreshOutput.accessTokenExpiresIn).toBe('number') - - // Step 7: Logout with refresh token (expects HTTP 200) - console.log('[Step 7] Logging out...') - const logoutSuccess = await realService.tokenLogout(refreshToken) - expect(logoutSuccess).toBe(true) - }, 60000) // 60 second timeout for complete payment flow + ' - RAIACCEPT_CERT_BASE64 + RAIACCEPT_KEY_BASE64 (base64 of full PEM files)' + ) + } + const { cert, key } = certKey + return new RaiAcceptService(httpClient, cert, key) + } + return new RaiAcceptService(httpClient) +} + +async function runPaymentFlow(service, { username, password, integrationContext }) { + console.log('[Step 1] Authenticating...') + const authResult = await service.retrieveAccessTokenWithCredentials(username, password, integrationContext) + const accessToken = authResult?.accessToken + expect(accessToken).toBeTruthy() + expect(typeof accessToken).toBe('string') + expect(accessToken.length).toBeGreaterThan(10) + expect(accessToken).toMatch(/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/) + + console.log('[Step 2] Creating order entry...') + const consumer = Consumer.fromObject({ + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + mobilePhone: '+421908123456', + }) + + const invoice = Invoice.fromObject({ + amount: 100.0, + currency: 'USD', + description: 'Test Order', + merchantOrderReference: `test-order-${Date.now()}`, + items: [], + }) + + const urls = Urls.fromObject({ + successUrl: 'https://example.com/success', + failUrl: 'https://example.com/fail', + cancelUrl: 'https://example.com/cancel', + notificationUrl: 'https://example.com/notification', + }) + + const orderRequest = CreateOrderEntryRequest.fromObject({ + consumer, + invoice, + urls, + paymentMethodPreference: 'CARD', + }) + + const orderResult = await service.createOrderEntry(accessToken, orderRequest) + expect(orderResult).toBeDefined() + expect(orderResult).toHaveProperty('object') + + const orderResponse = orderResult.object + expect(orderResponse).toBeDefined() + expect(orderResponse.orderIdentification).toBeTruthy() + expect(typeof orderResponse.orderIdentification).toBe('string') + expect(orderResponse).toHaveProperty('createdOn') + expect(orderResponse).toHaveProperty('isProduction') + expect(typeof orderResponse.isProduction).toBe('boolean') + + const orderId = orderResponse.orderIdentification + + console.log('[Step 3] Creating payment session...') + const paymentResult = await service.createPaymentSession(accessToken, orderRequest, orderId) + expect(paymentResult).toBeDefined() + expect(paymentResult).toHaveProperty('object') + + const paymentResponse = paymentResult.object + expect(paymentResponse).toBeDefined() + expect(paymentResponse.sessionId).toBeTruthy() + expect(paymentResponse.paymentRedirectURL).toBeTruthy() + expect(paymentResponse.paymentRedirectURL).toMatch(/^https?:\/\//) + + console.log('[Step 4] Getting order details...') + const orderDetailsResult = await service.getOrderDetails(accessToken, orderId) + expect(orderDetailsResult).toBeDefined() + expect(orderDetailsResult.object?.status).toBeTruthy() + expect(orderDetailsResult.object?.consumer?.email).toBe(consumer.email) + + console.log('[Step 5] Getting order transactions...') + const transactionsResult = await service.getOrderTransactions(accessToken, orderId) + expect(transactionsResult.object?.transactions).toBeDefined() + expect(Array.isArray(transactionsResult.object.transactions)).toBe(true) + expect(transactionsResult.object.transactions.length).toBe(0) + + console.log('[Step 6] Refreshing token...') + const refreshToken = authResult?.refreshToken + expect(refreshToken).toBeTruthy() + const refreshResult = await service.tokenRefresh(refreshToken, integrationContext) + expect(refreshResult.object?.accessToken).toBeTruthy() + expect(typeof refreshResult.object?.accessTokenExpiresIn).toBe('number') + + console.log('[Step 7] Logging out...') + const logoutSuccess = await service.tokenLogout(refreshToken) + expect(logoutSuccess).toBe(true) +} + +describe('RaiAcceptService Integration Tests', () => { + describe('merchant mode integration', () => { + it('should run complete payment flow without mTLS', async () => { + const credentials = loadCredentials() + const service = createService('merchant') + await runPaymentFlow(service, credentials) + }, 60000) + }) + + const partnerCertKey = loadCertAndKey() + const describePartner = partnerCertKey ? describe : describe.skip + + describePartner('partner mode integration', () => { + it('should run complete payment flow with mTLS', async () => { + const credentials = loadCredentials() + const service = createService('partner') + await runPaymentFlow(service, credentials) + }, 60000) }) }) diff --git a/tests/routing.test.js b/tests/routing.test.js new file mode 100644 index 0000000..7327105 --- /dev/null +++ b/tests/routing.test.js @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'vitest' +import { RaiAcceptAPIApi } from '../src/api/RaiAcceptAPIApi.ts' +import { HttpClient } from '../src/HttpClient.ts' +import { InvalidArgumentException } from '../src/exceptions/InvalidArgumentException.ts' + +const TEST_CERT = '-----BEGIN CERTIFICATE-----\ntest-cert\n-----END CERTIFICATE-----' +const TEST_KEY = '-----BEGIN PRIVATE KEY-----\ntest-key\n-----END PRIVATE KEY-----' + +const integrationContext = { + type: 'CODE', + data: { + name: 'test-sdk', + version: '1.0.0', + vendor: 'Test Vendor', + }, +} + +describe('RaiAcceptAPIApi routing', () => { + describe('merchant mode (default)', () => { + const api = new RaiAcceptAPIApi(new HttpClient()) + + it('uses auth.raiaccept.com for login', () => { + const request = api.tokenRequest('user', 'pass', integrationContext) + expect(request.url).toBe('https://auth.raiaccept.com/auth/api/login') + expect(request.cert).toBeUndefined() + expect(request.key).toBeUndefined() + }) + + it('uses auth.raiaccept.com for refresh', () => { + const request = api.tokenRefreshRequest('refresh-token', integrationContext) + expect(request.url).toBe('https://auth.raiaccept.com/auth/api/refresh') + expect(request.cert).toBeUndefined() + expect(request.key).toBeUndefined() + }) + + it('uses auth.raiaccept.com for logout', () => { + const request = api.tokenLogoutRequest('refresh-token') + expect(request.url).toBe('https://auth.raiaccept.com/auth/api/logout') + expect(request.cert).toBeUndefined() + expect(request.key).toBeUndefined() + }) + + it('uses trapi.raiaccept.com for create order', () => { + const request = api.createOrderEntryRequest('access-token', {} as any) + expect(request.url).toBe('https://trapi.raiaccept.com/orders') + expect(request.cert).toBeUndefined() + expect(request.key).toBeUndefined() + }) + + it('throws when only cert or only key is provided', () => { + expect(() => new RaiAcceptAPIApi(new HttpClient(), TEST_CERT, undefined)).toThrow(InvalidArgumentException) + expect(() => new RaiAcceptAPIApi(new HttpClient(), undefined, TEST_KEY)).toThrow(InvalidArgumentException) + }) + + it('uses merchant mode when cert/key passed with explicit authMode merchant', () => { + const apiWithCert = new RaiAcceptAPIApi(new HttpClient(), TEST_CERT, TEST_KEY, { authMode: 'merchant' }) + expect(apiWithCert.getAuthMode()).toBe('merchant') + const request = apiWithCert.tokenRequest('user', 'pass', integrationContext) + expect(request.url).toBe('https://auth.raiaccept.com/auth/api/login') + expect(request.cert).toBeUndefined() + expect(request.key).toBeUndefined() + }) + }) + + describe('partner mode (explicit)', () => { + const api = new RaiAcceptAPIApi(new HttpClient(), TEST_CERT, TEST_KEY, { authMode: 'partner' }) + + it('uses api.raiaccept.com for login with mTLS', () => { + const request = api.tokenRequest('user', 'pass', integrationContext) + expect(request.url).toBe('https://api.raiaccept.com/auth/api/login') + expect(request.cert).toBe(TEST_CERT) + expect(request.key).toBe(TEST_KEY) + }) + + it('uses api.raiaccept.com for refresh with mTLS', () => { + const request = api.tokenRefreshRequest('refresh-token', integrationContext) + expect(request.url).toBe('https://api.raiaccept.com/auth/api/refresh') + expect(request.cert).toBe(TEST_CERT) + expect(request.key).toBe(TEST_KEY) + }) + + it('uses api.raiaccept.com for API calls with mTLS', () => { + const request = api.createOrderEntryRequest('access-token', {} as any) + expect(request.url).toBe('https://api.raiaccept.com/orders') + expect(request.cert).toBe(TEST_CERT) + expect(request.key).toBe(TEST_KEY) + }) + + it('throws when cert/key missing', () => { + const apiWithoutTls = new RaiAcceptAPIApi(new HttpClient(), undefined, undefined, { authMode: 'partner' }) + expect(() => apiWithoutTls.tokenRequest('user', 'pass', integrationContext)).toThrow(InvalidArgumentException) + }) + }) + + describe('partner mode (auto-detected from cert + key)', () => { + const api = new RaiAcceptAPIApi(new HttpClient(), TEST_CERT, TEST_KEY) + + it('infers partner when cert and key provided without explicit authMode', () => { + expect(api.getAuthMode()).toBe('partner') + }) + + it('uses api.raiaccept.com with mTLS', () => { + const request = api.tokenRequest('user', 'pass', integrationContext) + expect(request.url).toBe('https://api.raiaccept.com/auth/api/login') + expect(request.cert).toBe(TEST_CERT) + expect(request.key).toBe(TEST_KEY) + }) + }) +}) From 0d0234e2d4d44956c988cf910d62e88bdb125e64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=BDubom=C3=ADr=20Samotn=C3=BD?= Date: Thu, 6 Aug 2026 11:11:06 +0200 Subject: [PATCH 2/3] triv: fix unit tests --- tests/routing.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/routing.test.js b/tests/routing.test.js index 7327105..ac192f3 100644 --- a/tests/routing.test.js +++ b/tests/routing.test.js @@ -41,7 +41,7 @@ describe('RaiAcceptAPIApi routing', () => { }) it('uses trapi.raiaccept.com for create order', () => { - const request = api.createOrderEntryRequest('access-token', {} as any) + const request = api.createOrderEntryRequest('access-token', {}) expect(request.url).toBe('https://trapi.raiaccept.com/orders') expect(request.cert).toBeUndefined() expect(request.key).toBeUndefined() @@ -80,7 +80,7 @@ describe('RaiAcceptAPIApi routing', () => { }) it('uses api.raiaccept.com for API calls with mTLS', () => { - const request = api.createOrderEntryRequest('access-token', {} as any) + const request = api.createOrderEntryRequest('access-token', {}) expect(request.url).toBe('https://api.raiaccept.com/orders') expect(request.cert).toBe(TEST_CERT) expect(request.key).toBe(TEST_KEY) From a5309835f2932e6a6842999d5820ee54aed080a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=BDubom=C3=ADr=20Samotn=C3=BD?= Date: Thu, 6 Aug 2026 11:30:01 +0200 Subject: [PATCH 3/3] triv: fix minor issues --- CHANGELOG.md | 4 +++- README.md | 18 +++++++++++++++- src/api/RaiAcceptAPIApi.ts | 41 ++++++++++++------------------------ src/index.ts | 2 ++ src/types/IntegrationMode.ts | 31 +++++++++++++++++++++++++++ tests/routing.test.js | 5 ++--- 6 files changed, 68 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb2ddc7..6f087ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - **Merchant integration mode** (default): auth on `https://auth.raiaccept.com`, API on `https://trapi.raiaccept.com`, no mTLS. - **Partner integration mode**: mTLS cert/key; auth and API on `https://api.raiaccept.com`. Auto-detected when both cert and key are provided; override with `{ authMode: 'partner' }` or `{ authMode: 'merchant' }`. -- Exported `AuthMode`, `RaiAcceptClientConfig`, `DEFAULT_AUTH_MODE`, `RAIACCEPT_URLS`, `resolveAuthMode`, and `assertTlsCredentialsPair`. +- Exported `AuthMode`, `RaiAcceptClientConfig`, `DEFAULT_AUTH_MODE`, `RAIACCEPT_URLS`, `resolveAuthMode`, `assertTlsCredentialsPair`, `assertPartnerTlsRequired`, and `validateAuthModeConfiguration`. - Unit tests for mode-aware URL routing and mTLS behavior. - Separate merchant and partner integration test suites (shared username/password credentials). @@ -14,6 +14,8 @@ - **Breaking:** Default auth mode is now `merchant`. Partner mode applies when both cert and key are provided (same as 0.9.x), or via `{ authMode: 'partner' }`. - Partial TLS config (only cert or only key) throws `InvalidArgumentException` at construction time. +- Explicit partner mode without both cert and key throws `InvalidArgumentException` at construction time. +- Removed deprecated `RaiAcceptAPIApi.AUTH_URL` and `RaiAcceptAPIApi.API_URL`; use exported `RAIACCEPT_URLS` instead. - `RaiAcceptService` and `RaiAcceptAPIApi` constructors accept an optional fourth argument `config?: RaiAcceptClientConfig`. - `ErrorResponse` model accepts both partner (`message`, `code`, `details`) and trapi (`traceId`, `timestamp`, `status`, `errors`) error shapes. - API error parsing now includes HTTP 401 and 403 in addition to 400. diff --git a/README.md b/README.md index 9dbb843..e9cb5f6 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ npm install @smartbase-js/raiaccept-api-client | API | `https://trapi.raiaccept.com` | `https://api.raiaccept.com` | | mTLS | Not used | Required (cert + key) | -When both `cert` and `key` are passed to the constructor, partner mode is selected automatically. Use `{ authMode: 'merchant' }` to force merchant mode despite cert/key (e.g. testing), or `{ authMode: 'partner' }` for explicit opt-in. Providing only cert or only key throws `InvalidArgumentException` at construction time. +When both `cert` and `key` are passed to the constructor, partner mode is selected automatically. Use `{ authMode: 'merchant' }` to force merchant mode despite cert/key (e.g. testing), or `{ authMode: 'partner' }` for explicit opt-in. Providing only cert or only key, or partner mode without both cert and key, throws `InvalidArgumentException` at construction time. ## Merchant integration (default) @@ -153,6 +153,22 @@ new RaiAcceptService(httpClient, cert, key, { authMode: 'merchant' }); - `getTransactionDetails(accessToken, orderId, transactionId)` - `refund(accessToken, orderId, transactionId, refundRequest)` +### Utility functions + +Static helpers on `RaiAcceptService` for normalizing order/payment payload data: + +- `RaiAcceptService.transliterate(string)` — transliterate non-Latin characters to Latin +- `RaiAcceptService.transliterateAndLimitLength(string, limit?)` — transliterate and truncate (default limit 127) +- `RaiAcceptService.cleanPhoneNumber(phoneNumber)` — normalize phone number format (digits + leading `+`, max 15 chars) +- `RaiAcceptService.getCountryIso3(countryCode)` — convert 2-letter ISO country code to 3-letter +- `RaiAcceptService.getPaidStatuses()` / `getFailedStatuses()` / `getCancelledStatuses()` / `getRejectedStatuses()` — payment status groupings + +```typescript +RaiAcceptService.transliterate('Γεια σου'); // 'Geia sou' +RaiAcceptService.cleanPhoneNumber('+1 (234) 567-8900'); // '+12345678900' +RaiAcceptService.getCountryIso3('SK'); // 'SVK' +``` + ## Migration from 0.9.x Version 0.10.0 defaults to **merchant mode**. Partner integrations with cert + key work as in 0.9.x — mode is auto-detected. You may still pass `{ authMode: 'partner' }` explicitly. diff --git a/src/api/RaiAcceptAPIApi.ts b/src/api/RaiAcceptAPIApi.ts index 89649ce..f7dcad8 100644 --- a/src/api/RaiAcceptAPIApi.ts +++ b/src/api/RaiAcceptAPIApi.ts @@ -19,7 +19,7 @@ import { type AuthMode, type RaiAcceptClientConfig, RAIACCEPT_URLS, - resolveAuthMode, + validateAuthModeConfiguration, } from '../types/IntegrationMode.js'; export interface ApiResponse { @@ -34,11 +34,6 @@ type TlsOptions = Pick; * Main API client for RaiAccept payment gateway */ export class RaiAcceptAPIApi { - /** @deprecated Use mode-specific URLs via authMode config. Partner auth URL. */ - static AUTH_URL = RAIACCEPT_URLS.partner.auth; - /** @deprecated Use mode-specific URLs via authMode config. Partner API URL. */ - static API_URL = RAIACCEPT_URLS.partner.api; - static ACCEPTED_LANGUAGES = [ 'en', 'de', 'fr', 'cs', 'sk', 'sr', 'al', 'ro', 'pl', 'hr' ]; @@ -64,7 +59,7 @@ export class RaiAcceptAPIApi { this.client = client || new HttpClient(); this.cert = cert; this.key = key; - this.authMode = resolveAuthMode(cert, key, config); + this.authMode = validateAuthModeConfiguration(cert, key, config); } getAuthMode(): AuthMode { @@ -79,21 +74,11 @@ export class RaiAcceptAPIApi { return RAIACCEPT_URLS[this.authMode].api; } - private requirePartnerTls(context: string): TlsOptions { + private requirePartnerTls(): TlsOptions { if (this.authMode !== 'partner') { return {}; } - if (!this.cert) { - throw new InvalidArgumentException( - `Missing the required parameter $cert when calling ${context} (provide in constructor with authMode: 'partner')` - ); - } - if (!this.key) { - throw new InvalidArgumentException( - `Missing the required parameter $key when calling ${context} (provide in constructor with authMode: 'partner')` - ); - } - return { cert: this.cert, key: this.key }; + return { cert: this.cert!, key: this.key! }; } getAcceptedLanguages(): string[] { @@ -189,7 +174,7 @@ export class RaiAcceptAPIApi { url: `${this.authBaseUrl}/auth/api/login`, headers: headers, body: httpBody, - ...this.requirePartnerTls('token'), + ...this.requirePartnerTls(), } as HttpRequest; } @@ -226,7 +211,7 @@ export class RaiAcceptAPIApi { url: `${this.authBaseUrl}/auth/api/refresh`, headers: headers, body: httpBody, - ...this.requirePartnerTls('tokenRefresh'), + ...this.requirePartnerTls(), } as HttpRequest; } @@ -260,7 +245,7 @@ export class RaiAcceptAPIApi { url: `${this.authBaseUrl}/auth/api/logout`, headers: headers, body: httpBody, - ...this.requirePartnerTls('tokenLogout'), + ...this.requirePartnerTls(), } as HttpRequest; } @@ -294,7 +279,7 @@ export class RaiAcceptAPIApi { url: this.apiBaseUrl + resourcePath, headers: headers, body: httpBody, - ...this.requirePartnerTls('createOrderEntry'), + ...this.requirePartnerTls(), } as HttpRequest; } @@ -336,7 +321,7 @@ export class RaiAcceptAPIApi { url: resourcePath, headers: headers, body: httpBody, - ...this.requirePartnerTls('createPaymentSession'), + ...this.requirePartnerTls(), } as HttpRequest; } @@ -368,7 +353,7 @@ export class RaiAcceptAPIApi { method: 'GET', url: resourcePath, headers: headers, - ...this.requirePartnerTls('getOrderDetails'), + ...this.requirePartnerTls(), } as HttpRequest; } @@ -405,7 +390,7 @@ export class RaiAcceptAPIApi { method: 'GET', url: resourcePath, headers: headers, - ...this.requirePartnerTls('getTransactionDetails'), + ...this.requirePartnerTls(), } as HttpRequest; } @@ -437,7 +422,7 @@ export class RaiAcceptAPIApi { method: 'GET', url: resourcePath, headers: headers, - ...this.requirePartnerTls('getOrderTransactions'), + ...this.requirePartnerTls(), } as HttpRequest; } @@ -482,7 +467,7 @@ export class RaiAcceptAPIApi { url: resourcePath, headers: headers, body: httpBody, - ...this.requirePartnerTls('refund'), + ...this.requirePartnerTls(), } as HttpRequest; } } diff --git a/src/index.ts b/src/index.ts index a28dca8..a994bb8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,8 @@ export { RAIACCEPT_URLS, resolveAuthMode, assertTlsCredentialsPair, + assertPartnerTlsRequired, + validateAuthModeConfiguration, } from './types/IntegrationMode.js'; export { HttpClient, type Logger, type HttpClientConfig, type HttpRequest, type HttpResponse } from './HttpClient.js'; export { RaiAcceptLogger } from './RaiAcceptLogger.js'; diff --git a/src/types/IntegrationMode.ts b/src/types/IntegrationMode.ts index a604d2a..ef12833 100644 --- a/src/types/IntegrationMode.ts +++ b/src/types/IntegrationMode.ts @@ -39,6 +39,24 @@ export function assertTlsCredentialsPair( } } +/** + * Fail fast when partner mode is selected without both cert and key. + */ +export function assertPartnerTlsRequired( + cert?: string | Buffer, + key?: string | Buffer, + authMode?: AuthMode +): void { + if (authMode !== 'partner') { + return; + } + if (!hasTlsCredential(cert) || !hasTlsCredential(key)) { + throw new InvalidArgumentException( + 'Partner mode requires both cert and key in the constructor.' + ); + } +} + /** * Resolve auth mode: explicit config wins; otherwise partner when both cert and key * are provided, merchant by default. Throws when only one of cert/key is set. @@ -58,3 +76,16 @@ export function resolveAuthMode( } return DEFAULT_AUTH_MODE; } + +/** + * Validate TLS credentials for the resolved auth mode. + */ +export function validateAuthModeConfiguration( + cert?: string | Buffer, + key?: string | Buffer, + config?: RaiAcceptClientConfig +): AuthMode { + const authMode = resolveAuthMode(cert, key, config); + assertPartnerTlsRequired(cert, key, authMode); + return authMode; +} diff --git a/tests/routing.test.js b/tests/routing.test.js index ac192f3..ecb49c5 100644 --- a/tests/routing.test.js +++ b/tests/routing.test.js @@ -86,9 +86,8 @@ describe('RaiAcceptAPIApi routing', () => { expect(request.key).toBe(TEST_KEY) }) - it('throws when cert/key missing', () => { - const apiWithoutTls = new RaiAcceptAPIApi(new HttpClient(), undefined, undefined, { authMode: 'partner' }) - expect(() => apiWithoutTls.tokenRequest('user', 'pass', integrationContext)).toThrow(InvalidArgumentException) + it('throws at construction when cert/key missing for explicit partner mode', () => { + expect(() => new RaiAcceptAPIApi(new HttpClient(), undefined, undefined, { authMode: 'partner' })).toThrow(InvalidArgumentException) }) })