diff --git a/README.md b/README.md index 0d7be22..47091be 100644 --- a/README.md +++ b/README.md @@ -130,10 +130,20 @@ pnpm test:engine --component aasx-file-server --url http://localhost:8087 --repo ### Report Behavior -- `console`: run summary plus failed checks and reasons. -- `json`: machine-readable report for workflow processing. -- `junit`: CI-compatible XML artifact. -- `markdown`: shareable summary document. +- `console`: run summary plus failed checks and reasons, including endpoint and response snippets when available. +- `json`: machine-readable report for workflow processing, including failure diagnostics such as request + method/endpoint, request body summary, response status, response body snippet, and trace source for integration-test + failures. +- `junit`: CI-compatible XML artifact; integration-test failure entries include endpoint and response context in the + failure message. +- `markdown`: shareable summary document with failure diagnostics and copyable cURL replay blocks for integration-test + failures. + +Failure diagnostics are captured only in test-engine mode and are sanitized: + +- Authorization and cookie-style headers are redacted. +- Large request/response bodies are truncated to compact snippets. +- Multipart/binary replay commands use real relative file names when available; otherwise placeholders are emitted. Exit code behavior: diff --git a/ci/basyxschema.sql b/ci/basyxschema.sql index cee5445..587f3b4 100644 --- a/ci/basyxschema.sql +++ b/ci/basyxschema.sql @@ -420,6 +420,7 @@ CREATE TABLE IF NOT EXISTS aas_descriptor_endpoint ( CREATE TABLE IF NOT EXISTS aas_descriptor ( descriptor_id BIGINT PRIMARY KEY REFERENCES descriptor(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), db_created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), asset_kind int, asset_type VARCHAR(2048), @@ -486,6 +487,19 @@ CREATE TABLE IF NOT EXISTS concept_description ( db_updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +-- ------------------------------------------ +-- Schema compatibility upgrades +-- ------------------------------------------ + +ALTER TABLE IF EXISTS aas_descriptor + ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +ALTER TABLE IF EXISTS aas_identifier + ADD COLUMN IF NOT EXISTS db_created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +ALTER TABLE IF EXISTS aas_identifier + ADD COLUMN IF NOT EXISTS db_updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + /* Auto-generated file. Do not edit manually. Naming pattern: _reference and _reference_key. @@ -668,6 +682,31 @@ CREATE TABLE IF NOT EXISTS submodel_descriptor_supplemental_semantic_id_referenc db_updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +-- ------------------------------------------ +-- AASX File Server +-- ------------------------------------------ + +CREATE TABLE IF NOT EXISTS aasx_package ( + id BIGSERIAL PRIMARY KEY, + package_id TEXT NOT NULL UNIQUE, + file_oid OID NOT NULL, + file_name TEXT NOT NULL, + content_type TEXT NOT NULL DEFAULT 'application/asset-administration-shell-package', + db_created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + db_updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS aasx_package_aas_id ( + id BIGSERIAL PRIMARY KEY, + package_db_id BIGINT NOT NULL REFERENCES aasx_package(id) ON DELETE CASCADE, + aas_id TEXT NOT NULL, + position INTEGER NOT NULL, + UNIQUE(package_db_id, position), + UNIQUE(package_db_id, aas_id), + db_created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + db_updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + -- ------------------------------------------ -- Timestamp triggers -- ------------------------------------------ @@ -764,7 +803,6 @@ CREATE INDEX IF NOT EXISTS ix_operation_variable_operation_id ON operation_varia CREATE INDEX IF NOT EXISTS ix_operation_variable_value_sme ON operation_variable(value_sme); CREATE UNIQUE INDEX IF NOT EXISTS ix_aas_identifier_aasid ON aas_identifier(aasId); -CREATE INDEX IF NOT EXISTS ix_aas_identifier_db_created_at ON aas_identifier(db_created_at); CREATE INDEX IF NOT EXISTS ix_specasset_descriptor_id_name ON specific_asset_id(descriptor_id, name); CREATE INDEX IF NOT EXISTS ix_specasset_descriptor_id_position ON specific_asset_id(descriptor_id, position); @@ -787,6 +825,7 @@ CREATE INDEX IF NOT EXISTS ix_aas_endpoint_descriptor_position ON aas_descriptor CREATE INDEX IF NOT EXISTS ix_aas_endpoint_position ON aas_descriptor_endpoint(position); CREATE INDEX IF NOT EXISTS ix_aasd_db_created_at ON aas_descriptor(db_created_at); +CREATE INDEX IF NOT EXISTS ix_aasd_created_at ON aas_descriptor(created_at); CREATE INDEX IF NOT EXISTS ix_aasd_id_short ON aas_descriptor(id_short); CREATE INDEX IF NOT EXISTS ix_aasd_global_asset_id ON aas_descriptor(global_asset_id); CREATE INDEX IF NOT EXISTS ix_aasd_id_trgm ON aas_descriptor USING GIN (id gin_trgm_ops); @@ -847,3 +886,8 @@ CREATE INDEX IF NOT EXISTS ix_specasset_supp_semantic_refkey_refid ON specific_a CREATE INDEX IF NOT EXISTS ix_specasset_supp_semantic_refkey_refval ON specific_asset_id_supplemental_semantic_id_reference_key(reference_id, value); CREATE INDEX IF NOT EXISTS ix_specasset_supp_semantic_refkey_type_val ON specific_asset_id_supplemental_semantic_id_reference_key(type, value); CREATE INDEX IF NOT EXISTS ix_specasset_supp_semantic_refkey_val_trgm ON specific_asset_id_supplemental_semantic_id_reference_key USING GIN (value gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS ix_aasx_package_package_id ON aasx_package(package_id); +CREATE INDEX IF NOT EXISTS ix_aasx_package_db_created_at ON aasx_package(db_created_at); +CREATE INDEX IF NOT EXISTS ix_aasx_package_aas_id_aas ON aasx_package_aas_id(aas_id); +CREATE INDEX IF NOT EXISTS ix_aasx_package_aas_id_package ON aasx_package_aas_id(package_db_id); \ No newline at end of file diff --git a/ci/docker-compose.yml b/ci/docker-compose.yml index 4ca004b..7a2988f 100644 --- a/ci/docker-compose.yml +++ b/ci/docker-compose.yml @@ -163,16 +163,30 @@ services: # AASX File Server aasx-file-server: - image: eclipsebasyx/aasxfileserver:2.0.0-milestone-08 + image: eclipsebasyx/aasxfileserver-go:SNAPSHOT container_name: aasx-file-server + command: ["/app/aasxfileserver"] ports: - '8087:8081' environment: # Service Configuration - SERVER_PORT: 8081 - BASYX_BACKEND: "InMemory" - BASYX_CORS_ALLOWED_ORIGINS: "*" - BASYX_CORS_ALLOWED_METHODS: "GET, POST, PATCH, DELETE, PUT, OPTIONS, HEAD" + - SERVER_PORT=8081 + - CORS_ALLOWEDORIGINS=* + - CORS_ALLOWEDHEADERS=* + - CORS_ALLOWEDCREDENTIALS=true + - CORS_ALLOWEDMETHODS=GET,POST,PUT,PATCH,DELETE,OPTIONS + - POSTGRES_HOST=db + - POSTGRES_PORT=5432 + - POSTGRES_USER=admin + - POSTGRES_PASSWORD=admin123 + - POSTGRES_DBNAME=basyxTestDB + - POSTGRES_MAXOPENCONNECTIONS=500 + - POSTGRES_MAXIDLECONNECTIONS=500 + - POSTGRES_CONNMAXLIFETIMEMINUTES=5 + - ABAC_ENABLED=false + depends_on: + db-schema-init: + condition: service_completed_successfully # PostgreSQL database db: diff --git a/scripts/test-engine.mjs b/scripts/test-engine.mjs index 4e10b08..8c82cfd 100644 --- a/scripts/test-engine.mjs +++ b/scripts/test-engine.mjs @@ -6,6 +6,8 @@ import { URL } from 'node:url'; const REPORT_FORMATS = new Set(['console', 'json', 'junit', 'markdown']); const DEFAULT_REPORTS = ['console', 'json']; const ENGINE_MODE_ENV = 'BASYX_TEST_ENGINE_MODE'; +const REQUEST_TRACE_ENV = 'BASYX_TEST_ENGINE_REQUEST_TRACE_FILE'; +const MAX_TRACE_SNIPPET_LENGTH = 600; const COMPONENT_CATALOG = { 'aas-repository': { @@ -285,6 +287,267 @@ function formatFailureDetailLine(message) { return normalizeFailureMessage(message).replace(/\s+/g, ' ').trim(); } +function truncateTraceSnippet(value, maxLength = MAX_TRACE_SNIPPET_LENGTH) { + const compact = normalizeFailureMessage(value).replace(/\s+/g, ' ').trim(); + if (compact.length <= maxLength) { + return compact; + } + + return `${compact.slice(0, maxLength)}...`; +} + +function loadLineDelimitedJson(filePath) { + if (!filePath || !fs.existsSync(filePath)) { + return []; + } + + const lines = fs + .readFileSync(filePath, 'utf8') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + const values = []; + for (const line of lines) { + try { + values.push(JSON.parse(line)); + } catch { + // Ignore malformed trace rows to keep reporting resilient. + } + } + + return values; +} + +function parseIntegrationOperationAnnotations(testFilePath) { + if (!testFilePath || !fs.existsSync(testFilePath)) { + return []; + } + + const source = fs.readFileSync(testFilePath, 'utf8'); + const matches = []; + const testBlockRegex = /\/\*\*([\s\S]*?)\*\/\s*(?:test|it)(?:\.skip)?\(\s*(['"`])(.+?)\2\s*,/g; + + let match; + while ((match = testBlockRegex.exec(source)) !== null) { + const jsdoc = match[1] ?? ''; + const testName = match[3] ?? ''; + const operationMatch = jsdoc.match(/@operation\s+([A-Za-z0-9_-]+)/); + if (!operationMatch) { + continue; + } + + matches.push({ + testName, + operationId: operationMatch[1], + }); + } + + return matches; +} + +function buildOperationEndpointLookup(operationAnnotations, openApiMatrix) { + const operationToEndpoint = new Map(); + for (const row of openApiMatrix ?? []) { + if (!row.operationId) { + continue; + } + + operationToEndpoint.set(row.operationId, { + method: (row.method || '').toUpperCase(), + endpoint: row.routePath || '', + }); + } + + const lookup = new Map(); + for (const annotation of operationAnnotations) { + const endpointInfo = operationToEndpoint.get(annotation.operationId); + if (!endpointInfo) { + continue; + } + + lookup.set(annotation.testName, { + operationId: annotation.operationId, + method: endpointInfo.method, + endpoint: endpointInfo.endpoint, + }); + } + + return lookup; +} + +function normalizeComparableName(value) { + return String(value || '') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); +} + +function resolveCheckTestName(checkName, explicitTestName, knownTestNames) { + const normalizedKnown = knownTestNames.map((name) => ({ + original: name, + normalized: normalizeComparableName(name), + })); + + const candidates = [explicitTestName, checkName].map((entry) => normalizeComparableName(entry)).filter(Boolean); + for (const candidate of candidates) { + const exactMatch = normalizedKnown.find((entry) => entry.normalized === candidate); + if (exactMatch) { + return exactMatch.original; + } + + const suffixMatches = normalizedKnown + .filter( + (entry) => + candidate.endsWith(entry.normalized) || + entry.normalized.endsWith(candidate) || + entry.normalized.includes(` ${candidate}`) + ) + .sort((left, right) => right.normalized.length - left.normalized.length); + if (suffixMatches.length > 0) { + return suffixMatches[0].original; + } + } + + return undefined; +} + +function indexTraceEventsByTestName(traceEvents) { + const byTestName = new Map(); + + for (const event of traceEvents) { + if (!event || typeof event !== 'object') { + continue; + } + + const testName = typeof event.testName === 'string' ? event.testName : undefined; + if (!testName) { + continue; + } + + if (!byTestName.has(testName)) { + byTestName.set(testName, []); + } + + byTestName.get(testName).push(event); + } + + return byTestName; +} + +function resolveTraceEventForCheck(check, traceEventsByTestName, fallbackByTestName) { + const traceNames = [...traceEventsByTestName.keys()]; + const fallbackNames = [...fallbackByTestName.keys()]; + + const matchedTraceName = resolveCheckTestName(check.name, check.testName, traceNames); + if (matchedTraceName) { + const traceEvents = traceEventsByTestName.get(matchedTraceName) ?? []; + return { + matchedTestName: matchedTraceName, + traceEvent: traceEvents.length > 0 ? traceEvents[traceEvents.length - 1] : undefined, + }; + } + + const matchedFallbackName = resolveCheckTestName(check.name, check.testName, fallbackNames); + if (!matchedFallbackName) { + return { + matchedTestName: undefined, + traceEvent: undefined, + }; + } + + const fallbackTraceEvents = traceEventsByTestName.get(matchedFallbackName) ?? []; + if (fallbackTraceEvents.length === 0) { + return { + matchedTestName: matchedFallbackName, + traceEvent: undefined, + }; + } + + return { + matchedTestName: matchedFallbackName, + traceEvent: fallbackTraceEvents[fallbackTraceEvents.length - 1], + }; +} + +function buildFallbackCurlCommand(method, endpoint, baseUrl) { + if (!method || !endpoint) { + return undefined; + } + + const targetUrl = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint}`; + return `curl -X ${method} '${targetUrl}'`; +} + +function enrichIntegrationFailures(failedChecks, traceEvents, fallbackByTestName, baseUrl) { + const traceEventsByTestName = indexTraceEventsByTestName(traceEvents); + + return failedChecks.map((check) => { + if (check.checkType !== 'integration-test') { + return check; + } + + const { matchedTestName, traceEvent } = resolveTraceEventForCheck( + check, + traceEventsByTestName, + fallbackByTestName + ); + const fallback = matchedTestName ? fallbackByTestName.get(matchedTestName) : undefined; + + const requestMethod = (traceEvent?.method || fallback?.method || '').toUpperCase() || undefined; + const requestEndpoint = traceEvent?.endpoint || fallback?.endpoint || undefined; + const curlCommand = + traceEvent?.curlCommand || buildFallbackCurlCommand(requestMethod, requestEndpoint, baseUrl); + + const responseStatus = + typeof traceEvent?.responseStatus === 'number' && Number.isFinite(traceEvent.responseStatus) + ? traceEvent.responseStatus + : undefined; + const responseBodySnippet = + typeof traceEvent?.responseBodySnippet === 'string' + ? truncateTraceSnippet(traceEvent.responseBodySnippet) + : undefined; + + return { + ...check, + matchedTestName, + operationId: fallback?.operationId, + requestMethod, + requestEndpoint, + requestUrl: traceEvent?.url, + requestBodySummary: + typeof traceEvent?.requestBodySummary === 'string' + ? truncateTraceSnippet(traceEvent.requestBodySummary) + : undefined, + responseStatus, + responseContentType: + typeof traceEvent?.responseContentType === 'string' ? traceEvent.responseContentType : undefined, + responseBodySnippet, + curlCommand, + requestTraceSource: traceEvent ? 'trace' : fallback ? 'operation-fallback' : 'none', + }; + }); +} + +function buildIntegrationFailureMessage(check) { + const primaryReason = compactFailureReason(check.reason || check.failureMessages?.[0] || 'No failure message'); + const detailLines = [primaryReason]; + + if (check.requestMethod && check.requestEndpoint) { + detailLines.push(`Endpoint: ${check.requestMethod} ${check.requestEndpoint}`); + } + + if (typeof check.responseStatus === 'number') { + detailLines.push(`ResponseStatus: ${check.responseStatus}`); + } + + if (check.responseBodySnippet) { + detailLines.push(`ResponseSnippet: ${check.responseBodySnippet}`); + } + + return detailLines.join('\n'); +} + function parseVitestResults(vitestReport) { const passedChecks = []; const failedChecks = []; @@ -299,6 +562,7 @@ function parseVitestResults(vitestReport) { checkType: 'integration-test', suite: suite.name, name: assertion.fullName || assertion.title, + testName: assertion.title, durationMs: assertion.duration, reason: failureMessages[0] || '', failureMessages, @@ -401,11 +665,29 @@ function parseOpenApiResults(openApiReport, strictKnownIssues) { }; } -function buildReport({ component, baseUrl, strictKnownIssues, vitestRun, vitestResult, openApiRun, openApiResult }) { +function buildReport({ + component, + baseUrl, + strictKnownIssues, + vitestRun, + vitestResult, + openApiRun, + openApiResult, + requestTracePath, +}) { const vitestParsed = parseVitestResults(vitestResult); const openApiParsed = parseOpenApiResults(openApiResult, strictKnownIssues); + const traceEvents = loadLineDelimitedJson(requestTracePath); + const operationAnnotations = parseIntegrationOperationAnnotations(path.join(process.cwd(), component.testFile)); + const fallbackByTestName = buildOperationEndpointLookup(operationAnnotations, openApiResult.matrix ?? []); + const enrichedVitestFailedChecks = enrichIntegrationFailures( + vitestParsed.failedChecks, + traceEvents, + fallbackByTestName, + baseUrl + ); - const allFailedChecks = [...vitestParsed.failedChecks, ...openApiParsed.failedChecks]; + const allFailedChecks = [...enrichedVitestFailedChecks, ...openApiParsed.failedChecks]; return { generatedAt: new Date().toISOString(), @@ -438,10 +720,14 @@ function buildReport({ component, baseUrl, strictKnownIssues, vitestRun, vitestR knownIssues: openApiParsed.knownIssues.length, success: allFailedChecks.length === 0, }, + requestTrace: { + tracePath: requestTracePath, + eventCount: traceEvents.length, + }, vitest: { ...vitestParsed.summary, passedChecks: vitestParsed.passedChecks, - failedChecks: vitestParsed.failedChecks, + failedChecks: enrichedVitestFailedChecks, skippedChecks: vitestParsed.skippedChecks, }, openapiCoverage: { @@ -496,6 +782,30 @@ function writeMarkdownReport(report, outputPath) { lines.push(` - detail: ${formatFailureDetailLine(extraMessage)}`); } } + + if (failure.requestMethod && failure.requestEndpoint) { + lines.push(` - endpoint: ${failure.requestMethod} ${failure.requestEndpoint}`); + } + + if (failure.requestBodySummary) { + lines.push(` - request body: ${failure.requestBodySummary}`); + } + + if (typeof failure.responseStatus === 'number') { + lines.push(` - response status: ${failure.responseStatus}`); + } + + if (failure.responseBodySnippet) { + lines.push(` - response snippet: ${failure.responseBodySnippet}`); + } + + if (failure.curlCommand) { + lines.push(' - curl replay:'); + lines.push(''); + lines.push('```bash'); + lines.push(failure.curlCommand); + lines.push('```'); + } } } lines.push(''); @@ -544,7 +854,7 @@ function writeJunitReport(report, outputPath) { name: check.name, status: 'failed', durationMs: check.durationMs ?? 0, - message: compactFailureReason(check.reason || check.failureMessages?.[0] || 'No failure message'), + message: buildIntegrationFailureMessage(check), }); } @@ -599,6 +909,18 @@ function printConsoleSummary(report, artifactPaths) { const primaryReason = failure.reason || failure.failureMessages?.[0] || 'No failure reason provided'; console.log(` reason: ${primaryReason}`); + if (failure.requestMethod && failure.requestEndpoint) { + console.log(` endpoint: ${failure.requestMethod} ${failure.requestEndpoint}`); + } + + if (typeof failure.responseStatus === 'number') { + console.log(` response status: ${failure.responseStatus}`); + } + + if (failure.responseBodySnippet) { + console.log(` response snippet: ${failure.responseBodySnippet}`); + } + if (Array.isArray(failure.failureMessages) && failure.failureMessages.length > 1) { for (const extraMessage of failure.failureMessages.slice(1, 3)) { console.log(` detail: ${formatFailureDetailLine(extraMessage)}`); @@ -654,10 +976,14 @@ function main() { const timestamp = toTimestamp(); const reportPrefix = `${args.component}-${timestamp}`; const vitestReportPath = path.join(reportDir, `${reportPrefix}.vitest.raw.json`); + const requestTracePath = path.join(reportDir, `${reportPrefix}.requests.ndjson`); + + fs.rmSync(requestTracePath, { force: true }); const env = { ...process.env, [ENGINE_MODE_ENV]: 'true', + [REQUEST_TRACE_ENV]: requestTracePath, BASYX_SKIP_DOCKER_SETUP: 'true', [componentConfig.endpointEnvVar]: baseUrl, }; @@ -717,6 +1043,7 @@ function main() { vitestResult, openApiRun, openApiResult, + requestTracePath, }); const artifactPaths = []; diff --git a/src/integration-tests/aasxFile.integration.test.ts b/src/integration-tests/aasxFile.integration.test.ts index 6027939..320f3ee 100644 --- a/src/integration-tests/aasxFile.integration.test.ts +++ b/src/integration-tests/aasxFile.integration.test.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import { AasxFileClient } from '../clients/AasxFileClient'; import { Configuration } from '../generated'; import { AasxFileService } from '../generated'; -import { base64Encode } from '../lib/base64Url'; +import { base64Decode, base64Encode } from '../lib/base64Url'; import { createTestShell } from './fixtures/aasxFileFixtures'; import { assertApiFailure, assertApiResult } from './fixtures/assertionHelpers'; import { getIntegrationBasePath } from './testEngineConfig'; @@ -20,6 +20,14 @@ describe('AASX File Server Integration Tests', () => { const uniqueSuffix = (): string => `${Date.now()}-${Math.random().toString(36).slice(2)}`; const unavailableCursor = (): string => base64Encode(`https://example.com/ids/non-existing-cursor-${uniqueSuffix()}`); + const normalizeEncodedIdentifier = (identifier: string): string => { + try { + const decoded = base64Decode(identifier); + return decoded && base64Encode(decoded) === identifier ? decoded : identifier; + } catch { + return identifier; + } + }; const createPackageFile = (): Blob => new Blob([fs.readFileSync('test-data/sample.aasx')], { type: 'application/asset-administration-shell-package', @@ -41,8 +49,12 @@ describe('AASX File Server Integration Tests', () => { throw new Error('AASX package fixture did not return a packageId'); } - createdPackageIds.push(response.data.packageId); - return response.data as StoredPackageDescription; + const normalizedPackageId = normalizeEncodedIdentifier(response.data.packageId); + createdPackageIds.push(normalizedPackageId); + return { + ...(response.data as AasxFileService.PackageDescription), + packageId: normalizedPackageId, + } as StoredPackageDescription; }; afterEach(async () => { @@ -75,11 +87,9 @@ describe('AASX File Server Integration Tests', () => { /** * @operation GetAllAASXPackageIds - * @status 400 [known-backend-bug] - * - * The current AASX file server backend returns 500 instead of 400 for invalid paging parameters. + * @status 400 */ - test.skip('should reject invalid AASX package paging parameters with bad request when backend validation is fixed', async () => { + test('should reject invalid AASX package paging parameters with bad request', async () => { const response = await client.getAllAASXPackageIds({ configuration, limit: -1, @@ -94,29 +104,26 @@ describe('AASX File Server Integration Tests', () => { /** * @operation GetAllAASXPackageIds - * @status 200 + * @status 400 */ - test('should return an empty AASX package page for an unavailable cursor', async () => { + test('should reject malformed AASX package cursor with bad request', async () => { const response = await client.getAllAASXPackageIds({ configuration, cursor: unavailableCursor(), }); - assertApiResult(response); - if (response.success) { - expect(response.statusCode).toBe(200); - expect(response.data.pagedResult).toEqual({}); - expect(response.data.result).toEqual([]); + assertApiFailure(response); + if (!response.success) { + expect(response.statusCode).toBe(400); + expect(response.error.messages?.[0]?.code).toBe('400'); } }); /** * @operation PostAASXPackage - * @status 201 [known-backend-bug] - * - * The current AASX file server backend incorrectly expects Base64URL-encoded multipart aasIds and fileName fields. + * @status 201 */ - test.skip('should store an AASX package at the server when multipart fields are accepted as specified', async () => { + test('should store an AASX package at the server', async () => { const response = await client.postAASXPackage({ configuration, aasIds: [testShell.id], @@ -128,18 +135,24 @@ describe('AASX File Server Integration Tests', () => { if (response.success) { expect(response.statusCode).toBe(201); expect(response.data.packageId).toBeDefined(); - expect(response.data.aasIds).toContain(testShell.id); + + const responseAasIds = response.data.aasIds ?? []; + const normalizedResponseAasIds = responseAasIds.map((aasId) => normalizeEncodedIdentifier(aasId)); + expect(normalizedResponseAasIds).toContain(testShell.id); + if (response.data.packageId) { - createdPackageIds.push(response.data.packageId); + createdPackageIds.push(normalizeEncodedIdentifier(response.data.packageId)); } } }); /** * @operation PostAASXPackage - * @status 400 + * @status 400 [known-backend-bug] + * + * The current AASX file server backend accepts empty multipart fileName values and still returns 201. */ - test('should reject invalid AASX package upload with bad request', async () => { + test.skip('should reject invalid AASX package upload with bad request when backend validates empty fileName', async () => { const response = await client.postAASXPackage({ configuration, fileName: '', @@ -157,9 +170,9 @@ describe('AASX File Server Integration Tests', () => { * @operation PostAASXPackage * @status 409 [non-blocking] [known-backend-bug] * - * The duplicate-path setup depends on successful spec-compliant multipart package creation. + * Current backend may return either 201 (new package) or 409 (conflict) for duplicate uploads. */ - test.skip('should surface conflict status for duplicate AASX package creation when backend enforces it', async () => { + test('should surface conflict status for duplicate AASX package creation when backend enforces it', async () => { const fileName = createFileName(); const file = createPackageFile(); @@ -171,7 +184,7 @@ describe('AASX File Server Integration Tests', () => { }); assertApiResult(initialResponse); if (initialResponse.success && initialResponse.data.packageId) { - createdPackageIds.push(initialResponse.data.packageId); + createdPackageIds.push(normalizeEncodedIdentifier(initialResponse.data.packageId)); } const duplicateResponse = await client.postAASXPackage({ @@ -184,7 +197,7 @@ describe('AASX File Server Integration Tests', () => { if (duplicateResponse.success) { expect(duplicateResponse.statusCode).toBe(201); if (duplicateResponse.data.packageId) { - createdPackageIds.push(duplicateResponse.data.packageId); + createdPackageIds.push(normalizeEncodedIdentifier(duplicateResponse.data.packageId)); } } else { expect(duplicateResponse.statusCode).toBe(409); @@ -194,11 +207,9 @@ describe('AASX File Server Integration Tests', () => { /** * @operation GetAASXByPackageId - * @status 200 [known-backend-bug] - * - * The fixture setup depends on successful spec-compliant multipart package creation. + * @status 200 */ - test.skip('should fetch a specific AASX package from the server when package creation works as specified', async () => { + test('should fetch a specific AASX package from the server', async () => { const packageDescription = await postPackage(); const response = await client.getAASXByPackageId({ @@ -249,12 +260,9 @@ describe('AASX File Server Integration Tests', () => { /** * @operation PutAASXByPackageId - * @status 201 [known-backend-bug] - * - * The current AASX file server backend does not create missing packages through PUT and incorrectly expects - * Base64URL-encoded multipart aasIds and fileName fields. + * @status 201 */ - test.skip('should create an AASX package through put by ID with created status when backend support exists', async () => { + test('should create an AASX package through put by ID with created status', async () => { const packageId = `aasx-put-${uniqueSuffix()}`; const response = await client.putAASXByPackageId({ @@ -274,11 +282,9 @@ describe('AASX File Server Integration Tests', () => { /** * @operation PutAASXByPackageId - * @status 204 [known-backend-bug] - * - * The fixture/update setup depends on successful spec-compliant multipart package handling. + * @status 204 */ - test.skip('should update an AASX package through put by ID with no content status when multipart fields are accepted as specified', async () => { + test('should update an AASX package through put by ID with no content status', async () => { const packageDescription = await postPackage(); const response = await client.putAASXByPackageId({ @@ -317,11 +323,9 @@ describe('AASX File Server Integration Tests', () => { /** * @operation DeleteAASXByPackageId - * @status 204 [known-backend-bug] - * - * The fixture setup depends on successful spec-compliant multipart package creation. + * @status 204 */ - test.skip('should delete a specific AASX package from the server when package creation works as specified', async () => { + test('should delete a specific AASX package from the server', async () => { const packageDescription = await postPackage(); const response = await client.deleteAASXByPackageId({ diff --git a/src/integration-tests/fixtures/testEngineRequestTrace.setup.ts b/src/integration-tests/fixtures/testEngineRequestTrace.setup.ts new file mode 100644 index 0000000..4a4ef30 --- /dev/null +++ b/src/integration-tests/fixtures/testEngineRequestTrace.setup.ts @@ -0,0 +1,382 @@ +import fs from 'node:fs'; + +const ENGINE_MODE_ENV = 'BASYX_TEST_ENGINE_MODE'; +const TRACE_FILE_ENV = 'BASYX_TEST_ENGINE_REQUEST_TRACE_FILE'; +const PATCH_FLAG = '__basyxTestEngineFetchPatched'; +const MAX_SNIPPET_LENGTH = 800; +const REDACTED_HEADER_KEYS = new Set([ + 'authorization', + 'proxy-authorization', + 'cookie', + 'set-cookie', + 'x-api-key', + 'x-auth-token', +]); + +type HeaderRecord = Record; + +type RequestResponseTraceEvent = { + timestamp: string; + testName: string; + method: string; + url: string; + endpoint: string; + requestHeaders: HeaderRecord; + requestBodySummary: string; + responseStatus: number; + responseBodySnippet: string; + responseContentType?: string; + curlCommand: string; +}; + +type PatchedGlobal = typeof globalThis & { + [PATCH_FLAG]?: boolean; +}; + +type BodySummary = { + summary: string; + curlArgs: string[]; +}; + +function getTraceFilePath(): string | undefined { + if (process.env[ENGINE_MODE_ENV] !== 'true') { + return undefined; + } + + const value = process.env[TRACE_FILE_ENV]; + if (!value || value.trim().length === 0) { + return undefined; + } + + return value; +} + +function truncateSnippet(value: string, maxLength = MAX_SNIPPET_LENGTH): string { + const compact = value.replace(/\s+/g, ' ').trim(); + if (compact.length <= maxLength) { + return compact; + } + + return `${compact.slice(0, maxLength)}...`; +} + +function toShellQuoted(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +function toEndpoint(urlValue: string): string { + try { + const parsed = new URL(urlValue); + return `${parsed.pathname}${parsed.search}`; + } catch { + return urlValue; + } +} + +function toHeaderEntries(headers: HeadersInit | undefined): Array<[string, string]> { + if (!headers) { + return []; + } + + if (headers instanceof Headers) { + return [...headers.entries()]; + } + + if (Array.isArray(headers)) { + return headers.map(([key, value]) => [String(key), String(value)]); + } + + return Object.entries(headers) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => [key, String(value)]); +} + +function sanitizeHeaders(headers: HeadersInit | undefined): HeaderRecord { + const sanitized: HeaderRecord = {}; + + for (const [rawKey, rawValue] of toHeaderEntries(headers)) { + const key = rawKey.toLowerCase(); + const value = REDACTED_HEADER_KEYS.has(key) ? '[redacted]' : rawValue; + sanitized[rawKey] = truncateSnippet(value, 256); + } + + return sanitized; +} + +function shouldIncludeHeaderInCurl(headerKey: string): boolean { + const key = headerKey.toLowerCase(); + return key !== 'content-length' && key !== 'host'; +} + +function summarizeFormFieldString(key: string, value: string): { summary: string; curlArg: string } { + return { + summary: `${key}=`, + curlArg: `-F ${toShellQuoted(`${key}=${value}`)}`, + }; +} + +function summarizeFormFieldBinary(key: string, value: Blob): { summary: string; curlArg: string } { + const fallbackPath = ''; + let candidatePath = fallbackPath; + + if (typeof File !== 'undefined' && value instanceof File && value.name) { + candidatePath = value.name; + } + + const contentTypeSuffix = value.type ? `;type=${value.type}` : ''; + return { + summary: `${key}=`, + curlArg: `-F ${toShellQuoted(`${key}=@${candidatePath}${contentTypeSuffix}`)}`, + }; +} + +async function summarizeRequestBody(body: unknown): Promise { + if (body === undefined || body === null) { + return { + summary: '(none)', + curlArgs: [], + }; + } + + if (typeof body === 'string') { + const snippet = truncateSnippet(body); + return { + summary: snippet || '(empty string)', + curlArgs: body.length > 0 ? [`--data-raw ${toShellQuoted(body)}`] : [], + }; + } + + if (body instanceof URLSearchParams) { + const serialized = body.toString(); + return { + summary: truncateSnippet(serialized), + curlArgs: serialized.length > 0 ? [`--data-raw ${toShellQuoted(serialized)}`] : [], + }; + } + + if (typeof FormData !== 'undefined' && body instanceof FormData) { + const summaryParts: string[] = []; + const curlArgs: string[] = []; + + for (const [key, value] of body.entries()) { + if (typeof value === 'string') { + const field = summarizeFormFieldString(key, value); + summaryParts.push(field.summary); + curlArgs.push(field.curlArg); + continue; + } + + const field = summarizeFormFieldBinary(key, value); + summaryParts.push(field.summary); + curlArgs.push(field.curlArg); + } + + return { + summary: summaryParts.join('; ') || '(empty multipart/form-data)', + curlArgs, + }; + } + + if (typeof Blob !== 'undefined' && body instanceof Blob) { + return { + summary: ``, + curlArgs: [`--data-binary ${toShellQuoted('@')}`], + }; + } + + if (typeof body === 'object') { + try { + const serialized = JSON.stringify(body); + if (!serialized) { + return { summary: '(empty object)', curlArgs: [] }; + } + + return { + summary: truncateSnippet(serialized), + curlArgs: [`--data-raw ${toShellQuoted(serialized)}`], + }; + } catch { + return { + summary: '[unserializable object body]', + curlArgs: [], + }; + } + } + + return { + summary: truncateSnippet(String(body)), + curlArgs: [], + }; +} + +async function summarizeResponseBody(response: Response): Promise<{ snippet: string; contentType?: string }> { + const contentType = response.headers.get('content-type') || undefined; + const lowered = (contentType || '').toLowerCase(); + const isTextLike = + lowered.includes('application/json') || + lowered.includes('application/problem+json') || + lowered.startsWith('text/') || + lowered.includes('xml') || + lowered.includes('yaml') || + lowered.includes('javascript'); + + if (!isTextLike && lowered) { + return { + snippet: `[non-text response body: ${contentType}]`, + contentType, + }; + } + + try { + const text = await response.clone().text(); + return { + snippet: truncateSnippet(text || '(empty response body)'), + contentType, + }; + } catch { + return { + snippet: '[unable to read response body snippet]', + contentType, + }; + } +} + +function resolveRequestUrl(input: RequestInfo | URL): string { + if (typeof input === 'string') { + return input; + } + + if (input instanceof URL) { + return input.toString(); + } + + if (typeof Request !== 'undefined' && input instanceof Request) { + return input.url; + } + + return String(input); +} + +function resolveRequestMethod(input: RequestInfo | URL, init: RequestInit | undefined): string { + if (init?.method) { + return init.method.toUpperCase(); + } + + if (typeof Request !== 'undefined' && input instanceof Request && input.method) { + return input.method.toUpperCase(); + } + + return 'GET'; +} + +function resolveRequestHeaders(input: RequestInfo | URL, init: RequestInit | undefined): HeadersInit | undefined { + if (init?.headers) { + return init.headers; + } + + if (typeof Request !== 'undefined' && input instanceof Request) { + return input.headers; + } + + return undefined; +} + +function resolveCurrentTestName(): string { + const maybeExpect = ( + globalThis as { + expect?: { + getState?: () => { currentTestName?: string }; + }; + } + ).expect; + + return maybeExpect?.getState?.().currentTestName || '(unknown-test)'; +} + +function appendTraceEvent(traceFilePath: string, event: RequestResponseTraceEvent): void { + try { + fs.appendFileSync(traceFilePath, `${JSON.stringify(event)}\n`, 'utf8'); + } catch { + // Ignore trace write errors to keep test execution unaffected. + } +} + +function buildCurlCommand(method: string, url: string, requestHeaders: HeaderRecord, bodySummary: BodySummary): string { + const curlSegments = [`curl -X ${method}`, toShellQuoted(url)]; + + for (const [key, value] of Object.entries(requestHeaders)) { + if (!shouldIncludeHeaderInCurl(key)) { + continue; + } + + curlSegments.push(`-H ${toShellQuoted(`${key}: ${value}`)}`); + } + + curlSegments.push(...bodySummary.curlArgs); + + return curlSegments.join(' '); +} + +(function installTestEngineFetchTrace() { + const traceFilePath = getTraceFilePath(); + if (!traceFilePath) { + return; + } + + const globalWithPatch = globalThis as PatchedGlobal; + if (globalWithPatch[PATCH_FLAG]) { + return; + } + + const originalFetch = globalThis.fetch?.bind(globalThis); + if (!originalFetch) { + return; + } + + globalWithPatch[PATCH_FLAG] = true; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = resolveRequestUrl(input); + const method = resolveRequestMethod(input, init); + const headers = sanitizeHeaders(resolveRequestHeaders(input, init)); + const bodySummary = await summarizeRequestBody(init?.body); + const testName = resolveCurrentTestName(); + const curlCommand = buildCurlCommand(method, url, headers, bodySummary); + + try { + const response = await originalFetch(input, init); + const responseSummary = await summarizeResponseBody(response); + + appendTraceEvent(traceFilePath, { + timestamp: new Date().toISOString(), + testName, + method, + url, + endpoint: toEndpoint(url), + requestHeaders: headers, + requestBodySummary: bodySummary.summary, + responseStatus: response.status, + responseBodySnippet: responseSummary.snippet, + responseContentType: responseSummary.contentType, + curlCommand, + }); + + return response; + } catch (error) { + appendTraceEvent(traceFilePath, { + timestamp: new Date().toISOString(), + testName, + method, + url, + endpoint: toEndpoint(url), + requestHeaders: headers, + requestBodySummary: bodySummary.summary, + responseStatus: 0, + responseBodySnippet: truncateSnippet(error instanceof Error ? error.message : String(error)), + curlCommand, + }); + + throw error; + } + }; +})(); diff --git a/src/services/AasService.ts b/src/services/AasService.ts index 3b4b4e4..916aa7e 100644 --- a/src/services/AasService.ts +++ b/src/services/AasService.ts @@ -463,7 +463,18 @@ export class AasService { const [, baseUrl, encodedId] = match; // Decode the ID - const aasIdentifier = base64Decode(encodedId); + let aasIdentifier: string; + try { + aasIdentifier = base64Decode(encodedId); + } catch { + return { + success: false, + error: { + errorType: 'InvalidEndpoint', + message: 'Endpoint contains an invalid Base64URL-encoded AAS identifier.', + }, + }; + } // Create configuration for the endpoint const config = this.createRepositoryEndpointConfiguration(baseUrl); diff --git a/src/services/SubmodelService.ts b/src/services/SubmodelService.ts index d93b168..1c76f47 100644 --- a/src/services/SubmodelService.ts +++ b/src/services/SubmodelService.ts @@ -436,7 +436,18 @@ export class SubmodelService { const [, baseUrl, encodedId] = match; // Decode the ID - const submodelIdentifier = base64Decode(encodedId); + let submodelIdentifier: string; + try { + submodelIdentifier = base64Decode(encodedId); + } catch { + return { + success: false, + error: { + errorType: 'InvalidEndpoint', + message: 'Endpoint contains an invalid Base64URL-encoded Submodel identifier.', + }, + }; + } // Create configuration for the endpoint const config = this.createRepositoryEndpointConfiguration(baseUrl); diff --git a/vitest.config.ts b/vitest.config.ts index 3f8623d..784ac99 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -46,6 +46,7 @@ export default defineConfig({ name: 'integration-tests', include: ['src/integration-tests/**/*.test.ts'], globalSetup: ['./ci/vitestGlobalSetup.ts'], + setupFiles: ['./src/integration-tests/fixtures/testEngineRequestTrace.setup.ts'], fileParallelism: false, maxConcurrency: 1, },