Skip to content
Closed
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
3,220 changes: 3,220 additions & 0 deletions cdk/lib/__snapshots__/imovo-rewards.test.ts.snap

Large diffs are not rendered by default.

93 changes: 93 additions & 0 deletions cdk/lib/imovo-rewards.test.ts
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'),
]),
]),
}),
],
});
});
});
});
168 changes: 168 additions & 0 deletions cdk/lib/imovo-rewards.ts
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',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
description: 'A lambda that handles stripe disputes SQS events',
description: 'A lambda that handles imovo voucher SQS requests',

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
Copy link
Member

Choose a reason for hiding this comment

The 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
Copy link
Member

Choose a reason for hiding this comment

The 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)

}
17 changes: 17 additions & 0 deletions handlers/imovo-rewards/BUILDCHECK.md
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)
1 change: 1 addition & 0 deletions handlers/imovo-rewards/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# imovo-rewards
18 changes: 18 additions & 0 deletions handlers/imovo-rewards/eslint.config.mjs
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',
},
},
]);
11 changes: 11 additions & 0 deletions handlers/imovo-rewards/jest.config.js
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',
},
};
29 changes: 29 additions & 0 deletions handlers/imovo-rewards/package.json
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"
}
}
27 changes: 27 additions & 0 deletions handlers/imovo-rewards/riff-raff.yaml
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
9 changes: 9 additions & 0 deletions handlers/imovo-rewards/src/consumer.ts
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');
};
10 changes: 10 additions & 0 deletions handlers/imovo-rewards/src/producer.ts
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)}`);
};
3 changes: 3 additions & 0 deletions handlers/imovo-rewards/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}
Loading