mirror of
https://github.com/twentyhq/twenty.git
synced 2026-08-02 18:54:43 -04:00
fix: Decrypt encrypted front component variables (#23494)
## Summary Fixes #23492 Fixes front-component application variables returning their encrypted at-rest value instead of their configured plaintext value. Non-secret application variables (`isSecret: false`) are now decrypted server-side before being injected into the front-component environment. Secret variables remain excluded and are never decrypted or exposed to the browser. ## Root cause The front-component resolver filtered secret application variables correctly, but forwarded the cached `encryptedValue` directly. As a result, `getApplicationVariable()` returned an `enc:v2:...` envelope rather than the configured value. ## Changes - Decrypt recognized versioned envelopes for non-secret application variables. - Preserve empty and legacy/plain values unchanged for backwards compatibility. - Add `SecretEncryptionModule` to the front-component module. - Add coverage for: - decrypting public variables; - retaining plaintext compatibility; - excluding secret variables without attempting decryption. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23494?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: prastoin <paul@twenty.com>
This commit is contained in:
@@ -3,6 +3,8 @@ import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
|
||||
import {
|
||||
ApplicationVariableEntityException,
|
||||
@@ -10,8 +12,11 @@ import {
|
||||
} from 'src/engine/core-modules/application/application-variable/application-variable.exception';
|
||||
import { ApplicationVariableEntityService } from 'src/engine/core-modules/application/application-variable/application-variable.service';
|
||||
import { SECRET_APPLICATION_VARIABLE_MASK } from 'src/engine/core-modules/application/application-variable/constants/secret-application-variable-mask.constant';
|
||||
import { type ApplicationVariableCacheMaps } from 'src/engine/core-modules/application/application-variable/types/application-variable-cache-maps.type';
|
||||
import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
|
||||
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
|
||||
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
|
||||
import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
|
||||
describe('ApplicationVariableEntityService', () => {
|
||||
@@ -65,6 +70,7 @@ describe('ApplicationVariableEntityService', () => {
|
||||
provide: WorkspaceCacheService,
|
||||
useValue: {
|
||||
invalidateAndRecompute: jest.fn(),
|
||||
getOrRecompute: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -78,10 +84,206 @@ describe('ApplicationVariableEntityService', () => {
|
||||
workspaceCacheService = module.get(WorkspaceCacheService);
|
||||
});
|
||||
|
||||
const workspaceA = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
||||
const workspaceB = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
|
||||
|
||||
const makeFlatVariable = (
|
||||
overrides: Partial<FlatApplicationVariable>,
|
||||
): FlatApplicationVariable =>
|
||||
({
|
||||
id: '1',
|
||||
key: 'KEY',
|
||||
value: '' as EncryptedString | '',
|
||||
description: '',
|
||||
isSecret: false,
|
||||
type: FieldMetadataType.TEXT,
|
||||
options: null,
|
||||
applicationId: mockApplicationId,
|
||||
workspaceId: workspaceA,
|
||||
universalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
}) as FlatApplicationVariable;
|
||||
|
||||
const makeApplicationVariableMaps = (
|
||||
flatApplicationVariables: FlatApplicationVariable[],
|
||||
) =>
|
||||
({
|
||||
byUniversalIdentifier: Object.fromEntries(
|
||||
flatApplicationVariables.map((flatApplicationVariable) => [
|
||||
flatApplicationVariable.universalIdentifier,
|
||||
flatApplicationVariable,
|
||||
]),
|
||||
),
|
||||
universalIdentifiersByApplicationId: {
|
||||
[mockApplicationId]: flatApplicationVariables.map(
|
||||
({ universalIdentifier }) => universalIdentifier,
|
||||
),
|
||||
},
|
||||
}) as unknown as ApplicationVariableCacheMaps;
|
||||
|
||||
const mockCachedApplicationVariables = (
|
||||
flatApplicationVariables: FlatApplicationVariable[],
|
||||
) => {
|
||||
workspaceCacheService.getOrRecompute.mockResolvedValue({
|
||||
applicationVariableMaps: makeApplicationVariableMaps(
|
||||
flatApplicationVariables,
|
||||
),
|
||||
} as never);
|
||||
};
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getServerEnvVariables', () => {
|
||||
it('should return an empty object when the application has no variable', async () => {
|
||||
mockCachedApplicationVariables([]);
|
||||
|
||||
await expect(
|
||||
service.getServerEnvVariables({
|
||||
workspaceId: workspaceA,
|
||||
applicationId: mockApplicationId,
|
||||
}),
|
||||
).resolves.toEqual({});
|
||||
});
|
||||
|
||||
it('should decrypt all encrypted variables regardless of isSecret', async () => {
|
||||
mockCachedApplicationVariables([
|
||||
makeFlatVariable({
|
||||
universalIdentifier: 'variable-1',
|
||||
key: 'PUBLIC_URL',
|
||||
value:
|
||||
`enc:v2:deadbeef:https://example.com|${workspaceA}` as EncryptedString,
|
||||
}),
|
||||
makeFlatVariable({
|
||||
universalIdentifier: 'variable-2',
|
||||
key: 'API_SECRET',
|
||||
value: `enc:v2:deadbeef:secret-123|${workspaceA}` as EncryptedString,
|
||||
isSecret: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await service.getServerEnvVariables({
|
||||
workspaceId: workspaceA,
|
||||
applicationId: mockApplicationId,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
PUBLIC_URL: 'https://example.com',
|
||||
API_SECRET: 'secret-123',
|
||||
});
|
||||
expect(
|
||||
secretEncryptionService.decryptVersionedOrThrow,
|
||||
).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should route each variable to its own workspace HKDF context', async () => {
|
||||
mockCachedApplicationVariables([
|
||||
makeFlatVariable({
|
||||
universalIdentifier: 'variable-1',
|
||||
key: 'A_SECRET',
|
||||
value: `enc:v2:deadbeef:value-a|${workspaceA}` as EncryptedString,
|
||||
isSecret: true,
|
||||
}),
|
||||
makeFlatVariable({
|
||||
universalIdentifier: 'variable-2',
|
||||
key: 'B_SECRET',
|
||||
value: `enc:v2:deadbeef:value-b|${workspaceB}` as EncryptedString,
|
||||
isSecret: true,
|
||||
workspaceId: workspaceB,
|
||||
}),
|
||||
]);
|
||||
|
||||
await service.getServerEnvVariables({
|
||||
workspaceId: workspaceA,
|
||||
applicationId: mockApplicationId,
|
||||
});
|
||||
|
||||
expect(
|
||||
secretEncryptionService.decryptVersionedOrThrow,
|
||||
).toHaveBeenCalledWith(`enc:v2:deadbeef:value-a|${workspaceA}`, {
|
||||
workspaceId: workspaceA,
|
||||
});
|
||||
expect(
|
||||
secretEncryptionService.decryptVersionedOrThrow,
|
||||
).toHaveBeenCalledWith(`enc:v2:deadbeef:value-b|${workspaceB}`, {
|
||||
workspaceId: workspaceB,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an empty string for uninitialised variables without decrypting', async () => {
|
||||
mockCachedApplicationVariables([
|
||||
makeFlatVariable({
|
||||
universalIdentifier: 'variable-1',
|
||||
key: 'EMPTY_VALUE',
|
||||
value: '',
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await service.getServerEnvVariables({
|
||||
workspaceId: workspaceA,
|
||||
applicationId: mockApplicationId,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ EMPTY_VALUE: '' });
|
||||
expect(
|
||||
secretEncryptionService.decryptVersionedOrThrow,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reuse provided application variable maps instead of reading the cache', async () => {
|
||||
const applicationVariableMaps = makeApplicationVariableMaps([
|
||||
makeFlatVariable({
|
||||
universalIdentifier: 'variable-1',
|
||||
key: 'PUBLIC_URL',
|
||||
value:
|
||||
`enc:v2:deadbeef:https://example.com|${workspaceA}` as EncryptedString,
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await service.getServerEnvVariables({
|
||||
workspaceId: workspaceA,
|
||||
applicationId: mockApplicationId,
|
||||
applicationVariableMaps,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ PUBLIC_URL: 'https://example.com' });
|
||||
expect(workspaceCacheService.getOrRecompute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPublicEnvVariables', () => {
|
||||
it('should exclude secret variables without decrypting them', async () => {
|
||||
mockCachedApplicationVariables([
|
||||
makeFlatVariable({
|
||||
universalIdentifier: 'variable-1',
|
||||
key: 'PUBLIC_URL',
|
||||
value:
|
||||
`enc:v2:deadbeef:https://example.com|${workspaceA}` as EncryptedString,
|
||||
}),
|
||||
makeFlatVariable({
|
||||
universalIdentifier: 'variable-2',
|
||||
key: 'API_SECRET',
|
||||
value: `enc:v2:deadbeef:secret-123|${workspaceA}` as EncryptedString,
|
||||
isSecret: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await service.getPublicEnvVariables({
|
||||
workspaceId: workspaceA,
|
||||
applicationId: mockApplicationId,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ PUBLIC_URL: 'https://example.com' });
|
||||
expect(
|
||||
secretEncryptionService.decryptVersionedOrThrow,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should encrypt value with workspaceId-scoped envelope when variable is secret', async () => {
|
||||
const existingVariable = {
|
||||
|
||||
@@ -10,10 +10,18 @@ import {
|
||||
ApplicationVariableEntityExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application-variable/application-variable.exception';
|
||||
import { SECRET_APPLICATION_VARIABLE_MASK } from 'src/engine/core-modules/application/application-variable/constants/secret-application-variable-mask.constant';
|
||||
import { type ApplicationVariableCacheMaps } from 'src/engine/core-modules/application/application-variable/types/application-variable-cache-maps.type';
|
||||
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
|
||||
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
|
||||
import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
|
||||
type GetEnvVariablesArgs = {
|
||||
workspaceId: string;
|
||||
applicationId: string;
|
||||
applicationVariableMaps?: ApplicationVariableCacheMaps;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationVariableEntityService {
|
||||
constructor(
|
||||
@@ -42,6 +50,80 @@ export class ApplicationVariableEntityService {
|
||||
);
|
||||
}
|
||||
|
||||
async getServerEnvVariables(
|
||||
args: GetEnvVariablesArgs,
|
||||
): Promise<Record<string, string>> {
|
||||
const flatApplicationVariables =
|
||||
await this.findFlatApplicationVariables(args);
|
||||
|
||||
return this.toEnvVariables(flatApplicationVariables);
|
||||
}
|
||||
|
||||
async getPublicEnvVariables(
|
||||
args: GetEnvVariablesArgs,
|
||||
): Promise<Record<string, string>> {
|
||||
const flatApplicationVariables =
|
||||
await this.findFlatApplicationVariables(args);
|
||||
|
||||
return this.toEnvVariables(
|
||||
flatApplicationVariables.filter(({ isSecret }) => !isSecret),
|
||||
);
|
||||
}
|
||||
|
||||
private async findFlatApplicationVariables({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
applicationVariableMaps: preloadedApplicationVariableMaps,
|
||||
}: GetEnvVariablesArgs): Promise<FlatApplicationVariable[]> {
|
||||
const applicationVariableMaps =
|
||||
preloadedApplicationVariableMaps ??
|
||||
(
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'applicationVariableMaps',
|
||||
])
|
||||
).applicationVariableMaps;
|
||||
|
||||
const universalIdentifiers =
|
||||
applicationVariableMaps.universalIdentifiersByApplicationId[
|
||||
applicationId
|
||||
] ?? [];
|
||||
|
||||
return universalIdentifiers
|
||||
.map(
|
||||
(universalIdentifier) =>
|
||||
applicationVariableMaps.byUniversalIdentifier[universalIdentifier],
|
||||
)
|
||||
.filter(isDefined);
|
||||
}
|
||||
|
||||
private toEnvVariables(
|
||||
flatApplicationVariables: FlatApplicationVariable[],
|
||||
): Record<string, string> {
|
||||
return flatApplicationVariables.reduce<Record<string, string>>(
|
||||
(acc, flatApplicationVariable) => {
|
||||
acc[flatApplicationVariable.key] = this.decryptValue(
|
||||
flatApplicationVariable,
|
||||
);
|
||||
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
private decryptValue({
|
||||
value,
|
||||
workspaceId,
|
||||
}: FlatApplicationVariable): string {
|
||||
if (value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return this.secretEncryptionService.decryptVersionedOrThrow(value, {
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
async update({
|
||||
key,
|
||||
plainTextValue,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
|
||||
import { ApplicationVariableEntityModule } from 'src/engine/core-modules/application/application-variable/application-variable.module';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { EventLogEmitterModule } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.module';
|
||||
import { EventLogLiveModule } from 'src/engine/core-modules/event-logs/live/event-log-live.module';
|
||||
@@ -29,6 +30,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
|
||||
FeatureFlagModule,
|
||||
WorkspaceDomainsModule,
|
||||
ApplicationModule,
|
||||
ApplicationVariableEntityModule,
|
||||
TypeOrmModule.forFeature([
|
||||
ApplicationRegistrationVariableEntity,
|
||||
WorkspaceEntity,
|
||||
|
||||
@@ -23,10 +23,11 @@ import { parseApplicationLogLines } from 'src/engine/core-modules/event-logs/pro
|
||||
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
|
||||
import { ApplicationStopService } from 'src/engine/core-modules/application/application-stop/application-stop.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import type { FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
|
||||
import { FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
|
||||
import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service';
|
||||
import { LOGIC_FUNCTION_EXECUTED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/logic-function/logic-function-executed';
|
||||
import { ApplicationVariableEntityService } from 'src/engine/core-modules/application/application-variable/application-variable.service';
|
||||
import { type ApplicationVariableCacheMaps } from 'src/engine/core-modules/application/application-variable/types/application-variable-cache-maps.type';
|
||||
import { isBillingExemptApplication } from 'src/engine/core-modules/application/application-marketplace/utils/is-billing-exempt-application.util';
|
||||
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
|
||||
import { NO_BILLING_SUBSCRIPTION } from 'src/engine/core-modules/billing/constants/no-billing-subscription.constant';
|
||||
@@ -35,7 +36,6 @@ import { BillingService } from 'src/engine/core-modules/billing/services/billing
|
||||
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { LogicFunctionDriverFactory } from 'src/engine/core-modules/logic-function/logic-function-drivers/logic-function-driver.factory';
|
||||
import { buildEnvVar } from 'src/engine/core-modules/logic-function/logic-function-executor/utils/build-env-var';
|
||||
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
|
||||
import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
@@ -85,6 +85,7 @@ export class LogicFunctionExecutorService {
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly applicationTokenService: ApplicationTokenService,
|
||||
private readonly secretEncryptionService: SecretEncryptionService,
|
||||
private readonly applicationVariableService: ApplicationVariableEntityService,
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
private readonly eventLogLiveService: EventLogLiveService,
|
||||
private readonly eventLogEmitterService: EventLogEmitterService,
|
||||
@@ -116,7 +117,7 @@ export class LogicFunctionExecutorService {
|
||||
userWorkspaceId?: string;
|
||||
executionMode?: LogicFunctionExecutionMode;
|
||||
}): Promise<LogicFunctionExecuteResult> {
|
||||
const { flatApplication, flatLogicFunction, flatApplicationVariables } =
|
||||
const { flatApplication, flatLogicFunction, applicationVariableMaps } =
|
||||
await this.getFlatEntitiesOrThrow({
|
||||
workspaceId,
|
||||
logicFunctionId,
|
||||
@@ -131,7 +132,7 @@ export class LogicFunctionExecutorService {
|
||||
const envVariables = await this.getExecutionEnvVariables({
|
||||
workspaceId,
|
||||
flatApplication,
|
||||
flatApplicationVariables,
|
||||
applicationVariableMaps,
|
||||
userId,
|
||||
userWorkspaceId,
|
||||
});
|
||||
@@ -314,31 +315,19 @@ export class LogicFunctionExecutorService {
|
||||
);
|
||||
}
|
||||
|
||||
const flatApplicationVariableUniversalIdentifiers =
|
||||
applicationVariableMaps.universalIdentifiersByApplicationId[
|
||||
flatApplication.id
|
||||
] ?? [];
|
||||
|
||||
const flatApplicationVariables = flatApplicationVariableUniversalIdentifiers
|
||||
.map(
|
||||
(universalIdentifier) =>
|
||||
applicationVariableMaps.byUniversalIdentifier[universalIdentifier],
|
||||
)
|
||||
.filter(isDefined);
|
||||
|
||||
return { flatApplication, flatLogicFunction, flatApplicationVariables };
|
||||
return { flatApplication, flatLogicFunction, applicationVariableMaps };
|
||||
}
|
||||
|
||||
private async getExecutionEnvVariables({
|
||||
workspaceId,
|
||||
flatApplication,
|
||||
flatApplicationVariables,
|
||||
applicationVariableMaps,
|
||||
userId,
|
||||
userWorkspaceId,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
flatApplication: FlatApplication;
|
||||
flatApplicationVariables: FlatApplicationVariable[];
|
||||
applicationVariableMaps: ApplicationVariableCacheMaps;
|
||||
userId?: string;
|
||||
userWorkspaceId?: string;
|
||||
}) {
|
||||
@@ -359,10 +348,12 @@ export class LogicFunctionExecutorService {
|
||||
const serverVariables = await this.buildServerVariableEnvMap(
|
||||
flatApplication.applicationRegistrationId,
|
||||
);
|
||||
const workspaceVariables = buildEnvVar(
|
||||
flatApplicationVariables,
|
||||
this.secretEncryptionService,
|
||||
);
|
||||
const workspaceVariables =
|
||||
await this.applicationVariableService.getServerEnvVariables({
|
||||
workspaceId,
|
||||
applicationId: flatApplication.id,
|
||||
applicationVariableMaps,
|
||||
});
|
||||
|
||||
return {
|
||||
[DEFAULT_API_URL_NAME]: baseUrl ?? '',
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
|
||||
import { buildEnvVar } from 'src/engine/core-modules/logic-function/logic-function-executor/utils/build-env-var';
|
||||
import { type SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
|
||||
import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
|
||||
|
||||
describe('buildEnvVar', () => {
|
||||
const workspaceA = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
||||
const workspaceB = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
|
||||
|
||||
const mockSecretEncryptionService = {
|
||||
encryptVersioned: jest.fn(
|
||||
(value: string, opts?: { workspaceId?: string }) =>
|
||||
`enc:v2:deadbeef:${value}|${opts?.workspaceId ?? 'instance'}`,
|
||||
),
|
||||
decryptVersionedOrThrow: jest.fn(
|
||||
(value: string, _opts?: { workspaceId?: string }) =>
|
||||
value.replace(/^enc:v2:[0-9a-f]+:/, '').replace(/\|.*$/, ''),
|
||||
),
|
||||
} as unknown as SecretEncryptionService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return empty object for empty array', () => {
|
||||
const result = buildEnvVar([], mockSecretEncryptionService);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should decrypt all encrypted variables regardless of isSecret', () => {
|
||||
const flatVariables: FlatApplicationVariable[] = [
|
||||
{
|
||||
id: '1',
|
||||
key: 'PUBLIC_URL',
|
||||
value:
|
||||
`enc:v2:deadbeef:https://example.com|${workspaceA}` as EncryptedString,
|
||||
description: 'Public URL',
|
||||
isSecret: false,
|
||||
type: FieldMetadataType.TEXT,
|
||||
options: null,
|
||||
applicationId: 'app-1',
|
||||
workspaceId: workspaceA,
|
||||
universalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: 'API_SECRET',
|
||||
value: `enc:v2:deadbeef:secret-123|${workspaceA}` as EncryptedString,
|
||||
description: 'API secret',
|
||||
isSecret: true,
|
||||
type: FieldMetadataType.TEXT,
|
||||
options: null,
|
||||
applicationId: 'app-1',
|
||||
workspaceId: workspaceA,
|
||||
universalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
key: 'DEBUG',
|
||||
value: `enc:v2:deadbeef:true|${workspaceA}` as EncryptedString,
|
||||
description: 'Debug flag',
|
||||
isSecret: false,
|
||||
type: FieldMetadataType.TEXT,
|
||||
options: null,
|
||||
applicationId: 'app-1',
|
||||
workspaceId: workspaceA,
|
||||
universalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildEnvVar(flatVariables, mockSecretEncryptionService);
|
||||
|
||||
expect(result).toEqual({
|
||||
PUBLIC_URL: 'https://example.com',
|
||||
API_SECRET: 'secret-123',
|
||||
DEBUG: 'true',
|
||||
});
|
||||
expect(
|
||||
mockSecretEncryptionService.decryptVersionedOrThrow,
|
||||
).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('routes each secret variable to its own workspace HKDF context', () => {
|
||||
const flatVariables: FlatApplicationVariable[] = [
|
||||
{
|
||||
id: '1',
|
||||
key: 'A_SECRET',
|
||||
value: `enc:v2:deadbeef:value-a|${workspaceA}` as EncryptedString,
|
||||
description: '',
|
||||
isSecret: true,
|
||||
type: FieldMetadataType.TEXT,
|
||||
options: null,
|
||||
applicationId: 'app-1',
|
||||
workspaceId: workspaceA,
|
||||
universalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: 'B_SECRET',
|
||||
value: `enc:v2:deadbeef:value-b|${workspaceB}` as EncryptedString,
|
||||
description: '',
|
||||
isSecret: true,
|
||||
type: FieldMetadataType.TEXT,
|
||||
options: null,
|
||||
applicationId: 'app-1',
|
||||
workspaceId: workspaceB,
|
||||
universalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
buildEnvVar(flatVariables, mockSecretEncryptionService);
|
||||
|
||||
expect(
|
||||
mockSecretEncryptionService.decryptVersionedOrThrow,
|
||||
).toHaveBeenCalledWith(`enc:v2:deadbeef:value-a|${workspaceA}`, {
|
||||
workspaceId: workspaceA,
|
||||
});
|
||||
expect(
|
||||
mockSecretEncryptionService.decryptVersionedOrThrow,
|
||||
).toHaveBeenCalledWith(`enc:v2:deadbeef:value-b|${workspaceB}`, {
|
||||
workspaceId: workspaceB,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle null or undefined values', () => {
|
||||
const flatVariables: FlatApplicationVariable[] = [
|
||||
{
|
||||
id: '1',
|
||||
key: 'NULL_VALUE',
|
||||
value: null as unknown as EncryptedString | '',
|
||||
description: '',
|
||||
isSecret: false,
|
||||
type: FieldMetadataType.TEXT,
|
||||
options: null,
|
||||
applicationId: 'app-1',
|
||||
workspaceId: workspaceA,
|
||||
universalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: 'UNDEFINED_VALUE',
|
||||
value: undefined as unknown as EncryptedString | '',
|
||||
description: '',
|
||||
isSecret: false,
|
||||
type: FieldMetadataType.TEXT,
|
||||
options: null,
|
||||
applicationId: 'app-1',
|
||||
workspaceId: workspaceA,
|
||||
universalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildEnvVar(flatVariables, mockSecretEncryptionService);
|
||||
|
||||
expect(result).toEqual({
|
||||
NULL_VALUE: '',
|
||||
UNDEFINED_VALUE: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert non-string values to strings', () => {
|
||||
const flatVariables: FlatApplicationVariable[] = [
|
||||
{
|
||||
id: '1',
|
||||
key: 'NUMBER_VALUE',
|
||||
value: 123 as unknown as EncryptedString | '',
|
||||
description: '',
|
||||
isSecret: false,
|
||||
type: FieldMetadataType.TEXT,
|
||||
options: null,
|
||||
applicationId: 'app-1',
|
||||
workspaceId: workspaceA,
|
||||
universalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildEnvVar(flatVariables, mockSecretEncryptionService);
|
||||
|
||||
expect(result).toEqual({
|
||||
NUMBER_VALUE: '123',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
import { isEncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/is-encrypted-string.util';
|
||||
import { type SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
|
||||
import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
|
||||
|
||||
export const buildEnvVar = (
|
||||
flatApplicationVariables: FlatApplicationVariable[],
|
||||
secretEncryptionService: SecretEncryptionService,
|
||||
): Record<string, string> => {
|
||||
return flatApplicationVariables.reduce<Record<string, string>>(
|
||||
(acc, flatApplicationVariable) => {
|
||||
const value = String(flatApplicationVariable.value ?? '');
|
||||
|
||||
// TODO: After 2-9 slow instance command has run everywhere, turn
|
||||
// the else branch into an invariant violation for non-empty values.
|
||||
acc[flatApplicationVariable.key] =
|
||||
isNonEmptyString(value) && isEncryptedString(value)
|
||||
? secretEncryptionService.decryptVersionedOrThrow(value, {
|
||||
workspaceId: flatApplicationVariable.workspaceId,
|
||||
})
|
||||
: value;
|
||||
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { ApplicationVariableEntityModule } from 'src/engine/core-modules/application/application-variable/application-variable.module';
|
||||
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
|
||||
import { FlatFrontComponentModule } from 'src/engine/metadata-modules/flat-front-component/flat-front-component.module';
|
||||
@@ -21,6 +22,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheModule,
|
||||
WorkspaceMigrationModule,
|
||||
ApplicationModule,
|
||||
ApplicationVariableEntityModule,
|
||||
TokenModule,
|
||||
PermissionsModule,
|
||||
FlatFrontComponentModule,
|
||||
|
||||
@@ -2,10 +2,10 @@ import { Inject, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { Args, Mutation, Query } from '@nestjs/graphql';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ApplicationVariableEntityService } from 'src/engine/core-modules/application/application-variable/application-variable.service';
|
||||
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
@@ -22,8 +22,6 @@ import { FrontComponentDTO } from 'src/engine/metadata-modules/front-component/d
|
||||
import { UpdateFrontComponentInput } from 'src/engine/metadata-modules/front-component/dtos/update-front-component.input';
|
||||
import { FrontComponentService } from 'src/engine/metadata-modules/front-component/front-component.service';
|
||||
import { FrontComponentGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/front-component/interceptors/front-component-graphql-api-exception.interceptor';
|
||||
import { stripSecretFromApplicationVariables } from 'src/engine/metadata-modules/front-component/utils/strip-secret-from-application-variables';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
@@ -38,7 +36,7 @@ export class FrontComponentResolver {
|
||||
private readonly frontComponentService: FrontComponentService,
|
||||
@Inject(ApplicationTokenService)
|
||||
private readonly applicationTokenService: ApplicationTokenService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly applicationVariableService: ApplicationVariableEntityService,
|
||||
) {}
|
||||
|
||||
@Query(() => [FrontComponentDTO])
|
||||
@@ -71,26 +69,11 @@ export class FrontComponentResolver {
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const { applicationVariableMaps } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspace.id, [
|
||||
'applicationVariableMaps',
|
||||
]);
|
||||
|
||||
const variableUniversalIdentifiers =
|
||||
applicationVariableMaps.universalIdentifiersByApplicationId[
|
||||
dto.applicationId
|
||||
] ?? [];
|
||||
|
||||
const flatApplicationVariables = variableUniversalIdentifiers
|
||||
.map(
|
||||
(universalIdentifier) =>
|
||||
applicationVariableMaps.byUniversalIdentifier[universalIdentifier],
|
||||
)
|
||||
.filter(isDefined);
|
||||
|
||||
const applicationVariables = stripSecretFromApplicationVariables(
|
||||
flatApplicationVariables,
|
||||
);
|
||||
const applicationVariables =
|
||||
await this.applicationVariableService.getPublicEnvVariables({
|
||||
workspaceId: workspace.id,
|
||||
applicationId: dto.applicationId,
|
||||
});
|
||||
|
||||
return {
|
||||
...dto,
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
|
||||
import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
|
||||
import { stripSecretFromApplicationVariables } from 'src/engine/metadata-modules/front-component/utils/strip-secret-from-application-variables';
|
||||
|
||||
const makeFlatVariable = (
|
||||
overrides: Partial<FlatApplicationVariable>,
|
||||
): FlatApplicationVariable => ({
|
||||
id: '1',
|
||||
key: 'KEY',
|
||||
value: 'value' as EncryptedString,
|
||||
description: '',
|
||||
isSecret: false,
|
||||
type: FieldMetadataType.TEXT,
|
||||
options: null,
|
||||
applicationId: 'app-1',
|
||||
workspaceId: '00000000-0000-0000-0000-000000000000',
|
||||
universalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('stripSecretFromApplicationVariables', () => {
|
||||
it('should return empty object for empty array', () => {
|
||||
expect(stripSecretFromApplicationVariables([])).toEqual({});
|
||||
});
|
||||
|
||||
it('should include non-secret variables', () => {
|
||||
const variables = [
|
||||
makeFlatVariable({
|
||||
key: 'PUBLIC_URL',
|
||||
value: 'https://example.com' as EncryptedString,
|
||||
}),
|
||||
makeFlatVariable({
|
||||
id: '2',
|
||||
key: 'DEBUG',
|
||||
value: 'true' as EncryptedString,
|
||||
}),
|
||||
];
|
||||
|
||||
expect(stripSecretFromApplicationVariables(variables)).toEqual({
|
||||
PUBLIC_URL: 'https://example.com',
|
||||
DEBUG: 'true',
|
||||
});
|
||||
});
|
||||
|
||||
it('should exclude secret variables', () => {
|
||||
const variables = [
|
||||
makeFlatVariable({
|
||||
key: 'PUBLIC_URL',
|
||||
value: 'https://example.com' as EncryptedString,
|
||||
}),
|
||||
makeFlatVariable({
|
||||
id: '2',
|
||||
key: 'API_SECRET',
|
||||
value: 'encrypted_secret' as EncryptedString,
|
||||
isSecret: true,
|
||||
}),
|
||||
makeFlatVariable({
|
||||
id: '3',
|
||||
key: 'DEBUG',
|
||||
value: 'true' as EncryptedString,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = stripSecretFromApplicationVariables(variables);
|
||||
|
||||
expect(result).toEqual({
|
||||
PUBLIC_URL: 'https://example.com',
|
||||
DEBUG: 'true',
|
||||
});
|
||||
expect(result).not.toHaveProperty('API_SECRET');
|
||||
});
|
||||
|
||||
it('should handle null and undefined values', () => {
|
||||
const variables = [
|
||||
makeFlatVariable({
|
||||
key: 'NULL_VALUE',
|
||||
value: null as unknown as EncryptedString | '',
|
||||
}),
|
||||
makeFlatVariable({
|
||||
id: '2',
|
||||
key: 'UNDEFINED_VALUE',
|
||||
value: undefined as unknown as EncryptedString | '',
|
||||
}),
|
||||
];
|
||||
|
||||
expect(stripSecretFromApplicationVariables(variables)).toEqual({
|
||||
NULL_VALUE: '',
|
||||
UNDEFINED_VALUE: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert non-string values to strings', () => {
|
||||
const variables = [
|
||||
makeFlatVariable({
|
||||
key: 'NUMBER_VALUE',
|
||||
value: 123 as unknown as EncryptedString | '',
|
||||
}),
|
||||
];
|
||||
|
||||
expect(stripSecretFromApplicationVariables(variables)).toEqual({
|
||||
NUMBER_VALUE: '123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty object when all variables are secret', () => {
|
||||
const variables = [
|
||||
makeFlatVariable({
|
||||
key: 'SECRET_1',
|
||||
value: 'val1' as EncryptedString,
|
||||
isSecret: true,
|
||||
}),
|
||||
makeFlatVariable({
|
||||
id: '2',
|
||||
key: 'SECRET_2',
|
||||
value: 'val2' as EncryptedString,
|
||||
isSecret: true,
|
||||
}),
|
||||
];
|
||||
|
||||
expect(stripSecretFromApplicationVariables(variables)).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
|
||||
|
||||
export const stripSecretFromApplicationVariables = (
|
||||
flatApplicationVariables: FlatApplicationVariable[],
|
||||
): Record<string, string> => {
|
||||
return flatApplicationVariables.reduce<Record<string, string>>(
|
||||
(acc, flatApplicationVariable) => {
|
||||
if (flatApplicationVariable.isSecret) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc[flatApplicationVariable.key] = String(
|
||||
flatApplicationVariable.value ?? '',
|
||||
);
|
||||
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
|
||||
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
|
||||
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
|
||||
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
|
||||
import { uploadApplicationFile } from 'test/integration/metadata/suites/application/utils/upload-application-file.util';
|
||||
import { findFrontComponent } from 'test/integration/metadata/suites/front-component/utils/find-front-component.util';
|
||||
import { findFrontComponents } from 'test/integration/metadata/suites/front-component/utils/find-front-components.util';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const TEST_APP_ID = uuidv4();
|
||||
const TEST_ROLE_ID = uuidv4();
|
||||
const FRONT_COMPONENT_ID = uuidv4();
|
||||
const PUBLIC_VARIABLE_ID = uuidv4();
|
||||
const SECRET_VARIABLE_ID = uuidv4();
|
||||
|
||||
const BUILT_COMPONENT_PATH = 'src/front-components/variables.mjs';
|
||||
const PUBLIC_VARIABLE_VALUE = 'pk.public-access-token';
|
||||
|
||||
const buildManifest = (): Manifest => {
|
||||
const baseManifest = buildBaseManifest({
|
||||
appId: TEST_APP_ID,
|
||||
roleId: TEST_ROLE_ID,
|
||||
});
|
||||
|
||||
return {
|
||||
...baseManifest,
|
||||
application: {
|
||||
...baseManifest.application,
|
||||
applicationVariables: {
|
||||
PUBLIC_ACCESS_TOKEN: {
|
||||
universalIdentifier: PUBLIC_VARIABLE_ID,
|
||||
value: PUBLIC_VARIABLE_VALUE,
|
||||
},
|
||||
API_SECRET: {
|
||||
universalIdentifier: SECRET_VARIABLE_ID,
|
||||
isSecret: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
frontComponents: [
|
||||
{
|
||||
universalIdentifier: FRONT_COMPONENT_ID,
|
||||
name: 'VariablesComponent',
|
||||
description: 'A front component reading application variables',
|
||||
sourceComponentPath: 'src/front-components/variables.tsx',
|
||||
builtComponentPath: BUILT_COMPONENT_PATH,
|
||||
builtComponentChecksum: 'variables-checksum',
|
||||
componentName: 'VariablesComponent',
|
||||
isHeadless: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
describe('Front component application variables', () => {
|
||||
let frontComponentId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupApplicationForSync({
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
name: 'Test Application Variables App',
|
||||
description: 'App for testing front component application variables',
|
||||
sourcePath: 'test-application-variables',
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
|
||||
await uploadApplicationFile({
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
fileFolder: 'BuiltFrontComponent',
|
||||
filePath: BUILT_COMPONENT_PATH,
|
||||
fileBuffer: Buffer.from('dummy built component content'),
|
||||
filename: 'variables.mjs',
|
||||
contentType: 'application/javascript',
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
await syncApplication({
|
||||
manifest: buildManifest(),
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
const { data } = await findFrontComponents({});
|
||||
|
||||
const syncedFrontComponent = data.frontComponents.find(
|
||||
({ universalIdentifier }) => universalIdentifier === FRONT_COMPONENT_ID,
|
||||
);
|
||||
|
||||
if (!isDefined(syncedFrontComponent)) {
|
||||
throw new Error('Synced front component was not found');
|
||||
}
|
||||
|
||||
frontComponentId = syncedFrontComponent.id;
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupApplicationAndAppRegistration({
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it('should store application variable values encrypted at rest', async () => {
|
||||
const rows = await globalThis.testDataSource.query(
|
||||
`SELECT key, value FROM core."applicationVariable"
|
||||
WHERE "universalIdentifier" = ANY($1)`,
|
||||
[[PUBLIC_VARIABLE_ID, SECRET_VARIABLE_ID]],
|
||||
);
|
||||
|
||||
const publicVariable = rows.find(
|
||||
({ key }: { key: string }) => key === 'PUBLIC_ACCESS_TOKEN',
|
||||
);
|
||||
|
||||
expect(publicVariable.value).toMatch(/^enc:v2:/);
|
||||
expect(publicVariable.value).not.toContain(PUBLIC_VARIABLE_VALUE);
|
||||
});
|
||||
|
||||
it('should expose non-secret application variables decrypted and exclude secret ones', async () => {
|
||||
const { data } = await findFrontComponent({
|
||||
input: { id: frontComponentId },
|
||||
gqlFields: `
|
||||
id
|
||||
applicationVariables
|
||||
`,
|
||||
});
|
||||
|
||||
expect(data.frontComponent.applicationVariables).toEqual({
|
||||
PUBLIC_ACCESS_TOKEN: PUBLIC_VARIABLE_VALUE,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import gql from 'graphql-tag';
|
||||
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
|
||||
|
||||
const DEFAULT_FRONT_COMPONENTS_GQL_FIELDS = `
|
||||
id
|
||||
name
|
||||
universalIdentifier
|
||||
applicationId
|
||||
`;
|
||||
|
||||
export const findFrontComponentsQueryFactory = ({
|
||||
gqlFields = DEFAULT_FRONT_COMPONENTS_GQL_FIELDS,
|
||||
}: Partial<PerformMetadataQueryParams<undefined>>) => ({
|
||||
query: gql`
|
||||
query FrontComponents {
|
||||
frontComponents {
|
||||
${gqlFields}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { findFrontComponentsQueryFactory } from 'test/integration/metadata/suites/front-component/utils/find-front-components-query-factory.util';
|
||||
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
|
||||
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
|
||||
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
|
||||
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
|
||||
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
|
||||
|
||||
import { type FrontComponentDTO } from 'src/engine/metadata-modules/front-component/dtos/front-component.dto';
|
||||
|
||||
export const findFrontComponents = async ({
|
||||
gqlFields,
|
||||
expectToFail = false,
|
||||
token,
|
||||
}: Partial<PerformMetadataQueryParams<undefined>>): CommonResponseBody<{
|
||||
frontComponents: FrontComponentDTO[];
|
||||
}> => {
|
||||
const graphqlOperation = findFrontComponentsQueryFactory({ gqlFields });
|
||||
|
||||
const response = await makeMetadataAPIRequest(graphqlOperation, token);
|
||||
|
||||
if (expectToFail === true) {
|
||||
warnIfNoErrorButExpectedToFail({
|
||||
response,
|
||||
errorMessage: 'Finding front components should have failed but did not',
|
||||
});
|
||||
}
|
||||
|
||||
if (expectToFail === false) {
|
||||
warnIfErrorButNotExpectedToFail({
|
||||
response,
|
||||
errorMessage: 'Finding front components has failed but should not',
|
||||
});
|
||||
}
|
||||
|
||||
return { data: response.body.data, errors: response.body.errors };
|
||||
};
|
||||
Reference in New Issue
Block a user