diff --git a/README.md b/README.md index 2bf51a5b..57dc03b1 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,12 @@ High-level orchestration services: - Create, read, update, delete AAS with automatic registry synchronization - Fetch AAS with their submodels in a single call - Automatic endpoint resolution and fallback handling + - Optional registry resolution warnings and strict fail-fast mode for list operations - **SubmodelService**: Unified API for Submodel operations across registry and repository - Create, read, update, delete Submodels with automatic registry synchronization - Flexible endpoint-based retrieval - Automatic fallback to repository when registry unavailable + - Optional registry resolution warnings and strict fail-fast mode for list operations Clients for the AAS API components: @@ -258,8 +260,17 @@ if (createResult.success) { const listResult = await service.getAasList(); if (listResult.success) { console.log('All shells:', listResult.data.shells); + // Optional warnings for descriptors that could not be resolved + if (listResult.data.warnings?.length) { + console.warn('Registry resolution warnings:', listResult.data.warnings); + } } +// Strict mode: fail immediately if any registry descriptor cannot be resolved +const strictListResult = await service.getAasList({ + strictRegistryResolution: true, +}); + // Get AAS by ID (uses registry endpoint if available) const getResult = await service.getAasById({ aasIdentifier: 'https://example.com/ids/aas/my-aas', @@ -431,8 +442,18 @@ const listResult = await service.getSubmodelList({ preferRegistry: true }); if (listResult.success) { console.log('All submodels:', listResult.data.submodels); console.log('Fetched from:', listResult.data.source); // 'registry' or 'repository' + // Optional warnings for descriptors that could not be resolved + if (listResult.data.warnings?.length) { + console.warn('Registry resolution warnings:', listResult.data.warnings); + } } +// Strict mode: fail immediately if any registry descriptor cannot be resolved +const strictSubmodelListResult = await service.getSubmodelList({ + preferRegistry: true, + strictRegistryResolution: true, +}); + // Get Submodel by ID (uses registry endpoint if available) const getResult = await service.getSubmodelById({ submodelIdentifier: 'https://example.com/ids/sm/my-submodel', diff --git a/src/integration-tests/aasService.integration.test.ts b/src/integration-tests/aasService.integration.test.ts index dbc14309..f9a1df5d 100644 --- a/src/integration-tests/aasService.integration.test.ts +++ b/src/integration-tests/aasService.integration.test.ts @@ -6,7 +6,7 @@ import { SpecificAssetId, } from '@aas-core-works/aas-core3.1-typescript/types'; import { AasDiscoveryClient } from '../clients/AasDiscoveryClient'; -import { Configuration } from '../generated'; +import { Configuration, type FetchAPI } from '../generated'; import { base64Encode } from '../lib/base64Url'; import { AasService } from '../services/AasService'; import { createGlobalAssetIdFromAasId, createTestShell } from './fixtures/aasFixtures'; @@ -287,6 +287,43 @@ describe('AasService Integration Tests', () => { // Cleanup await aasService.deleteAas({ aasIdentifier: testShell.id }); }); + + test('should apply repository fetchApi when resolving registry descriptor endpoints', async () => { + const { testShell } = createUniqueTestData(); + + // Register an AAS first + await aasService.createAas({ + shell: testShell, + }); + + let repositoryFetchCalls = 0; + const trackedFetchApi: FetchAPI = async (input, init) => { + repositoryFetchCalls++; + return fetch(input, init); + }; + + const serviceWithTrackedRepositoryFetch = new AasService({ + aasRegistryConfig: new Configuration({ basePath: 'http://localhost:8084' }), + aasRepositoryConfig: new Configuration({ + basePath: 'http://localhost:8081', + fetchApi: trackedFetchApi, + }), + }); + + const result = await serviceWithTrackedRepositoryFetch.getAasList({ preferRegistry: true }); + + assertApiResult(result); + if (result.success) { + expect(result.data.source).toBe('registry'); + const foundShell = result.data.shells.find((s) => s.id === testShell.id); + expect(foundShell).toBeDefined(); + } + // Ensures endpoint-resolution repository calls actually used repository Configuration. + expect(repositoryFetchCalls).toBeGreaterThan(0); + + // Cleanup + await aasService.deleteAas({ aasIdentifier: testShell.id }); + }); }); describe('getAasById', () => { diff --git a/src/integration-tests/submodelService.integration.test.ts b/src/integration-tests/submodelService.integration.test.ts index 50b1ceb4..f117a225 100644 --- a/src/integration-tests/submodelService.integration.test.ts +++ b/src/integration-tests/submodelService.integration.test.ts @@ -1,4 +1,4 @@ -import { Configuration } from '../generated'; +import { Configuration, type FetchAPI } from '../generated'; import { base64Encode } from '../lib/base64Url'; import { SubmodelService } from '../services/SubmodelService'; import { createTestSubmodelDescriptor } from './fixtures/aasregistryFixtures'; @@ -278,6 +278,43 @@ describe('SubmodelService Integration Tests', () => { // Cleanup await submodelService.deleteSubmodel({ submodelIdentifier: testSubmodel.id }); }); + + test('should apply repository fetchApi when resolving registry descriptor endpoints', async () => { + const { testSubmodel } = createUniqueTestData(); + + // Register a Submodel first + await submodelService.createSubmodel({ + submodel: testSubmodel, + }); + + let repositoryFetchCalls = 0; + const trackedFetchApi: FetchAPI = async (input, init) => { + repositoryFetchCalls++; + return fetch(input, init); + }; + + const serviceWithTrackedRepositoryFetch = new SubmodelService({ + submodelRegistryConfig: new Configuration({ basePath: 'http://localhost:8085' }), + submodelRepositoryConfig: new Configuration({ + basePath: 'http://localhost:8082', + fetchApi: trackedFetchApi, + }), + }); + + const result = await serviceWithTrackedRepositoryFetch.getSubmodelList({ preferRegistry: true }); + + assertApiResult(result); + if (result.success) { + expect(result.data.source).toBe('registry'); + const foundSubmodel = result.data.submodels.find((s) => s.id === testSubmodel.id); + expect(foundSubmodel).toBeDefined(); + } + // Ensures endpoint-resolution repository calls actually used repository Configuration. + expect(repositoryFetchCalls).toBeGreaterThan(0); + + // Cleanup + await submodelService.deleteSubmodel({ submodelIdentifier: testSubmodel.id }); + }); }); describe('getSubmodelById', () => { diff --git a/src/services/AasService.ts b/src/services/AasService.ts index 3bc82d82..3b4b4e40 100644 --- a/src/services/AasService.ts +++ b/src/services/AasService.ts @@ -25,6 +25,13 @@ export interface AasServiceConfig { discoveryConfig?: Configuration; } +export interface AasListResolutionWarning { + descriptorId?: string; + endpoint?: string; + errorType: string; + message: string; +} + /** * AasService combines and orchestrates multiple clients and services, including: * - AasRegistryClient @@ -70,15 +77,21 @@ export class AasService { * * @param options Object containing: * - preferRegistry?: Whether to prefer registry over repository (default: true) + * - strictRegistryResolution?: Whether registry endpoint resolution failures should fail immediately (default: false) * - limit?: Maximum number of elements to retrieve * - cursor?: Pagination cursor * - includeSubmodels?: Whether to fetch submodels for each shell (default: false) * - includeConceptDescriptions?: Whether to fetch concept descriptions (default: false) * - * @returns Either `{ success: true; data: { shells, source, submodels? } }` or `{ success: false; error: ... }`. + * @returns Either + * `{ success: true; data: { shells, source, submodels?, warnings? } }` + * or `{ success: false; error: ... }`. + * When `strictRegistryResolution` is `true`, unresolved registry descriptors return + * `RegistryResolutionError` with `warnings`. */ async getAasList(options?: { preferRegistry?: boolean; + strictRegistryResolution?: boolean; limit?: number; cursor?: string; includeSubmodels?: boolean; @@ -89,12 +102,15 @@ export class AasService { shells: AssetAdministrationShell[]; source: 'registry' | 'repository'; submodels?: Record; + warnings?: AasListResolutionWarning[]; }, any > > { const preferRegistry = options?.preferRegistry ?? true; + const strictRegistryResolution = options?.strictRegistryResolution ?? false; const includeSubmodels = options?.includeSubmodels ?? false; + let registryResolutionWarnings: AasListResolutionWarning[] = []; // Try registry first if configured and preferred if (preferRegistry && this.aasRegistryConfig) { @@ -107,36 +123,88 @@ export class AasService { if (registryResult.success) { // Fetch actual shells from descriptor endpoints const shells: AssetAdministrationShell[] = []; + const warnings: AasListResolutionWarning[] = []; for (const descriptor of registryResult.data.result) { + if (!descriptor.id) { + warnings.push({ + errorType: 'MissingDescriptorId', + message: 'AAS descriptor does not contain an id and cannot be resolved', + }); + continue; + } + const endpoint = extractEndpointHref(descriptor, 'AAS-3.0'); - if (endpoint && descriptor.id) { - // Extract base URL from endpoint (remove /shells/{id} part) - const baseUrl = endpoint.match(/^(https?:\/\/[^/]+(?::\d+)?)/)?.[1] || endpoint; - const config = new Configuration({ basePath: baseUrl }); - const shellResult = await this.aasRepositoryClient.getAssetAdministrationShellById({ - configuration: config, - aasIdentifier: descriptor.id, + if (!endpoint) { + warnings.push({ + descriptorId: descriptor.id, + errorType: 'MissingEndpoint', + message: 'AAS descriptor has no AAS-3.0 endpoint and cannot be resolved', + }); + continue; + } + + // Extract base URL from endpoint (remove /shells/{id} part) + const baseUrl = endpoint.match(/^(https?:\/\/[^/]+(?::\d+)?)/)?.[1] || endpoint; + const config = this.createRepositoryEndpointConfiguration(baseUrl); + const shellResult = await this.aasRepositoryClient.getAssetAdministrationShellById({ + configuration: config, + aasIdentifier: descriptor.id, + }); + if (shellResult.success) { + shells.push(shellResult.data); + } else { + const resolutionError = shellResult.error as + | { + errorType?: string; + message?: string; + messages?: Array<{ text?: string }>; + } + | undefined; + warnings.push({ + descriptorId: descriptor.id, + endpoint, + errorType: resolutionError?.errorType || 'EndpointResolutionError', + message: + resolutionError?.message || + resolutionError?.messages?.[0]?.text || + 'Failed to resolve AAS from descriptor endpoint', }); - if (shellResult.success) { - shells.push(shellResult.data); - } } } - // Fetch submodels if requested - let submodelsMap: Record | undefined; - if (includeSubmodels) { - submodelsMap = await this.fetchSubmodelsForShells(shells, options?.includeConceptDescriptions); + if (strictRegistryResolution && warnings.length > 0) { + return { + success: false, + error: { + errorType: 'RegistryResolutionError', + message: 'Failed to resolve one or more AAS descriptors via registry endpoints', + warnings, + }, + }; } - return { - success: true, - data: { - shells, - source: 'registry', - ...(submodelsMap && { submodels: submodelsMap }), - }, - }; + // If registry descriptors exist but none were resolvable, fall back to repository when possible. + if (warnings.length > 0 && shells.length === 0 && this.aasRepositoryConfig) { + registryResolutionWarnings = warnings; + } else { + // Fetch submodels if requested + let submodelsMap: Record | undefined; + if (includeSubmodels) { + submodelsMap = await this.fetchSubmodelsForShells(shells, options?.includeConceptDescriptions); + } + + return { + success: true, + data: { + shells, + source: 'registry', + ...(submodelsMap && { submodels: submodelsMap }), + ...(warnings.length > 0 && { warnings }), + }, + }; + } + } else { + registryResolutionWarnings = []; } } @@ -163,6 +231,7 @@ export class AasService { shells, source: 'repository', ...(submodelsMap && { submodels: submodelsMap }), + ...(registryResolutionWarnings.length > 0 && { warnings: registryResolutionWarnings }), }, }; } @@ -226,7 +295,7 @@ export class AasService { // Extract base URL from endpoint (remove /shells/{id} part) const baseUrl = endpoint.match(/^(https?:\/\/[^/]+(?::\d+)?)/)?.[1] || endpoint; // Try to fetch from descriptor endpoint - const config = new Configuration({ basePath: baseUrl }); + const config = this.createRepositoryEndpointConfiguration(baseUrl); const shellResult = await this.aasRepositoryClient.getAssetAdministrationShellById({ configuration: config, aasIdentifier, @@ -397,7 +466,7 @@ export class AasService { const aasIdentifier = base64Decode(encodedId); // Create configuration for the endpoint - const config = new Configuration({ basePath: baseUrl }); + const config = this.createRepositoryEndpointConfiguration(baseUrl); // Fetch the shell const shellResult = await this.aasRepositoryClient.getAssetAdministrationShellById({ @@ -862,6 +931,31 @@ export class AasService { }; } + /** + * Creates a repository configuration for an endpoint while preserving auth and middleware. + * + * @param basePath The endpoint base path to target + * @returns Configuration with endpoint basePath plus repository auth/settings + */ + private createRepositoryEndpointConfiguration(basePath: string): Configuration { + if (!this.aasRepositoryConfig) { + return new Configuration({ basePath }); + } + + return new Configuration({ + basePath, + fetchApi: this.aasRepositoryConfig.fetchApi, + middleware: this.aasRepositoryConfig.middleware, + queryParamsStringify: this.aasRepositoryConfig.queryParamsStringify, + username: this.aasRepositoryConfig.username, + password: this.aasRepositoryConfig.password, + apiKey: this.aasRepositoryConfig.apiKey, + accessToken: this.aasRepositoryConfig.accessToken, + headers: this.aasRepositoryConfig.headers, + credentials: this.aasRepositoryConfig.credentials, + }); + } + /** * Helper method to create an AAS descriptor from an AssetAdministrationShell. * Populates descriptor fields from the shell including metadata and endpoint configuration. diff --git a/src/services/SubmodelService.ts b/src/services/SubmodelService.ts index a311b4e6..d93b1686 100644 --- a/src/services/SubmodelService.ts +++ b/src/services/SubmodelService.ts @@ -14,6 +14,13 @@ export interface SubmodelServiceConfig { conceptDescriptionRepositoryConfig?: Configuration; } +export interface SubmodelListResolutionWarning { + submodelIdentifier?: string; + endpoint?: string; + errorType: string; + message: string; +} + /** * SubmodelService combines Submodel Registry, Submodel Repository, and Concept Description Repository clients * to provide higher-level functionality for working with Submodels. @@ -49,14 +56,20 @@ export class SubmodelService { * * @param options Object containing: * - preferRegistry?: Whether to prefer registry over repository (default: true) + * - strictRegistryResolution?: Whether registry endpoint resolution failures should fail immediately (default: false) * - limit?: Maximum number of elements to retrieve * - cursor?: Pagination cursor * - includeConceptDescriptions?: Whether to fetch concept descriptions (default: false) * - * @returns Either `{ success: true; data: { submodels, source, conceptDescriptions? } }` or `{ success: false; error: ... }`. + * @returns Either + * `{ success: true; data: { submodels, source, conceptDescriptions?, warnings? } }` + * or `{ success: false; error: ... }`. + * When `strictRegistryResolution` is `true`, unresolved registry descriptors return + * `RegistryResolutionError` with `warnings`. */ async getSubmodelList(options?: { preferRegistry?: boolean; + strictRegistryResolution?: boolean; limit?: number; cursor?: string; includeConceptDescriptions?: boolean; @@ -66,12 +79,15 @@ export class SubmodelService { submodels: Submodel[]; source: 'registry' | 'repository'; conceptDescriptions?: ConceptDescription[]; + warnings?: SubmodelListResolutionWarning[]; }, any > > { const preferRegistry = options?.preferRegistry ?? true; + const strictRegistryResolution = options?.strictRegistryResolution ?? false; const includeConceptDescriptions = options?.includeConceptDescriptions ?? false; + let registryResolutionWarnings: SubmodelListResolutionWarning[] = []; // Try registry first if configured and preferred if (preferRegistry && this.submodelRegistryConfig) { @@ -84,33 +100,105 @@ export class SubmodelService { if (registryResult.success) { const descriptors = registryResult.data.result; - // Fetch submodels from their endpoints - const submodelPromises = descriptors.map(async (descriptor) => { - const endpoint = descriptor.endpoints?.[0]?.protocolInformation?.href; - if (!endpoint) { - return null; - } - - const submodelResult = await this.getSubmodelByEndpoint({ endpoint }); - return submodelResult.success ? submodelResult.data.submodel : null; - }); + // Resolve submodels from descriptor endpoints and aggregate warnings deterministically. + const descriptorResults = await Promise.all( + descriptors.map( + async ( + descriptor + ): Promise<{ + submodel: Submodel | null; + warning?: SubmodelListResolutionWarning; + }> => { + if (!descriptor.id) { + return { + submodel: null, + warning: { + errorType: 'MissingDescriptorId', + message: 'Submodel descriptor does not contain an id and cannot be resolved', + }, + }; + } + + const endpoint = descriptor.endpoints?.[0]?.protocolInformation?.href; + if (!endpoint) { + return { + submodel: null, + warning: { + submodelIdentifier: descriptor.id, + errorType: 'MissingEndpoint', + message: 'Submodel descriptor has no endpoint and cannot be resolved', + }, + }; + } + + const submodelResult = await this.getSubmodelByEndpoint({ endpoint }); + if (submodelResult.success) { + return { submodel: submodelResult.data.submodel }; + } + + const resolutionError = submodelResult.error as + | { + errorType?: string; + message?: string; + messages?: Array<{ text?: string }>; + } + | undefined; + return { + submodel: null, + warning: { + submodelIdentifier: descriptor.id, + endpoint, + errorType: resolutionError?.errorType || 'EndpointResolutionError', + message: + resolutionError?.message || + resolutionError?.messages?.[0]?.text || + 'Failed to resolve Submodel from descriptor endpoint', + }, + }; + } + ) + ); + + const submodels = descriptorResults + .map((result) => result.submodel) + .filter((sm): sm is Submodel => sm !== null); + const warnings = descriptorResults + .map((result) => result.warning) + .filter((warning): warning is SubmodelListResolutionWarning => warning !== undefined); + + if (strictRegistryResolution && warnings.length > 0) { + return { + success: false, + error: { + errorType: 'RegistryResolutionError', + message: 'Failed to resolve one or more Submodel descriptors via registry endpoints', + warnings, + }, + }; + } - const submodels = (await Promise.all(submodelPromises)).filter((sm): sm is Submodel => sm !== null); + // If registry descriptors exist but none were resolvable, fall back to repository when possible. + if (warnings.length > 0 && submodels.length === 0 && this.submodelRepositoryConfig) { + registryResolutionWarnings = warnings; + } else { + // Fetch concept descriptions if requested + let conceptDescriptions: ConceptDescription[] | undefined; + if (includeConceptDescriptions) { + conceptDescriptions = await this.fetchConceptDescriptionsForSubmodels(submodels); + } - // Fetch concept descriptions if requested - let conceptDescriptions: ConceptDescription[] | undefined; - if (includeConceptDescriptions) { - conceptDescriptions = await this.fetchConceptDescriptionsForSubmodels(submodels); + return { + success: true, + data: { + submodels, + source: 'registry', + ...(conceptDescriptions && { conceptDescriptions }), + ...(warnings.length > 0 && { warnings }), + }, + }; } - - return { - success: true, - data: { - submodels, - source: 'registry', - ...(conceptDescriptions && { conceptDescriptions }), - }, - }; + } else { + registryResolutionWarnings = []; } } @@ -137,6 +225,7 @@ export class SubmodelService { submodels, source: 'repository', ...(conceptDescriptions && { conceptDescriptions }), + ...(registryResolutionWarnings.length > 0 && { warnings: registryResolutionWarnings }), }, }; } @@ -350,7 +439,7 @@ export class SubmodelService { const submodelIdentifier = base64Decode(encodedId); // Create configuration for the endpoint - const config = new Configuration({ basePath: baseUrl }); + const config = this.createRepositoryEndpointConfiguration(baseUrl); // Fetch the submodel const submodelResult = await this.submodelRepositoryClient.getSubmodelById({ @@ -379,6 +468,31 @@ export class SubmodelService { }; } + /** + * Creates a repository configuration for an endpoint while preserving auth and middleware. + * + * @param basePath The endpoint base path to target + * @returns Configuration with endpoint basePath plus repository auth/settings + */ + private createRepositoryEndpointConfiguration(basePath: string): Configuration { + if (!this.submodelRepositoryConfig) { + return new Configuration({ basePath }); + } + + return new Configuration({ + basePath, + fetchApi: this.submodelRepositoryConfig.fetchApi, + middleware: this.submodelRepositoryConfig.middleware, + queryParamsStringify: this.submodelRepositoryConfig.queryParamsStringify, + username: this.submodelRepositoryConfig.username, + password: this.submodelRepositoryConfig.password, + apiKey: this.submodelRepositoryConfig.apiKey, + accessToken: this.submodelRepositoryConfig.accessToken, + headers: this.submodelRepositoryConfig.headers, + credentials: this.submodelRepositoryConfig.credentials, + }); + } + /** * Creates a Submodel and optionally registers it in the registry. * diff --git a/src/unit-tests/services/AasService.test.ts b/src/unit-tests/services/AasService.test.ts index 34452a1c..290d173c 100644 --- a/src/unit-tests/services/AasService.test.ts +++ b/src/unit-tests/services/AasService.test.ts @@ -107,6 +107,116 @@ describe('AasService Unit Tests', () => { expect(mockRepositoryClient.getAssetAdministrationShellById).toHaveBeenCalledTimes(1); }); + it('should preserve repository auth configuration when using descriptor endpoints', async () => { + const accessToken = vi.fn().mockResolvedValue('oidc-token'); + const middleware = [{ pre: vi.fn(async () => undefined) }]; + const headers = { Authorization: 'Bearer test-token' }; + const repositoryConfigWithAuth = new Configuration({ + basePath: 'http://localhost:8081', + accessToken, + middleware, + headers, + }); + + const serviceWithAuthConfig = new AasService({ + aasRegistryConfig, + aasRepositoryConfig: repositoryConfigWithAuth, + }); + const registryClientForAuthConfig = ( + AasRegistryClient as MockedClass + ).mock.instances.at(-1) as Mocked; + const repositoryClientForAuthConfig = ( + AasRepositoryClient as MockedClass + ).mock.instances.at(-1) as Mocked; + + registryClientForAuthConfig.getAllAssetAdministrationShellDescriptors = vi.fn().mockResolvedValue({ + success: true, + data: { + result: [testDescriptor], + pagedResult: undefined, + }, + }); + repositoryClientForAuthConfig.getAssetAdministrationShellById = vi.fn().mockResolvedValue({ + success: true, + data: testShell, + }); + + const result = await serviceWithAuthConfig.getAasList(); + + expect(result.success).toBe(true); + expect(repositoryClientForAuthConfig.getAssetAdministrationShellById).toHaveBeenCalledTimes(1); + + const endpointConfig = repositoryClientForAuthConfig.getAssetAdministrationShellById.mock.calls[0][0] + .configuration as Configuration; + expect(endpointConfig.basePath).toBe('http://localhost:8081'); + expect(endpointConfig.accessToken).toBe(accessToken); + expect(endpointConfig.middleware).toEqual(middleware); + expect(endpointConfig.headers).toEqual(headers); + }); + + it('should fall back to repository when all registry endpoint resolutions fail', async () => { + const mockShells = [testShell]; + mockRegistryClient.getAllAssetAdministrationShellDescriptors = vi.fn().mockResolvedValue({ + success: true, + data: { + result: [testDescriptor], + pagedResult: undefined, + }, + }); + mockRepositoryClient.getAssetAdministrationShellById = vi.fn().mockResolvedValue({ + success: false, + error: { errorType: 'UnauthorizedError', message: 'Token missing' }, + }); + mockRepositoryClient.getAllAssetAdministrationShells = vi.fn().mockResolvedValue({ + success: true, + data: { + result: mockShells, + pagedResult: undefined, + }, + }); + + const result = await aasService.getAasList(); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.source).toBe('repository'); + expect(result.data.shells).toEqual(mockShells); + expect(result.data.warnings).toBeDefined(); + expect(result.data.warnings?.[0]).toMatchObject({ + descriptorId: testAasId, + errorType: 'UnauthorizedError', + }); + } + expect(mockRepositoryClient.getAllAssetAdministrationShells).toHaveBeenCalledTimes(1); + }); + + it('should fail when strictRegistryResolution is true and registry endpoint resolution fails', async () => { + mockRegistryClient.getAllAssetAdministrationShellDescriptors = vi.fn().mockResolvedValue({ + success: true, + data: { + result: [testDescriptor], + pagedResult: undefined, + }, + }); + mockRepositoryClient.getAssetAdministrationShellById = vi.fn().mockResolvedValue({ + success: false, + error: { errorType: 'UnauthorizedError', message: 'Token missing' }, + }); + + const result = await aasService.getAasList({ strictRegistryResolution: true }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorType).toBe('RegistryResolutionError'); + expect(result.error.warnings).toBeDefined(); + expect(result.error.warnings[0]).toMatchObject({ + descriptorId: testAasId, + errorType: 'UnauthorizedError', + }); + } + expect(mockRepositoryClient.getAllAssetAdministrationShells).not.toHaveBeenCalled(); + }); + it('should fall back to repository when registry fails', async () => { const mockShells = [testShell]; mockRegistryClient.getAllAssetAdministrationShellDescriptors = vi.fn().mockResolvedValue({ diff --git a/src/unit-tests/services/SubmodelService.test.ts b/src/unit-tests/services/SubmodelService.test.ts index 6cfb5ca0..e8288c55 100644 --- a/src/unit-tests/services/SubmodelService.test.ts +++ b/src/unit-tests/services/SubmodelService.test.ts @@ -91,6 +91,116 @@ describe('SubmodelService Unit Tests', () => { expect(mockRepositoryClient.getSubmodelById).toHaveBeenCalledTimes(1); }); + it('should preserve repository auth configuration when using descriptor endpoints', async () => { + const accessToken = vi.fn().mockResolvedValue('oidc-token'); + const middleware = [{ pre: vi.fn(async () => undefined) }]; + const headers = { Authorization: 'Bearer test-token' }; + const repositoryConfigWithAuth = new Configuration({ + basePath: 'http://localhost:8081', + accessToken, + middleware, + headers, + }); + + const serviceWithAuthConfig = new SubmodelService({ + submodelRegistryConfig, + submodelRepositoryConfig: repositoryConfigWithAuth, + }); + const registryClientForAuthConfig = ( + SubmodelRegistryClient as MockedClass + ).mock.instances.at(-1) as Mocked; + const repositoryClientForAuthConfig = ( + SubmodelRepositoryClient as MockedClass + ).mock.instances.at(-1) as Mocked; + + registryClientForAuthConfig.getAllSubmodelDescriptors = vi.fn().mockResolvedValue({ + success: true, + data: { + result: [testDescriptor], + pagedResult: undefined, + }, + }); + repositoryClientForAuthConfig.getSubmodelById = vi.fn().mockResolvedValue({ + success: true, + data: testSubmodel, + }); + + const result = await serviceWithAuthConfig.getSubmodelList(); + + expect(result.success).toBe(true); + expect(repositoryClientForAuthConfig.getSubmodelById).toHaveBeenCalledTimes(1); + + const endpointConfig = repositoryClientForAuthConfig.getSubmodelById.mock.calls[0][0] + .configuration as Configuration; + expect(endpointConfig.basePath).toBe('http://localhost:8081'); + expect(endpointConfig.accessToken).toBe(accessToken); + expect(endpointConfig.middleware).toEqual(middleware); + expect(endpointConfig.headers).toEqual(headers); + }); + + it('should fall back to repository when all registry endpoint resolutions fail', async () => { + const mockSubmodels = [testSubmodel]; + mockRegistryClient.getAllSubmodelDescriptors = vi.fn().mockResolvedValue({ + success: true, + data: { + result: [testDescriptor], + pagedResult: undefined, + }, + }); + mockRepositoryClient.getSubmodelById = vi.fn().mockResolvedValue({ + success: false, + error: { errorType: 'UnauthorizedError', message: 'Token missing' }, + }); + mockRepositoryClient.getAllSubmodels = vi.fn().mockResolvedValue({ + success: true, + data: { + result: mockSubmodels, + pagedResult: undefined, + }, + }); + + const result = await submodelService.getSubmodelList(); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.source).toBe('repository'); + expect(result.data.submodels).toEqual(mockSubmodels); + expect(result.data.warnings).toBeDefined(); + expect(result.data.warnings?.[0]).toMatchObject({ + submodelIdentifier: testSubmodelId, + errorType: 'UnauthorizedError', + }); + } + expect(mockRepositoryClient.getAllSubmodels).toHaveBeenCalledTimes(1); + }); + + it('should fail when strictRegistryResolution is true and registry endpoint resolution fails', async () => { + mockRegistryClient.getAllSubmodelDescriptors = vi.fn().mockResolvedValue({ + success: true, + data: { + result: [testDescriptor], + pagedResult: undefined, + }, + }); + mockRepositoryClient.getSubmodelById = vi.fn().mockResolvedValue({ + success: false, + error: { errorType: 'UnauthorizedError', message: 'Token missing' }, + }); + + const result = await submodelService.getSubmodelList({ strictRegistryResolution: true }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorType).toBe('RegistryResolutionError'); + expect(result.error.warnings).toBeDefined(); + expect(result.error.warnings[0]).toMatchObject({ + submodelIdentifier: testSubmodelId, + errorType: 'UnauthorizedError', + }); + } + expect(mockRepositoryClient.getAllSubmodels).not.toHaveBeenCalled(); + }); + it('should fall back to repository when registry fails', async () => { const mockSubmodels = [testSubmodel]; mockRegistryClient.getAllSubmodelDescriptors = vi.fn().mockResolvedValue({