@medplum/cdk 2.1.17 → 2.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cloudtrail.ts DELETED
@@ -1,134 +0,0 @@
1
- import { MedplumInfraConfig } from '@medplum/core';
2
- import {
3
- aws_cloudtrail as cloudtrail,
4
- aws_cloudwatch as cloudwatch,
5
- aws_cloudwatch_actions as cloudwatch_actions,
6
- aws_logs as logs,
7
- aws_sns as sns,
8
- } from 'aws-cdk-lib';
9
- import { Construct } from 'constructs';
10
-
11
- export class CloudTrailAlarms extends Construct {
12
- config: MedplumInfraConfig;
13
- logGroup?: logs.ILogGroup;
14
- cloudTrail?: cloudtrail.Trail;
15
- alarmTopic?: sns.ITopic;
16
-
17
- constructor(scope: Construct, config: MedplumInfraConfig) {
18
- super(scope, 'CloudTrailAlarms');
19
- this.config = config;
20
-
21
- // CloudTrail is optional
22
- if (!config.cloudTrailAlarms) {
23
- return;
24
- }
25
-
26
- // Get the CloudTrail log group
27
- // This can be created or imported by name
28
- if (config.cloudTrailAlarms.logGroupCreate) {
29
- this.logGroup = new logs.LogGroup(this, 'CloudTrailLogGroup', {
30
- logGroupName: config.cloudTrailAlarms.logGroupName,
31
- retention: logs.RetentionDays.ONE_YEAR,
32
- });
33
- this.cloudTrail = new cloudtrail.Trail(this, 'CloudTrail', {
34
- sendToCloudWatchLogs: true,
35
- cloudWatchLogGroup: this.logGroup,
36
- includeGlobalServiceEvents: true,
37
- });
38
- } else {
39
- this.logGroup = logs.LogGroup.fromLogGroupName(this, 'CloudTrailLogGroup', config.cloudTrailAlarms.logGroupName);
40
- }
41
-
42
- // Get the SNS Topic
43
- // This can be created or imported by name
44
- if (config.cloudTrailAlarms.snsTopicArn) {
45
- this.alarmTopic = sns.Topic.fromTopicArn(this, 'AlarmTopic', config.cloudTrailAlarms.snsTopicArn);
46
- } else {
47
- this.alarmTopic = new sns.Topic(this, 'AlarmTopic', { topicName: config.cloudTrailAlarms.snsTopicName });
48
- }
49
- const alarmDefinitions = [
50
- ['UnauthorizedApiCalls', '{ ($.errorCode = *UnauthorizedOperation) || ($.errorCode = AccessDenied*) }'],
51
- ['SignInWithoutMfa', '{ ($.eventName = ConsoleLogin) && ($.additionalEventData.MFAUsed != Yes) }'],
52
- [
53
- 'RootAccountUsage',
54
- '{ $.userIdentity.type = Root && $.userIdentity.invokedBy NOT EXISTS && $.eventType != AwsServiceEvent }',
55
- ],
56
- [
57
- 'IamPolicyChanges',
58
- '{($.eventName=DeleteGroupPolicy)||($.eventName=DeleteRolePolicy)||($.eventName=DeleteUserPolicy)||($.eventName=PutGroupPolicy)||($.eventName=PutRolePolicy)||($.eventName=PutUserPolicy)||($.eventName=CreatePolicy)||($.eventName=DeletePolicy)||($.eventName=CreatePolicyVersion)||($.eventName=DeletePolicyVersion)||($.eventName=AttachRolePolicy)||($.eventName=DetachRolePolicy)||($.eventName=AttachUserPolicy)||($.eventName=DetachUserPolicy)||($.eventName=AttachGroupPolicy)||($.eventName=DetachGroupPolicy)}',
59
- ],
60
- [
61
- 'CloudTrailConfigurationChanges',
62
- '{ ($.eventName = CreateTrail) || ($.eventName = UpdateTrail) || ($.eventName = DeleteTrail) || ($.eventName = StartLogging) || ($.eventName = StopLogging) }',
63
- ],
64
- ['SignInFailures', '{ ($.eventName = ConsoleLogin) && ($.errorMessage = "Failed authentication") }'],
65
- [
66
- 'DisabledCmks',
67
- '{($.eventSource = kms.amazonaws.com) && (($.eventName=DisableKey)||($.eventName=ScheduleKeyDeletion)) }',
68
- ],
69
- [
70
- 'S3PolicyChanges',
71
- '{ ($.eventSource = s3.amazonaws.com) && (($.eventName = PutBucketAcl) || ($.eventName = PutBucketPolicy) || ($.eventName = PutBucketCors) || ($.eventName = PutBucketLifecycle) || ($.eventName = PutBucketReplication) || ($.eventName = DeleteBucketPolicy) || ($.eventName = DeleteBucketCors) || ($.eventName = DeleteBucketLifecycle) || ($.eventName = DeleteBucketReplication)) }',
72
- ],
73
- [
74
- 'ConfigServiceChanges',
75
- '{($.eventSource = config.amazonaws.com) && (($.eventName=StopConfigurationRecorder)||($.eventName=DeleteDeliveryChannel)||($.eventName=PutDeliveryChannel)||($.eventName=PutConfigurationRecorder))}',
76
- ],
77
- [
78
- 'SecurityGroupChanges',
79
- '{ ($.eventName = AuthorizeSecurityGroupIngress) || ($.eventName = AuthorizeSecurityGroupEgress) || ($.eventName = RevokeSecurityGroupIngress) || ($.eventName = RevokeSecurityGroupEgress) || ($.eventName = CreateSecurityGroup) || ($.eventName = DeleteSecurityGroup)}',
80
- ],
81
- [
82
- 'NetworkAclChanges',
83
- '{ ($.eventName = CreateNetworkAcl) || ($.eventName = CreateNetworkAclEntry) || ($.eventName = DeleteNetworkAcl) || ($.eventName = DeleteNetworkAclEntry) || ($.eventName = ReplaceNetworkAclEntry) || ($.eventName = ReplaceNetworkAclAssociation) }',
84
- ],
85
- [
86
- 'NetworkGatewayChanges',
87
- '{ ($.eventName = CreateCustomerGateway) || ($.eventName = DeleteCustomerGateway) || ($.eventName = AttachInternetGateway) || ($.eventName = CreateInternetGateway) || ($.eventName = DeleteInternetGateway) || ($.eventName = DetachInternetGateway) }',
88
- ],
89
- [
90
- 'RouteTableChanges',
91
- '{ ($.eventName = CreateRoute) || ($.eventName = CreateRouteTable) || ($.eventName = ReplaceRoute) || ($.eventName = ReplaceRouteTableAssociation) || ($.eventName = DeleteRouteTable) || ($.eventName = DeleteRoute) || ($.eventName = DisassociateRouteTable) }',
92
- ],
93
- [
94
- 'VpcChanges',
95
- '{ ($.eventName = CreateVpc) || ($.eventName = DeleteVpc) || ($.eventName = ModifyVpcAttribute) || ($.eventName = AcceptVpcPeeringConnection) || ($.eventName = CreateVpcPeeringConnection) || ($.eventName = DeleteVpcPeeringConnection) || ($.eventName = RejectVpcPeeringConnection) || ($.eventName = AttachClassicLinkVpc) || ($.eventName = DetachClassicLinkVpc) || ($.eventName = DisableVpcClassicLink) || ($.eventName = EnableVpcClassicLink) }',
96
- ],
97
- [
98
- 'OrganizationsChanges',
99
- '{ ($.eventSource = organizations.amazonaws.com) && (($.eventName = AcceptHandshake) || ($.eventName = AttachPolicy) || ($.eventName = CreateAccount) || ($.eventName = CreateOrganizationalUnit) || ($.eventName = CreatePolicy) || ($.eventName = DeclineHandshake) || ($.eventName = DeleteOrganization) || ($.eventName = DeleteOrganizationalUnit) || ($.eventName = DeletePolicy) || ($.eventName = DetachPolicy) || ($.eventName = DisablePolicyType) || ($.eventName = EnablePolicyType) || ($.eventName = InviteAccountToOrganization) || ($.eventName = LeaveOrganization) || ($.eventName = MoveAccount) || ($.eventName = RemoveAccountFromOrganization) || ($.eventName = UpdatePolicy) || ($.eventName = UpdateOrganizationalUnit)) }',
100
- ],
101
- ];
102
-
103
- for (const [name, filterPattern] of alarmDefinitions) {
104
- this.createMetricAlarm(name, filterPattern);
105
- }
106
- }
107
-
108
- createMetricAlarm(name: string, filterPattern: string): void {
109
- const filterName = `${this.config.stackName}${name}MetricFilter`;
110
- const metricName = `${this.config.stackName}${name}Metric`;
111
- const metricNamespace = `${this.config.stackName}Metrics`;
112
- const alarmName = `${this.config.stackName}${name}Alarm`;
113
-
114
- const metricFilter = new logs.MetricFilter(this, filterName, {
115
- logGroup: this.logGroup as logs.ILogGroup,
116
- filterPattern: { logPatternString: filterPattern },
117
- metricNamespace,
118
- metricName,
119
- });
120
-
121
- const alarm = new cloudwatch.Alarm(this, alarmName, {
122
- metric: metricFilter.metric({}),
123
- threshold: 1,
124
- evaluationPeriods: 1,
125
- alarmName,
126
- actionsEnabled: true,
127
- treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
128
- comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
129
- datapointsToAlarm: 1,
130
- });
131
-
132
- alarm.addAlarmAction(new cloudwatch_actions.SnsAction(this.alarmTopic as sns.ITopic));
133
- }
134
- }
@@ -1,487 +0,0 @@
1
- import { GetParameterCommand, SSMClient } from '@aws-sdk/client-ssm';
2
- import { ExternalSecret, MedplumInfraConfig, MedplumSourceInfraConfig, OperationOutcomeError } from '@medplum/core';
3
- import { AwsClientStub, mockClient } from 'aws-sdk-client-mock';
4
- import 'aws-sdk-client-mock-jest';
5
- import {
6
- InfraConfigNormalizer,
7
- assertValidExternalSecret,
8
- isExternalSecret,
9
- normalizeFetchedValue,
10
- normalizeInfraConfig,
11
- } from './config';
12
-
13
- const baseConfig = {
14
- name: 'MyMedplumApp',
15
- stackName: { system: 'aws_ssm_parameter_store', key: 'stackName', type: 'string' },
16
- accountNumber: 'medplum123',
17
- region: 'us-east-1',
18
- domainName: 'foomedical.com',
19
- vpcId: 'abc-321123',
20
- apiPort: { system: 'aws_ssm_parameter_store', key: 'apiPort', type: 'number' },
21
- apiDomainName: { system: 'aws_ssm_parameter_store', key: 'apiDomainName', type: 'string' },
22
- apiSslCertArn: { system: 'aws_ssm_parameter_store', key: 'apiSslCertArn', type: 'string' },
23
- apiInternetFacing: true,
24
- appDomainName: 'app.foomedical.com',
25
- appSslCertArn: 'arn:abc-123',
26
- appApiProxy: { system: 'aws_ssm_parameter_store', key: 'appApiProxy', type: 'boolean' },
27
- storageBucketName: { system: 'aws_ssm_parameter_store', key: 'storageBucketName', type: 'string' },
28
- storageDomainName: 'storage.foomedical.com',
29
- storageSslCertArn: 'arn:def-123',
30
- signingKeyId: { system: 'aws_ssm_parameter_store', key: 'signingKeyId', type: 'string' },
31
- storagePublicKey: { system: 'aws_ssm_parameter_store', key: 'storagePublicKey', type: 'string' },
32
- baseUrl: 'foomedical.com',
33
- maxAzs: { system: 'aws_ssm_parameter_store', key: 'maxAzs', type: 'number' },
34
- rdsInstances: { system: 'aws_ssm_parameter_store', key: 'rdsInstances', type: 'number' },
35
- rdsInstanceType: 'big',
36
- desiredServerCount: { system: 'aws_ssm_parameter_store', key: 'desiredServerCount', type: 'number' },
37
- serverImage: 'arn:our-image',
38
- serverMemory: { system: 'aws_ssm_parameter_store', key: 'serverMemory', type: 'number' },
39
- serverCpu: { system: 'aws_ssm_parameter_store', key: 'serverCpu', type: 'number' },
40
- clamscanEnabled: false,
41
- clamscanLoggingBucket: 'no_logging',
42
- clamscanLoggingPrefix: 'foo_',
43
- skipDns: true,
44
- } as const satisfies MedplumSourceInfraConfig;
45
-
46
- // TODO: Test throwing on missing region
47
-
48
- const additionalContainers = [
49
- {
50
- name: 'BIG IMAGE',
51
- image: 'arn:big_image',
52
- environment: {
53
- FOO: 'BAR',
54
- MED: { system: 'aws_ssm_parameter_store', key: 'MED', type: 'string' },
55
- },
56
- },
57
- ] as const;
58
-
59
- const cloudTrailAlarms = {
60
- logGroupName: { system: 'aws_ssm_parameter_store', key: 'logGroupName', type: 'string' },
61
- logGroupCreate: { system: 'aws_ssm_parameter_store', key: 'logGroupCreate', type: 'boolean' },
62
- } as const;
63
-
64
- describe('Config', () => {
65
- describe('normalizeInfraConfig', () => {
66
- let mockSSMClient: AwsClientStub<SSMClient>;
67
-
68
- beforeEach(() => {
69
- mockSSMClient = mockClient(SSMClient);
70
-
71
- mockSSMClient.on(GetParameterCommand).rejects();
72
- mockSSMClient.on(GetParameterCommand, { Name: 'stackName' }).resolves({
73
- Parameter: { Name: 'stackName', Value: 'MyFoomedicalStack' },
74
- });
75
- mockSSMClient.on(GetParameterCommand, { Name: 'apiPort' }).resolves({
76
- Parameter: { Name: 'apiPort', Value: '1337' },
77
- });
78
- mockSSMClient.on(GetParameterCommand, { Name: 'apiDomainName' }).resolves({
79
- Parameter: { Name: 'apiDomainName', Value: 'api.foomedical.com' },
80
- });
81
- mockSSMClient.on(GetParameterCommand, { Name: 'apiSslCertArn' }).resolves({
82
- Parameter: { Name: 'apiSslCertArn', Value: 'arn:foomedical_api_ssl_cert' },
83
- });
84
- mockSSMClient.on(GetParameterCommand, { Name: 'appApiProxy' }).resolves({
85
- Parameter: { Name: 'appApiProxy', Value: 'true' },
86
- });
87
- mockSSMClient.on(GetParameterCommand, { Name: 'signingKeyId' }).resolves({
88
- Parameter: { Name: 'signingKeyId', Value: 'key-abc123' },
89
- });
90
- mockSSMClient.on(GetParameterCommand, { Name: 'storageBucketName' }).resolves({
91
- Parameter: { Name: 'storageBucketName', Value: 'foomedical_storage_bucket' },
92
- });
93
- mockSSMClient.on(GetParameterCommand, { Name: 'storagePublicKey' }).resolves({
94
- Parameter: { Name: 'storagePublicKey', Value: 'VERY_LONG_KEY' },
95
- });
96
- mockSSMClient.on(GetParameterCommand, { Name: 'maxAzs' }).resolves({
97
- Parameter: { Name: 'maxAzs', Value: '6' },
98
- });
99
- mockSSMClient.on(GetParameterCommand, { Name: 'rdsInstances' }).resolves({
100
- Parameter: { Name: 'rdsInstances', Value: '10' },
101
- });
102
- mockSSMClient.on(GetParameterCommand, { Name: 'desiredServerCount' }).resolves({
103
- Parameter: { Name: 'desiredServerCount', Value: '0' },
104
- });
105
- mockSSMClient.on(GetParameterCommand, { Name: 'serverMemory' }).resolves({
106
- Parameter: { Name: 'serverMemory', Value: '16384' },
107
- });
108
- mockSSMClient.on(GetParameterCommand, { Name: 'serverCpu' }).resolves({
109
- Parameter: { Name: 'serverCpu', Value: '4096' },
110
- });
111
- mockSSMClient.on(GetParameterCommand, { Name: 'MED' }).resolves({
112
- Parameter: { Name: 'MED', Value: 'PLUM' },
113
- });
114
- mockSSMClient.on(GetParameterCommand, { Name: 'logGroupName' }).resolves({
115
- Parameter: { Name: 'logGroupName', Value: 'FOOMEDICAL_PROD' },
116
- });
117
- mockSSMClient.on(GetParameterCommand, { Name: 'logGroupCreate' }).resolves({
118
- Parameter: { Name: 'logGroupCreate', Value: 'false' },
119
- });
120
- });
121
-
122
- afterEach(() => {
123
- mockSSMClient.restore();
124
- });
125
-
126
- test('Missing `region` in config', async () => {
127
- // @ts-expect-error Region must be defined
128
- await expect(normalizeInfraConfig({ ...baseConfig, region: undefined })).rejects.toBeInstanceOf(
129
- OperationOutcomeError
130
- );
131
- });
132
-
133
- test('Valid infra source config w/ external secrets', async () => {
134
- const result = await normalizeInfraConfig(baseConfig);
135
- expect(result).toEqual<MedplumInfraConfig>({
136
- name: 'MyMedplumApp',
137
- stackName: 'MyFoomedicalStack',
138
- accountNumber: 'medplum123',
139
- region: 'us-east-1',
140
- domainName: 'foomedical.com',
141
- vpcId: 'abc-321123',
142
- apiPort: 1337,
143
- apiDomainName: 'api.foomedical.com',
144
- apiSslCertArn: 'arn:foomedical_api_ssl_cert',
145
- apiInternetFacing: true,
146
- appDomainName: 'app.foomedical.com',
147
- appSslCertArn: 'arn:abc-123',
148
- appApiProxy: true,
149
- storageBucketName: 'foomedical_storage_bucket',
150
- storageDomainName: 'storage.foomedical.com',
151
- storageSslCertArn: 'arn:def-123',
152
- signingKeyId: 'key-abc123',
153
- storagePublicKey: 'VERY_LONG_KEY',
154
- baseUrl: 'foomedical.com',
155
- maxAzs: 6,
156
- rdsInstances: 10,
157
- rdsInstanceType: 'big',
158
- desiredServerCount: 0,
159
- serverImage: 'arn:our-image',
160
- serverMemory: 16384,
161
- serverCpu: 4096,
162
- clamscanEnabled: false,
163
- clamscanLoggingBucket: 'no_logging',
164
- clamscanLoggingPrefix: 'foo_',
165
- skipDns: true,
166
- });
167
- });
168
-
169
- test('Valid source config w/ additional containers', async () => {
170
- const result = await normalizeInfraConfig({ ...baseConfig, additionalContainers: [...additionalContainers] });
171
- expect(result).toEqual<MedplumInfraConfig>({
172
- name: 'MyMedplumApp',
173
- stackName: 'MyFoomedicalStack',
174
- accountNumber: 'medplum123',
175
- region: 'us-east-1',
176
- domainName: 'foomedical.com',
177
- vpcId: 'abc-321123',
178
- apiPort: 1337,
179
- apiDomainName: 'api.foomedical.com',
180
- apiSslCertArn: 'arn:foomedical_api_ssl_cert',
181
- apiInternetFacing: true,
182
- appDomainName: 'app.foomedical.com',
183
- appSslCertArn: 'arn:abc-123',
184
- appApiProxy: true,
185
- storageBucketName: 'foomedical_storage_bucket',
186
- storageDomainName: 'storage.foomedical.com',
187
- storageSslCertArn: 'arn:def-123',
188
- signingKeyId: 'key-abc123',
189
- storagePublicKey: 'VERY_LONG_KEY',
190
- baseUrl: 'foomedical.com',
191
- maxAzs: 6,
192
- rdsInstances: 10,
193
- rdsInstanceType: 'big',
194
- desiredServerCount: 0,
195
- serverImage: 'arn:our-image',
196
- serverMemory: 16384,
197
- serverCpu: 4096,
198
- clamscanEnabled: false,
199
- clamscanLoggingBucket: 'no_logging',
200
- clamscanLoggingPrefix: 'foo_',
201
- skipDns: true,
202
- additionalContainers: [
203
- {
204
- name: 'BIG IMAGE',
205
- image: 'arn:big_image',
206
- environment: {
207
- FOO: 'BAR',
208
- MED: 'PLUM',
209
- },
210
- },
211
- ],
212
- });
213
- });
214
-
215
- test('Valid source config w/ `cloudTrailAlarms`', async () => {
216
- const result = await normalizeInfraConfig({ ...baseConfig, cloudTrailAlarms: { ...cloudTrailAlarms } });
217
- expect(result).toEqual<MedplumInfraConfig>({
218
- name: 'MyMedplumApp',
219
- stackName: 'MyFoomedicalStack',
220
- accountNumber: 'medplum123',
221
- region: 'us-east-1',
222
- domainName: 'foomedical.com',
223
- vpcId: 'abc-321123',
224
- apiPort: 1337,
225
- apiDomainName: 'api.foomedical.com',
226
- apiSslCertArn: 'arn:foomedical_api_ssl_cert',
227
- apiInternetFacing: true,
228
- appDomainName: 'app.foomedical.com',
229
- appSslCertArn: 'arn:abc-123',
230
- appApiProxy: true,
231
- storageBucketName: 'foomedical_storage_bucket',
232
- storageDomainName: 'storage.foomedical.com',
233
- storageSslCertArn: 'arn:def-123',
234
- signingKeyId: 'key-abc123',
235
- storagePublicKey: 'VERY_LONG_KEY',
236
- baseUrl: 'foomedical.com',
237
- maxAzs: 6,
238
- rdsInstances: 10,
239
- rdsInstanceType: 'big',
240
- desiredServerCount: 0,
241
- serverImage: 'arn:our-image',
242
- serverMemory: 16384,
243
- serverCpu: 4096,
244
- clamscanEnabled: false,
245
- clamscanLoggingBucket: 'no_logging',
246
- clamscanLoggingPrefix: 'foo_',
247
- skipDns: true,
248
- cloudTrailAlarms: {
249
- logGroupName: 'FOOMEDICAL_PROD',
250
- logGroupCreate: false,
251
- },
252
- });
253
- });
254
-
255
- test('Invalid system', async () => {
256
- await expect(
257
- // @ts-expect-error System is not valid
258
- normalizeInfraConfig({ ...baseConfig, apiPort: { system: 'google_drive', key: 'abc', type: 'number' } })
259
- ).rejects.toBeInstanceOf(OperationOutcomeError);
260
- });
261
-
262
- test('Invalid AWS Param Store key', async () => {
263
- await expect(
264
- normalizeInfraConfig({
265
- ...baseConfig,
266
- apiPort: { system: 'aws_ssm_parameter_store', key: 'abc', type: 'number' },
267
- })
268
- ).rejects.toBeInstanceOf(Error);
269
- });
270
-
271
- test('Invalid type specified', async () => {
272
- await expect(
273
- normalizeInfraConfig({
274
- ...baseConfig,
275
- // @ts-expect-error Type 'plum' not a valid type
276
- apiPort: { system: 'aws_ssm_parameter_store', key: 'abc', type: 'plum' },
277
- })
278
- ).rejects.toBeInstanceOf(OperationOutcomeError);
279
- });
280
-
281
- test('Mismatched type specified', async () => {
282
- await expect(
283
- normalizeInfraConfig({
284
- ...baseConfig,
285
- // @ts-expect-error Type 'boolean' is not the proper type for `apiPort`
286
- apiPort: { system: 'aws_ssm_parameter_store', key: 'apiPort', type: 'boolean' },
287
- })
288
- ).rejects.toBeInstanceOf(OperationOutcomeError);
289
- });
290
- });
291
-
292
- describe('normalizeFetchedValue', () => {
293
- // Test [object, string] => throws
294
- test('Provided object, expected string => throws', () => {
295
- // @ts-expect-error rawValue must be a valid primitive, string | boolean | number
296
- expect(() => normalizeFetchedValue('medplumString', { med: 'plum' }, 'string')).toThrowError(
297
- OperationOutcomeError
298
- );
299
- });
300
- // Test [string, string] => return raw
301
- test('Provided string, expected string => rawValue', () => {
302
- expect(normalizeFetchedValue('medplumString', 'medplum', 'string')).toEqual('medplum');
303
- });
304
- // Test [string, number] => number
305
- test('Provided string, expected number => parseInt(string)', () => {
306
- expect(normalizeFetchedValue('medplumNumber', '20', 'number')).toEqual(20);
307
- });
308
- // Test [number, number] => rawValue
309
- test('Provided number, expected number => rawValue', () => {
310
- expect(normalizeFetchedValue('medplumNumber', 20, 'number')).toEqual(20);
311
- });
312
- // Test [invalidNumStr, number] => throws
313
- test('Provided non-numeric string, expected number => throws', () => {
314
- expect(() => normalizeFetchedValue('medplumNumber', 'medplum', 'number')).toThrowError(OperationOutcomeError);
315
- });
316
- // Test [string, boolean] => boolean
317
- test('Provided string, expected boolean => parsedBoolean', () => {
318
- expect(normalizeFetchedValue('medplumBool', 'TRUE', 'boolean')).toEqual(true);
319
- expect(normalizeFetchedValue('medplumBool', 'false', 'boolean')).toEqual(false);
320
- expect(normalizeFetchedValue('medplumBool', 'TrUe', 'boolean')).toEqual(true);
321
- expect(normalizeFetchedValue('medplumBool', 'FALSE', 'boolean')).toEqual(false);
322
- });
323
- // Test [invalidStr, boolean] => throws
324
- test('Provided string, expected boolean => parsedBoolean', () => {
325
- expect(() => normalizeFetchedValue('medplumBool', 'TRUEE', 'boolean')).toThrowError(OperationOutcomeError);
326
- expect(() => normalizeFetchedValue('medplumBool', '10', 'boolean')).toThrowError(OperationOutcomeError);
327
- expect(() => normalizeFetchedValue('medplumBool', '0', 'boolean')).toThrowError(OperationOutcomeError);
328
- });
329
- // Test [bool, number] => throws
330
- test('Provided boolean, expected number => throws', () => {
331
- expect(() => normalizeFetchedValue('medplumNumber', true, 'number')).toThrowError(OperationOutcomeError);
332
- });
333
- // Test [string, invalid_type] => throws
334
- test('Provided string, expected {invalidType} => throws', () => {
335
- // @ts-expect-error Plum is not a valid expectedType
336
- expect(() => normalizeFetchedValue('medplum???', 'medplum', 'plum')).toThrowError(OperationOutcomeError);
337
- });
338
- });
339
-
340
- describe('fetchParameterStoreSecret', () => {
341
- let mockSSMClient: AwsClientStub<SSMClient>;
342
- let configNormalizer: InfraConfigNormalizer;
343
-
344
- beforeEach(() => {
345
- mockSSMClient = mockClient(SSMClient);
346
-
347
- mockSSMClient.on(GetParameterCommand).rejects();
348
- mockSSMClient.on(GetParameterCommand, { Name: 'stackName' }).resolves({
349
- Parameter: { Name: 'stackName', Value: 'MyFoomedicalStack' },
350
- });
351
- mockSSMClient.on(GetParameterCommand, { Name: 'emptyValue' }).resolves({
352
- Parameter: { Name: 'emptyValue' },
353
- });
354
-
355
- configNormalizer = new InfraConfigNormalizer(baseConfig);
356
- });
357
-
358
- afterEach(() => {
359
- mockSSMClient.restore();
360
- });
361
-
362
- test('Valid key in param store', async () => {
363
- await expect(configNormalizer.fetchParameterStoreSecret('stackName')).resolves.toEqual('MyFoomedicalStack');
364
- });
365
-
366
- test('Invalid key in param store', async () => {
367
- await expect(configNormalizer.fetchParameterStoreSecret('medplum')).rejects.toBeInstanceOf(Error);
368
- });
369
-
370
- test('Valid key with no value', async () => {
371
- await expect(configNormalizer.fetchParameterStoreSecret('emptyValue')).rejects.toBeInstanceOf(
372
- OperationOutcomeError
373
- );
374
- });
375
- });
376
-
377
- describe('normalizeObjectInInfraConfig', () => {
378
- let mockSSMClient: AwsClientStub<SSMClient>;
379
- let configNormalizer: InfraConfigNormalizer;
380
-
381
- beforeEach(() => {
382
- mockSSMClient = mockClient(SSMClient);
383
-
384
- mockSSMClient.on(GetParameterCommand).rejects();
385
- mockSSMClient.on(GetParameterCommand, { Name: 'medplumSecret' }).resolves({
386
- Parameter: { Name: 'medplumSecret', Value: 'MyMedplumSecret' },
387
- });
388
-
389
- configNormalizer = new InfraConfigNormalizer(baseConfig);
390
- });
391
-
392
- afterEach(() => {
393
- mockSSMClient.restore();
394
- });
395
-
396
- test('Array of primitives or secrets', async () => {
397
- expect(
398
- await configNormalizer.normalizeObjectInInfraConfig({
399
- medplumStuff: [
400
- 'medplum',
401
- {
402
- system: 'aws_ssm_parameter_store',
403
- key: 'medplumSecret',
404
- type: 'string',
405
- } satisfies ExternalSecret<'string'>,
406
- ],
407
- })
408
- ).toEqual({ medplumStuff: ['medplum', 'MyMedplumSecret'] });
409
- expect(
410
- await configNormalizer.normalizeObjectInInfraConfig({
411
- medplumStuff: [
412
- {
413
- system: 'aws_ssm_parameter_store',
414
- key: 'medplumSecret',
415
- type: 'string',
416
- } satisfies ExternalSecret<'string'>,
417
- 'medplum',
418
- ],
419
- })
420
- ).toEqual({ medplumStuff: ['MyMedplumSecret', 'medplum'] });
421
- });
422
- });
423
-
424
- describe('assertValidExternalSecret', () => {
425
- // Test perfectly valid secret
426
- test('Valid ExternalSecret', () => {
427
- expect(() =>
428
- assertValidExternalSecret({
429
- system: 'aws_ssm_parameter_store',
430
- key: 'medplumString',
431
- type: 'string',
432
- } satisfies ExternalSecret<'string'>)
433
- ).not.toThrow();
434
- });
435
- // Test secret with shape but invalid type
436
- test('Almost valid ExternalSecret, invalid type value', () => {
437
- expect(() =>
438
- assertValidExternalSecret({
439
- system: 'aws_ssm_parameter_store',
440
- key: 'medplumString',
441
- type: 'plum',
442
- })
443
- ).toThrowError(OperationOutcomeError);
444
- });
445
- // Test completely invalid secret
446
- test('Invalid ExternalSecret', () => {
447
- expect(() =>
448
- assertValidExternalSecret({
449
- key: 10,
450
- type: true,
451
- })
452
- ).toThrowError(OperationOutcomeError);
453
- });
454
- });
455
-
456
- describe('isExternalSecret', () => {
457
- // Test perfectly valid secret
458
- test('Valid ExternalSecret', () => {
459
- expect(
460
- isExternalSecret({
461
- system: 'aws_ssm_parameter_store',
462
- key: 'medplumString',
463
- type: 'string',
464
- } satisfies ExternalSecret<'string'>)
465
- ).toEqual(true);
466
- });
467
- // Test secret with shape but invalid type
468
- test('Almost valid ExternalSecret, invalid type value', () => {
469
- expect(
470
- isExternalSecret({
471
- system: 'aws_ssm_parameter_store',
472
- key: 'medplumString',
473
- type: 'plum',
474
- })
475
- ).toEqual(false);
476
- });
477
- // Test completely invalid secret
478
- test('Invalid ExternalSecret', () => {
479
- expect(
480
- isExternalSecret({
481
- key: 10,
482
- type: true,
483
- })
484
- ).toEqual(false);
485
- });
486
- });
487
- });