Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ import {
CONNECTION_CONFIG_SHAPE_VALIDATOR_REGISTRY_TOKEN,
ConnectionCredentialsShapeValidatorRegistryService,
CONNECTION_CREDENTIALS_SHAPE_VALIDATOR_REGISTRY_TOKEN,
ConnectionCredentialsRewriterRegistryService,
CONNECTION_CREDENTIALS_REWRITER_REGISTRY_TOKEN,
ConnectionCredentialsRewriteException,
} from '@openlinker/core/integrations';
import type { ConnectionCredentialsRewriterPort } from '@openlinker/core/integrations';
import { AllegroConnectionConfigShapeValidatorAdapter } from '@openlinker/integrations-allegro';
import {
PrestashopConnectionConfigShapeValidatorAdapter,
Expand All @@ -61,6 +65,7 @@ describe('ConnectionService', () => {
let mockWebhookProvisioner: jest.Mocked<WebhookProvisioningPort>;
let configValidatorRegistry: ConnectionConfigShapeValidatorRegistryService;
let credentialsValidatorRegistry: ConnectionCredentialsShapeValidatorRegistryService;
let credentialsRewriterRegistry: ConnectionCredentialsRewriterRegistryService;

const mockConnection = new Connection(
'connection-123',
Expand Down Expand Up @@ -128,6 +133,14 @@ describe('ConnectionService', () => {
})
),
update: jest.fn(),
getByRef: jest.fn().mockResolvedValue({
id: 'cred-row-1',
ref: 'cred-ref-1',
platformType: 'prestashop',
credentialsJson: {},
createdAt: new Date(),
updatedAt: new Date(),
}),
delete: jest.fn().mockResolvedValue(true),
} as unknown as jest.Mocked<ICredentialsService>;

Expand Down Expand Up @@ -163,6 +176,12 @@ describe('ConnectionService', () => {
new PrestashopConnectionCredentialsShapeValidatorAdapter()
);

// Credentials-rewriter registry (#1387, ADR-031). Empty by default so
// `updateCredentials` exercises the no-op passthrough for every existing
// test; individual tests register a stub rewriter to exercise the
// delegation path.
credentialsRewriterRegistry = new ConnectionCredentialsRewriterRegistryService();

const mockCredentialsResolver: CredentialsResolverPort = {
get: jest.fn(),
} as unknown as CredentialsResolverPort;
Expand All @@ -184,6 +203,10 @@ describe('ConnectionService', () => {
provide: CONNECTION_CREDENTIALS_SHAPE_VALIDATOR_REGISTRY_TOKEN,
useValue: credentialsValidatorRegistry,
},
{
provide: CONNECTION_CREDENTIALS_REWRITER_REGISTRY_TOKEN,
useValue: credentialsRewriterRegistry,
},
{ provide: CREDENTIALS_RESOLVER_TOKEN, useValue: mockCredentialsResolver },
],
}).compile();
Expand Down Expand Up @@ -815,6 +838,36 @@ describe('ConnectionService', () => {
});
});

it('should merge onto existing stored fields instead of replacing the whole blob', async () => {
const dbConnection = new Connection(
'connection-123',
'prestashop',
'Test Connection',
'active',
{},
'db:cred-ref-1',
new Date(),
new Date(),
undefined,
['ProductMaster']
);
connectionPort.get.mockResolvedValue(dbConnection);
credentials.getByRef.mockResolvedValue({
id: 'cred-row-1',
ref: 'cred-ref-1',
platformType: 'prestashop',
credentialsJson: { webserviceApiKey: 'OLD', otherField: 'keep-me' },
createdAt: new Date(),
updatedAt: new Date(),
});

await service.updateCredentials('connection-123', { webserviceApiKey: 'NEW' });

expect(credentials.update).toHaveBeenCalledWith('cred-ref-1', {
credentialsJson: { webserviceApiKey: 'NEW', otherField: 'keep-me' },
});
});

it('should reject rotation on non-db-backed connection', async () => {
const legacy = new Connection(
'connection-123',
Expand All @@ -835,6 +888,80 @@ describe('ConnectionService', () => {
).rejects.toThrow(/does not have a db-backed/);
expect(credentials.update).not.toHaveBeenCalled();
});

describe('credentials rewriter dispatch (#1387, ADR-031)', () => {
// ConnectionService has zero platform-specific knowledge of what a
// rewriter does (that logic — e.g. Erli's Allegro-credentials-reuse
// resolution — lives behind `ConnectionCredentialsRewriterPort` in the
// owning plugin package and is unit-tested there). These tests only
// pin the generic dispatch contract: no-op passthrough when nothing is
// registered for the adapterKey, and delegation + error-mapping when a
// rewriter is registered.
const dbConnection = new Connection(
'connection-123',
'prestashop',
'Test Connection',
'active',
{},
'db:cred-ref-1',
new Date(),
new Date(),
undefined,
['ProductMaster']
);

beforeEach(() => {
connectionPort.get.mockResolvedValue(dbConnection);
credentials.getByRef.mockResolvedValue({
id: 'cred-row-1',
ref: 'cred-ref-1',
platformType: 'prestashop',
credentialsJson: { webserviceApiKey: 'EXISTING' },
createdAt: new Date(),
updatedAt: new Date(),
});
});

it('should pass the credentials through unchanged when no rewriter is registered for the adapterKey', async () => {
await service.updateCredentials('connection-123', { webserviceApiKey: 'NEW' });

expect(credentials.update).toHaveBeenCalledWith('cred-ref-1', {
credentialsJson: { webserviceApiKey: 'NEW' },
});
});

it('should delegate to the registered rewriter and persist its returned payload', async () => {
const stubRewriter: jest.Mocked<ConnectionCredentialsRewriterPort> = {
rewrite: jest
.fn()
.mockResolvedValue({ webserviceApiKey: 'REWRITTEN', extraField: 'added-by-rewriter' }),
};
credentialsRewriterRegistry.register('prestashop.webservice.v1', stubRewriter);

await service.updateCredentials('connection-123', { webserviceApiKey: 'NEW' });

expect(stubRewriter.rewrite).toHaveBeenCalledWith({ webserviceApiKey: 'NEW' });
expect(credentials.update).toHaveBeenCalledWith('cred-ref-1', {
credentialsJson: { webserviceApiKey: 'REWRITTEN', extraField: 'added-by-rewriter' },
});
});

it('should map a ConnectionCredentialsRewriteException from the rewriter to BadRequestException', async () => {
const stubRewriter: jest.Mocked<ConnectionCredentialsRewriterPort> = {
rewrite: jest
.fn()
.mockRejectedValue(
new ConnectionCredentialsRewriteException('Stub', 'source connection is invalid')
),
};
credentialsRewriterRegistry.register('prestashop.webservice.v1', stubRewriter);

await expect(
service.updateCredentials('connection-123', { webserviceApiKey: 'NEW' })
).rejects.toThrow(BadRequestException);
expect(credentials.update).not.toHaveBeenCalled();
});
});
});

describe('testConnection', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,11 @@ import {
CONNECTION_CONFIG_SHAPE_VALIDATOR_REGISTRY_TOKEN,
ConnectionCredentialsShapeValidatorRegistryService,
CONNECTION_CREDENTIALS_SHAPE_VALIDATOR_REGISTRY_TOKEN,
ConnectionCredentialsRewriterRegistryService,
CONNECTION_CREDENTIALS_REWRITER_REGISTRY_TOKEN,
InvalidConnectionConfigException,
InvalidCredentialsShapeException,
ConnectionCredentialsRewriteException,
} from '@openlinker/core/integrations';
import type { SyncJobRequest } from '@openlinker/core/sync';
import { JobEnqueuePort, JOB_ENQUEUE_TOKEN } from '@openlinker/core/sync';
Expand Down Expand Up @@ -72,6 +75,8 @@ export class ConnectionService implements IConnectionService {
private readonly connectionConfigShapeValidatorRegistry: ConnectionConfigShapeValidatorRegistryService,
@Inject(CONNECTION_CREDENTIALS_SHAPE_VALIDATOR_REGISTRY_TOKEN)
private readonly connectionCredentialsShapeValidatorRegistry: ConnectionCredentialsShapeValidatorRegistryService,
@Inject(CONNECTION_CREDENTIALS_REWRITER_REGISTRY_TOKEN)
private readonly connectionCredentialsRewriterRegistry: ConnectionCredentialsRewriterRegistryService,
@Inject(CREDENTIALS_RESOLVER_TOKEN)
private readonly credentialsResolver: CredentialsResolverPort
) {}
Expand Down Expand Up @@ -118,6 +123,32 @@ export class ConnectionService implements IConnectionService {
}
}

/**
* Run the plugin's credentials rewriter if one is registered for this
* adapterKey (#1387, ADR-031). A rewriter transforms the raw credentials
* payload BEFORE it is merged onto the existing stored blob and shape-
* validated — e.g. Erli resolves `reuseAllegroConnectionId` into a concrete
* `allegroClientId`/`allegroClientSecret` pair fetched server-side, so the
* raw Allegro `clientSecret` never round-trips through this HTTP layer.
* This service has zero platform-specific knowledge of what a rewriter
* does — it's a no-op passthrough when nothing is registered.
*/
private async rewriteCredentials(
adapterKey: string,
credentials: Record<string, unknown>
): Promise<Record<string, unknown>> {
const rewriter = this.connectionCredentialsRewriterRegistry.get(adapterKey);
if (!rewriter) return credentials;
try {
return await rewriter.rewrite(credentials);
} catch (error) {
if (error instanceof ConnectionCredentialsRewriteException) {
throw new BadRequestException(error.message);
}
throw error;
}
}

async installWebhooks(
connectionId: string,
actorUserId?: string
Expand Down Expand Up @@ -416,9 +447,17 @@ export class ConnectionService implements IConnectionService {
platformType: connection.platformType,
adapterKey: connection.adapterKey,
});
await this.validateCredentialsShape(metadata.adapterKey, credentials);
const resolvedCredentials = await this.rewriteCredentials(metadata.adapterKey, credentials);
const ref = connection.credentialsRef.slice('db:'.length);
await this.credentials.update(ref, { credentialsJson: credentials });
// Merge onto the existing stored credentials rather than replacing the
// whole blob: callers only send the fields they actually changed (e.g.
// rotating just `apiKey`), and a full replace would silently delete any
// other previously-stored field (e.g. Erli's optional Allegro
// `allegroClientId`/`allegroClientSecret` pair, #1401 review).
const existing = await this.credentials.getByRef(ref);
const mergedCredentials = { ...existing.credentialsJson, ...resolvedCredentials };
await this.validateCredentialsShape(metadata.adapterKey, mergedCredentials);
await this.credentials.update(ref, { credentialsJson: mergedCredentials });
this.logger.log(`Rotated credentials for connection ${connectionId}`);
}

Expand Down
Loading
Loading