-
Notifications
You must be signed in to change notification settings - Fork 5
feat(imovo): imovo-rewards-project-startup #3389
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| import { App } from 'aws-cdk-lib'; | ||
| import { Match, Template } from 'aws-cdk-lib/assertions'; | ||
| import { ImovoRewards } from './imovo-rewards'; | ||
|
|
||
| describe('The stripe disputes webhook API stack', () => { | ||
| it('matches the snapshot', () => { | ||
| const app = new App(); | ||
| const codeStack = new ImovoRewards(app, 'CODE'); | ||
| const prodStack = new ImovoRewards(app, 'PROD'); | ||
|
|
||
| expect(Template.fromStack(codeStack).toJSON()).toMatchSnapshot(); | ||
| expect(Template.fromStack(prodStack).toJSON()).toMatchSnapshot(); | ||
| }); | ||
|
|
||
| describe('CloudWatch Alarms', () => { | ||
| let app: App; | ||
| let stack: ImovoRewards; | ||
| let template: Template; | ||
|
|
||
| beforeEach(() => { | ||
| app = new App(); | ||
| stack = new ImovoRewards(app, 'PROD'); | ||
| template = Template.fromStack(stack); | ||
| }); | ||
|
|
||
| it('should create Consumer Lambda Error alarm', () => { | ||
| template.hasResourceProperties('AWS::CloudWatch::Alarm', { | ||
| MetricName: 'Errors', | ||
| Namespace: 'AWS/Lambda', | ||
| Statistic: 'Sum', | ||
| Threshold: 3, | ||
| ComparisonOperator: 'GreaterThanOrEqualToThreshold', | ||
| EvaluationPeriods: 1, | ||
| }); | ||
| }); | ||
|
|
||
| it('should create Producer API Gateway 5XX alarm', () => { | ||
| template.hasResourceProperties('AWS::CloudWatch::Alarm', { | ||
| MetricName: '5XXError', | ||
| Namespace: 'AWS/ApiGateway', | ||
| Statistic: 'Sum', | ||
| Threshold: 1, | ||
| ComparisonOperator: 'GreaterThanOrEqualToThreshold', | ||
| }); | ||
| }); | ||
|
|
||
| it('should create Producer API Gateway 4XX alarm', () => { | ||
| template.hasResourceProperties('AWS::CloudWatch::Alarm', { | ||
| AlarmName: 'PROD imovo-rewards - Producer API high 4XX error rate', | ||
| MetricName: '4XXError', | ||
| Namespace: 'AWS/ApiGateway', | ||
| Statistic: 'Sum', | ||
| Threshold: 10, | ||
| ComparisonOperator: 'GreaterThanThreshold', | ||
| }); | ||
| }); | ||
|
|
||
| it('should create SQS Message Age alarm', () => { | ||
| template.hasResourceProperties('AWS::CloudWatch::Alarm', { | ||
| AlarmName: | ||
| 'PROD imovo-rewards - SQS messages taking too long to process', | ||
| MetricName: 'ApproximateAgeOfOldestMessage', | ||
| Namespace: 'AWS/SQS', | ||
| Threshold: 5 * 60, | ||
| ComparisonOperator: 'GreaterThanThreshold', | ||
| }); | ||
| }); | ||
|
|
||
| it('should create DLQ alarm with correct properties', () => { | ||
| template.hasResourceProperties('AWS::CloudWatch::Alarm', { | ||
| MetricName: 'ApproximateNumberOfMessagesVisible', | ||
| Namespace: 'AWS/SQS', | ||
| Threshold: 0, | ||
| ComparisonOperator: 'GreaterThanThreshold', | ||
| }); | ||
| }); | ||
|
|
||
| it('should have alarms pointing to correct SNS topic', () => { | ||
| template.hasResourceProperties('AWS::CloudWatch::Alarm', { | ||
| AlarmActions: [ | ||
| Match.objectLike({ | ||
| 'Fn::Join': Match.arrayWith([ | ||
| '', | ||
| Match.arrayWith([ | ||
| Match.stringLikeRegexp('alarms-handler-topic-PROD'), | ||
| ]), | ||
| ]), | ||
| }), | ||
| ], | ||
| }); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,168 @@ | ||
| import { GuAlarm } from '@guardian/cdk/lib/constructs/cloudwatch'; | ||
| import { GuAllowPolicy } from '@guardian/cdk/lib/constructs/iam'; | ||
| import type { App } from 'aws-cdk-lib'; | ||
| import { Duration } from 'aws-cdk-lib'; | ||
| import { | ||
| ComparisonOperator, | ||
| TreatMissingData, | ||
| } from 'aws-cdk-lib/aws-cloudwatch'; | ||
| import { | ||
| AllowGetSecretValuePolicy, | ||
| AllowSqsSendPolicy, | ||
| AllowZuoraOAuthSecretsPolicy, | ||
| } from './cdk/policies'; | ||
| import { SrApiLambda } from './cdk/SrApiLambda'; | ||
| import { SrLambdaAlarm } from './cdk/SrLambdaAlarm'; | ||
| import { SrSqsLambda } from './cdk/SrSqsLambda'; | ||
| import type { SrStageNames } from './cdk/SrStack'; | ||
| import { SrStack } from './cdk/SrStack'; | ||
|
|
||
| export class ImovoRewards extends SrStack { | ||
| constructor(scope: App, stage: SrStageNames) { | ||
| super(scope, { stack: 'support', stage, app: 'imovo-rewards' }); | ||
|
|
||
| const app = this.app; | ||
|
|
||
| const lambdaConsumer = new SrSqsLambda(this, 'ConsumerLambda', { | ||
| legacyId: `${app}-lambda-consumer`, | ||
| nameSuffix: 'consumer', | ||
| queueNameSuffix: `events`, | ||
| lambdaOverrides: { | ||
| description: 'A lambda that handles stripe disputes SQS events', | ||
| handler: 'consumer.handler', | ||
| timeout: Duration.minutes(5), | ||
| }, | ||
| monitoring: { | ||
| errorImpact: | ||
| `There are one or more failed dispute webhook events in the ${app} dead letter queue (DLQ). ` + | ||
| `Check the attributes of the failed message(s) for details of the error and ` + | ||
| 'ensure the Stripe webhook processing is working correctly.', | ||
|
Comment on lines
+37
to
+39
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A few copy and paste bits from the Stripe disputes work |
||
| }, | ||
| maxReceiveCount: 3, | ||
| visibilityTimeout: Duration.minutes(5), // Match lambda timeout | ||
| legacyQueueIds: { | ||
| queue: `${app}-events-queue`, | ||
| dlq: `dead-letters-${app}-queue`, | ||
| dlqNameOverride: `dead-letters-${app}-queue-${this.stage}`, | ||
| }, | ||
| }); | ||
|
|
||
| const lambdaProducer = new SrApiLambda(this, `ProducerLambda`, { | ||
| legacyId: `${app}-lambda-producer`, | ||
| nameSuffix: 'producer', | ||
| lambdaOverrides: { | ||
| description: | ||
| 'A lambda that handles stripe disputes webhook events and processes SQS events', | ||
| handler: 'producer.handler', | ||
| environment: { | ||
| DISPUTE_EVENTS_QUEUE_URL: lambdaConsumer.inputQueue.queueUrl, | ||
| }, | ||
| }, | ||
| isPublic: true, | ||
| monitoring: { | ||
| errorImpact: | ||
| `The ${app} producer API is returning 5XX errors. ` + | ||
| `This prevents Stripe from delivering webhook events. ` + | ||
| `Check for Lambda errors, timeout issues, or signature verification failures. `, | ||
| }, | ||
| }); | ||
|
|
||
| lambdaProducer.addPolicies( | ||
| getStripeSecretPolicy(this), | ||
| getQueueSendPolicy(this, lambdaConsumer), | ||
| ); | ||
|
|
||
| lambdaConsumer.addPolicies( | ||
| new AllowZuoraOAuthSecretsPolicy( | ||
| this, | ||
| 'Allow Secrets Manager Zuora policy', | ||
| ), | ||
| getSalesforceSecretPolicy(this), | ||
| AllowSqsSendPolicy.createWithId( | ||
| this, | ||
| 'Allow SQS SendMessage to Braze Emails Queue', | ||
| 'braze-emails', | ||
| ), | ||
| ); | ||
|
|
||
| new SrLambdaAlarm(this, 'ConsumerLambdaErrorAlarm', { | ||
| app: app, | ||
| alarmName: `${this.stage} ${app} - Consumer Lambda high error rate`, | ||
| alarmDescription: | ||
| `The ${app} consumer Lambda has experienced more than 3 errors in 5 minutes. ` + | ||
| `This indicates failures in processing dispute webhooks from SQS. ` + | ||
| `Common causes: Salesforce API errors, Zuora API errors, malformed webhook data`, | ||
| lambdaFunctionNames: lambdaConsumer.functionName, | ||
| metric: lambdaConsumer.metricErrors({ | ||
| statistic: 'Sum', | ||
| period: Duration.minutes(5), | ||
| }), | ||
| threshold: 3, // 3 errors in 5 minutes | ||
| evaluationPeriods: 1, | ||
| comparisonOperator: ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, | ||
| treatMissingData: TreatMissingData.NOT_BREACHING, | ||
| }); | ||
|
|
||
| new SrLambdaAlarm(this, 'ProducerApiGateway4XXAlarm', { | ||
| app: app, | ||
| alarmName: `${this.stage} ${app} - Producer API high 4XX error rate`, | ||
| alarmDescription: | ||
| `The ${app} producer API has high 4XX error rate (>10 in 5 min). ` + | ||
| `This may indicate invalid webhook signatures or missing headers. ` + | ||
| `Check Stripe webhook configuration and secret key. `, | ||
| lambdaFunctionNames: lambdaProducer.functionName, | ||
| metric: lambdaProducer.api.metricClientError({ | ||
| statistic: 'Sum', | ||
| period: Duration.minutes(5), | ||
| }), | ||
| threshold: 10, // More than 10 4XX errors in 5 minutes | ||
| evaluationPeriods: 1, | ||
| comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD, | ||
| treatMissingData: TreatMissingData.NOT_BREACHING, | ||
| }); | ||
|
|
||
| new GuAlarm(this, 'SQSMessageAgeAlarm', { | ||
| app: app, | ||
| alarmName: `${this.stage} ${app} - SQS messages taking too long to process`, | ||
| alarmDescription: | ||
| `Messages in the ${app} queue are older than 5 minutes. ` + | ||
| `This indicates the consumer Lambda is not processing messages fast enough. ` + | ||
| `Check for Lambda throttling or processing errors. ` + | ||
| `Queue: https://${this.region}.console.aws.amazon.com/sqs/v2/home?region=${this.region}#/queues/https%3A%2F%2Fsqs.${this.region}.amazonaws.com%2F${this.account}%2F${lambdaConsumer.inputQueue.queueName}`, | ||
| metric: lambdaConsumer.inputQueue.metricApproximateAgeOfOldestMessage(), | ||
| threshold: Duration.minutes(5).toSeconds(), | ||
| evaluationPeriods: 1, | ||
| comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD, | ||
| treatMissingData: TreatMissingData.NOT_BREACHING, | ||
| snsTopicName: `alarms-handler-topic-${this.stage}`, | ||
| actionsEnabled: this.stage === 'PROD', | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| function getQueueSendPolicy(scope: SrStack, lambdaConsumer: SrSqsLambda) { | ||
| return new GuAllowPolicy( | ||
| scope, | ||
| 'Allow SQS SendMessage and GetQueueAttributes to Dispute Events Queue', | ||
| { | ||
| actions: ['sqs:SendMessage', 'sqs:GetQueueAttributes'], | ||
| resources: [lambdaConsumer.inputQueue.queueArn], | ||
| }, | ||
| ); | ||
| } | ||
|
|
||
| function getStripeSecretPolicy(scope: SrStack) { | ||
| return new AllowGetSecretValuePolicy( | ||
| scope, | ||
| 'Allow Secrets Manager Stripe Webhooks policy', | ||
| 'Stripe/ConnectedApp/StripeDisputeWebhooks-*', | ||
| ); | ||
| } | ||
|
|
||
| function getSalesforceSecretPolicy(scope: SrStack) { | ||
| return new AllowGetSecretValuePolicy( | ||
| scope, | ||
| 'Allow Secrets Manager Salesforce policy', | ||
| 'Salesforce/ConnectedApp/StripeDisputeWebhooks-*', | ||
| ); | ||
|
Comment on lines
+154
to
+167
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We won't need these for this project (we will need the imovo credentials) |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| MANAGED FILE: to push changes see buildcheck/README.md - template: buildcheck/data/snippets/BUILDCHECK.md.ts | ||
| # Buildcheck managed file list | ||
|
|
||
| The files listed below are managed by buildcheck and their content is checked by the build. | ||
|
|
||
| ## HOWTO edit managed files | ||
| 1. edit the build definition in buildcheck/data/ | ||
| 2. run `pnpm snapshot:update` at the root | ||
|
|
||
| For further details, see [buildcheck/README.md](../../buildcheck/README.md) | ||
|
|
||
| ## Generated file list: | ||
| - [jest.config.js](jest.config.js) | ||
| - [package.json](package.json) | ||
| - [riff-raff.yaml](riff-raff.yaml) | ||
| - [tsconfig.json](tsconfig.json) | ||
| - [BUILDCHECK.md](BUILDCHECK.md) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| # imovo-rewards |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import guardian from '@guardian/eslint-config'; | ||
| import { defineConfig } from 'eslint/config'; | ||
|
|
||
| export default defineConfig([ | ||
| guardian.configs.recommended, | ||
| { | ||
| extends: [guardian.configs.recommended], | ||
| files: ['test/**/*.ts', '**/*.test.ts'], | ||
| rules: { | ||
| '@typescript-eslint/no-explicit-any': 'off', | ||
| '@typescript-eslint/no-unsafe-assignment': 'off', | ||
| '@typescript-eslint/no-unsafe-call': 'off', | ||
| '@typescript-eslint/no-unsafe-member-access': 'off', | ||
| '@typescript-eslint/no-require-imports': 'off', | ||
| '@typescript-eslint/unbound-method': 'off', | ||
| }, | ||
| }, | ||
| ]); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| // MANAGED FILE: to push changes see buildcheck/README.md - template: buildcheck/data/templates/handler/jest.config.js.ts | ||
| /** @type {import('ts-jest').JestConfigWithTsJest} */ | ||
| module.exports = { | ||
| preset: 'ts-jest', | ||
| testEnvironment: 'node', | ||
| runner: 'groups', | ||
| moduleNameMapper: { | ||
| '@modules/([^/]*)/(.*)$': '<rootDir>/../../modules/$1/src/$2', | ||
| '@modules/(.*)$': '<rootDir>/../../modules/$1', | ||
| }, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| { | ||
| "name": "imovo-rewards", | ||
| "scripts": { | ||
| "test": "jest --group=-integration", | ||
| "it-test": "jest --group=integration", | ||
| "type-check": "tsc --noEmit", | ||
| "lint": "eslint --cache --cache-location /tmp/eslintcache/ 'src/**/*.ts' 'test/**/*.ts'", | ||
| "check-formatting": "prettier --check \"**/*.ts\"", | ||
| "fix-formatting": "prettier --write \"**/*.ts\"", | ||
| "build": "esbuild --bundle --platform=node --target=node20 --outdir=target/ src/producer.ts src/consumer.ts --sourcemap", | ||
| "package": "pnpm type-check && pnpm lint && pnpm check-formatting && pnpm test && pnpm build && cd target && zip -qr imovo-rewards.zip ./*.js.map ./*.js", | ||
| "cdk:test": "pnpm --filter cdk test imovo-rewards", | ||
| "cdk:test-update": "pnpm --filter cdk test-update imovo-rewards", | ||
| "update-lambda": "../../update-lambda.sh \"imovo-rewards\" imovo-rewards-producer- imovo-rewards-consumer-", | ||
| "update-stack": "../../update-stack.sh \"imovo-rewards\"" | ||
| }, | ||
| "NOTICE1": "MANAGED FILE: to push changes see buildcheck/README.md - template: buildcheck/data/templates/handler/package.json.ts", | ||
| "NOTICE2": "all dependencies are defined in buildcheck/data/build.ts", | ||
| "dependencies": { | ||
| "aws-sdk": "^2.1692.0", | ||
| "dayjs": "^1.11.13", | ||
| "stripe": "^18.5.0", | ||
| "zod": "catalog:" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/aws-lambda": "^8.10.147", | ||
| "@types/stripe": "^8.0.417" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| # MANAGED FILE: to push changes see buildcheck/README.md - template: buildcheck/data/templates/handler/riff-raff.yaml.ts | ||
| stacks: | ||
| - support | ||
| regions: | ||
| - eu-west-1 | ||
| allowedStages: | ||
| - CODE | ||
| - PROD | ||
| deployments: | ||
| imovo-rewards-cloudformation: | ||
| type: cloud-formation | ||
| app: imovo-rewards | ||
| parameters: | ||
| templateStagePaths: | ||
| CODE: imovo-rewards-CODE.template.json | ||
| PROD: imovo-rewards-PROD.template.json | ||
| imovo-rewards: | ||
| type: aws-lambda | ||
| parameters: | ||
| fileName: imovo-rewards.zip | ||
| bucketSsmLookup: true | ||
| prefixStack: false | ||
| functionNames: | ||
| - imovo-rewards-producer- | ||
| - imovo-rewards-consumer- | ||
| dependencies: | ||
| - imovo-rewards-cloudformation |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import { Logger } from '@modules/routing/logger'; | ||
| import type { SQSEvent } from 'aws-lambda'; | ||
|
|
||
| const logger = new Logger(); | ||
|
|
||
| export const handler = async (event: SQSEvent): Promise<void> => { | ||
| logger.log(`Input: ${JSON.stringify(event)}`); | ||
| logger.log('SQS events processed successfully'); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import { Logger } from '@modules/routing/logger'; | ||
| import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; | ||
|
|
||
| const logger = new Logger(); | ||
|
|
||
| export const handler = async ( | ||
| event: APIGatewayProxyEvent, | ||
| ): Promise<APIGatewayProxyResult | void> => { | ||
| logger.log(`Input: ${JSON.stringify(event)}`); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| { | ||
| "extends": "../../tsconfig.json" | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.