Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
14 changes: 10 additions & 4 deletions packages/idempotency/src/IdempotencyHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,11 +313,17 @@ export class IdempotencyHandler<Func extends AnyFunction> {
);
} catch (e) {
if (e instanceof IdempotencyItemAlreadyExistsError) {
const idempotencyRecord: IdempotencyRecord =
e.existingRecord ||
(await this.#persistenceStore.getRecord(
let idempotencyRecord = e.existingRecord;
if (idempotencyRecord !== undefined) {
this.#persistenceStore.validatePayload(
this.#functionPayloadToBeHashed,
idempotencyRecord
);
} else {
idempotencyRecord = await this.#persistenceStore.getRecord(
this.#functionPayloadToBeHashed
));
);
}

return IdempotencyHandler.determineResultFromIdempotencyRecord(
idempotencyRecord
Expand Down
37 changes: 25 additions & 12 deletions packages/idempotency/src/persistence/BasePersistenceLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,31 @@ abstract class BasePersistenceLayer implements BasePersistenceLayerInterface {
this.saveToCache(idempotencyRecord);
}

/**
* Validates the payload against the stored record. If the payload does not match the stored record,
* an `IdempotencyValidationError` error is thrown.
*
* @param data - The data payload to validate against the stored record
* @param storedDataRecord - The stored record to validate against
*/
public validatePayload(
data: JSONValue | IdempotencyRecord,
storedDataRecord: IdempotencyRecord
): void {
if (this.payloadValidationEnabled) {
const hashedPayload =
data instanceof IdempotencyRecord
? data.payloadHash
: this.getHashedPayload(data);
if (hashedPayload !== storedDataRecord.payloadHash) {
throw new IdempotencyValidationError(
'Payload does not match stored record for this event key',
storedDataRecord
);
}
}
}

protected abstract _deleteRecord(record: IdempotencyRecord): Promise<void>;

protected abstract _getRecord(
Expand Down Expand Up @@ -313,18 +338,6 @@ abstract class BasePersistenceLayer implements BasePersistenceLayerInterface {
if (record.getStatus() === IdempotencyRecordStatus.INPROGRESS) return;
this.cache?.add(record.idempotencyKey, record);
}

private validatePayload(data: JSONValue, record: IdempotencyRecord): void {
if (this.payloadValidationEnabled) {
const hashedPayload: string = this.getHashedPayload(data);
if (hashedPayload !== record.payloadHash) {
throw new IdempotencyValidationError(
'Payload does not match stored record for this event key',
record
);
}
}
}
}

export { BasePersistenceLayer };
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*
* @group unit/idempotency/persistence/base
*/
import { createHash } from 'node:crypto';
import { ContextExamples as dummyContext } from '@aws-lambda-powertools/commons';
import { IdempotencyConfig, IdempotencyRecordStatus } from '../../../src';
import {
Expand Down Expand Up @@ -464,6 +465,92 @@ describe('Class: BasePersistenceLayer', () => {
});
});

describe('Method: validatePayload', () => {
it('throws an error if the payload does not match the stored record', () => {
// Prepare
const persistenceLayer = new PersistenceLayerTestClass();
persistenceLayer.configure({
config: new IdempotencyConfig({
payloadValidationJmesPath: 'foo',
}),
});
const existingRecord = new IdempotencyRecord({
idempotencyKey: 'my-lambda-function#mocked-hash',
status: IdempotencyRecordStatus.INPROGRESS,
payloadHash: 'different-hash',
});

// Act & Assess
expect(() =>
persistenceLayer.validatePayload({ foo: 'bar' }, existingRecord)
).toThrow(
new IdempotencyValidationError(
'Payload does not match stored record for this event key',
existingRecord
)
);
});

it('returns if the payload matches the stored record', () => {
// Prepare
const persistenceLayer = new PersistenceLayerTestClass();
persistenceLayer.configure({
config: new IdempotencyConfig({
payloadValidationJmesPath: 'foo',
}),
});
const existingRecord = new IdempotencyRecord({
idempotencyKey: 'my-lambda-function#mocked-hash',
status: IdempotencyRecordStatus.INPROGRESS,
payloadHash: 'mocked-hash',
});

// Act & Assess
expect(() =>
persistenceLayer.validatePayload({ foo: 'bar' }, existingRecord)
).not.toThrow();
});

it('skips validation if payload validation is not enabled', () => {
// Prepare
const persistenceLayer = new PersistenceLayerTestClass();
const existingRecord = new IdempotencyRecord({
idempotencyKey: 'my-lambda-function#mocked-hash',
status: IdempotencyRecordStatus.INPROGRESS,
payloadHash: 'different-hash',
});

// Act & Assess
expect(() =>
persistenceLayer.validatePayload({ foo: 'bar' }, existingRecord)
).not.toThrow();
});

it('skips hashing if the payload is already an IdempotencyRecord', () => {
// Prepare
const persistenceLayer = new PersistenceLayerTestClass();
persistenceLayer.configure({
config: new IdempotencyConfig({
payloadValidationJmesPath: 'foo',
}),
});
const existingRecord = new IdempotencyRecord({
idempotencyKey: 'my-lambda-function#mocked-hash',
status: IdempotencyRecordStatus.INPROGRESS,
payloadHash: 'mocked-hash',
});
const payload = new IdempotencyRecord({
idempotencyKey: 'my-lambda-function#mocked-hash',
status: IdempotencyRecordStatus.INPROGRESS,
payloadHash: 'mocked-hash',
});

// Act
persistenceLayer.validatePayload(payload, existingRecord);
expect(createHash).toHaveBeenCalledTimes(0);
});
});

describe('Method: getExpiresAfterSeconds', () => {
it('returns the configured value', () => {
// Prepare
Expand Down