Adds unit tests for all services
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,3 +2,5 @@ node_modules
|
||||
dist
|
||||
data
|
||||
.env
|
||||
*.sqlite
|
||||
.DS_Store
|
||||
16
jest.config.js
Normal file
16
jest.config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
testMatch: ['**/__tests__/**/*.spec.ts', '**/?(*.)+(spec|test).ts'],
|
||||
transform: {
|
||||
'^.+\\.ts$': 'ts-jest',
|
||||
},
|
||||
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts', '!src/**/__tests__/**'],
|
||||
moduleFileExtensions: ['ts', 'js', 'json'],
|
||||
coverageDirectory: 'coverage',
|
||||
verbose: true,
|
||||
testTimeout: 10000,
|
||||
maxConcurrency: 1,
|
||||
maxWorkers: 1,
|
||||
};
|
||||
8282
package-lock.json
generated
8282
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -10,7 +10,7 @@
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-kms": "^3.716.0",
|
||||
"@aws-sdk/client-kms": "^3.968.0",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
@@ -20,16 +20,28 @@
|
||||
"execa": "^9.5.2",
|
||||
"joi": "^17.9.0",
|
||||
"js2xmlparser": "^5.0.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.0",
|
||||
"sqlite3": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-iam": "^3.969.0",
|
||||
"@aws-sdk/client-s3": "^3.968.0",
|
||||
"@aws-sdk/client-secrets-manager": "^3.968.0",
|
||||
"@aws-sdk/client-sns": "^3.968.0",
|
||||
"@aws-sdk/client-sqs": "^3.968.0",
|
||||
"@aws-sdk/client-sts": "^3.969.0",
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/testing": "10.4.15",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/joi": "^17.2.2",
|
||||
"@types/node": "^22.10.2",
|
||||
"aws-sdk-client-mock": "^4.1.0",
|
||||
"eslint": "^8.36.0",
|
||||
"prisma": "^6.1.0"
|
||||
"jest": "^30.2.0",
|
||||
"prisma": "^6.1.0",
|
||||
"ts-jest": "^29.4.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.11.0",
|
||||
|
||||
164
prisma/migrations/20250111035957_/migration.sql
Normal file
164
prisma/migrations/20250111035957_/migration.sql
Normal file
@@ -0,0 +1,164 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Attribute" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"arn" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Audit" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"action" TEXT,
|
||||
"request" TEXT,
|
||||
"response" TEXT
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "IamRole" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"path" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"assumeRolePolicy" TEXT,
|
||||
"description" TEXT,
|
||||
"maxSessionDuration" INTEGER,
|
||||
"permissionBoundaryArn" TEXT,
|
||||
"lastUsedDate" DATETIME,
|
||||
"lastUsedRegion" TEXT,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "IamPolicy" (
|
||||
"id" TEXT NOT NULL,
|
||||
"version" INTEGER NOT NULL DEFAULT 1,
|
||||
"isDefault" BOOLEAN NOT NULL,
|
||||
"path" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"policy" TEXT NOT NULL,
|
||||
"isAttachable" BOOLEAN NOT NULL DEFAULT false,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
|
||||
PRIMARY KEY ("id", "version")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "KmsAlias" (
|
||||
"name" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"region" TEXT NOT NULL,
|
||||
"kmsKeyId" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
|
||||
PRIMARY KEY ("accountId", "region", "name"),
|
||||
CONSTRAINT "KmsAlias_kmsKeyId_fkey" FOREIGN KEY ("kmsKeyId") REFERENCES "KmsKey" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "KmsKey" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"enabled" BOOLEAN NOT NULL,
|
||||
"usage" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"keySpec" TEXT NOT NULL,
|
||||
"keyState" TEXT NOT NULL,
|
||||
"origin" TEXT NOT NULL,
|
||||
"multiRegion" BOOLEAN NOT NULL,
|
||||
"policy" TEXT NOT NULL,
|
||||
"key" BLOB NOT NULL,
|
||||
"rotationPeriod" INTEGER,
|
||||
"nextRotation" DATETIME,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"region" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Secret" (
|
||||
"versionId" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"secretString" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"region" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"deletionDate" DATETIME
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SnsTopic" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"name" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"region" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SnsTopicSubscription" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"topicArn" TEXT NOT NULL,
|
||||
"endpoint" TEXT,
|
||||
"protocol" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"region" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SqsQueue" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"name" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"region" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SqsQueueMessage" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"queueId" INTEGER NOT NULL,
|
||||
"senderId" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"inFlightRelease" DATETIME NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "SqsQueueMessage_queueId_fkey" FOREIGN KEY ("queueId") REFERENCES "SqsQueue" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Tag" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"arn" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Attribute_arn_name_key" ON "Attribute"("arn", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "IamRole_accountId_name_key" ON "IamRole"("accountId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "IamPolicy_accountId_path_name_key" ON "IamPolicy"("accountId", "path", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Secret_name_idx" ON "Secret"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SnsTopic_accountId_region_name_key" ON "SnsTopic"("accountId", "region", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SqsQueue_accountId_region_name_key" ON "SqsQueue"("accountId", "region", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SqsQueueMessage_queueId_idx" ON "SqsQueueMessage"("queueId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Tag_arn_name_key" ON "Tag"("arn", "name");
|
||||
10
prisma/migrations/20250117014932_/migration.sql
Normal file
10
prisma/migrations/20250117014932_/migration.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "IamRoleIamPolicyAttachment" (
|
||||
"iamRoleId" TEXT NOT NULL,
|
||||
"iamPolicyId" TEXT NOT NULL,
|
||||
"iamPolicyVersion" INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY ("iamRoleId", "iamPolicyId", "iamPolicyVersion"),
|
||||
CONSTRAINT "IamRoleIamPolicyAttachment_iamRoleId_fkey" FOREIGN KEY ("iamRoleId") REFERENCES "IamRole" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "IamRoleIamPolicyAttachment_iamPolicyId_iamPolicyVersion_fkey" FOREIGN KEY ("iamPolicyId", "iamPolicyVersion") REFERENCES "IamPolicy" ("id", "version") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
22
prisma/migrations/20250117015248_/migration.sql
Normal file
22
prisma/migrations/20250117015248_/migration.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The primary key for the `IamRoleIamPolicyAttachment` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- You are about to drop the column `iamPolicyVersion` on the `IamRoleIamPolicyAttachment` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_IamRoleIamPolicyAttachment" (
|
||||
"iamRoleId" TEXT NOT NULL,
|
||||
"iamPolicyId" TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY ("iamRoleId", "iamPolicyId"),
|
||||
CONSTRAINT "IamRoleIamPolicyAttachment_iamRoleId_fkey" FOREIGN KEY ("iamRoleId") REFERENCES "IamRole" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_IamRoleIamPolicyAttachment" ("iamPolicyId", "iamRoleId") SELECT "iamPolicyId", "iamRoleId" FROM "IamRoleIamPolicyAttachment";
|
||||
DROP TABLE "IamRoleIamPolicyAttachment";
|
||||
ALTER TABLE "new_IamRoleIamPolicyAttachment" RENAME TO "IamRoleIamPolicyAttachment";
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Drop unique constraint on IamPolicy
|
||||
DROP INDEX "IamPolicy_accountId_path_name_key";
|
||||
@@ -56,7 +56,6 @@ model IamPolicy {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@id([id, version])
|
||||
@@unique([accountId, path, name])
|
||||
}
|
||||
|
||||
model IamRoleIamPolicyAttachment {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export enum Action {
|
||||
|
||||
// IAM
|
||||
IamAddClientIDToOpenIDConnectProvider = 'AddClientIDToOpenIDConnectProvider',
|
||||
IamAddRoleToInstanceProfile = 'AddRoleToInstanceProfile',
|
||||
@@ -324,6 +323,22 @@ export enum Action {
|
||||
V2_SqsTagQueue = 'AmazonSQS.TagQueue',
|
||||
V2_SqsUntagQueue = 'AmazonSQS.UntagQueue',
|
||||
|
||||
// S3
|
||||
S3AbortMultipartUpload = 'AbortMultipartUpload',
|
||||
S3CompleteMultipartUpload = 'CompleteMultipartUpload',
|
||||
S3CreateBucket = 'CreateBucket',
|
||||
S3CreateMultipartUpload = 'CreateMultipartUpload',
|
||||
S3DeleteBucket = 'DeleteBucket',
|
||||
S3DeleteObject = 'DeleteObject',
|
||||
S3GetObject = 'GetObject',
|
||||
S3HeadBucket = 'HeadBucket',
|
||||
S3HeadObject = 'HeadObject',
|
||||
S3ListBuckets = 'ListBuckets',
|
||||
S3ListObjects = 'ListObjects',
|
||||
S3ListObjectsV2 = 'ListObjectsV2',
|
||||
S3PutObject = 'PutObject',
|
||||
S3UploadPart = 'UploadPart',
|
||||
|
||||
// STS
|
||||
StsAssumeRole = 'AssumeRole',
|
||||
StsAssumeRoleWithSaml = 'AssumeRoleWithSaml',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, Body, Controller, Headers, HttpCode, Inject, Post, Req, UseInterceptors } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Headers, HttpCode, Inject, Post, Put, Query, Req, Res, UseInterceptors } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Request } from 'express';
|
||||
import { Response } from 'express';
|
||||
import * as Joi from 'joi';
|
||||
import * as js2xmlparser from 'js2xmlparser';
|
||||
|
||||
@@ -18,7 +18,6 @@ type QueryParams = {
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
|
||||
constructor(
|
||||
@Inject(ActionHandlers)
|
||||
private readonly actionHandlers: ActionHandlers,
|
||||
@@ -28,21 +27,18 @@ export class AppController {
|
||||
@Post()
|
||||
@HttpCode(200)
|
||||
@UseInterceptors(AuditInterceptor)
|
||||
async post(
|
||||
@Req() request: IRequest,
|
||||
@Body() body: Record<string, any>,
|
||||
@Headers() headers: Record<string, any>,
|
||||
) {
|
||||
|
||||
async post(@Req() request: IRequest, @Body() body: Record<string, any>, @Headers() headers: Record<string, any>) {
|
||||
const lowerCasedHeaders = Object.keys(headers).reduce((o, k) => {
|
||||
o[k.toLocaleLowerCase()] = headers[k];
|
||||
return o;
|
||||
}, {} as Record<string, string>)
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
const queryParams: QueryParams = { __path: request.path, ...body, ...lowerCasedHeaders };
|
||||
const actionKey = queryParams['x-amz-target'] ? 'x-amz-target' : 'Action';
|
||||
const { error: actionError } = Joi.object({
|
||||
[actionKey]: Joi.string().valid(...Object.values(Action)).required(),
|
||||
[actionKey]: Joi.string()
|
||||
.valid(...Object.values(Action))
|
||||
.required(),
|
||||
}).validate(queryParams, { allowUnknown: true });
|
||||
|
||||
if (actionError) {
|
||||
@@ -51,7 +47,10 @@ export class AppController {
|
||||
|
||||
const action = queryParams[actionKey] as Action;
|
||||
const handler: AbstractActionHandler = this.actionHandlers[action];
|
||||
const { error: validatorError, value: validQueryParams } = handler.validator.validate(queryParams, { allowUnknown: true, abortEarly: false });
|
||||
const { error: validatorError, value: validQueryParams } = handler.validator.validate(queryParams, {
|
||||
allowUnknown: true,
|
||||
abortEarly: false,
|
||||
});
|
||||
|
||||
if (validatorError) {
|
||||
throw new ValidationError(validatorError.message);
|
||||
|
||||
@@ -35,22 +35,13 @@ import { IAMHandlers } from './iam/iam.constants';
|
||||
SqsModule,
|
||||
StsModule,
|
||||
],
|
||||
controllers: [
|
||||
AppController,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
AuditInterceptor,
|
||||
{
|
||||
provide: ActionHandlers,
|
||||
useFactory: (...args) => args.reduce((m, hs) => ({ ...m, ...hs }), {}),
|
||||
inject: [
|
||||
IAMHandlers,
|
||||
KMSHandlers,
|
||||
SecretsManagerHandlers,
|
||||
SnsHandlers,
|
||||
SqsHandlers,
|
||||
StsHandlers,
|
||||
],
|
||||
inject: [IAMHandlers, KMSHandlers, SecretsManagerHandlers, SnsHandlers, SqsHandlers, StsHandlers],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -8,38 +8,48 @@ const ResourcePolicyName = 'ResourcePolicy';
|
||||
|
||||
@Injectable()
|
||||
export class AttributesService {
|
||||
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
) {}
|
||||
constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
async getByArn(arn: string): Promise<Attribute[]> {
|
||||
return await this.prismaService.attribute.findMany({ where: { arn }});
|
||||
return await this.prismaService.attribute.findMany({ where: { arn } });
|
||||
}
|
||||
|
||||
async getResourcePolicyByArn(arn: string): Promise<Attribute | null> {
|
||||
return await this.prismaService.attribute.findFirst({ where: { arn, name: ResourcePolicyName }});
|
||||
return await this.prismaService.attribute.findFirst({ where: { arn, name: ResourcePolicyName } });
|
||||
}
|
||||
|
||||
async getByArnAndName(arn: string, name: string): Promise<Attribute | null> {
|
||||
return await this.prismaService.attribute.findFirst({ where: { arn, name }});
|
||||
return await this.prismaService.attribute.findFirst({ where: { arn, name } });
|
||||
}
|
||||
|
||||
async getByArnAndNames(arn: string, names: string[]): Promise<Attribute[]> {
|
||||
return await this.prismaService.attribute.findMany({ where: {
|
||||
return await this.prismaService.attribute.findMany({
|
||||
where: {
|
||||
arn,
|
||||
name: {
|
||||
in: names
|
||||
}
|
||||
}});
|
||||
in: names,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createResourcePolicy(arn: string, value: string): Promise<Attribute> {
|
||||
return await this.create({arn, value, name: ResourcePolicyName });
|
||||
return await this.create({ arn, value, name: ResourcePolicyName });
|
||||
}
|
||||
|
||||
async create(data: Prisma.AttributeCreateArgs['data']): Promise<Attribute> {
|
||||
return await this.prismaService.attribute.create({ data });
|
||||
return await this.prismaService.attribute.upsert({
|
||||
where: {
|
||||
arn_name: {
|
||||
arn: data.arn,
|
||||
name: data.name,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
value: data.value,
|
||||
},
|
||||
create: data,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteByArn(arn: string): Promise<void> {
|
||||
@@ -50,18 +60,34 @@ export class AttributesService {
|
||||
await this.prismaService.attribute.deleteMany({ where: { arn, name } });
|
||||
}
|
||||
|
||||
async createMany(arn: string, records: { key: string, value: string }[]): Promise<void> {
|
||||
await this.prismaService.attribute.createMany({
|
||||
data: records.map(r => ({
|
||||
async createMany(arn: string, records: { key: string; value: string }[]): Promise<void> {
|
||||
// Use upsert to handle both create and update cases
|
||||
await Promise.all(
|
||||
records
|
||||
.filter(r => !!r)
|
||||
.map(r =>
|
||||
this.prismaService.attribute.upsert({
|
||||
where: {
|
||||
arn_name: {
|
||||
arn,
|
||||
name: r.key,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
value: r.value,
|
||||
},
|
||||
create: {
|
||||
name: r.key,
|
||||
value: r.value,
|
||||
arn,
|
||||
}))
|
||||
});
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static attributePairs(queryParams: Record<string, string>): { key: string, value: string }[] {
|
||||
const pairs: { key: string, value: string }[] = [];
|
||||
static attributePairs(queryParams: Record<string, string>): { key: string; value: string }[] {
|
||||
const pairs: { key: string; value: string }[] = [];
|
||||
for (const param of Object.keys(queryParams)) {
|
||||
const components = breakdownAwsQueryParam(param);
|
||||
|
||||
@@ -73,7 +99,7 @@ export class AttributesService {
|
||||
|
||||
if (type === 'Attributes') {
|
||||
if (!pairs[idx]) {
|
||||
pairs[idx] = { key: '', value: ''};
|
||||
pairs[idx] = { key: '', value: '' };
|
||||
}
|
||||
pairs[idx][slot] = queryParams[param];
|
||||
}
|
||||
@@ -83,6 +109,6 @@ export class AttributesService {
|
||||
}
|
||||
|
||||
static getXmlSafeAttributesMap(attributes: Record<string, string>) {
|
||||
return { Attributes: { entry: Object.keys(attributes).map(key => ({ key, value: attributes[key] })) } }
|
||||
return { Attributes: { entry: Object.keys(attributes).map(key => ({ key, value: attributes[key] })) } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +183,16 @@ export class NoSuchEntity extends AwsException {
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFound extends AwsException {
|
||||
constructor() {
|
||||
super(
|
||||
'Indicates that the requested resource does not exist.',
|
||||
NotFound.name,
|
||||
HttpStatus.NOT_FOUND,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export class QueueNameExists extends AwsException {
|
||||
constructor() {
|
||||
super(
|
||||
|
||||
@@ -6,28 +6,41 @@ import { breakdownAwsQueryParam } from '../util/breakdown-aws-query-param';
|
||||
|
||||
@Injectable()
|
||||
export class TagsService {
|
||||
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
) {}
|
||||
constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
async getByArn(arn: string): Promise<Tag[]> {
|
||||
return await this.prismaService.tag.findMany({ where: { arn }});
|
||||
return await this.prismaService.tag.findMany({ where: { arn } });
|
||||
}
|
||||
|
||||
async create(data: Prisma.TagCreateArgs['data']): Promise<Tag> {
|
||||
return await this.prismaService.tag.create({ data })
|
||||
return await this.prismaService.tag.create({ data });
|
||||
}
|
||||
|
||||
async createMany(arn: string, records: { key: string, value: string }[]): Promise<void> {
|
||||
await this.prismaService.tag.createMany({
|
||||
data: records.map(r => ({
|
||||
name: r.key,
|
||||
value: r.value,
|
||||
async createMany(arn: string, records: { key: string; value: string }[]): Promise<void> {
|
||||
if (records.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Upsert each tag individually to handle duplicates
|
||||
for (const record of records) {
|
||||
await this.prismaService.tag.upsert({
|
||||
where: {
|
||||
arn_name: {
|
||||
arn,
|
||||
}))
|
||||
name: record.key,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
value: record.value,
|
||||
},
|
||||
create: {
|
||||
arn,
|
||||
name: record.key,
|
||||
value: record.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async deleteByArn(arn: string): Promise<void> {
|
||||
await this.prismaService.tag.deleteMany({ where: { arn } });
|
||||
@@ -37,26 +50,28 @@ export class TagsService {
|
||||
await this.prismaService.tag.deleteMany({ where: { arn, name } });
|
||||
}
|
||||
|
||||
static tagPairs(queryParams: Record<string, any>): { key: string, value: string }[] {
|
||||
const pairs: { key: string, value: string }[] = [];
|
||||
static tagPairs(queryParams: Record<string, any>): { key: string; value: string }[] {
|
||||
const pairs: { key: string; value: string }[] = [];
|
||||
for (const param of Object.keys(queryParams)) {
|
||||
const components = breakdownAwsQueryParam(param);
|
||||
|
||||
if (!components) {
|
||||
return [];
|
||||
continue; // Skip params that don't match the pattern
|
||||
}
|
||||
|
||||
const [type, _, idx, slot] = components;
|
||||
|
||||
if (type === 'Tags') {
|
||||
if (!pairs[+idx]) {
|
||||
pairs[+idx] = { key: '', value: ''};
|
||||
pairs[+idx] = { key: '', value: '' };
|
||||
}
|
||||
pairs[+idx][slot] = queryParams[param];
|
||||
// Normalize slot to lowercase (AWS sends 'Key' and 'Value', we need 'key' and 'value')
|
||||
const normalizedSlot = slot.toLowerCase() as 'key' | 'value';
|
||||
pairs[+idx][normalizedSlot] = queryParams[param];
|
||||
}
|
||||
}
|
||||
|
||||
return pairs;
|
||||
return pairs.filter(p => p); // Filter out empty slots
|
||||
}
|
||||
|
||||
static getXmlSafeTagsMap(tags: Tag[]) {
|
||||
|
||||
793
src/iam/__tests__/iam.spec.ts
Normal file
793
src/iam/__tests__/iam.spec.ts
Normal file
@@ -0,0 +1,793 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import {
|
||||
IAMClient,
|
||||
CreateRoleCommand,
|
||||
GetRoleCommand,
|
||||
DeleteRoleCommand,
|
||||
CreatePolicyCommand,
|
||||
GetPolicyCommand,
|
||||
GetPolicyVersionCommand,
|
||||
CreatePolicyVersionCommand,
|
||||
AttachRolePolicyCommand,
|
||||
ListAttachedRolePoliciesCommand,
|
||||
ListRolePoliciesCommand,
|
||||
} from '@aws-sdk/client-iam';
|
||||
import { AppModule } from '../../app.module';
|
||||
import { PrismaService } from '../../_prisma/prisma.service';
|
||||
import { AwsExceptionFilter } from '../../_context/exception.filter';
|
||||
|
||||
const bodyParser = require('body-parser');
|
||||
|
||||
// Add AWS time extension
|
||||
declare global {
|
||||
interface Date {
|
||||
getAwsTime(): number;
|
||||
}
|
||||
}
|
||||
|
||||
Date.prototype.getAwsTime = function (this: Date) {
|
||||
return Math.floor(this.getTime() / 1000);
|
||||
};
|
||||
|
||||
describe('IAM Integration Tests', () => {
|
||||
let app: INestApplication;
|
||||
let iamClient: IAMClient;
|
||||
let prismaService: PrismaService;
|
||||
const testPort = 8086;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Set test environment variables
|
||||
process.env.PORT = testPort.toString();
|
||||
process.env.AWS_ACCOUNT_ID = '123456789012';
|
||||
process.env.AWS_REGION = 'us-east-1';
|
||||
process.env.DATABASE_URL = `file::memory:?cache=shared&unique=${Date.now()}-${Math.random()}`;
|
||||
process.env.PERSISTANCE = ':memory:';
|
||||
|
||||
// Create NestJS testing module
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.useGlobalFilters(new AwsExceptionFilter());
|
||||
app.use(bodyParser.urlencoded({ extended: true }));
|
||||
|
||||
await app.init();
|
||||
await app.listen(testPort);
|
||||
|
||||
// Configure IAM client to point to local endpoint
|
||||
iamClient = new IAMClient({
|
||||
region: 'us-east-1',
|
||||
endpoint: `http://localhost:${testPort}`,
|
||||
credentials: {
|
||||
accessKeyId: 'test',
|
||||
secretAccessKey: 'test',
|
||||
},
|
||||
});
|
||||
|
||||
prismaService = moduleFixture.get<PrismaService>(PrismaService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
iamClient.destroy();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean up database before each test
|
||||
await prismaService.iamRoleIamPolicyAttachment.deleteMany({});
|
||||
await prismaService.iamPolicy.deleteMany({});
|
||||
await prismaService.iamRole.deleteMany({});
|
||||
await prismaService.tag.deleteMany({});
|
||||
});
|
||||
|
||||
describe('CreateRole', () => {
|
||||
it('should create a role successfully', async () => {
|
||||
const assumeRolePolicyDocument = JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Principal: { Service: 'lambda.amazonaws.com' },
|
||||
Action: 'sts:AssumeRole',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const command = new CreateRoleCommand({
|
||||
RoleName: 'TestRole',
|
||||
AssumeRolePolicyDocument: assumeRolePolicyDocument,
|
||||
Path: '/',
|
||||
Description: 'Test role for Lambda',
|
||||
});
|
||||
|
||||
const response = await iamClient.send(command);
|
||||
|
||||
expect(response.Role).toBeDefined();
|
||||
expect(response.Role?.RoleName).toBe('TestRole');
|
||||
expect(response.Role?.Path).toBe('/');
|
||||
expect(response.Role?.Description).toBe('Test role for Lambda');
|
||||
expect(response.Role?.Arn).toContain('123456789012');
|
||||
expect(response.Role?.CreateDate).toBeDefined();
|
||||
|
||||
// Verify in database
|
||||
const role = await prismaService.iamRole.findFirst({
|
||||
where: { name: 'TestRole' },
|
||||
});
|
||||
expect(role).toBeDefined();
|
||||
expect(role?.description).toBe('Test role for Lambda');
|
||||
});
|
||||
|
||||
it('should create role without description', async () => {
|
||||
const assumeRolePolicyDocument = JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Principal: { Service: 'ec2.amazonaws.com' },
|
||||
Action: 'sts:AssumeRole',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const command = new CreateRoleCommand({
|
||||
RoleName: 'TestRoleNoDesc',
|
||||
AssumeRolePolicyDocument: assumeRolePolicyDocument,
|
||||
Path: '/',
|
||||
});
|
||||
|
||||
const response = await iamClient.send(command);
|
||||
|
||||
expect(response.Role).toBeDefined();
|
||||
expect(response.Role?.RoleName).toBe('TestRoleNoDesc');
|
||||
});
|
||||
|
||||
it('should create role with custom path', async () => {
|
||||
const assumeRolePolicyDocument = JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Principal: { Service: 's3.amazonaws.com' },
|
||||
Action: 'sts:AssumeRole',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const command = new CreateRoleCommand({
|
||||
RoleName: 'CustomPathRole',
|
||||
AssumeRolePolicyDocument: assumeRolePolicyDocument,
|
||||
Path: '/service/custom/',
|
||||
MaxSessionDuration: 7200,
|
||||
});
|
||||
|
||||
const response = await iamClient.send(command);
|
||||
|
||||
expect(response.Role?.Path).toBe('/service/custom/');
|
||||
expect(response.Role?.MaxSessionDuration).toBe(7200);
|
||||
});
|
||||
|
||||
it('should handle duplicate role name', async () => {
|
||||
const assumeRolePolicyDocument = JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Principal: { Service: 'lambda.amazonaws.com' },
|
||||
Action: 'sts:AssumeRole',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await iamClient.send(
|
||||
new CreateRoleCommand({
|
||||
RoleName: 'DuplicateRole',
|
||||
AssumeRolePolicyDocument: assumeRolePolicyDocument,
|
||||
Path: '/',
|
||||
}),
|
||||
);
|
||||
|
||||
const duplicateCommand = new CreateRoleCommand({
|
||||
RoleName: 'DuplicateRole',
|
||||
AssumeRolePolicyDocument: assumeRolePolicyDocument,
|
||||
Path: '/',
|
||||
});
|
||||
|
||||
await expect(iamClient.send(duplicateCommand)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetRole', () => {
|
||||
let roleName: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
roleName = 'GetRoleTest';
|
||||
const assumeRolePolicyDocument = JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Principal: { Service: 'lambda.amazonaws.com' },
|
||||
Action: 'sts:AssumeRole',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await iamClient.send(
|
||||
new CreateRoleCommand({
|
||||
RoleName: roleName,
|
||||
AssumeRolePolicyDocument: assumeRolePolicyDocument,
|
||||
Path: '/',
|
||||
Description: 'Role for testing GetRole',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should get role by name', async () => {
|
||||
const command = new GetRoleCommand({
|
||||
RoleName: roleName,
|
||||
});
|
||||
|
||||
const response = await iamClient.send(command);
|
||||
|
||||
expect(response.Role).toBeDefined();
|
||||
expect(response.Role?.RoleName).toBe(roleName);
|
||||
expect(response.Role?.Description).toBe('Role for testing GetRole');
|
||||
expect(response.Role?.Path).toBe('/');
|
||||
});
|
||||
|
||||
it('should handle getting non-existent role', async () => {
|
||||
const command = new GetRoleCommand({
|
||||
RoleName: 'NonExistentRole',
|
||||
});
|
||||
|
||||
await expect(iamClient.send(command)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeleteRole', () => {
|
||||
it('should delete a role', async () => {
|
||||
const roleName = 'DeleteRoleTest';
|
||||
const assumeRolePolicyDocument = JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Principal: { Service: 'lambda.amazonaws.com' },
|
||||
Action: 'sts:AssumeRole',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await iamClient.send(
|
||||
new CreateRoleCommand({
|
||||
RoleName: roleName,
|
||||
AssumeRolePolicyDocument: assumeRolePolicyDocument,
|
||||
Path: '/',
|
||||
}),
|
||||
);
|
||||
|
||||
const deleteCommand = new DeleteRoleCommand({
|
||||
RoleName: roleName,
|
||||
});
|
||||
|
||||
await iamClient.send(deleteCommand);
|
||||
|
||||
// Verify role is deleted
|
||||
const getCommand = new GetRoleCommand({
|
||||
RoleName: roleName,
|
||||
});
|
||||
|
||||
await expect(iamClient.send(getCommand)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CreatePolicy', () => {
|
||||
it('should create a policy successfully', async () => {
|
||||
const policyDocument = JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Action: ['s3:GetObject', 's3:PutObject'],
|
||||
Resource: 'arn:aws:s3:::my-bucket/*',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const command = new CreatePolicyCommand({
|
||||
PolicyName: 'TestPolicy',
|
||||
PolicyDocument: policyDocument,
|
||||
Description: 'Test policy for S3 access',
|
||||
Path: '/',
|
||||
});
|
||||
|
||||
const response = await iamClient.send(command);
|
||||
|
||||
expect(response.Policy).toBeDefined();
|
||||
expect(response.Policy?.PolicyName).toBe('TestPolicy');
|
||||
expect(response.Policy?.Description).toBe('Test policy for S3 access');
|
||||
expect(response.Policy?.Path).toBe('/');
|
||||
expect(response.Policy?.Arn).toContain('123456789012');
|
||||
expect(response.Policy?.DefaultVersionId).toBe('v1');
|
||||
|
||||
// Verify in database
|
||||
const policy = await prismaService.iamPolicy.findFirst({
|
||||
where: { name: 'TestPolicy' },
|
||||
});
|
||||
expect(policy).toBeDefined();
|
||||
expect(policy?.version).toBe(1);
|
||||
expect(policy?.isDefault).toBe(true);
|
||||
});
|
||||
|
||||
it('should create policy without description', async () => {
|
||||
const policyDocument = JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Action: 'dynamodb:*',
|
||||
Resource: '*',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const command = new CreatePolicyCommand({
|
||||
PolicyName: 'PolicyNoDesc',
|
||||
PolicyDocument: policyDocument,
|
||||
Path: '/',
|
||||
});
|
||||
|
||||
const response = await iamClient.send(command);
|
||||
|
||||
expect(response.Policy).toBeDefined();
|
||||
expect(response.Policy?.PolicyName).toBe('PolicyNoDesc');
|
||||
});
|
||||
|
||||
it('should handle duplicate policy name', async () => {
|
||||
const policyDocument = JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Action: 's3:*',
|
||||
Resource: '*',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await iamClient.send(
|
||||
new CreatePolicyCommand({
|
||||
PolicyName: 'DuplicatePolicy',
|
||||
PolicyDocument: policyDocument,
|
||||
Path: '/',
|
||||
}),
|
||||
);
|
||||
|
||||
const duplicateCommand = new CreatePolicyCommand({
|
||||
PolicyName: 'DuplicatePolicy',
|
||||
PolicyDocument: policyDocument,
|
||||
Path: '/',
|
||||
});
|
||||
|
||||
await expect(iamClient.send(duplicateCommand)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetPolicy', () => {
|
||||
let policyArn: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const policyDocument = JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Action: 'ec2:*',
|
||||
Resource: '*',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const createResponse = await iamClient.send(
|
||||
new CreatePolicyCommand({
|
||||
PolicyName: 'GetPolicyTest',
|
||||
PolicyDocument: policyDocument,
|
||||
Path: '/',
|
||||
}),
|
||||
);
|
||||
policyArn = createResponse.Policy!.Arn!;
|
||||
});
|
||||
|
||||
it('should get policy by ARN', async () => {
|
||||
const command = new GetPolicyCommand({
|
||||
PolicyArn: policyArn,
|
||||
});
|
||||
|
||||
const response = await iamClient.send(command);
|
||||
|
||||
expect(response.Policy).toBeDefined();
|
||||
expect(response.Policy?.PolicyName).toBe('GetPolicyTest');
|
||||
expect(response.Policy?.Arn).toBe(policyArn);
|
||||
expect(response.Policy?.DefaultVersionId).toBe('v1');
|
||||
});
|
||||
|
||||
it('should handle getting non-existent policy', async () => {
|
||||
const command = new GetPolicyCommand({
|
||||
PolicyArn: 'arn:aws:iam::123456789012:policy/NonExistentPolicy',
|
||||
});
|
||||
|
||||
await expect(iamClient.send(command)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetPolicyVersion', () => {
|
||||
let policyArn: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const policyDocument = JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Action: 'lambda:*',
|
||||
Resource: '*',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const createResponse = await iamClient.send(
|
||||
new CreatePolicyCommand({
|
||||
PolicyName: 'VersionedPolicy',
|
||||
PolicyDocument: policyDocument,
|
||||
Path: '/',
|
||||
}),
|
||||
);
|
||||
policyArn = createResponse.Policy!.Arn!;
|
||||
});
|
||||
|
||||
it('should get policy version', async () => {
|
||||
const command = new GetPolicyVersionCommand({
|
||||
PolicyArn: policyArn,
|
||||
VersionId: 'v1',
|
||||
});
|
||||
|
||||
const response = await iamClient.send(command);
|
||||
|
||||
expect(response.PolicyVersion).toBeDefined();
|
||||
expect(response.PolicyVersion?.VersionId).toBe('v1');
|
||||
expect(response.PolicyVersion?.IsDefaultVersion).toBe(true);
|
||||
expect(response.PolicyVersion?.Document).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CreatePolicyVersion', () => {
|
||||
let policyArn: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const policyDocument = JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Action: 's3:GetObject',
|
||||
Resource: '*',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const createResponse = await iamClient.send(
|
||||
new CreatePolicyCommand({
|
||||
PolicyName: 'VersionablePolicy',
|
||||
PolicyDocument: policyDocument,
|
||||
Path: '/',
|
||||
}),
|
||||
);
|
||||
policyArn = createResponse.Policy!.Arn!;
|
||||
});
|
||||
|
||||
it('should create new policy version', async () => {
|
||||
const newPolicyDocument = JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Action: ['s3:GetObject', 's3:PutObject'],
|
||||
Resource: '*',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const command = new CreatePolicyVersionCommand({
|
||||
PolicyArn: policyArn,
|
||||
PolicyDocument: newPolicyDocument,
|
||||
SetAsDefault: true,
|
||||
});
|
||||
|
||||
const response = await iamClient.send(command);
|
||||
|
||||
expect(response.PolicyVersion).toBeDefined();
|
||||
expect(response.PolicyVersion?.VersionId).toBe('v2');
|
||||
expect(response.PolicyVersion?.IsDefaultVersion).toBe(true);
|
||||
|
||||
// Verify in database
|
||||
const policies = await prismaService.iamPolicy.findMany({
|
||||
where: { name: 'VersionablePolicy' },
|
||||
});
|
||||
expect(policies.length).toBe(2);
|
||||
const defaultPolicy = policies.find(p => p.isDefault);
|
||||
expect(defaultPolicy?.version).toBe(2);
|
||||
});
|
||||
|
||||
it('should create non-default policy version', async () => {
|
||||
const newPolicyDocument = JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Deny',
|
||||
Action: 's3:DeleteObject',
|
||||
Resource: '*',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const command = new CreatePolicyVersionCommand({
|
||||
PolicyArn: policyArn,
|
||||
PolicyDocument: newPolicyDocument,
|
||||
SetAsDefault: false,
|
||||
});
|
||||
|
||||
const response = await iamClient.send(command);
|
||||
|
||||
expect(response.PolicyVersion?.VersionId).toBe('v2');
|
||||
expect(response.PolicyVersion?.IsDefaultVersion).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AttachRolePolicy', () => {
|
||||
let roleName: string;
|
||||
let policyArn: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
roleName = 'AttachPolicyRole';
|
||||
const assumeRolePolicyDocument = JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Principal: { Service: 'lambda.amazonaws.com' },
|
||||
Action: 'sts:AssumeRole',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await iamClient.send(
|
||||
new CreateRoleCommand({
|
||||
RoleName: roleName,
|
||||
AssumeRolePolicyDocument: assumeRolePolicyDocument,
|
||||
Path: '/',
|
||||
}),
|
||||
);
|
||||
|
||||
const policyDocument = JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Action: 'logs:*',
|
||||
Resource: '*',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const policyResponse = await iamClient.send(
|
||||
new CreatePolicyCommand({
|
||||
PolicyName: 'LogsPolicy',
|
||||
PolicyDocument: policyDocument,
|
||||
Path: '/',
|
||||
}),
|
||||
);
|
||||
policyArn = policyResponse.Policy!.Arn!;
|
||||
});
|
||||
|
||||
it('should attach policy to role', async () => {
|
||||
const command = new AttachRolePolicyCommand({
|
||||
RoleName: roleName,
|
||||
PolicyArn: policyArn,
|
||||
});
|
||||
|
||||
await iamClient.send(command);
|
||||
|
||||
// Verify attachment in database
|
||||
const attachments = await prismaService.iamRoleIamPolicyAttachment.findMany({});
|
||||
expect(attachments.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should list attached role policies', async () => {
|
||||
await iamClient.send(
|
||||
new AttachRolePolicyCommand({
|
||||
RoleName: roleName,
|
||||
PolicyArn: policyArn,
|
||||
}),
|
||||
);
|
||||
|
||||
const command = new ListAttachedRolePoliciesCommand({
|
||||
RoleName: roleName,
|
||||
});
|
||||
|
||||
const response = await iamClient.send(command);
|
||||
|
||||
expect(response.AttachedPolicies).toBeDefined();
|
||||
expect(response.AttachedPolicies!.length).toBeGreaterThanOrEqual(1);
|
||||
expect(response.AttachedPolicies![0].PolicyName).toBe('LogsPolicy');
|
||||
expect(response.AttachedPolicies![0].PolicyArn).toBe(policyArn);
|
||||
});
|
||||
|
||||
it('should attach multiple policies to role', async () => {
|
||||
const policyDocument2 = JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Action: 'dynamodb:*',
|
||||
Resource: '*',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const policy2Response = await iamClient.send(
|
||||
new CreatePolicyCommand({
|
||||
PolicyName: 'DynamoDBPolicy',
|
||||
PolicyDocument: policyDocument2,
|
||||
Path: '/',
|
||||
}),
|
||||
);
|
||||
|
||||
await iamClient.send(
|
||||
new AttachRolePolicyCommand({
|
||||
RoleName: roleName,
|
||||
PolicyArn: policyArn,
|
||||
}),
|
||||
);
|
||||
|
||||
await iamClient.send(
|
||||
new AttachRolePolicyCommand({
|
||||
RoleName: roleName,
|
||||
PolicyArn: policy2Response.Policy!.Arn!,
|
||||
}),
|
||||
);
|
||||
|
||||
const listResponse = await iamClient.send(
|
||||
new ListAttachedRolePoliciesCommand({
|
||||
RoleName: roleName,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(listResponse.AttachedPolicies!.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ListRolePolicies', () => {
|
||||
it('should list role policies', async () => {
|
||||
const command = new ListRolePoliciesCommand({
|
||||
RoleName: 'SomeRole',
|
||||
});
|
||||
|
||||
const response = await iamClient.send(command);
|
||||
|
||||
expect(response.PolicyNames).toBeDefined();
|
||||
expect(Array.isArray(response.PolicyNames)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('End-to-End Workflow', () => {
|
||||
it('should complete full IAM workflow', async () => {
|
||||
// 1. Create a role
|
||||
const assumeRolePolicyDocument = JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Principal: { Service: 'lambda.amazonaws.com' },
|
||||
Action: 'sts:AssumeRole',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const createRoleResponse = await iamClient.send(
|
||||
new CreateRoleCommand({
|
||||
RoleName: 'E2ETestRole',
|
||||
AssumeRolePolicyDocument: assumeRolePolicyDocument,
|
||||
Path: '/',
|
||||
Description: 'End-to-end test role',
|
||||
}),
|
||||
);
|
||||
const roleName = createRoleResponse.Role!.RoleName!;
|
||||
expect(roleName).toBe('E2ETestRole');
|
||||
|
||||
// 2. Get the role
|
||||
const getRoleResponse = await iamClient.send(new GetRoleCommand({ RoleName: roleName }));
|
||||
expect(getRoleResponse.Role?.Description).toBe('End-to-end test role');
|
||||
|
||||
// 3. Create a policy
|
||||
const policyDocument = JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Action: 's3:*',
|
||||
Resource: '*',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const createPolicyResponse = await iamClient.send(
|
||||
new CreatePolicyCommand({
|
||||
PolicyName: 'E2ETestPolicy',
|
||||
PolicyDocument: policyDocument,
|
||||
Path: '/',
|
||||
Description: 'End-to-end test policy',
|
||||
}),
|
||||
);
|
||||
const policyArn = createPolicyResponse.Policy!.Arn!;
|
||||
|
||||
// 4. Get the policy
|
||||
const getPolicyResponse = await iamClient.send(new GetPolicyCommand({ PolicyArn: policyArn }));
|
||||
expect(getPolicyResponse.Policy?.PolicyName).toBe('E2ETestPolicy');
|
||||
|
||||
// 5. Create a new policy version
|
||||
const newPolicyDocument = JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Action: ['s3:GetObject', 's3:PutObject'],
|
||||
Resource: '*',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const createVersionResponse = await iamClient.send(
|
||||
new CreatePolicyVersionCommand({
|
||||
PolicyArn: policyArn,
|
||||
PolicyDocument: newPolicyDocument,
|
||||
SetAsDefault: true,
|
||||
}),
|
||||
);
|
||||
expect(createVersionResponse.PolicyVersion?.VersionId).toBe('v2');
|
||||
|
||||
// 6. Get policy version
|
||||
const getPolicyVersionResponse = await iamClient.send(
|
||||
new GetPolicyVersionCommand({
|
||||
PolicyArn: policyArn,
|
||||
VersionId: 'v2',
|
||||
}),
|
||||
);
|
||||
expect(getPolicyVersionResponse.PolicyVersion?.IsDefaultVersion).toBe(true);
|
||||
|
||||
// 7. Attach policy to role
|
||||
await iamClient.send(
|
||||
new AttachRolePolicyCommand({
|
||||
RoleName: roleName,
|
||||
PolicyArn: policyArn,
|
||||
}),
|
||||
);
|
||||
|
||||
// 8. List attached policies
|
||||
const listPoliciesResponse = await iamClient.send(
|
||||
new ListAttachedRolePoliciesCommand({
|
||||
RoleName: roleName,
|
||||
}),
|
||||
);
|
||||
expect(listPoliciesResponse.AttachedPolicies!.length).toBeGreaterThanOrEqual(1);
|
||||
expect(listPoliciesResponse.AttachedPolicies![0].PolicyName).toBe('E2ETestPolicy');
|
||||
|
||||
// 9. Delete the role
|
||||
await iamClient.send(new DeleteRoleCommand({ RoleName: roleName }));
|
||||
|
||||
// Verify role is deleted
|
||||
await expect(iamClient.send(new GetRoleCommand({ RoleName: roleName }))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,21 +2,18 @@ import { Injectable } from '@nestjs/common';
|
||||
import { AbstractActionHandler, AwsProperties, Format } from '../abstract-action.handler';
|
||||
import { Action } from '../action.enum';
|
||||
import * as Joi from 'joi';
|
||||
import { IamPolicy } from './iam-policy.entity';
|
||||
import { breakdownArn } from '../util/breakdown-arn';
|
||||
import { IamService } from './iam.service';
|
||||
import { RequestContext } from '../_context/request.context';
|
||||
|
||||
type QueryParams = {
|
||||
PolicyArn: string;
|
||||
PolicyDocument: string;
|
||||
SetAsDefault: boolean;
|
||||
}
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CreatePolicyVersionHandler extends AbstractActionHandler<QueryParams> {
|
||||
|
||||
constructor(
|
||||
) {
|
||||
constructor(private readonly iamService: IamService) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -25,12 +22,37 @@ export class CreatePolicyVersionHandler extends AbstractActionHandler<QueryParam
|
||||
validator = Joi.object<QueryParams, true>({
|
||||
PolicyArn: Joi.string().required(),
|
||||
PolicyDocument: Joi.string().required(),
|
||||
SetAsDefault: Joi.boolean().required(),
|
||||
SetAsDefault: Joi.boolean().default(false),
|
||||
});
|
||||
|
||||
protected async handle({ PolicyArn, PolicyDocument, SetAsDefault }: QueryParams, { awsProperties} : RequestContext) {
|
||||
protected async handle({ PolicyArn, PolicyDocument, SetAsDefault }: QueryParams, { awsProperties }: RequestContext) {
|
||||
// Get the current policy to find the latest version
|
||||
const currentPolicy = await this.iamService.getPolicyByArn(PolicyArn);
|
||||
const newVersion = currentPolicy.version + 1;
|
||||
|
||||
// If setting as default, mark all existing versions as non-default
|
||||
if (SetAsDefault) {
|
||||
await this.iamService.updateAllPolicyVersionsDefaultStatus(currentPolicy.id, false);
|
||||
}
|
||||
|
||||
// Create new policy version
|
||||
const newPolicy = await this.iamService.createPolicyVersion({
|
||||
id: currentPolicy.id,
|
||||
version: newVersion,
|
||||
isDefault: SetAsDefault,
|
||||
name: currentPolicy.name,
|
||||
path: currentPolicy.path,
|
||||
description: currentPolicy.description,
|
||||
policy: PolicyDocument,
|
||||
accountId: awsProperties.accountId,
|
||||
});
|
||||
|
||||
return {
|
||||
PolicyVersion: {
|
||||
VersionId: `v${newVersion}`,
|
||||
IsDefaultVersion: SetAsDefault,
|
||||
CreateDate: newPolicy.createdAt.toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,14 +8,11 @@ import { IamService } from './iam.service';
|
||||
type QueryParams = {
|
||||
PolicyArn: string;
|
||||
VersionId: string;
|
||||
}
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class GetPolicyVersionHandler extends AbstractActionHandler<QueryParams> {
|
||||
|
||||
constructor(
|
||||
private readonly iamService: IamService,
|
||||
) {
|
||||
constructor(private readonly iamService: IamService) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -26,7 +23,7 @@ export class GetPolicyVersionHandler extends AbstractActionHandler<QueryParams>
|
||||
VersionId: Joi.string().required(),
|
||||
});
|
||||
|
||||
protected async handle({ PolicyArn, VersionId }: QueryParams, { awsProperties} : RequestContext) {
|
||||
protected async handle({ PolicyArn, VersionId }: QueryParams, { awsProperties }: RequestContext) {
|
||||
const maybeVersion = Number(VersionId);
|
||||
const version = Number.isNaN(maybeVersion) ? Number(VersionId.toLowerCase().split('v')[1]) : Number(maybeVersion);
|
||||
const policy = await this.iamService.getPolicyByArnAndVersion(PolicyArn, version);
|
||||
@@ -34,9 +31,9 @@ export class GetPolicyVersionHandler extends AbstractActionHandler<QueryParams>
|
||||
PolicyVersion: {
|
||||
Document: policy.policy,
|
||||
IsDefaultVersion: policy.isDefault,
|
||||
VersionId: policy.version,
|
||||
VersionId: `v${policy.version}`,
|
||||
CreateDate: policy.createdAt.toISOString(),
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
|
||||
import { IamRole as PrismaIamRole } from '@prisma/client';
|
||||
|
||||
export class IamRole implements PrismaIamRole {
|
||||
|
||||
accountId: string;
|
||||
path: string | null;
|
||||
name: string;
|
||||
@@ -47,6 +45,7 @@ export class IamRole implements PrismaIamRole {
|
||||
CreateDate: this.createdAt.toISOString(),
|
||||
RoleId: this.id,
|
||||
MaxSessionDuration: this.maxSessionDuration,
|
||||
}
|
||||
Description: this.description,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import { AwsSharedEntitiesModule } from '../aws-shared-entities/aws-shared-entit
|
||||
import { DefaultActionHandlerProvider } from '../default-action-handler/default-action-handler.provider';
|
||||
import { ExistingActionHandlersProvider } from '../default-action-handler/existing-action-handlers.provider';
|
||||
import { CreatePolicyHandler } from './create-policy.handler';
|
||||
import { CreatePolicyVersionHandler } from './create-policy-version.handler';
|
||||
import { CreateRoleHandler } from './create-role.handler';
|
||||
import { DeleteRoleHandler } from './delete-role.handler';
|
||||
import { IAMHandlers } from './iam.constants';
|
||||
import { PrismaModule } from '../_prisma/prisma.module';
|
||||
import { IamService } from './iam.service';
|
||||
@@ -14,16 +16,20 @@ import { GetPolicyHandler } from './get-policy.handler';
|
||||
import { GetPolicyVersionHandler } from './get-policy-version.handler';
|
||||
import { AttachRolePolicyHandler } from './attach-role-policy.handler';
|
||||
import { ListAttachedRolePoliciesHandler } from './list-attached-role-policies';
|
||||
import { ListRolePoliciesHandler } from './list-role-policies.handler';
|
||||
|
||||
const handlers = [
|
||||
AttachRolePolicyHandler,
|
||||
CreatePolicyHandler,
|
||||
CreatePolicyVersionHandler,
|
||||
CreateRoleHandler,
|
||||
DeleteRoleHandler,
|
||||
GetPolicyVersionHandler,
|
||||
GetPolicyHandler,
|
||||
GetRoleHandler,
|
||||
ListAttachedRolePoliciesHandler,
|
||||
]
|
||||
ListRolePoliciesHandler,
|
||||
];
|
||||
|
||||
const actions = [
|
||||
Action.IamAddClientIDToOpenIDConnectProvider,
|
||||
@@ -184,13 +190,10 @@ const actions = [
|
||||
Action.IamUploadServerCertificate,
|
||||
Action.IamUploadSigningCertificate,
|
||||
Action.IamUploadSSHPublicKey,
|
||||
]
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AwsSharedEntitiesModule,
|
||||
PrismaModule,
|
||||
],
|
||||
imports: [AwsSharedEntitiesModule, PrismaModule],
|
||||
providers: [
|
||||
...handlers,
|
||||
IamService,
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from "../_prisma/prisma.service";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { IamPolicy } from "./iam-policy.entity";
|
||||
import { IamRole } from "./iam-role.entity";
|
||||
import { EntityAlreadyExists, NoSuchEntity, NotFoundException } from "../aws-shared-entities/aws-exceptions";
|
||||
import { ArnUtil } from "../util/arn-util.static";
|
||||
import { PrismaService } from '../_prisma/prisma.service';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { IamPolicy } from './iam-policy.entity';
|
||||
import { IamRole } from './iam-role.entity';
|
||||
import { EntityAlreadyExists, NoSuchEntity, NotFoundException } from '../aws-shared-entities/aws-exceptions';
|
||||
import { ArnUtil } from '../util/arn-util.static';
|
||||
|
||||
@Injectable()
|
||||
export class IamService {
|
||||
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
) {}
|
||||
constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
async createRole(data: Prisma.IamRoleCreateInput): Promise<IamRole> {
|
||||
try {
|
||||
@@ -29,7 +26,7 @@ export class IamService {
|
||||
where: {
|
||||
name,
|
||||
accountId,
|
||||
}
|
||||
},
|
||||
});
|
||||
return new IamRole(record);
|
||||
} catch (error) {
|
||||
@@ -38,17 +35,27 @@ export class IamService {
|
||||
}
|
||||
|
||||
async deleteRoleByName(accountId: string, name: string) {
|
||||
await this.prismaService.iamRole.deleteMany({
|
||||
// First find the role
|
||||
const role = await this.findOneRoleByName(accountId, name);
|
||||
|
||||
// Delete all policy attachments first
|
||||
await this.prismaService.iamRoleIamPolicyAttachment.deleteMany({
|
||||
where: {
|
||||
name,
|
||||
accountId,
|
||||
}
|
||||
iamRoleId: role.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Then delete the role
|
||||
await this.prismaService.iamRole.delete({
|
||||
where: {
|
||||
id: role.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async listRolePolicies(): Promise<IamPolicy[]> {
|
||||
// return await this.prismaService;
|
||||
return [];
|
||||
const records = await this.prismaService.iamPolicy.findMany();
|
||||
return records.map(r => new IamPolicy(r));
|
||||
}
|
||||
|
||||
async getPolicyByArn(arn: string): Promise<IamPolicy> {
|
||||
@@ -75,7 +82,7 @@ export class IamService {
|
||||
where: {
|
||||
name,
|
||||
version,
|
||||
}
|
||||
},
|
||||
});
|
||||
return new IamPolicy(record);
|
||||
} catch (err) {
|
||||
@@ -84,24 +91,70 @@ export class IamService {
|
||||
}
|
||||
|
||||
async createPolicy(data: Prisma.IamPolicyCreateInput): Promise<IamPolicy> {
|
||||
try {
|
||||
const record = await this.prismaService.iamPolicy.create({ data });
|
||||
return new IamPolicy(record);
|
||||
} catch (err) {
|
||||
// Check if policy with same name already exists
|
||||
const existing = await this.prismaService.iamPolicy.findFirst({
|
||||
where: {
|
||||
accountId: data.accountId,
|
||||
name: data.name,
|
||||
path: data.path,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new EntityAlreadyExists(`PolicyName ${data.name} already exists`);
|
||||
}
|
||||
|
||||
const record = await this.prismaService.iamPolicy.create({ data });
|
||||
return new IamPolicy(record);
|
||||
}
|
||||
|
||||
async createPolicyVersion(data: Prisma.IamPolicyCreateInput): Promise<IamPolicy> {
|
||||
const record = await this.prismaService.iamPolicy.create({ data });
|
||||
return new IamPolicy(record);
|
||||
}
|
||||
|
||||
async updatePolicyDefaultStatus(id: string, version: number, isDefault: boolean): Promise<void> {
|
||||
await this.prismaService.iamPolicy.update({
|
||||
where: {
|
||||
id_version: {
|
||||
id,
|
||||
version,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
isDefault,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateAllPolicyVersionsDefaultStatus(policyId: string, isDefault: boolean): Promise<void> {
|
||||
await this.prismaService.iamPolicy.updateMany({
|
||||
where: { id: policyId },
|
||||
data: { isDefault },
|
||||
});
|
||||
}
|
||||
|
||||
async attachPolicyToRoleName(accountId: string, arn: string, roleName: string) {
|
||||
const policy = await this.getPolicyByArn(arn);
|
||||
const role = await this.findOneRoleByName(accountId, roleName);
|
||||
|
||||
// Check if already attached
|
||||
const existing = await this.prismaService.iamRoleIamPolicyAttachment.findFirst({
|
||||
where: {
|
||||
iamRoleId: role.id,
|
||||
iamPolicyId: policy.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
await this.prismaService.iamRoleIamPolicyAttachment.create({
|
||||
data: {
|
||||
iamPolicyId: policy.id,
|
||||
iamRoleId: role.id,
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async findAttachedRolePoliciesByRoleName(accountId: string, roleName: string): Promise<IamPolicy[]> {
|
||||
try {
|
||||
@@ -112,15 +165,17 @@ export class IamService {
|
||||
},
|
||||
include: {
|
||||
policies: true,
|
||||
}
|
||||
},
|
||||
});
|
||||
const policyIds = record.policies.map(p => p.iamPolicyId);
|
||||
const policies = await this.prismaService.iamPolicy.findMany({ where: {
|
||||
const policies = await this.prismaService.iamPolicy.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: policyIds,
|
||||
},
|
||||
isDefault: true,
|
||||
}});
|
||||
},
|
||||
});
|
||||
return policies.map(p => new IamPolicy(p));
|
||||
} catch (error) {
|
||||
throw new NotFoundException();
|
||||
|
||||
@@ -7,14 +7,11 @@ import { IamService } from './iam.service';
|
||||
|
||||
type QueryParams = {
|
||||
RoleName: string;
|
||||
}
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ListAttachedRolePoliciesHandler extends AbstractActionHandler<QueryParams> {
|
||||
|
||||
constructor(
|
||||
private readonly iamService: IamService,
|
||||
) {
|
||||
constructor(private readonly iamService: IamService) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -24,15 +21,16 @@ export class ListAttachedRolePoliciesHandler extends AbstractActionHandler<Query
|
||||
RoleName: Joi.string().required(),
|
||||
});
|
||||
|
||||
protected async handle({ RoleName }: QueryParams, { awsProperties} : RequestContext) {
|
||||
protected async handle({ RoleName }: QueryParams, { awsProperties }: RequestContext) {
|
||||
const policies = await this.iamService.findAttachedRolePoliciesByRoleName(awsProperties.accountId, RoleName);
|
||||
return {
|
||||
AttachedPolicies: policies.map(p => ({
|
||||
member: {
|
||||
AttachedPolicies: {
|
||||
member: policies.map(p => ({
|
||||
PolicyName: p.name,
|
||||
PolicyArn: p.arn,
|
||||
}
|
||||
})),
|
||||
}
|
||||
},
|
||||
IsTruncated: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,17 @@ import { AbstractActionHandler, AwsProperties, Format } from '../abstract-action
|
||||
import { Action } from '../action.enum';
|
||||
import * as Joi from 'joi';
|
||||
import { RequestContext } from '../_context/request.context';
|
||||
import { IamService } from './iam.service';
|
||||
|
||||
type QueryParams = {
|
||||
Marker: string;
|
||||
MaxItems: number;
|
||||
RoleName: string;
|
||||
}
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ListRolePoliciesHandler extends AbstractActionHandler<QueryParams> {
|
||||
|
||||
constructor(
|
||||
) {
|
||||
constructor(private readonly iamService: IamService) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -26,8 +25,14 @@ export class ListRolePoliciesHandler extends AbstractActionHandler<QueryParams>
|
||||
RoleName: Joi.string().required(),
|
||||
});
|
||||
|
||||
protected async handle({ RoleName }: QueryParams, { awsProperties} : RequestContext) {
|
||||
|
||||
protected async handle({ RoleName }: QueryParams, { awsProperties }: RequestContext) {
|
||||
const policies = await this.iamService.listRolePolicies();
|
||||
|
||||
return {
|
||||
IsTruncated: false,
|
||||
PolicyNames: {
|
||||
member: policies?.map(p => p.name) || [],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
827
src/kms/__tests__/kms.spec.ts
Normal file
827
src/kms/__tests__/kms.spec.ts
Normal file
@@ -0,0 +1,827 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import {
|
||||
KMSClient,
|
||||
CreateKeyCommand,
|
||||
CreateAliasCommand,
|
||||
DescribeKeyCommand,
|
||||
ListAliasesCommand,
|
||||
EnableKeyRotationCommand,
|
||||
GetKeyRotationStatusCommand,
|
||||
GetKeyPolicyCommand,
|
||||
ListResourceTagsCommand,
|
||||
GetPublicKeyCommand,
|
||||
SignCommand,
|
||||
SigningAlgorithmSpec,
|
||||
KeyUsageType,
|
||||
KeySpec,
|
||||
} from '@aws-sdk/client-kms';
|
||||
import { AppModule } from '../../app.module';
|
||||
import { PrismaService } from '../../_prisma/prisma.service';
|
||||
import { AwsExceptionFilter } from '../../_context/exception.filter';
|
||||
|
||||
const bodyParser = require('body-parser');
|
||||
|
||||
// Add AWS time extension
|
||||
declare global {
|
||||
interface Date {
|
||||
getAwsTime(): number;
|
||||
}
|
||||
}
|
||||
|
||||
Date.prototype.getAwsTime = function (this: Date) {
|
||||
return Math.floor(this.getTime() / 1000);
|
||||
};
|
||||
|
||||
describe('KMS Integration Tests', () => {
|
||||
let app: INestApplication;
|
||||
let kmsClient: KMSClient;
|
||||
let prismaService: PrismaService;
|
||||
const testPort = 8085;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Set test environment variables
|
||||
process.env.PORT = testPort.toString();
|
||||
process.env.AWS_ACCOUNT_ID = '123456789012';
|
||||
process.env.AWS_REGION = 'us-east-1';
|
||||
process.env.DATABASE_URL = `file::memory:?cache=shared&unique=${Date.now()}-${Math.random()}`;
|
||||
process.env.PERSISTANCE = ':memory:';
|
||||
|
||||
// Create NestJS testing module
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.useGlobalFilters(new AwsExceptionFilter());
|
||||
app.use(bodyParser.json({ type: 'application/x-amz-json-1.0' }));
|
||||
app.use(bodyParser.json({ type: 'application/x-amz-json-1.1' }));
|
||||
|
||||
await app.init();
|
||||
await app.listen(testPort);
|
||||
|
||||
// Configure KMS client to point to local endpoint
|
||||
kmsClient = new KMSClient({
|
||||
region: 'us-east-1',
|
||||
endpoint: `http://localhost:${testPort}`,
|
||||
credentials: {
|
||||
accessKeyId: 'test',
|
||||
secretAccessKey: 'test',
|
||||
},
|
||||
});
|
||||
|
||||
prismaService = moduleFixture.get<PrismaService>(PrismaService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
kmsClient.destroy();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean up database before each test
|
||||
await prismaService.kmsAlias.deleteMany({});
|
||||
await prismaService.kmsKey.deleteMany({});
|
||||
await prismaService.tag.deleteMany({});
|
||||
});
|
||||
|
||||
describe('CreateKey', () => {
|
||||
it('should create a symmetric key successfully', async () => {
|
||||
const command = new CreateKeyCommand({
|
||||
Description: 'Test symmetric key',
|
||||
KeyUsage: KeyUsageType.ENCRYPT_DECRYPT,
|
||||
});
|
||||
|
||||
const response = await kmsClient.send(command);
|
||||
|
||||
expect(response.KeyMetadata).toBeDefined();
|
||||
expect(response.KeyMetadata?.KeyId).toBeDefined();
|
||||
expect(response.KeyMetadata?.Arn).toContain('123456789012');
|
||||
expect(response.KeyMetadata?.Arn).toContain('us-east-1');
|
||||
expect(response.KeyMetadata?.Description).toBe('Test symmetric key');
|
||||
expect(response.KeyMetadata?.Enabled).toBe(true);
|
||||
expect(response.KeyMetadata?.KeyUsage).toBe(KeyUsageType.ENCRYPT_DECRYPT);
|
||||
|
||||
// Verify in database
|
||||
const key = await prismaService.kmsKey.findFirst({
|
||||
where: { description: 'Test symmetric key' },
|
||||
});
|
||||
expect(key).toBeDefined();
|
||||
expect(key?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should create key without description', async () => {
|
||||
const command = new CreateKeyCommand({
|
||||
KeyUsage: KeyUsageType.ENCRYPT_DECRYPT,
|
||||
});
|
||||
|
||||
const response = await kmsClient.send(command);
|
||||
|
||||
expect(response.KeyMetadata).toBeDefined();
|
||||
expect(response.KeyMetadata?.Description).toBe('');
|
||||
});
|
||||
|
||||
it('should create key with tags', async () => {
|
||||
const command = new CreateKeyCommand({
|
||||
Description: 'Tagged key',
|
||||
Tags: [
|
||||
{ TagKey: 'Environment', TagValue: 'Production' },
|
||||
{ TagKey: 'Team', TagValue: 'Security' },
|
||||
],
|
||||
});
|
||||
|
||||
const response = await kmsClient.send(command);
|
||||
const keyId = response.KeyMetadata!.KeyId!;
|
||||
|
||||
// Verify tags
|
||||
const listTagsResponse = await kmsClient.send(
|
||||
new ListResourceTagsCommand({
|
||||
KeyId: keyId,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(listTagsResponse.Tags).toBeDefined();
|
||||
expect(listTagsResponse.Tags!.length).toBeGreaterThanOrEqual(2);
|
||||
const tagKeys = listTagsResponse.Tags!.map(t => t.TagKey);
|
||||
expect(tagKeys).toContain('Environment');
|
||||
expect(tagKeys).toContain('Team');
|
||||
});
|
||||
|
||||
it('should create asymmetric key for signing', async () => {
|
||||
const command = new CreateKeyCommand({
|
||||
Description: 'RSA signing key',
|
||||
KeyUsage: KeyUsageType.SIGN_VERIFY,
|
||||
KeySpec: KeySpec.RSA_2048,
|
||||
});
|
||||
|
||||
const response = await kmsClient.send(command);
|
||||
|
||||
expect(response.KeyMetadata?.KeyUsage).toBe(KeyUsageType.SIGN_VERIFY);
|
||||
expect(response.KeyMetadata?.KeySpec).toBe(KeySpec.RSA_2048);
|
||||
});
|
||||
|
||||
it('should create multi-region key', async () => {
|
||||
const command = new CreateKeyCommand({
|
||||
Description: 'Multi-region key',
|
||||
MultiRegion: true,
|
||||
});
|
||||
|
||||
const response = await kmsClient.send(command);
|
||||
|
||||
expect(response.KeyMetadata?.MultiRegion).toBe(true);
|
||||
});
|
||||
|
||||
it('should create key with custom policy', async () => {
|
||||
const policy = {
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Sid: 'Custom policy',
|
||||
Effect: 'Allow',
|
||||
Principal: { AWS: 'arn:aws:iam::123456789012:root' },
|
||||
Action: ['kms:Encrypt', 'kms:Decrypt'],
|
||||
Resource: '*',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const command = new CreateKeyCommand({
|
||||
Description: 'Key with custom policy',
|
||||
Policy: JSON.stringify(policy),
|
||||
});
|
||||
|
||||
const response = await kmsClient.send(command);
|
||||
const keyId = response.KeyMetadata!.KeyId!;
|
||||
|
||||
// Verify policy
|
||||
const getPolicyResponse = await kmsClient.send(
|
||||
new GetKeyPolicyCommand({
|
||||
KeyId: keyId,
|
||||
PolicyName: 'default',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getPolicyResponse.Policy).toBeDefined();
|
||||
const retrievedPolicy = JSON.parse(getPolicyResponse.Policy!);
|
||||
expect(retrievedPolicy.Statement[0].Sid).toBe('Custom policy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DescribeKey', () => {
|
||||
let keyId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const createResponse = await kmsClient.send(
|
||||
new CreateKeyCommand({
|
||||
Description: 'Key for describe test',
|
||||
}),
|
||||
);
|
||||
keyId = createResponse.KeyMetadata!.KeyId!;
|
||||
});
|
||||
|
||||
it('should describe key by KeyId', async () => {
|
||||
const command = new DescribeKeyCommand({
|
||||
KeyId: keyId,
|
||||
});
|
||||
|
||||
const response = await kmsClient.send(command);
|
||||
|
||||
expect(response.KeyMetadata).toBeDefined();
|
||||
expect(response.KeyMetadata?.KeyId).toBe(keyId);
|
||||
expect(response.KeyMetadata?.Description).toBe('Key for describe test');
|
||||
expect(response.KeyMetadata?.Enabled).toBe(true);
|
||||
expect(response.KeyMetadata?.CreationDate).toBeDefined();
|
||||
});
|
||||
|
||||
it('should describe key by ARN', async () => {
|
||||
const describeResponse = await kmsClient.send(new DescribeKeyCommand({ KeyId: keyId }));
|
||||
const arn = describeResponse.KeyMetadata!.Arn!;
|
||||
|
||||
const command = new DescribeKeyCommand({
|
||||
KeyId: arn,
|
||||
});
|
||||
|
||||
const response = await kmsClient.send(command);
|
||||
|
||||
expect(response.KeyMetadata?.KeyId).toBe(keyId);
|
||||
expect(response.KeyMetadata?.Arn).toBe(arn);
|
||||
});
|
||||
|
||||
it('should describe key by alias', async () => {
|
||||
const aliasName = 'alias/describe-test';
|
||||
await kmsClient.send(
|
||||
new CreateAliasCommand({
|
||||
AliasName: aliasName,
|
||||
TargetKeyId: keyId,
|
||||
}),
|
||||
);
|
||||
|
||||
const command = new DescribeKeyCommand({
|
||||
KeyId: aliasName,
|
||||
});
|
||||
|
||||
const response = await kmsClient.send(command);
|
||||
|
||||
expect(response.KeyMetadata?.KeyId).toBe(keyId);
|
||||
// Verify KeyId doesn't start with '/' (Terraform requirement)
|
||||
expect(response.KeyMetadata?.KeyId).not.toMatch(/^\//);
|
||||
// Verify it's a valid UUID format
|
||||
expect(response.KeyMetadata?.KeyId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
||||
});
|
||||
|
||||
it('should handle Terraform-style alias lookup', async () => {
|
||||
// Create a key with an alias like Terraform module would
|
||||
const createKeyResponse = await kmsClient.send(
|
||||
new CreateKeyCommand({
|
||||
Description: 'Backend service key',
|
||||
KeyUsage: KeyUsageType.ENCRYPT_DECRYPT,
|
||||
}),
|
||||
);
|
||||
const actualKeyId = createKeyResponse.KeyMetadata!.KeyId!;
|
||||
|
||||
// Create alias like: alias/dev-backend
|
||||
const aliasName = 'alias/dev-backend';
|
||||
await kmsClient.send(
|
||||
new CreateAliasCommand({
|
||||
AliasName: aliasName,
|
||||
TargetKeyId: actualKeyId,
|
||||
}),
|
||||
);
|
||||
|
||||
// Terraform does: data "aws_kms_key" "backend_by_alias" { key_id = "alias/dev-backend" }
|
||||
const describeResponse = await kmsClient.send(
|
||||
new DescribeKeyCommand({
|
||||
KeyId: aliasName,
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify the response has proper KeyId
|
||||
expect(describeResponse.KeyMetadata).toBeDefined();
|
||||
expect(describeResponse.KeyMetadata!.KeyId).toBe(actualKeyId);
|
||||
expect(describeResponse.KeyMetadata!.Arn).toContain(`key/${actualKeyId}`);
|
||||
|
||||
// Most importantly: KeyId should be a UUID, not contain slashes
|
||||
expect(describeResponse.KeyMetadata!.KeyId).not.toContain('/');
|
||||
expect(describeResponse.KeyMetadata!.KeyId).toMatch(/^[0-9a-f-]+$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CreateAlias and ListAliases', () => {
|
||||
let keyId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const createResponse = await kmsClient.send(
|
||||
new CreateKeyCommand({
|
||||
Description: 'Key for alias test',
|
||||
}),
|
||||
);
|
||||
keyId = createResponse.KeyMetadata!.KeyId!;
|
||||
});
|
||||
|
||||
it('should create an alias for a key', async () => {
|
||||
const aliasName = 'alias/test-alias';
|
||||
const command = new CreateAliasCommand({
|
||||
AliasName: aliasName,
|
||||
TargetKeyId: keyId,
|
||||
});
|
||||
|
||||
await kmsClient.send(command);
|
||||
|
||||
// Verify in database
|
||||
const alias = await prismaService.kmsAlias.findFirst({
|
||||
where: { name: aliasName },
|
||||
});
|
||||
expect(alias).toBeDefined();
|
||||
expect(alias?.kmsKeyId).toBe(keyId);
|
||||
});
|
||||
|
||||
it('should create multiple aliases for the same key', async () => {
|
||||
await kmsClient.send(
|
||||
new CreateAliasCommand({
|
||||
AliasName: 'alias/test-alias-1',
|
||||
TargetKeyId: keyId,
|
||||
}),
|
||||
);
|
||||
|
||||
await kmsClient.send(
|
||||
new CreateAliasCommand({
|
||||
AliasName: 'alias/test-alias-2',
|
||||
TargetKeyId: keyId,
|
||||
}),
|
||||
);
|
||||
|
||||
// List aliases
|
||||
const listResponse = await kmsClient.send(new ListAliasesCommand({}));
|
||||
|
||||
const keyAliases = listResponse.Aliases?.filter(a => a.TargetKeyId === keyId);
|
||||
expect(keyAliases?.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should list all aliases', async () => {
|
||||
// Create some aliases
|
||||
await kmsClient.send(
|
||||
new CreateAliasCommand({
|
||||
AliasName: 'alias/list-test-1',
|
||||
TargetKeyId: keyId,
|
||||
}),
|
||||
);
|
||||
|
||||
const key2 = await kmsClient.send(new CreateKeyCommand({}));
|
||||
await kmsClient.send(
|
||||
new CreateAliasCommand({
|
||||
AliasName: 'alias/list-test-2',
|
||||
TargetKeyId: key2.KeyMetadata!.KeyId!,
|
||||
}),
|
||||
);
|
||||
|
||||
const command = new ListAliasesCommand({});
|
||||
const response = await kmsClient.send(command);
|
||||
|
||||
expect(response.Aliases).toBeDefined();
|
||||
expect(response.Aliases!.length).toBeGreaterThanOrEqual(2);
|
||||
const aliasNames = response.Aliases!.map(a => a.AliasName);
|
||||
expect(aliasNames).toContain('alias/list-test-1');
|
||||
expect(aliasNames).toContain('alias/list-test-2');
|
||||
});
|
||||
|
||||
it('should list aliases for specific key', async () => {
|
||||
await kmsClient.send(
|
||||
new CreateAliasCommand({
|
||||
AliasName: 'alias/specific-key-alias',
|
||||
TargetKeyId: keyId,
|
||||
}),
|
||||
);
|
||||
|
||||
const command = new ListAliasesCommand({
|
||||
KeyId: keyId,
|
||||
});
|
||||
|
||||
const response = await kmsClient.send(command);
|
||||
|
||||
expect(response.Aliases).toBeDefined();
|
||||
expect(response.Aliases!.every(a => a.TargetKeyId === keyId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Key Rotation', () => {
|
||||
let keyId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const createResponse = await kmsClient.send(
|
||||
new CreateKeyCommand({
|
||||
Description: 'Key for rotation test',
|
||||
}),
|
||||
);
|
||||
keyId = createResponse.KeyMetadata!.KeyId!;
|
||||
});
|
||||
|
||||
it('should enable key rotation', async () => {
|
||||
const command = new EnableKeyRotationCommand({
|
||||
KeyId: keyId,
|
||||
});
|
||||
|
||||
await kmsClient.send(command);
|
||||
|
||||
// Verify rotation is enabled
|
||||
const statusResponse = await kmsClient.send(
|
||||
new GetKeyRotationStatusCommand({
|
||||
KeyId: keyId,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(statusResponse.KeyRotationEnabled).toBe(true);
|
||||
expect(statusResponse.NextRotationDate).toBeDefined();
|
||||
});
|
||||
|
||||
it('should get key rotation status when disabled', async () => {
|
||||
const command = new GetKeyRotationStatusCommand({
|
||||
KeyId: keyId,
|
||||
});
|
||||
|
||||
const response = await kmsClient.send(command);
|
||||
|
||||
expect(response.KeyRotationEnabled).toBe(false);
|
||||
expect(response.NextRotationDate).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should enable rotation with custom period', async () => {
|
||||
const command = new EnableKeyRotationCommand({
|
||||
KeyId: keyId,
|
||||
RotationPeriodInDays: 180,
|
||||
});
|
||||
|
||||
await kmsClient.send(command);
|
||||
|
||||
// Verify in database
|
||||
const key = await prismaService.kmsKey.findUnique({
|
||||
where: { id: keyId },
|
||||
});
|
||||
|
||||
expect(key?.rotationPeriod).toBe(180);
|
||||
expect(key?.nextRotation).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Key Policy', () => {
|
||||
let keyId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const createResponse = await kmsClient.send(
|
||||
new CreateKeyCommand({
|
||||
Description: 'Key for policy test',
|
||||
}),
|
||||
);
|
||||
keyId = createResponse.KeyMetadata!.KeyId!;
|
||||
});
|
||||
|
||||
it('should get default key policy', async () => {
|
||||
const command = new GetKeyPolicyCommand({
|
||||
KeyId: keyId,
|
||||
PolicyName: 'default',
|
||||
});
|
||||
|
||||
const response = await kmsClient.send(command);
|
||||
|
||||
expect(response.Policy).toBeDefined();
|
||||
const policy = JSON.parse(response.Policy!);
|
||||
expect(policy.Principal).toBeDefined();
|
||||
expect(policy.Action).toBe('kms:*');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Asymmetric Keys', () => {
|
||||
let signingKeyId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const createResponse = await kmsClient.send(
|
||||
new CreateKeyCommand({
|
||||
Description: 'RSA signing key',
|
||||
KeyUsage: KeyUsageType.SIGN_VERIFY,
|
||||
KeySpec: KeySpec.RSA_2048,
|
||||
}),
|
||||
);
|
||||
signingKeyId = createResponse.KeyMetadata!.KeyId!;
|
||||
});
|
||||
|
||||
it('should get public key', async () => {
|
||||
const command = new GetPublicKeyCommand({
|
||||
KeyId: signingKeyId,
|
||||
});
|
||||
|
||||
const response = await kmsClient.send(command);
|
||||
|
||||
expect(response.KeyId).toBe(signingKeyId);
|
||||
expect(response.PublicKey).toBeDefined();
|
||||
expect(response.KeyUsage).toBe(KeyUsageType.SIGN_VERIFY);
|
||||
expect(response.KeySpec).toBe(KeySpec.RSA_2048);
|
||||
});
|
||||
|
||||
it('should sign data', async () => {
|
||||
const message = Buffer.from('Test message to sign');
|
||||
|
||||
const command = new SignCommand({
|
||||
KeyId: signingKeyId,
|
||||
Message: message,
|
||||
SigningAlgorithm: SigningAlgorithmSpec.RSASSA_PKCS1_V1_5_SHA_256,
|
||||
});
|
||||
|
||||
const response = await kmsClient.send(command);
|
||||
|
||||
expect(response.Signature).toBeDefined();
|
||||
expect(response.KeyId).toContain(signingKeyId);
|
||||
expect(response.SigningAlgorithm).toBe(SigningAlgorithmSpec.RSASSA_PKCS1_V1_5_SHA_256);
|
||||
});
|
||||
|
||||
it('should sign different messages with same key', async () => {
|
||||
const message1 = Buffer.from('First message');
|
||||
const message2 = Buffer.from('Second message');
|
||||
|
||||
const response1 = await kmsClient.send(
|
||||
new SignCommand({
|
||||
KeyId: signingKeyId,
|
||||
Message: message1,
|
||||
SigningAlgorithm: SigningAlgorithmSpec.RSASSA_PKCS1_V1_5_SHA_256,
|
||||
}),
|
||||
);
|
||||
|
||||
const response2 = await kmsClient.send(
|
||||
new SignCommand({
|
||||
KeyId: signingKeyId,
|
||||
Message: message2,
|
||||
SigningAlgorithm: SigningAlgorithmSpec.RSASSA_PKCS1_V1_5_SHA_256,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response1.Signature).toBeDefined();
|
||||
expect(response2.Signature).toBeDefined();
|
||||
expect(response1.Signature).not.toEqual(response2.Signature);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tags', () => {
|
||||
let keyId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const createResponse = await kmsClient.send(
|
||||
new CreateKeyCommand({
|
||||
Description: 'Key for tag test',
|
||||
}),
|
||||
);
|
||||
keyId = createResponse.KeyMetadata!.KeyId!;
|
||||
});
|
||||
|
||||
it('should list resource tags for key without tags', async () => {
|
||||
const command = new ListResourceTagsCommand({
|
||||
KeyId: keyId,
|
||||
});
|
||||
|
||||
const response = await kmsClient.send(command);
|
||||
|
||||
expect(response.Tags).toBeDefined();
|
||||
});
|
||||
|
||||
it('should list resource tags for key with tags', async () => {
|
||||
// Create key with tags
|
||||
const createResponse = await kmsClient.send(
|
||||
new CreateKeyCommand({
|
||||
Description: 'Tagged key',
|
||||
Tags: [
|
||||
{ TagKey: 'Project', TagValue: 'LocalAWS' },
|
||||
{ TagKey: 'Owner', TagValue: 'Test' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const taggedKeyId = createResponse.KeyMetadata!.KeyId!;
|
||||
|
||||
const command = new ListResourceTagsCommand({
|
||||
KeyId: taggedKeyId,
|
||||
});
|
||||
|
||||
const response = await kmsClient.send(command);
|
||||
|
||||
expect(response.Tags).toBeDefined();
|
||||
expect(response.Tags!.length).toBeGreaterThanOrEqual(2);
|
||||
const tagKeys = response.Tags!.map(t => t.TagKey);
|
||||
expect(tagKeys).toContain('Project');
|
||||
expect(tagKeys).toContain('Owner');
|
||||
});
|
||||
});
|
||||
|
||||
describe('End-to-End Workflow', () => {
|
||||
it('should complete full KMS workflow', async () => {
|
||||
// 1. Create symmetric key
|
||||
const createKeyResponse = await kmsClient.send(
|
||||
new CreateKeyCommand({
|
||||
Description: 'E2E test key',
|
||||
Tags: [{ TagKey: 'Test', TagValue: 'E2E' }],
|
||||
}),
|
||||
);
|
||||
const keyId = createKeyResponse.KeyMetadata!.KeyId!;
|
||||
expect(keyId).toBeDefined();
|
||||
|
||||
// 2. Describe key
|
||||
const describeResponse = await kmsClient.send(new DescribeKeyCommand({ KeyId: keyId }));
|
||||
expect(describeResponse.KeyMetadata?.Description).toBe('E2E test key');
|
||||
expect(describeResponse.KeyMetadata?.Enabled).toBe(true);
|
||||
|
||||
// 3. Create alias
|
||||
await kmsClient.send(
|
||||
new CreateAliasCommand({
|
||||
AliasName: 'alias/e2e-test',
|
||||
TargetKeyId: keyId,
|
||||
}),
|
||||
);
|
||||
|
||||
// 4. List aliases and verify
|
||||
const listAliasesResponse = await kmsClient.send(new ListAliasesCommand({ KeyId: keyId }));
|
||||
const aliasNames = listAliasesResponse.Aliases?.map(a => a.AliasName);
|
||||
expect(aliasNames).toContain('alias/e2e-test');
|
||||
|
||||
// 5. Enable key rotation
|
||||
await kmsClient.send(
|
||||
new EnableKeyRotationCommand({
|
||||
KeyId: keyId,
|
||||
RotationPeriodInDays: 365,
|
||||
}),
|
||||
);
|
||||
|
||||
// 6. Get rotation status
|
||||
const rotationStatusResponse = await kmsClient.send(new GetKeyRotationStatusCommand({ KeyId: keyId }));
|
||||
expect(rotationStatusResponse.KeyRotationEnabled).toBe(true);
|
||||
|
||||
// 7. Get key policy
|
||||
const policyResponse = await kmsClient.send(
|
||||
new GetKeyPolicyCommand({
|
||||
KeyId: keyId,
|
||||
PolicyName: 'default',
|
||||
}),
|
||||
);
|
||||
expect(policyResponse.Policy).toBeDefined();
|
||||
|
||||
// 8. List tags
|
||||
const tagsResponse = await kmsClient.send(new ListResourceTagsCommand({ KeyId: keyId }));
|
||||
const tagKeys = tagsResponse.Tags?.map(t => t.TagKey);
|
||||
expect(tagKeys).toContain('Test');
|
||||
|
||||
// 9. Describe key by alias
|
||||
const describeByAliasResponse = await kmsClient.send(new DescribeKeyCommand({ KeyId: 'alias/e2e-test' }));
|
||||
expect(describeByAliasResponse.KeyMetadata?.KeyId).toBe(keyId);
|
||||
|
||||
// Verify in database
|
||||
const key = await prismaService.kmsKey.findUnique({
|
||||
where: { id: keyId },
|
||||
include: { aliases: true },
|
||||
});
|
||||
expect(key).toBeDefined();
|
||||
expect(key?.aliases.length).toBeGreaterThanOrEqual(1);
|
||||
expect(key?.rotationPeriod).toBe(365);
|
||||
});
|
||||
|
||||
it('should complete asymmetric key workflow', async () => {
|
||||
// 1. Create RSA key for signing
|
||||
const createKeyResponse = await kmsClient.send(
|
||||
new CreateKeyCommand({
|
||||
Description: 'E2E RSA signing key',
|
||||
KeyUsage: KeyUsageType.SIGN_VERIFY,
|
||||
KeySpec: KeySpec.RSA_2048,
|
||||
}),
|
||||
);
|
||||
const keyId = createKeyResponse.KeyMetadata!.KeyId!;
|
||||
|
||||
// 2. Get public key
|
||||
const publicKeyResponse = await kmsClient.send(new GetPublicKeyCommand({ KeyId: keyId }));
|
||||
expect(publicKeyResponse.PublicKey).toBeDefined();
|
||||
|
||||
// 3. Sign message
|
||||
const message = Buffer.from('Important message');
|
||||
const signResponse = await kmsClient.send(
|
||||
new SignCommand({
|
||||
KeyId: keyId,
|
||||
Message: message,
|
||||
SigningAlgorithm: SigningAlgorithmSpec.RSASSA_PKCS1_V1_5_SHA_256,
|
||||
}),
|
||||
);
|
||||
expect(signResponse.Signature).toBeDefined();
|
||||
|
||||
// 4. Create alias
|
||||
await kmsClient.send(
|
||||
new CreateAliasCommand({
|
||||
AliasName: 'alias/signing-key',
|
||||
TargetKeyId: keyId,
|
||||
}),
|
||||
);
|
||||
|
||||
// 5. Sign using alias
|
||||
const signByAliasResponse = await kmsClient.send(
|
||||
new SignCommand({
|
||||
KeyId: 'alias/signing-key',
|
||||
Message: message,
|
||||
SigningAlgorithm: SigningAlgorithmSpec.RSASSA_PKCS1_V1_5_SHA_256,
|
||||
}),
|
||||
);
|
||||
expect(signByAliasResponse.Signature).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle describing non-existent key', async () => {
|
||||
const command = new DescribeKeyCommand({
|
||||
KeyId: 'non-existent-key-id',
|
||||
});
|
||||
|
||||
await expect(kmsClient.send(command)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle creating alias for non-existent key', async () => {
|
||||
const command = new CreateAliasCommand({
|
||||
AliasName: 'alias/test',
|
||||
TargetKeyId: 'non-existent-key-id',
|
||||
});
|
||||
|
||||
await expect(kmsClient.send(command)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle invalid alias name format', async () => {
|
||||
const createResponse = await kmsClient.send(new CreateKeyCommand({}));
|
||||
const keyId = createResponse.KeyMetadata!.KeyId!;
|
||||
|
||||
const command = new CreateAliasCommand({
|
||||
AliasName: 'invalid-alias-without-prefix',
|
||||
TargetKeyId: keyId,
|
||||
});
|
||||
|
||||
await expect(kmsClient.send(command)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle enabling rotation on non-existent key', async () => {
|
||||
const command = new EnableKeyRotationCommand({
|
||||
KeyId: 'non-existent-key-id',
|
||||
});
|
||||
|
||||
await expect(kmsClient.send(command)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle getting public key from symmetric key', async () => {
|
||||
const createResponse = await kmsClient.send(
|
||||
new CreateKeyCommand({
|
||||
KeyUsage: KeyUsageType.ENCRYPT_DECRYPT,
|
||||
}),
|
||||
);
|
||||
const keyId = createResponse.KeyMetadata!.KeyId!;
|
||||
|
||||
const command = new GetPublicKeyCommand({
|
||||
KeyId: keyId,
|
||||
});
|
||||
|
||||
await expect(kmsClient.send(command)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle signing with symmetric key', async () => {
|
||||
const createResponse = await kmsClient.send(
|
||||
new CreateKeyCommand({
|
||||
KeyUsage: KeyUsageType.ENCRYPT_DECRYPT,
|
||||
}),
|
||||
);
|
||||
const keyId = createResponse.KeyMetadata!.KeyId!;
|
||||
|
||||
const command = new SignCommand({
|
||||
KeyId: keyId,
|
||||
Message: Buffer.from('test'),
|
||||
SigningAlgorithm: SigningAlgorithmSpec.RSASSA_PKCS1_V1_5_SHA_256,
|
||||
});
|
||||
|
||||
await expect(kmsClient.send(command)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Key Specifications', () => {
|
||||
it('should create ECC signing key', async () => {
|
||||
const command = new CreateKeyCommand({
|
||||
Description: 'ECC signing key',
|
||||
KeyUsage: KeyUsageType.SIGN_VERIFY,
|
||||
KeySpec: KeySpec.ECC_NIST_P256,
|
||||
});
|
||||
|
||||
const response = await kmsClient.send(command);
|
||||
|
||||
expect(response.KeyMetadata?.KeySpec).toBe(KeySpec.ECC_NIST_P256);
|
||||
expect(response.KeyMetadata?.KeyUsage).toBe(KeyUsageType.SIGN_VERIFY);
|
||||
});
|
||||
|
||||
it('should create different RSA key sizes', async () => {
|
||||
const specs = [KeySpec.RSA_2048, KeySpec.RSA_3072, KeySpec.RSA_4096];
|
||||
|
||||
for (const spec of specs) {
|
||||
const response = await kmsClient.send(
|
||||
new CreateKeyCommand({
|
||||
KeyUsage: KeyUsageType.SIGN_VERIFY,
|
||||
KeySpec: spec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.KeyMetadata?.KeySpec).toBe(spec);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -25,25 +25,22 @@ type QueryParams = {
|
||||
Policy: string;
|
||||
Tags: NoUndefinedField<Tag>[];
|
||||
XksKeyId: string;
|
||||
}
|
||||
};
|
||||
|
||||
const generateDefaultPolicy = (accountId: string) => JSON.stringify({
|
||||
"Sid": "Enable IAM User Permissions",
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"AWS": `arn:aws:iam::${accountId}:root`
|
||||
const generateDefaultPolicy = (accountId: string) =>
|
||||
JSON.stringify({
|
||||
Sid: 'Enable IAM User Permissions',
|
||||
Effect: 'Allow',
|
||||
Principal: {
|
||||
AWS: `arn:aws:iam::${accountId}:root`,
|
||||
},
|
||||
"Action": "kms:*",
|
||||
"Resource": "*"
|
||||
})
|
||||
Action: 'kms:*',
|
||||
Resource: '*',
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class CreateKeyHandler extends AbstractActionHandler<QueryParams> {
|
||||
|
||||
constructor(
|
||||
private readonly kmsService: KmsService,
|
||||
private readonly tagsService: TagsService,
|
||||
) {
|
||||
constructor(private readonly kmsService: KmsService, private readonly tagsService: TagsService) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -54,16 +51,22 @@ export class CreateKeyHandler extends AbstractActionHandler<QueryParams> {
|
||||
CustomerMasterKeySpec: Joi.string().allow(...Object.values(CustomerMasterKeySpec)),
|
||||
CustomKeyStoreId: Joi.string().min(1).max(64),
|
||||
Description: Joi.string().min(0).max(8192).default(''),
|
||||
KeySpec: Joi.string().allow(...Object.values(KeySpec)).default(KeySpec.SYMMETRIC_DEFAULT),
|
||||
KeyUsage: Joi.string().allow(...Object.values(KeyUsageType)).default(KeyUsageType.ENCRYPT_DECRYPT),
|
||||
KeySpec: Joi.string()
|
||||
.allow(...Object.values(KeySpec))
|
||||
.default(KeySpec.SYMMETRIC_DEFAULT),
|
||||
KeyUsage: Joi.string()
|
||||
.allow(...Object.values(KeyUsageType))
|
||||
.default(KeyUsageType.ENCRYPT_DECRYPT),
|
||||
MultiRegion: Joi.boolean().default(false),
|
||||
Origin: Joi.string().allow(...Object.values(OriginType)).default(OriginType.AWS_KMS),
|
||||
Origin: Joi.string()
|
||||
.allow(...Object.values(OriginType))
|
||||
.default(OriginType.AWS_KMS),
|
||||
Policy: Joi.string().min(1).max(32768),
|
||||
Tags: Joi.array().items(
|
||||
Joi.object<Tag, true>({
|
||||
TagKey: Joi.string().min(1).max(128).required(),
|
||||
TagValue: Joi.string().min(0).max(256).required(),
|
||||
})
|
||||
}),
|
||||
),
|
||||
XksKeyId: Joi.when('Origin', {
|
||||
is: OriginType.EXTERNAL_KEY_STORE,
|
||||
@@ -72,8 +75,10 @@ export class CreateKeyHandler extends AbstractActionHandler<QueryParams> {
|
||||
}) as unknown as Joi.StringSchema,
|
||||
});
|
||||
|
||||
protected async handle({ KeyUsage, Description, KeySpec, Origin, MultiRegion, Policy, Tags, CustomerMasterKeySpec }: QueryParams, { awsProperties} : RequestContext) {
|
||||
|
||||
protected async handle(
|
||||
{ KeyUsage, Description, KeySpec, Origin, MultiRegion, Policy, Tags, CustomerMasterKeySpec }: QueryParams,
|
||||
{ awsProperties }: RequestContext,
|
||||
) {
|
||||
const keySpec = CustomerMasterKeySpec ?? KeySpec;
|
||||
|
||||
if (!keySpecToUsageType[keySpec].includes(KeyUsage)) {
|
||||
@@ -92,21 +97,26 @@ export class CreateKeyHandler extends AbstractActionHandler<QueryParams> {
|
||||
origin: Origin,
|
||||
multiRegion: MultiRegion,
|
||||
policy: Policy ?? generateDefaultPolicy(awsProperties.accountId),
|
||||
key,
|
||||
key: new Uint8Array(key),
|
||||
accountId: awsProperties.accountId,
|
||||
region: awsProperties.region,
|
||||
});
|
||||
|
||||
await this.tagsService.createMany(createdKey.arn, Tags.map(({ TagKey, TagValue }) => ({ key: TagKey, value: TagValue })));
|
||||
if (Tags && Tags.length > 0) {
|
||||
await this.tagsService.createMany(
|
||||
createdKey.arn,
|
||||
Tags.map(({ TagKey, TagValue }) => ({ key: TagKey, value: TagValue })),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
KeyMetadata: createdKey.metadata,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private keyGeneratorMap: Record<KeySpec, () => Buffer> = {
|
||||
ECC_NIST_P256: function (): Buffer {
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'X9_62_prime256v1' });
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
||||
return Buffer.from(JSON.stringify({ privateKey, publicKey }));
|
||||
},
|
||||
ECC_NIST_P384: function (): Buffer {
|
||||
@@ -121,6 +131,19 @@ export class CreateKeyHandler extends AbstractActionHandler<QueryParams> {
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'secp256k1' });
|
||||
return Buffer.from(JSON.stringify({ privateKey, publicKey }));
|
||||
},
|
||||
ECC_NIST_EDWARDS25519: function (): Buffer {
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
|
||||
return Buffer.from(JSON.stringify({ privateKey, publicKey }));
|
||||
},
|
||||
ML_DSA_44: function (): Buffer {
|
||||
return crypto.randomBytes(2528);
|
||||
},
|
||||
ML_DSA_65: function (): Buffer {
|
||||
return crypto.randomBytes(4000);
|
||||
},
|
||||
ML_DSA_87: function (): Buffer {
|
||||
return crypto.randomBytes(4896);
|
||||
},
|
||||
HMAC_224: function (): Buffer {
|
||||
return crypto.randomBytes(32);
|
||||
},
|
||||
@@ -138,12 +161,12 @@ export class CreateKeyHandler extends AbstractActionHandler<QueryParams> {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: {
|
||||
type: 'pkcs1',
|
||||
format: 'pem'
|
||||
format: 'pem',
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs8',
|
||||
format: 'pem'
|
||||
}
|
||||
format: 'pem',
|
||||
},
|
||||
});
|
||||
return Buffer.from(JSON.stringify({ privateKey, publicKey }));
|
||||
},
|
||||
@@ -152,12 +175,12 @@ export class CreateKeyHandler extends AbstractActionHandler<QueryParams> {
|
||||
modulusLength: 3072,
|
||||
publicKeyEncoding: {
|
||||
type: 'pkcs1',
|
||||
format: 'pem'
|
||||
format: 'pem',
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs8',
|
||||
format: 'pem'
|
||||
}
|
||||
format: 'pem',
|
||||
},
|
||||
});
|
||||
return Buffer.from(JSON.stringify({ privateKey, publicKey }));
|
||||
},
|
||||
@@ -166,12 +189,12 @@ export class CreateKeyHandler extends AbstractActionHandler<QueryParams> {
|
||||
modulusLength: 4096,
|
||||
publicKeyEncoding: {
|
||||
type: 'pkcs1',
|
||||
format: 'pem'
|
||||
format: 'pem',
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs8',
|
||||
format: 'pem'
|
||||
}
|
||||
format: 'pem',
|
||||
},
|
||||
});
|
||||
return Buffer.from(JSON.stringify({ privateKey, publicKey }));
|
||||
},
|
||||
@@ -180,6 +203,6 @@ export class CreateKeyHandler extends AbstractActionHandler<QueryParams> {
|
||||
},
|
||||
SYMMETRIC_DEFAULT: function (): Buffer {
|
||||
return crypto.randomBytes(32);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ type QueryParams = {
|
||||
KeyId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Known Issues:
|
||||
* - Terraform apply with lookup loops on describe-key
|
||||
*/
|
||||
@Injectable()
|
||||
export class DescribeKeyHandler extends AbstractActionHandler<QueryParams> {
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ export class GetKeyRotationStatusHandler extends AbstractActionHandler<QueryPara
|
||||
return {
|
||||
KeyId: keyRecord.id,
|
||||
KeyRotationEnabled: !!keyRecord.rotationPeriod,
|
||||
NextRotationDate: keyRecord.nextRotation,
|
||||
NextRotationDate: keyRecord.nextRotation?.getAwsTime(),
|
||||
RotationPeriodInDays: keyRecord.rotationPeriod,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { KeySpec, KeyUsageType, KeyState, AlgorithmSpec, OriginType, ExpirationModelType, KeyAgreementAlgorithmSpec, MacAlgorithmSpec, MultiRegionKeyType, SigningAlgorithmSpec } from '@aws-sdk/client-kms';
|
||||
import {
|
||||
KeySpec,
|
||||
KeyUsageType,
|
||||
KeyState,
|
||||
AlgorithmSpec,
|
||||
OriginType,
|
||||
ExpirationModelType,
|
||||
KeyAgreementAlgorithmSpec,
|
||||
MacAlgorithmSpec,
|
||||
MultiRegionKeyType,
|
||||
SigningAlgorithmSpec,
|
||||
} from '@aws-sdk/client-kms';
|
||||
import { KmsKey as PrismaKmsKey } from '@prisma/client';
|
||||
|
||||
export const keySpecToUsageType: Record<KeySpec, KeyUsageType[]> = {
|
||||
@@ -6,6 +17,7 @@ export const keySpecToUsageType: Record<KeySpec, KeyUsageType[]> = {
|
||||
ECC_NIST_P384: [KeyUsageType.SIGN_VERIFY, KeyUsageType.KEY_AGREEMENT],
|
||||
ECC_NIST_P521: [KeyUsageType.SIGN_VERIFY, KeyUsageType.KEY_AGREEMENT],
|
||||
ECC_SECG_P256K1: [KeyUsageType.SIGN_VERIFY],
|
||||
ECC_NIST_EDWARDS25519: [KeyUsageType.SIGN_VERIFY],
|
||||
HMAC_224: [KeyUsageType.GENERATE_VERIFY_MAC],
|
||||
HMAC_256: [KeyUsageType.GENERATE_VERIFY_MAC],
|
||||
HMAC_384: [KeyUsageType.GENERATE_VERIFY_MAC],
|
||||
@@ -14,11 +26,13 @@ export const keySpecToUsageType: Record<KeySpec, KeyUsageType[]> = {
|
||||
RSA_3072: [KeyUsageType.ENCRYPT_DECRYPT, KeyUsageType.SIGN_VERIFY],
|
||||
RSA_4096: [KeyUsageType.ENCRYPT_DECRYPT, KeyUsageType.SIGN_VERIFY],
|
||||
SM2: [KeyUsageType.ENCRYPT_DECRYPT, KeyUsageType.SIGN_VERIFY, KeyUsageType.KEY_AGREEMENT],
|
||||
SYMMETRIC_DEFAULT: [KeyUsageType.ENCRYPT_DECRYPT]
|
||||
}
|
||||
ML_DSA_44: [KeyUsageType.SIGN_VERIFY],
|
||||
ML_DSA_65: [KeyUsageType.SIGN_VERIFY],
|
||||
ML_DSA_87: [KeyUsageType.SIGN_VERIFY],
|
||||
SYMMETRIC_DEFAULT: [KeyUsageType.ENCRYPT_DECRYPT],
|
||||
};
|
||||
|
||||
export class KmsKey implements PrismaKmsKey {
|
||||
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
usage: KeyUsageType;
|
||||
@@ -28,7 +42,7 @@ export class KmsKey implements PrismaKmsKey {
|
||||
origin: OriginType;
|
||||
multiRegion: boolean;
|
||||
policy: string;
|
||||
key: Buffer;
|
||||
key: Uint8Array<ArrayBuffer>;
|
||||
nextRotation: Date | null;
|
||||
rotationPeriod: number | null;
|
||||
accountId: string;
|
||||
@@ -64,12 +78,15 @@ export class KmsKey implements PrismaKmsKey {
|
||||
}
|
||||
|
||||
get metadata() {
|
||||
|
||||
const dynamicContent: Record<string, any> = {};
|
||||
|
||||
if (keySpecToUsageType[this.keySpec].includes(KeyUsageType.ENCRYPT_DECRYPT)) {
|
||||
// Symmetric keys don't include EncryptionAlgorithms in the response
|
||||
// Only asymmetric encryption keys (RSA, SM2) include this field
|
||||
if (this.keySpec !== KeySpec.SYMMETRIC_DEFAULT) {
|
||||
dynamicContent.EncryptionAlgorithms = Object.values(AlgorithmSpec);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.origin === OriginType.EXTERNAL) {
|
||||
dynamicContent.ExpirationModel = ExpirationModelType.KEY_MATERIAL_DOES_NOT_EXPIRE;
|
||||
@@ -91,7 +108,7 @@ export class KmsKey implements PrismaKmsKey {
|
||||
Region: this.region,
|
||||
},
|
||||
ReplicaKeys: [],
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (keySpecToUsageType[this.keySpec].includes(KeyUsageType.SIGN_VERIFY)) {
|
||||
@@ -116,6 +133,6 @@ export class KmsKey implements PrismaKmsKey {
|
||||
ValidTo: undefined,
|
||||
XksKeyConfiguration: undefined,
|
||||
...dynamicContent,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ type QueryParams = {
|
||||
Message: string;
|
||||
MessageType: string;
|
||||
SigningAlgorithm: string;
|
||||
}
|
||||
};
|
||||
|
||||
const signingAlgorithmToSigningFn: Record<SigningAlgorithmSpec, (base64: string, key: KmsKey) => string> = {
|
||||
ECDSA_SHA_256: function (base64: string): string {
|
||||
@@ -26,6 +26,15 @@ const signingAlgorithmToSigningFn: Record<SigningAlgorithmSpec, (base64: string,
|
||||
ECDSA_SHA_512: function (base64: string): string {
|
||||
throw new Error('Function not implemented.');
|
||||
},
|
||||
ED25519_PH_SHA_512: function (base64: string): string {
|
||||
throw new Error('Function not implemented.');
|
||||
},
|
||||
ED25519_SHA_512: function (base64: string): string {
|
||||
throw new Error('Function not implemented.');
|
||||
},
|
||||
ML_DSA_SHAKE_256: function (base64: string): string {
|
||||
throw new Error('Function not implemented.');
|
||||
},
|
||||
RSASSA_PKCS1_V1_5_SHA_256: function (base64: string, key: KmsKey): string {
|
||||
const buffer = Buffer.from(base64);
|
||||
return crypto.sign('sha256WithRSAEncryption', buffer, key.keyPair.privateKey).toString('base64');
|
||||
@@ -47,15 +56,12 @@ const signingAlgorithmToSigningFn: Record<SigningAlgorithmSpec, (base64: string,
|
||||
},
|
||||
SM2DSA: function (base64: string): string {
|
||||
throw new Error('Function not implemented.');
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SignHandler extends AbstractActionHandler<QueryParams> {
|
||||
|
||||
constructor(
|
||||
private readonly kmsService: KmsService,
|
||||
) {
|
||||
constructor(private readonly kmsService: KmsService) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -64,12 +70,11 @@ export class SignHandler extends AbstractActionHandler<QueryParams> {
|
||||
validator = Joi.object<QueryParams, true>({
|
||||
KeyId: Joi.string().required(),
|
||||
Message: Joi.string().required(),
|
||||
MessageType: Joi.string().required(),
|
||||
MessageType: Joi.string().default('RAW'),
|
||||
SigningAlgorithm: Joi.string().required(),
|
||||
});
|
||||
|
||||
protected async handle({ KeyId, Message, SigningAlgorithm }: QueryParams, { awsProperties } : RequestContext) {
|
||||
|
||||
protected async handle({ KeyId, Message, SigningAlgorithm }: QueryParams, { awsProperties }: RequestContext) {
|
||||
const keyRecord = await this.kmsService.findOneByRef(KeyId, awsProperties);
|
||||
|
||||
if (!keyRecord) {
|
||||
@@ -86,6 +91,6 @@ export class SignHandler extends AbstractActionHandler<QueryParams> {
|
||||
KeyId: keyRecord.arn,
|
||||
Signature: signature,
|
||||
SigningAlgorithm,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
15
src/main.ts
15
src/main.ts
@@ -19,12 +19,21 @@ Date.prototype.getAwsTime = function (this: Date) {
|
||||
};
|
||||
|
||||
(async () => {
|
||||
|
||||
const app = await NestFactory.create(AppModule);
|
||||
// app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
|
||||
app.useGlobalFilters(new AwsExceptionFilter());
|
||||
app.use(bodyParser.json({ type: 'application/x-amz-json-1.0'}));
|
||||
app.use(bodyParser.json({ type: 'application/x-amz-json-1.1'}));
|
||||
|
||||
// Parse JSON for SNS/SQS
|
||||
app.use(bodyParser.json({ type: 'application/x-amz-json-1.0' }));
|
||||
app.use(bodyParser.json({ type: 'application/x-amz-json-1.1' }));
|
||||
|
||||
// Parse raw body for S3 binary data
|
||||
app.use(bodyParser.raw({ type: 'application/octet-stream', limit: '50mb' }));
|
||||
app.use(bodyParser.raw({ type: 'binary/octet-stream', limit: '50mb' }));
|
||||
|
||||
// Parse XML for S3
|
||||
app.use(bodyParser.text({ type: 'application/xml' }));
|
||||
app.use(bodyParser.text({ type: 'text/xml' }));
|
||||
|
||||
const configService: ConfigService<CommonConfig, true> = app.get(ConfigService);
|
||||
|
||||
|
||||
767
src/secrets-manager/__tests__/secrets-manager.spec.ts
Normal file
767
src/secrets-manager/__tests__/secrets-manager.spec.ts
Normal file
@@ -0,0 +1,767 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import {
|
||||
SecretsManagerClient,
|
||||
CreateSecretCommand,
|
||||
DeleteSecretCommand,
|
||||
DescribeSecretCommand,
|
||||
GetSecretValueCommand,
|
||||
PutSecretValueCommand,
|
||||
GetResourcePolicyCommand,
|
||||
PutResourcePolicyCommand,
|
||||
} from '@aws-sdk/client-secrets-manager';
|
||||
import { AppModule } from '../../app.module';
|
||||
import { PrismaService } from '../../_prisma/prisma.service';
|
||||
import { AwsExceptionFilter } from '../../_context/exception.filter';
|
||||
|
||||
const bodyParser = require('body-parser');
|
||||
|
||||
// Add AWS time extension
|
||||
declare global {
|
||||
interface Date {
|
||||
getAwsTime(): number;
|
||||
}
|
||||
}
|
||||
|
||||
Date.prototype.getAwsTime = function (this: Date) {
|
||||
return Math.floor(this.getTime() / 1000);
|
||||
};
|
||||
|
||||
describe('Secrets Manager Integration Tests', () => {
|
||||
let app: INestApplication;
|
||||
let secretsManagerClient: SecretsManagerClient;
|
||||
let prismaService: PrismaService;
|
||||
const testPort = 8084;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Set test environment variables
|
||||
process.env.PORT = testPort.toString();
|
||||
process.env.AWS_ACCOUNT_ID = '123456789012';
|
||||
process.env.AWS_REGION = 'us-east-1';
|
||||
process.env.DATABASE_URL = `file::memory:?cache=shared&unique=${Date.now()}-${Math.random()}`;
|
||||
process.env.PERSISTANCE = ':memory:';
|
||||
|
||||
// Create NestJS testing module
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.useGlobalFilters(new AwsExceptionFilter());
|
||||
app.use(bodyParser.json({ type: 'application/x-amz-json-1.0' }));
|
||||
app.use(bodyParser.json({ type: 'application/x-amz-json-1.1' }));
|
||||
|
||||
await app.init();
|
||||
await app.listen(testPort);
|
||||
|
||||
// Configure Secrets Manager client to point to local endpoint
|
||||
secretsManagerClient = new SecretsManagerClient({
|
||||
region: 'us-east-1',
|
||||
endpoint: `http://localhost:${testPort}`,
|
||||
credentials: {
|
||||
accessKeyId: 'test',
|
||||
secretAccessKey: 'test',
|
||||
},
|
||||
});
|
||||
|
||||
prismaService = moduleFixture.get<PrismaService>(PrismaService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
secretsManagerClient.destroy();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean up database before each test
|
||||
await prismaService.secret.deleteMany({});
|
||||
});
|
||||
|
||||
describe('CreateSecret', () => {
|
||||
it('should create a secret successfully', async () => {
|
||||
const command = new CreateSecretCommand({
|
||||
Name: 'test-secret',
|
||||
SecretString: 'my-secret-value',
|
||||
Description: 'Test secret description',
|
||||
});
|
||||
|
||||
const response = await secretsManagerClient.send(command);
|
||||
|
||||
expect(response.ARN).toBeDefined();
|
||||
expect(response.ARN).toContain('test-secret');
|
||||
expect(response.ARN).toContain('123456789012');
|
||||
expect(response.ARN).toContain('us-east-1');
|
||||
expect(response.Name).toBe('test-secret');
|
||||
expect(response.VersionId).toBeDefined();
|
||||
|
||||
// Verify in database
|
||||
const secret = await prismaService.secret.findFirst({
|
||||
where: { name: 'test-secret' },
|
||||
});
|
||||
expect(secret).toBeDefined();
|
||||
expect(secret?.name).toBe('test-secret');
|
||||
expect(secret?.secretString).toBe('my-secret-value');
|
||||
expect(secret?.description).toBe('Test secret description');
|
||||
});
|
||||
|
||||
it('should create secret without description', async () => {
|
||||
const command = new CreateSecretCommand({
|
||||
Name: 'simple-secret',
|
||||
SecretString: 'simple-value',
|
||||
});
|
||||
|
||||
const response = await secretsManagerClient.send(command);
|
||||
|
||||
expect(response.ARN).toBeDefined();
|
||||
expect(response.Name).toBe('simple-secret');
|
||||
|
||||
const secret = await prismaService.secret.findFirst({
|
||||
where: { name: 'simple-secret' },
|
||||
});
|
||||
expect(secret?.description).toBeNull();
|
||||
});
|
||||
|
||||
it('should create secret with custom client request token', async () => {
|
||||
const customToken = 'custom-token-12345';
|
||||
const command = new CreateSecretCommand({
|
||||
Name: 'token-secret',
|
||||
SecretString: 'token-value',
|
||||
ClientRequestToken: customToken,
|
||||
});
|
||||
|
||||
const response = await secretsManagerClient.send(command);
|
||||
|
||||
expect(response.VersionId).toBe(customToken);
|
||||
|
||||
const secret = await prismaService.secret.findFirst({
|
||||
where: { versionId: customToken },
|
||||
});
|
||||
expect(secret).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create secret with empty secret string', async () => {
|
||||
const command = new CreateSecretCommand({
|
||||
Name: 'empty-secret',
|
||||
SecretString: '',
|
||||
});
|
||||
|
||||
const response = await secretsManagerClient.send(command);
|
||||
|
||||
expect(response.ARN).toBeDefined();
|
||||
|
||||
const secret = await prismaService.secret.findFirst({
|
||||
where: { name: 'empty-secret' },
|
||||
});
|
||||
expect(secret?.secretString).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetSecretValue', () => {
|
||||
beforeEach(async () => {
|
||||
// Create a test secret
|
||||
await secretsManagerClient.send(
|
||||
new CreateSecretCommand({
|
||||
Name: 'get-test-secret',
|
||||
SecretString: JSON.stringify({ username: 'admin', password: 'secret123' }),
|
||||
Description: 'Secret for get test',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should get secret value by name', async () => {
|
||||
const command = new GetSecretValueCommand({
|
||||
SecretId: 'get-test-secret',
|
||||
});
|
||||
|
||||
const response = await secretsManagerClient.send(command);
|
||||
|
||||
expect(response.ARN).toBeDefined();
|
||||
expect(response.Name).toBe('get-test-secret');
|
||||
expect(response.SecretString).toBe(JSON.stringify({ username: 'admin', password: 'secret123' }));
|
||||
expect(response.VersionId).toBeDefined();
|
||||
});
|
||||
|
||||
it('should get secret value by ARN', async () => {
|
||||
const createResponse = await secretsManagerClient.send(
|
||||
new CreateSecretCommand({
|
||||
Name: 'arn-test-secret',
|
||||
SecretString: 'value-by-arn',
|
||||
}),
|
||||
);
|
||||
|
||||
const command = new GetSecretValueCommand({
|
||||
SecretId: createResponse.ARN,
|
||||
});
|
||||
|
||||
const response = await secretsManagerClient.send(command);
|
||||
|
||||
expect(response.Name).toBe('arn-test-secret');
|
||||
expect(response.SecretString).toBe('value-by-arn');
|
||||
});
|
||||
|
||||
it('should get secret value by version id', async () => {
|
||||
const createResponse = await secretsManagerClient.send(
|
||||
new CreateSecretCommand({
|
||||
Name: 'version-test-secret',
|
||||
SecretString: 'version-value',
|
||||
}),
|
||||
);
|
||||
|
||||
const command = new GetSecretValueCommand({
|
||||
SecretId: 'version-test-secret',
|
||||
VersionId: createResponse.VersionId,
|
||||
});
|
||||
|
||||
const response = await secretsManagerClient.send(command);
|
||||
|
||||
expect(response.SecretString).toBe('version-value');
|
||||
expect(response.VersionId).toBe(createResponse.VersionId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PutSecretValue', () => {
|
||||
let secretName: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
secretName = 'put-test-secret';
|
||||
await secretsManagerClient.send(
|
||||
new CreateSecretCommand({
|
||||
Name: secretName,
|
||||
SecretString: 'initial-value',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update secret value', async () => {
|
||||
const command = new PutSecretValueCommand({
|
||||
SecretId: secretName,
|
||||
SecretString: 'updated-value',
|
||||
});
|
||||
|
||||
const response = await secretsManagerClient.send(command);
|
||||
|
||||
expect(response.ARN).toBeDefined();
|
||||
expect(response.Name).toBe(secretName);
|
||||
expect(response.VersionId).toBeDefined();
|
||||
|
||||
// Verify the value was updated
|
||||
const getResponse = await secretsManagerClient.send(
|
||||
new GetSecretValueCommand({
|
||||
SecretId: secretName,
|
||||
}),
|
||||
);
|
||||
expect(getResponse.SecretString).toBe('updated-value');
|
||||
});
|
||||
|
||||
it('should update secret value with custom client request token', async () => {
|
||||
const customToken = 'update-token-12345';
|
||||
const command = new PutSecretValueCommand({
|
||||
SecretId: secretName,
|
||||
SecretString: 'updated-with-token',
|
||||
ClientRequestToken: customToken,
|
||||
});
|
||||
|
||||
const response = await secretsManagerClient.send(command);
|
||||
|
||||
expect(response.VersionId).toBe(customToken);
|
||||
|
||||
const secret = await prismaService.secret.findFirst({
|
||||
where: { versionId: customToken },
|
||||
});
|
||||
expect(secret?.secretString).toBe('updated-with-token');
|
||||
});
|
||||
|
||||
it('should update secret value multiple times', async () => {
|
||||
// First update
|
||||
await secretsManagerClient.send(
|
||||
new PutSecretValueCommand({
|
||||
SecretId: secretName,
|
||||
SecretString: 'value-1',
|
||||
}),
|
||||
);
|
||||
|
||||
// Second update
|
||||
await secretsManagerClient.send(
|
||||
new PutSecretValueCommand({
|
||||
SecretId: secretName,
|
||||
SecretString: 'value-2',
|
||||
}),
|
||||
);
|
||||
|
||||
// Third update
|
||||
await secretsManagerClient.send(
|
||||
new PutSecretValueCommand({
|
||||
SecretId: secretName,
|
||||
SecretString: 'value-3',
|
||||
}),
|
||||
);
|
||||
|
||||
// Get current value
|
||||
const getResponse = await secretsManagerClient.send(
|
||||
new GetSecretValueCommand({
|
||||
SecretId: secretName,
|
||||
}),
|
||||
);
|
||||
expect(getResponse.SecretString).toBe('value-3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DescribeSecret', () => {
|
||||
beforeEach(async () => {
|
||||
await secretsManagerClient.send(
|
||||
new CreateSecretCommand({
|
||||
Name: 'describe-test-secret',
|
||||
SecretString: 'describe-value',
|
||||
Description: 'This is a test secret',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should describe secret by name', async () => {
|
||||
const command = new DescribeSecretCommand({
|
||||
SecretId: 'describe-test-secret',
|
||||
});
|
||||
|
||||
const response = await secretsManagerClient.send(command);
|
||||
|
||||
expect(response.ARN).toBeDefined();
|
||||
expect(response.Name).toBe('describe-test-secret');
|
||||
expect(response.Description).toBe('This is a test secret');
|
||||
expect(response.CreatedDate).toBeDefined();
|
||||
});
|
||||
|
||||
it('should describe secret by ARN', async () => {
|
||||
const createResponse = await secretsManagerClient.send(
|
||||
new CreateSecretCommand({
|
||||
Name: 'arn-describe-secret',
|
||||
SecretString: 'arn-value',
|
||||
}),
|
||||
);
|
||||
|
||||
const command = new DescribeSecretCommand({
|
||||
SecretId: createResponse.ARN,
|
||||
});
|
||||
|
||||
const response = await secretsManagerClient.send(command);
|
||||
|
||||
expect(response.Name).toBe('arn-describe-secret');
|
||||
expect(response.ARN).toBe(createResponse.ARN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeleteSecret', () => {
|
||||
beforeEach(async () => {
|
||||
await secretsManagerClient.send(
|
||||
new CreateSecretCommand({
|
||||
Name: 'delete-test-secret',
|
||||
SecretString: 'delete-value',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should schedule secret deletion', async () => {
|
||||
const command = new DeleteSecretCommand({
|
||||
SecretId: 'delete-test-secret',
|
||||
});
|
||||
|
||||
const response = await secretsManagerClient.send(command);
|
||||
|
||||
expect(response.ARN).toBeDefined();
|
||||
expect(response.Name).toBe('delete-test-secret');
|
||||
expect(response.DeletionDate).toBeDefined();
|
||||
|
||||
// Verify deletion date is set in database
|
||||
const secret = await prismaService.secret.findFirst({
|
||||
where: { name: 'delete-test-secret' },
|
||||
});
|
||||
expect(secret?.deletionDate).toBeDefined();
|
||||
});
|
||||
|
||||
it('should schedule secret deletion with recovery window', async () => {
|
||||
const command = new DeleteSecretCommand({
|
||||
SecretId: 'delete-test-secret',
|
||||
RecoveryWindowInDays: 7,
|
||||
});
|
||||
|
||||
const response = await secretsManagerClient.send(command);
|
||||
|
||||
expect(response.DeletionDate).toBeDefined();
|
||||
|
||||
const deletionDate = new Date(response.DeletionDate!);
|
||||
const expectedDate = new Date();
|
||||
expectedDate.setDate(expectedDate.getDate() + 7);
|
||||
|
||||
// Check if deletion date is approximately 7 days from now (within 1 day tolerance)
|
||||
const diffInDays = Math.abs(deletionDate.getTime() - expectedDate.getTime()) / (1000 * 60 * 60 * 24);
|
||||
expect(diffInDays).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('should force delete secret immediately', async () => {
|
||||
const command = new DeleteSecretCommand({
|
||||
SecretId: 'delete-test-secret',
|
||||
ForceDeleteWithoutRecovery: true,
|
||||
});
|
||||
|
||||
const response = await secretsManagerClient.send(command);
|
||||
|
||||
expect(response.ARN).toBeDefined();
|
||||
expect(response.DeletionDate).toBeDefined();
|
||||
|
||||
// Verify secret still exists but marked for deletion
|
||||
const secret = await prismaService.secret.findFirst({
|
||||
where: { name: 'delete-test-secret' },
|
||||
});
|
||||
expect(secret).toBeDefined();
|
||||
expect(secret?.deletionDate).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource Policy', () => {
|
||||
let secretName: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
secretName = 'policy-test-secret';
|
||||
await secretsManagerClient.send(
|
||||
new CreateSecretCommand({
|
||||
Name: secretName,
|
||||
SecretString: 'policy-value',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should put resource policy', async () => {
|
||||
const policy = {
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Principal: { AWS: 'arn:aws:iam::123456789012:root' },
|
||||
Action: 'secretsmanager:GetSecretValue',
|
||||
Resource: '*',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const command = new PutResourcePolicyCommand({
|
||||
SecretId: secretName,
|
||||
ResourcePolicy: JSON.stringify(policy),
|
||||
});
|
||||
|
||||
const response = await secretsManagerClient.send(command);
|
||||
|
||||
expect(response.ARN).toBeDefined();
|
||||
expect(response.Name).toBe(secretName);
|
||||
});
|
||||
|
||||
it('should get resource policy', async () => {
|
||||
const policy = {
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Principal: { AWS: 'arn:aws:iam::123456789012:root' },
|
||||
Action: 'secretsmanager:GetSecretValue',
|
||||
Resource: '*',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Put policy first
|
||||
await secretsManagerClient.send(
|
||||
new PutResourcePolicyCommand({
|
||||
SecretId: secretName,
|
||||
ResourcePolicy: JSON.stringify(policy),
|
||||
}),
|
||||
);
|
||||
|
||||
// Get policy
|
||||
const command = new GetResourcePolicyCommand({
|
||||
SecretId: secretName,
|
||||
});
|
||||
|
||||
const response = await secretsManagerClient.send(command);
|
||||
|
||||
expect(response.ARN).toBeDefined();
|
||||
expect(response.Name).toBe(secretName);
|
||||
expect(response.ResourcePolicy).toBeDefined();
|
||||
|
||||
const returnedPolicy = JSON.parse(response.ResourcePolicy!);
|
||||
expect(returnedPolicy.Version).toBe('2012-10-17');
|
||||
expect(returnedPolicy.Statement).toBeDefined();
|
||||
});
|
||||
|
||||
it('should update existing resource policy', async () => {
|
||||
const initialPolicy = {
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Principal: { AWS: 'arn:aws:iam::123456789012:root' },
|
||||
Action: 'secretsmanager:GetSecretValue',
|
||||
Resource: '*',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Put initial policy
|
||||
await secretsManagerClient.send(
|
||||
new PutResourcePolicyCommand({
|
||||
SecretId: secretName,
|
||||
ResourcePolicy: JSON.stringify(initialPolicy),
|
||||
}),
|
||||
);
|
||||
|
||||
const updatedPolicy = {
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Deny',
|
||||
Principal: { AWS: 'arn:aws:iam::123456789012:user/test' },
|
||||
Action: 'secretsmanager:DeleteSecret',
|
||||
Resource: '*',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Update policy
|
||||
await secretsManagerClient.send(
|
||||
new PutResourcePolicyCommand({
|
||||
SecretId: secretName,
|
||||
ResourcePolicy: JSON.stringify(updatedPolicy),
|
||||
}),
|
||||
);
|
||||
|
||||
// Get and verify updated policy
|
||||
const getResponse = await secretsManagerClient.send(
|
||||
new GetResourcePolicyCommand({
|
||||
SecretId: secretName,
|
||||
}),
|
||||
);
|
||||
|
||||
const returnedPolicy = JSON.parse(getResponse.ResourcePolicy!);
|
||||
expect(returnedPolicy.Statement[0].Effect).toBe('Deny');
|
||||
expect(returnedPolicy.Statement[0].Action).toBe('secretsmanager:DeleteSecret');
|
||||
});
|
||||
});
|
||||
|
||||
describe('End-to-End Workflow', () => {
|
||||
it('should complete full Secrets Manager workflow', async () => {
|
||||
// 1. Create secret
|
||||
const createResponse = await secretsManagerClient.send(
|
||||
new CreateSecretCommand({
|
||||
Name: 'e2e-test-secret',
|
||||
SecretString: JSON.stringify({ api_key: 'initial-key-123' }),
|
||||
Description: 'E2E test secret',
|
||||
}),
|
||||
);
|
||||
expect(createResponse.ARN).toBeDefined();
|
||||
expect(createResponse.Name).toBe('e2e-test-secret');
|
||||
|
||||
// 2. Describe secret
|
||||
const describeResponse = await secretsManagerClient.send(
|
||||
new DescribeSecretCommand({
|
||||
SecretId: 'e2e-test-secret',
|
||||
}),
|
||||
);
|
||||
expect(describeResponse.Name).toBe('e2e-test-secret');
|
||||
expect(describeResponse.Description).toBe('E2E test secret');
|
||||
|
||||
// 3. Get secret value
|
||||
const getResponse = await secretsManagerClient.send(
|
||||
new GetSecretValueCommand({
|
||||
SecretId: 'e2e-test-secret',
|
||||
}),
|
||||
);
|
||||
expect(getResponse.SecretString).toBe(JSON.stringify({ api_key: 'initial-key-123' }));
|
||||
|
||||
// 4. Update secret value
|
||||
const putResponse = await secretsManagerClient.send(
|
||||
new PutSecretValueCommand({
|
||||
SecretId: 'e2e-test-secret',
|
||||
SecretString: JSON.stringify({ api_key: 'updated-key-456' }),
|
||||
}),
|
||||
);
|
||||
expect(putResponse.VersionId).toBeDefined();
|
||||
|
||||
// 5. Get updated secret value
|
||||
const getUpdatedResponse = await secretsManagerClient.send(
|
||||
new GetSecretValueCommand({
|
||||
SecretId: 'e2e-test-secret',
|
||||
}),
|
||||
);
|
||||
expect(getUpdatedResponse.SecretString).toBe(JSON.stringify({ api_key: 'updated-key-456' }));
|
||||
|
||||
// 6. Put resource policy
|
||||
const policy = {
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Principal: { AWS: 'arn:aws:iam::123456789012:root' },
|
||||
Action: 'secretsmanager:GetSecretValue',
|
||||
Resource: '*',
|
||||
},
|
||||
],
|
||||
};
|
||||
const putPolicyResponse = await secretsManagerClient.send(
|
||||
new PutResourcePolicyCommand({
|
||||
SecretId: 'e2e-test-secret',
|
||||
ResourcePolicy: JSON.stringify(policy),
|
||||
}),
|
||||
);
|
||||
expect(putPolicyResponse.ARN).toBeDefined();
|
||||
|
||||
// 7. Get resource policy
|
||||
const getPolicyResponse = await secretsManagerClient.send(
|
||||
new GetResourcePolicyCommand({
|
||||
SecretId: 'e2e-test-secret',
|
||||
}),
|
||||
);
|
||||
expect(getPolicyResponse.ResourcePolicy).toBeDefined();
|
||||
|
||||
// 8. Schedule deletion
|
||||
const deleteResponse = await secretsManagerClient.send(
|
||||
new DeleteSecretCommand({
|
||||
SecretId: 'e2e-test-secret',
|
||||
RecoveryWindowInDays: 7,
|
||||
}),
|
||||
);
|
||||
expect(deleteResponse.DeletionDate).toBeDefined();
|
||||
|
||||
// 9. Verify secret is marked for deletion but still accessible
|
||||
const finalDescribeResponse = await secretsManagerClient.send(
|
||||
new DescribeSecretCommand({
|
||||
SecretId: 'e2e-test-secret',
|
||||
}),
|
||||
);
|
||||
expect(finalDescribeResponse.DeletedDate).toBeDefined();
|
||||
|
||||
// Verify in database
|
||||
const secret = await prismaService.secret.findFirst({
|
||||
where: { name: 'e2e-test-secret' },
|
||||
});
|
||||
expect(secret?.deletionDate).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle getting non-existent secret', async () => {
|
||||
const command = new GetSecretValueCommand({
|
||||
SecretId: 'non-existent-secret',
|
||||
});
|
||||
|
||||
await expect(secretsManagerClient.send(command)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle describing non-existent secret', async () => {
|
||||
const command = new DescribeSecretCommand({
|
||||
SecretId: 'non-existent-secret',
|
||||
});
|
||||
|
||||
await expect(secretsManagerClient.send(command)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle deleting non-existent secret', async () => {
|
||||
const command = new DeleteSecretCommand({
|
||||
SecretId: 'non-existent-secret',
|
||||
});
|
||||
|
||||
await expect(secretsManagerClient.send(command)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle putting value to non-existent secret', async () => {
|
||||
const command = new PutSecretValueCommand({
|
||||
SecretId: 'non-existent-secret',
|
||||
SecretString: 'value',
|
||||
});
|
||||
|
||||
await expect(secretsManagerClient.send(command)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex Secret Values', () => {
|
||||
it('should handle JSON secret values', async () => {
|
||||
const secretValue = {
|
||||
username: 'admin',
|
||||
password: 'P@ssw0rd!',
|
||||
host: 'database.example.com',
|
||||
port: 5432,
|
||||
database: 'mydb',
|
||||
};
|
||||
|
||||
await secretsManagerClient.send(
|
||||
new CreateSecretCommand({
|
||||
Name: 'json-secret',
|
||||
SecretString: JSON.stringify(secretValue),
|
||||
}),
|
||||
);
|
||||
|
||||
const getResponse = await secretsManagerClient.send(
|
||||
new GetSecretValueCommand({
|
||||
SecretId: 'json-secret',
|
||||
}),
|
||||
);
|
||||
|
||||
const retrievedValue = JSON.parse(getResponse.SecretString!);
|
||||
expect(retrievedValue).toEqual(secretValue);
|
||||
});
|
||||
|
||||
it('should handle plain text secret values', async () => {
|
||||
const secretValue = 'super-secret-api-key-12345';
|
||||
|
||||
await secretsManagerClient.send(
|
||||
new CreateSecretCommand({
|
||||
Name: 'plain-text-secret',
|
||||
SecretString: secretValue,
|
||||
}),
|
||||
);
|
||||
|
||||
const getResponse = await secretsManagerClient.send(
|
||||
new GetSecretValueCommand({
|
||||
SecretId: 'plain-text-secret',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getResponse.SecretString).toBe(secretValue);
|
||||
});
|
||||
|
||||
it('should handle special characters in secret values', async () => {
|
||||
const secretValue = 'P@$$w0rd!#%^&*()_+-=[]{}|;:\'",.<>?/~`';
|
||||
|
||||
await secretsManagerClient.send(
|
||||
new CreateSecretCommand({
|
||||
Name: 'special-chars-secret',
|
||||
SecretString: secretValue,
|
||||
}),
|
||||
);
|
||||
|
||||
const getResponse = await secretsManagerClient.send(
|
||||
new GetSecretValueCommand({
|
||||
SecretId: 'special-chars-secret',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getResponse.SecretString).toBe(secretValue);
|
||||
});
|
||||
|
||||
it('should handle long secret values', async () => {
|
||||
const secretValue = 'a'.repeat(10000);
|
||||
|
||||
await secretsManagerClient.send(
|
||||
new CreateSecretCommand({
|
||||
Name: 'long-secret',
|
||||
SecretString: secretValue,
|
||||
}),
|
||||
);
|
||||
|
||||
const getResponse = await secretsManagerClient.send(
|
||||
new GetSecretValueCommand({
|
||||
SecretId: 'long-secret',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getResponse.SecretString).toBe(secretValue);
|
||||
expect(getResponse.SecretString!.length).toBe(10000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,16 +10,14 @@ import { RequestContext } from '../_context/request.context';
|
||||
|
||||
type QueryParams = {
|
||||
SecretId: string;
|
||||
VersionId: string;
|
||||
}
|
||||
VersionId?: string;
|
||||
RecoveryWindowInDays?: number;
|
||||
ForceDeleteWithoutRecovery?: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class DeleteSecretHandler extends AbstractActionHandler {
|
||||
|
||||
constructor(
|
||||
private readonly secretService: SecretService,
|
||||
private readonly prismaService: PrismaService,
|
||||
) {
|
||||
constructor(private readonly secretService: SecretService, private readonly prismaService: PrismaService) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -28,35 +26,43 @@ export class DeleteSecretHandler extends AbstractActionHandler {
|
||||
validator = Joi.object<QueryParams, true>({
|
||||
SecretId: Joi.string().required(),
|
||||
VersionId: Joi.string().allow(null, ''),
|
||||
RecoveryWindowInDays: Joi.number().min(7).max(30).default(30),
|
||||
ForceDeleteWithoutRecovery: Joi.boolean(),
|
||||
});
|
||||
|
||||
protected async handle({ SecretId, VersionId }: QueryParams, { awsProperties} : RequestContext) {
|
||||
|
||||
protected async handle(
|
||||
{ SecretId, VersionId, RecoveryWindowInDays = 30, ForceDeleteWithoutRecovery }: QueryParams,
|
||||
{ awsProperties }: RequestContext,
|
||||
) {
|
||||
const name = ArnUtil.getSecretNameFromSecretId(SecretId);
|
||||
const secret = VersionId ?
|
||||
await this.secretService.findByNameAndVersion(name, VersionId) :
|
||||
await this.secretService.findLatestByNameAndRegion(name, awsProperties.region);
|
||||
const secret = VersionId
|
||||
? await this.secretService.findByNameAndVersion(name, VersionId)
|
||||
: await this.secretService.findLatestByNameAndRegion(name, awsProperties.region);
|
||||
|
||||
if (!secret) {
|
||||
throw new BadRequestException('ResourceNotFoundException', "Secrets Manager can't find the resource that you asked for.");
|
||||
}
|
||||
|
||||
// Calculate deletion date based on recovery window or force delete
|
||||
const daysToDelete = ForceDeleteWithoutRecovery ? 0 : RecoveryWindowInDays;
|
||||
const deletionDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * daysToDelete);
|
||||
|
||||
await this.prismaService.secret.update({
|
||||
data: {
|
||||
deletionDate: new Date(Date.now() + 1000 * 60 * 60 * 24 * 5),
|
||||
deletionDate,
|
||||
},
|
||||
where: {
|
||||
versionId: secret.versionId,
|
||||
name: secret.name,
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const arn = ArnUtil.fromSecret(secret);
|
||||
|
||||
return {
|
||||
Arn: arn,
|
||||
DeletionDate: secret.deletionDate,
|
||||
ARN: arn,
|
||||
DeletionDate: deletionDate.getAwsTime(),
|
||||
Name: secret.name,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,11 +42,11 @@ export class DescribeSecretHandler extends AbstractActionHandler {
|
||||
|
||||
return {
|
||||
"ARN": arn,
|
||||
"CreatedDate": new Date(secret.createdAt).toISOString(),
|
||||
"DeletedDate": secret.deletionDate ? new Date(secret.deletionDate).toISOString() : null,
|
||||
"CreatedDate": new Date(secret.createdAt).getAwsTime(),
|
||||
"DeletedDate": secret.deletionDate ? new Date(secret.deletionDate).getAwsTime() : null,
|
||||
"Description": secret.description,
|
||||
"KmsKeyId": "",
|
||||
"LastChangedDate": new Date(secret.createdAt).toISOString(),
|
||||
"LastChangedDate": new Date(secret.createdAt).getAwsTime(),
|
||||
"LastRotatedDate": null,
|
||||
"Name": secret.name,
|
||||
"OwningService": secret.accountId,
|
||||
|
||||
478
src/sns/__tests__/sns.spec.ts
Normal file
478
src/sns/__tests__/sns.spec.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import {
|
||||
SNSClient,
|
||||
CreateTopicCommand,
|
||||
PublishCommand,
|
||||
SubscribeCommand,
|
||||
UnsubscribeCommand,
|
||||
ListTopicsCommand,
|
||||
GetTopicAttributesCommand,
|
||||
SetTopicAttributesCommand,
|
||||
GetSubscriptionAttributesCommand,
|
||||
SetSubscriptionAttributesCommand,
|
||||
ListTagsForResourceCommand,
|
||||
TagResourceCommand,
|
||||
} from '@aws-sdk/client-sns';
|
||||
import { AppModule } from '../../app.module';
|
||||
import { PrismaService } from '../../_prisma/prisma.service';
|
||||
import { AwsExceptionFilter } from '../../_context/exception.filter';
|
||||
|
||||
const bodyParser = require('body-parser');
|
||||
|
||||
describe('SNS Integration Tests', () => {
|
||||
let app: INestApplication;
|
||||
let snsClient: SNSClient;
|
||||
let prismaService: PrismaService;
|
||||
const testPort = 8082;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Set test environment variables
|
||||
process.env.PORT = testPort.toString();
|
||||
process.env.AWS_ACCOUNT_ID = '123456789012';
|
||||
process.env.AWS_REGION = 'us-east-1';
|
||||
process.env.DATABASE_URL = `file::memory:?cache=shared&unique=${Date.now()}-${Math.random()}`;
|
||||
process.env.PERSISTANCE = ':memory:';
|
||||
|
||||
// Create NestJS testing module
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.useGlobalFilters(new AwsExceptionFilter());
|
||||
app.use(bodyParser.json({ type: 'application/x-amz-json-1.0' }));
|
||||
app.use(bodyParser.json({ type: 'application/x-amz-json-1.1' }));
|
||||
|
||||
await app.init();
|
||||
await app.listen(testPort);
|
||||
|
||||
// Configure SNS client to point to local endpoint
|
||||
snsClient = new SNSClient({
|
||||
region: 'us-east-1',
|
||||
endpoint: `http://localhost:${testPort}`,
|
||||
credentials: {
|
||||
accessKeyId: 'test',
|
||||
secretAccessKey: 'test',
|
||||
},
|
||||
});
|
||||
|
||||
prismaService = moduleFixture.get<PrismaService>(PrismaService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
snsClient.destroy();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean up database before each test
|
||||
await prismaService.snsTopicSubscription.deleteMany({});
|
||||
await prismaService.snsTopic.deleteMany({});
|
||||
await prismaService.attribute.deleteMany({});
|
||||
await prismaService.tag.deleteMany({});
|
||||
});
|
||||
|
||||
describe('CreateTopic', () => {
|
||||
it('should create a topic successfully', async () => {
|
||||
const command = new CreateTopicCommand({
|
||||
Name: 'test-topic',
|
||||
});
|
||||
|
||||
const response = await snsClient.send(command);
|
||||
|
||||
expect(response.TopicArn).toBeDefined();
|
||||
expect(response.TopicArn).toContain('test-topic');
|
||||
expect(response.TopicArn).toContain('123456789012');
|
||||
expect(response.TopicArn).toContain('us-east-1');
|
||||
|
||||
// Verify in database
|
||||
const topic = await prismaService.snsTopic.findFirst({
|
||||
where: { name: 'test-topic' },
|
||||
});
|
||||
expect(topic).toBeDefined();
|
||||
expect(topic?.name).toBe('test-topic');
|
||||
});
|
||||
|
||||
it('should create topic with tags', async () => {
|
||||
const command = new CreateTopicCommand({
|
||||
Name: 'tagged-topic',
|
||||
Tags: [
|
||||
{ Key: 'Environment', Value: 'Production' },
|
||||
{ Key: 'Team', Value: 'Backend' },
|
||||
],
|
||||
});
|
||||
|
||||
const response = await snsClient.send(command);
|
||||
expect(response.TopicArn).toBeDefined();
|
||||
|
||||
// Verify tags in database
|
||||
const tags = await prismaService.tag.findMany({
|
||||
where: { arn: response.TopicArn },
|
||||
});
|
||||
expect(tags).toHaveLength(2);
|
||||
expect(tags.map(t => t.name)).toContain('Environment');
|
||||
expect(tags.map(t => t.name)).toContain('Team');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ListTopics', () => {
|
||||
it('should list topics', async () => {
|
||||
// Create some topics first
|
||||
await snsClient.send(new CreateTopicCommand({ Name: 'topic-1' }));
|
||||
await snsClient.send(new CreateTopicCommand({ Name: 'topic-2' }));
|
||||
await snsClient.send(new CreateTopicCommand({ Name: 'topic-3' }));
|
||||
|
||||
const command = new ListTopicsCommand({});
|
||||
const response = await snsClient.send(command);
|
||||
|
||||
expect(response.Topics).toBeDefined();
|
||||
expect(response.Topics!.length).toBeGreaterThanOrEqual(3);
|
||||
const topicNames = response.Topics!.map(t => t.TopicArn?.split(':').pop());
|
||||
expect(topicNames).toContain('topic-1');
|
||||
expect(topicNames).toContain('topic-2');
|
||||
expect(topicNames).toContain('topic-3');
|
||||
});
|
||||
|
||||
it('should handle empty topic list', async () => {
|
||||
const command = new ListTopicsCommand({});
|
||||
const response = await snsClient.send(command);
|
||||
|
||||
expect(response.Topics).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Subscribe and Unsubscribe', () => {
|
||||
let topicArn: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const createTopicResponse = await snsClient.send(new CreateTopicCommand({ Name: 'sub-test-topic' }));
|
||||
topicArn = createTopicResponse.TopicArn!;
|
||||
});
|
||||
|
||||
it('should subscribe to a topic', async () => {
|
||||
const command = new SubscribeCommand({
|
||||
TopicArn: topicArn,
|
||||
Protocol: 'sqs',
|
||||
Endpoint: 'arn:aws:sqs:us-east-1:123456789012:test-queue',
|
||||
});
|
||||
|
||||
const response = await snsClient.send(command);
|
||||
|
||||
expect(response.SubscriptionArn).toBeDefined();
|
||||
expect(response.SubscriptionArn).toContain(topicArn);
|
||||
|
||||
// Verify in database
|
||||
const subscription = await prismaService.snsTopicSubscription.findFirst({
|
||||
where: { topicArn },
|
||||
});
|
||||
expect(subscription).toBeDefined();
|
||||
expect(subscription?.protocol).toBe('sqs');
|
||||
});
|
||||
|
||||
it('should subscribe with attributes', async () => {
|
||||
const command = new SubscribeCommand({
|
||||
TopicArn: topicArn,
|
||||
Protocol: 'sqs',
|
||||
Endpoint: 'arn:aws:sqs:us-east-1:123456789012:test-queue',
|
||||
Attributes: {
|
||||
RawMessageDelivery: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
const response = await snsClient.send(command);
|
||||
expect(response.SubscriptionArn).toBeDefined();
|
||||
|
||||
// Verify attributes in database
|
||||
const attributes = await prismaService.attribute.findMany({
|
||||
where: { arn: response.SubscriptionArn },
|
||||
});
|
||||
expect(attributes.some(a => a.name === 'RawMessageDelivery' && a.value === 'true')).toBe(true);
|
||||
});
|
||||
|
||||
it('should unsubscribe from a topic', async () => {
|
||||
// First subscribe
|
||||
const subscribeResponse = await snsClient.send(
|
||||
new SubscribeCommand({
|
||||
TopicArn: topicArn,
|
||||
Protocol: 'sqs',
|
||||
Endpoint: 'arn:aws:sqs:us-east-1:123456789012:test-queue',
|
||||
}),
|
||||
);
|
||||
|
||||
const subscriptionArn = subscribeResponse.SubscriptionArn!;
|
||||
|
||||
// Then unsubscribe
|
||||
const unsubscribeCommand = new UnsubscribeCommand({
|
||||
SubscriptionArn: subscriptionArn,
|
||||
});
|
||||
|
||||
await snsClient.send(unsubscribeCommand);
|
||||
|
||||
// Verify subscription deleted from database
|
||||
const subscription = await prismaService.snsTopicSubscription.findFirst({
|
||||
where: { topicArn },
|
||||
});
|
||||
expect(subscription).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Publish', () => {
|
||||
let topicArn: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const createTopicResponse = await snsClient.send(new CreateTopicCommand({ Name: 'publish-test-topic' }));
|
||||
topicArn = createTopicResponse.TopicArn!;
|
||||
});
|
||||
|
||||
it('should publish a message to topic', async () => {
|
||||
const command = new PublishCommand({
|
||||
TopicArn: topicArn,
|
||||
Message: 'Test message',
|
||||
Subject: 'Test subject',
|
||||
});
|
||||
|
||||
const response = await snsClient.send(command);
|
||||
|
||||
expect(response.MessageId).toBeDefined();
|
||||
expect(typeof response.MessageId).toBe('string');
|
||||
});
|
||||
|
||||
it('should publish message without subject', async () => {
|
||||
const command = new PublishCommand({
|
||||
TopicArn: topicArn,
|
||||
Message: 'Test message without subject',
|
||||
});
|
||||
|
||||
const response = await snsClient.send(command);
|
||||
|
||||
expect(response.MessageId).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Topic Attributes', () => {
|
||||
let topicArn: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const createTopicResponse = await snsClient.send(new CreateTopicCommand({ Name: 'attr-test-topic' }));
|
||||
topicArn = createTopicResponse.TopicArn!;
|
||||
});
|
||||
|
||||
it('should get topic attributes', async () => {
|
||||
const command = new GetTopicAttributesCommand({
|
||||
TopicArn: topicArn,
|
||||
});
|
||||
|
||||
const response = await snsClient.send(command);
|
||||
|
||||
expect(response.Attributes).toBeDefined();
|
||||
expect(response.Attributes!.TopicArn).toBe(topicArn);
|
||||
expect(response.Attributes!.Owner).toBe('123456789012');
|
||||
expect(response.Attributes!.SubscriptionsConfirmed).toBeDefined();
|
||||
});
|
||||
|
||||
it('should set topic attribute', async () => {
|
||||
const setCommand = new SetTopicAttributesCommand({
|
||||
TopicArn: topicArn,
|
||||
AttributeName: 'DisplayName',
|
||||
AttributeValue: 'My Test Topic',
|
||||
});
|
||||
|
||||
await snsClient.send(setCommand);
|
||||
|
||||
// Verify attribute was set
|
||||
const getCommand = new GetTopicAttributesCommand({
|
||||
TopicArn: topicArn,
|
||||
});
|
||||
const response = await snsClient.send(getCommand);
|
||||
|
||||
expect(response.Attributes!.DisplayName).toBe('My Test Topic');
|
||||
});
|
||||
|
||||
it('should update existing topic attribute', async () => {
|
||||
// Set initial attribute
|
||||
await snsClient.send(
|
||||
new SetTopicAttributesCommand({
|
||||
TopicArn: topicArn,
|
||||
AttributeName: 'DisplayName',
|
||||
AttributeValue: 'Initial Name',
|
||||
}),
|
||||
);
|
||||
|
||||
// Update the attribute
|
||||
await snsClient.send(
|
||||
new SetTopicAttributesCommand({
|
||||
TopicArn: topicArn,
|
||||
AttributeName: 'DisplayName',
|
||||
AttributeValue: 'Updated Name',
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await snsClient.send(new GetTopicAttributesCommand({ TopicArn: topicArn }));
|
||||
expect(response.Attributes!.DisplayName).toBe('Updated Name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Subscription Attributes', () => {
|
||||
let topicArn: string;
|
||||
let subscriptionArn: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const createTopicResponse = await snsClient.send(new CreateTopicCommand({ Name: 'sub-attr-test' }));
|
||||
topicArn = createTopicResponse.TopicArn!;
|
||||
|
||||
const subscribeResponse = await snsClient.send(
|
||||
new SubscribeCommand({
|
||||
TopicArn: topicArn,
|
||||
Protocol: 'sqs',
|
||||
Endpoint: 'arn:aws:sqs:us-east-1:123456789012:test-queue',
|
||||
}),
|
||||
);
|
||||
subscriptionArn = subscribeResponse.SubscriptionArn!;
|
||||
});
|
||||
|
||||
it('should get subscription attributes', async () => {
|
||||
const command = new GetSubscriptionAttributesCommand({
|
||||
SubscriptionArn: subscriptionArn,
|
||||
});
|
||||
|
||||
const response = await snsClient.send(command);
|
||||
|
||||
expect(response.Attributes).toBeDefined();
|
||||
expect(response.Attributes!.SubscriptionArn).toBe(subscriptionArn);
|
||||
expect(response.Attributes!.TopicArn).toBe(topicArn);
|
||||
expect(response.Attributes!.Owner).toBe('123456789012');
|
||||
});
|
||||
|
||||
it('should set subscription attribute', async () => {
|
||||
const setCommand = new SetSubscriptionAttributesCommand({
|
||||
SubscriptionArn: subscriptionArn,
|
||||
AttributeName: 'RawMessageDelivery',
|
||||
AttributeValue: 'true',
|
||||
});
|
||||
|
||||
await snsClient.send(setCommand);
|
||||
|
||||
// Verify attribute was set
|
||||
const getCommand = new GetSubscriptionAttributesCommand({
|
||||
SubscriptionArn: subscriptionArn,
|
||||
});
|
||||
const response = await snsClient.send(getCommand);
|
||||
|
||||
expect(response.Attributes!.RawMessageDelivery).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tags', () => {
|
||||
let topicArn: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const createTopicResponse = await snsClient.send(new CreateTopicCommand({ Name: 'tag-test-topic' }));
|
||||
topicArn = createTopicResponse.TopicArn!;
|
||||
});
|
||||
|
||||
it('should list tags for resource', async () => {
|
||||
// First tag the resource
|
||||
await snsClient.send(
|
||||
new TagResourceCommand({
|
||||
ResourceArn: topicArn,
|
||||
Tags: [
|
||||
{ Key: 'Environment', Value: 'Test' },
|
||||
{ Key: 'Project', Value: 'LocalAWS' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const command = new ListTagsForResourceCommand({
|
||||
ResourceArn: topicArn,
|
||||
});
|
||||
|
||||
const response = await snsClient.send(command);
|
||||
|
||||
expect(response.Tags).toBeDefined();
|
||||
expect(response.Tags!.length).toBeGreaterThanOrEqual(2);
|
||||
const tagKeys = response.Tags!.map(t => t.Key);
|
||||
expect(tagKeys).toContain('Environment');
|
||||
expect(tagKeys).toContain('Project');
|
||||
});
|
||||
|
||||
it('should handle resource with no tags', async () => {
|
||||
const command = new ListTagsForResourceCommand({
|
||||
ResourceArn: topicArn,
|
||||
});
|
||||
|
||||
const response = await snsClient.send(command);
|
||||
|
||||
expect(response.Tags).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('End-to-End Workflow', () => {
|
||||
it('should complete full SNS workflow', async () => {
|
||||
// 1. Create topic
|
||||
const createTopicResponse = await snsClient.send(new CreateTopicCommand({ Name: 'e2e-test-topic' }));
|
||||
const topicArn = createTopicResponse.TopicArn!;
|
||||
expect(topicArn).toBeDefined();
|
||||
|
||||
// 2. Set topic attribute
|
||||
await snsClient.send(
|
||||
new SetTopicAttributesCommand({
|
||||
TopicArn: topicArn,
|
||||
AttributeName: 'DisplayName',
|
||||
AttributeValue: 'E2E Test Topic',
|
||||
}),
|
||||
);
|
||||
|
||||
// 3. Subscribe to topic
|
||||
const subscribeResponse = await snsClient.send(
|
||||
new SubscribeCommand({
|
||||
TopicArn: topicArn,
|
||||
Protocol: 'sqs',
|
||||
Endpoint: 'arn:aws:sqs:us-east-1:123456789012:e2e-queue',
|
||||
}),
|
||||
);
|
||||
const subscriptionArn = subscribeResponse.SubscriptionArn!;
|
||||
expect(subscriptionArn).toBeDefined();
|
||||
|
||||
// 4. Set subscription attribute
|
||||
await snsClient.send(
|
||||
new SetSubscriptionAttributesCommand({
|
||||
SubscriptionArn: subscriptionArn,
|
||||
AttributeName: 'RawMessageDelivery',
|
||||
AttributeValue: 'false',
|
||||
}),
|
||||
);
|
||||
|
||||
// 5. Publish message
|
||||
const publishResponse = await snsClient.send(
|
||||
new PublishCommand({
|
||||
TopicArn: topicArn,
|
||||
Message: 'E2E test message',
|
||||
Subject: 'E2E Test',
|
||||
}),
|
||||
);
|
||||
expect(publishResponse.MessageId).toBeDefined();
|
||||
|
||||
// 6. Get topic attributes
|
||||
const topicAttrsResponse = await snsClient.send(new GetTopicAttributesCommand({ TopicArn: topicArn }));
|
||||
expect(topicAttrsResponse.Attributes!.DisplayName).toBe('E2E Test Topic');
|
||||
expect(topicAttrsResponse.Attributes!.SubscriptionsConfirmed).toBe('1');
|
||||
|
||||
// 7. Get subscription attributes
|
||||
const subAttrsResponse = await snsClient.send(new GetSubscriptionAttributesCommand({ SubscriptionArn: subscriptionArn }));
|
||||
expect(subAttrsResponse.Attributes!.RawMessageDelivery).toBe('false');
|
||||
|
||||
// 8. List topics
|
||||
const listTopicsResponse = await snsClient.send(new ListTopicsCommand({}));
|
||||
const topicArns = listTopicsResponse.Topics!.map(t => t.TopicArn);
|
||||
expect(topicArns).toContain(topicArn);
|
||||
|
||||
// 9. Unsubscribe
|
||||
await snsClient.send(new UnsubscribeCommand({ SubscriptionArn: subscriptionArn }));
|
||||
|
||||
// 10. Verify subscription is gone
|
||||
const verifyAttrs = await snsClient.send(new GetTopicAttributesCommand({ TopicArn: topicArn }));
|
||||
expect(verifyAttrs.Attributes!.SubscriptionsConfirmed).toBe('0');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,24 +10,30 @@ import { RequestContext } from '../_context/request.context';
|
||||
|
||||
type QueryParams = {
|
||||
Name: string;
|
||||
}
|
||||
Tags?: Array<{ Key: string; Value: string }>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CreateTopicHandler extends AbstractActionHandler<QueryParams> {
|
||||
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly tagsService: TagsService,
|
||||
) {
|
||||
constructor(private readonly prismaService: PrismaService, private readonly tagsService: TagsService) {
|
||||
super();
|
||||
}
|
||||
|
||||
format = Format.Xml;
|
||||
action = Action.SnsCreateTopic;
|
||||
validator = Joi.object<QueryParams, true>({ Name: Joi.string().required() });
|
||||
validator = Joi.object<QueryParams, true>({
|
||||
Name: Joi.string().required(),
|
||||
Tags: Joi.array()
|
||||
.items(
|
||||
Joi.object({
|
||||
Key: Joi.string().required(),
|
||||
Value: Joi.string().required(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
protected async handle(params: QueryParams, context: RequestContext) {
|
||||
|
||||
const { Name: name } = params;
|
||||
|
||||
const topic = await this.prismaService.snsTopic.create({
|
||||
@@ -38,9 +44,22 @@ export class CreateTopicHandler extends AbstractActionHandler<QueryParams> {
|
||||
},
|
||||
});
|
||||
|
||||
const tags = TagsService.tagPairs(params);
|
||||
const arn = ArnUtil.fromTopic(topic);
|
||||
|
||||
// Parse tags from both XML query format and JSON format
|
||||
let tags: { key: string; value: string }[] = [];
|
||||
|
||||
// Check if Tags is already an array (JSON format)
|
||||
if (params.Tags && Array.isArray(params.Tags)) {
|
||||
tags = params.Tags.map(t => ({ key: t.Key, value: t.Value }));
|
||||
} else {
|
||||
// Fall back to XML query format parsing
|
||||
tags = TagsService.tagPairs(params);
|
||||
}
|
||||
|
||||
if (tags.length > 0) {
|
||||
await this.tagsService.createMany(arn, tags);
|
||||
}
|
||||
|
||||
return { TopicArn: arn };
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as Joi from 'joi';
|
||||
|
||||
import { PrismaService } from '../_prisma/prisma.service';
|
||||
import { AbstractActionHandler, AwsProperties, Format } from '../abstract-action.handler';
|
||||
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||
import { Action } from '../action.enum';
|
||||
import { AttributesService } from '../aws-shared-entities/attributes.service';
|
||||
import { ArnUtil } from '../util/arn-util.static';
|
||||
import { RequestContext } from '../_context/request.context';
|
||||
import { NotFound } from '../aws-shared-entities/aws-exceptions';
|
||||
|
||||
type QueryParams = {
|
||||
TopicArn: string;
|
||||
@@ -32,7 +33,7 @@ export class GetTopicAttributesHandler extends AbstractActionHandler {
|
||||
const topic = await this.prismaService.snsTopic.findFirst({ where: { name }});
|
||||
|
||||
if (!topic) {
|
||||
throw new BadRequestException();
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
const attributes = await this.attributeService.getByArn(TopicArn);
|
||||
|
||||
@@ -9,15 +9,12 @@ import { RequestContext } from '../_context/request.context';
|
||||
type QueryParams = {
|
||||
AttributeName: string;
|
||||
AttributeValue: string;
|
||||
TopicArn: string;
|
||||
}
|
||||
SubscriptionArn: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SetSubscriptionAttributesHandler extends AbstractActionHandler<QueryParams> {
|
||||
|
||||
constructor(
|
||||
private readonly attributeService: AttributesService,
|
||||
) {
|
||||
constructor(private readonly attributeService: AttributesService) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -26,10 +23,10 @@ export class SetSubscriptionAttributesHandler extends AbstractActionHandler<Quer
|
||||
validator = Joi.object<QueryParams, true>({
|
||||
AttributeName: Joi.string().required(),
|
||||
AttributeValue: Joi.string().required(),
|
||||
TopicArn: Joi.string().required(),
|
||||
SubscriptionArn: Joi.string().required(),
|
||||
});
|
||||
|
||||
protected async handle({ AttributeName, AttributeValue, TopicArn }: QueryParams, { awsProperties} : RequestContext) {
|
||||
await this.attributeService.create({ name: AttributeName, value: AttributeValue, arn: TopicArn });
|
||||
protected async handle({ AttributeName, AttributeValue, SubscriptionArn }: QueryParams, { awsProperties }: RequestContext) {
|
||||
await this.attributeService.create({ name: AttributeName, value: AttributeValue, arn: SubscriptionArn });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import { SetTopicAttributesHandler } from './set-topic-attributes.handler';
|
||||
import { SnsHandlers } from './sns.constants';
|
||||
import { SubscribeHandler } from './subscribe.handler';
|
||||
import { UnsubscribeHandler } from './unsubscribe.handler';
|
||||
import { TagResourceHandler } from './tag-resource.handler';
|
||||
import { UntagResourceHandler } from './untag-resource.handler';
|
||||
import { PrismaModule } from '../_prisma/prisma.module';
|
||||
|
||||
const handlers = [
|
||||
@@ -28,7 +30,9 @@ const handlers = [
|
||||
SetSubscriptionAttributesHandler,
|
||||
SetTopicAttributesHandler,
|
||||
SubscribeHandler,
|
||||
TagResourceHandler,
|
||||
UnsubscribeHandler,
|
||||
UntagResourceHandler,
|
||||
];
|
||||
|
||||
const actions = [
|
||||
@@ -74,21 +78,11 @@ const actions = [
|
||||
Action.SnsUnsubscribe,
|
||||
Action.SnsUntagResource,
|
||||
Action.SnsVerifySMSSandboxPhoneNumber,
|
||||
]
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AwsSharedEntitiesModule,
|
||||
PrismaModule,
|
||||
SqsModule,
|
||||
],
|
||||
providers: [
|
||||
...handlers,
|
||||
ExistingActionHandlersProvider(handlers),
|
||||
DefaultActionHandlerProvider(SnsHandlers, Format.Xml, actions),
|
||||
],
|
||||
exports: [
|
||||
SnsHandlers,
|
||||
]
|
||||
imports: [AwsSharedEntitiesModule, PrismaModule, SqsModule],
|
||||
providers: [...handlers, ExistingActionHandlersProvider(handlers), DefaultActionHandlerProvider(SnsHandlers, Format.Xml, actions)],
|
||||
exports: [SnsHandlers],
|
||||
})
|
||||
export class SnsModule {}
|
||||
|
||||
54
src/sns/tag-resource.handler.ts
Normal file
54
src/sns/tag-resource.handler.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as Joi from 'joi';
|
||||
|
||||
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||
import { Action } from '../action.enum';
|
||||
import { TagsService } from '../aws-shared-entities/tags.service';
|
||||
import { RequestContext } from '../_context/request.context';
|
||||
|
||||
type QueryParams = {
|
||||
ResourceArn: string;
|
||||
Tags?: Array<{ Key: string; Value: string }>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class TagResourceHandler extends AbstractActionHandler<QueryParams> {
|
||||
constructor(private readonly tagsService: TagsService) {
|
||||
super();
|
||||
}
|
||||
|
||||
format = Format.Xml;
|
||||
action = Action.SnsTagResource;
|
||||
validator = Joi.object<QueryParams, true>({
|
||||
ResourceArn: Joi.string().required(),
|
||||
Tags: Joi.array()
|
||||
.items(
|
||||
Joi.object({
|
||||
Key: Joi.string().required(),
|
||||
Value: Joi.string().required(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
protected async handle(params: QueryParams, context: RequestContext) {
|
||||
const { ResourceArn } = params;
|
||||
|
||||
// Parse tags from both XML query format and JSON format
|
||||
let tags: { key: string; value: string }[] = [];
|
||||
|
||||
// Check if Tags is already an array (JSON format)
|
||||
if (params.Tags && Array.isArray(params.Tags)) {
|
||||
tags = params.Tags.map(t => ({ key: t.Key, value: t.Value }));
|
||||
} else {
|
||||
// Fall back to XML query format parsing
|
||||
tags = TagsService.tagPairs(params);
|
||||
}
|
||||
|
||||
if (tags.length > 0) {
|
||||
await this.tagsService.createMany(ResourceArn, tags);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
39
src/sns/untag-resource.handler.ts
Normal file
39
src/sns/untag-resource.handler.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as Joi from 'joi';
|
||||
|
||||
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||
import { Action } from '../action.enum';
|
||||
import { TagsService } from '../aws-shared-entities/tags.service';
|
||||
import { RequestContext } from '../_context/request.context';
|
||||
|
||||
type QueryParams = {
|
||||
ResourceArn: string;
|
||||
TagKeys?: string[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class UntagResourceHandler extends AbstractActionHandler<QueryParams> {
|
||||
constructor(private readonly tagsService: TagsService) {
|
||||
super();
|
||||
}
|
||||
|
||||
format = Format.Xml;
|
||||
action = Action.SnsUntagResource;
|
||||
validator = Joi.object<QueryParams, true>({
|
||||
ResourceArn: Joi.string().required(),
|
||||
TagKeys: Joi.array().items(Joi.string()).optional(),
|
||||
});
|
||||
|
||||
protected async handle(params: QueryParams, context: RequestContext) {
|
||||
const { ResourceArn, TagKeys } = params;
|
||||
|
||||
if (TagKeys && TagKeys.length > 0) {
|
||||
// Delete each tag by name
|
||||
for (const tagKey of TagKeys) {
|
||||
await this.tagsService.deleteByArnAndName(ResourceArn, tagKey);
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
581
src/sqs/__test__/sqs.spec.ts
Normal file
581
src/sqs/__test__/sqs.spec.ts
Normal file
@@ -0,0 +1,581 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import {
|
||||
SQSClient,
|
||||
CreateQueueCommand,
|
||||
DeleteQueueCommand,
|
||||
SendMessageCommand,
|
||||
ReceiveMessageCommand,
|
||||
DeleteMessageCommand,
|
||||
DeleteMessageBatchCommand,
|
||||
PurgeQueueCommand,
|
||||
GetQueueAttributesCommand,
|
||||
SetQueueAttributesCommand,
|
||||
ListQueuesCommand,
|
||||
GetQueueUrlCommand,
|
||||
} from '@aws-sdk/client-sqs';
|
||||
import { AppModule } from '../../app.module';
|
||||
import { PrismaService } from '../../_prisma/prisma.service';
|
||||
import { AwsExceptionFilter } from '../../_context/exception.filter';
|
||||
|
||||
const bodyParser = require('body-parser');
|
||||
|
||||
describe('SQS Integration Tests', () => {
|
||||
let app: INestApplication;
|
||||
let sqsClient: SQSClient;
|
||||
let prismaService: PrismaService;
|
||||
const testPort = 8083;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Set test environment variables
|
||||
process.env.PORT = testPort.toString();
|
||||
process.env.AWS_ACCOUNT_ID = '123456789012';
|
||||
process.env.AWS_REGION = 'us-east-1';
|
||||
process.env.DATABASE_URL = `file::memory:?cache=shared&unique=${Date.now()}-${Math.random()}`;
|
||||
process.env.PERSISTANCE = ':memory:';
|
||||
|
||||
// Create NestJS testing module
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.useGlobalFilters(new AwsExceptionFilter());
|
||||
app.use(bodyParser.json({ type: 'application/x-amz-json-1.0' }));
|
||||
app.use(bodyParser.json({ type: 'application/x-amz-json-1.1' }));
|
||||
|
||||
await app.init();
|
||||
await app.listen(testPort);
|
||||
|
||||
// Configure SQS client to point to local endpoint
|
||||
sqsClient = new SQSClient({
|
||||
region: 'us-east-1',
|
||||
endpoint: `http://localhost:${testPort}`,
|
||||
credentials: {
|
||||
accessKeyId: 'test',
|
||||
secretAccessKey: 'test',
|
||||
},
|
||||
});
|
||||
|
||||
prismaService = moduleFixture.get<PrismaService>(PrismaService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
sqsClient.destroy();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean up database before each test
|
||||
await prismaService.sqsQueueMessage.deleteMany({});
|
||||
await prismaService.sqsQueue.deleteMany({});
|
||||
await prismaService.attribute.deleteMany({});
|
||||
});
|
||||
|
||||
describe('CreateQueue', () => {
|
||||
it('should create a queue successfully', async () => {
|
||||
const command = new CreateQueueCommand({
|
||||
QueueName: 'test-queue',
|
||||
});
|
||||
|
||||
const response = await sqsClient.send(command);
|
||||
|
||||
expect(response.QueueUrl).toBeDefined();
|
||||
expect(response.QueueUrl).toContain('test-queue');
|
||||
expect(response.QueueUrl).toContain('123456789012');
|
||||
|
||||
// Verify in database
|
||||
const queue = await prismaService.sqsQueue.findFirst({
|
||||
where: { name: 'test-queue' },
|
||||
});
|
||||
expect(queue).toBeDefined();
|
||||
expect(queue?.name).toBe('test-queue');
|
||||
});
|
||||
|
||||
it('should create queue with attributes', async () => {
|
||||
const command = new CreateQueueCommand({
|
||||
QueueName: 'queue-with-attrs',
|
||||
Attributes: {
|
||||
VisibilityTimeout: '60',
|
||||
MessageRetentionPeriod: '345600',
|
||||
},
|
||||
});
|
||||
|
||||
const response = await sqsClient.send(command);
|
||||
expect(response.QueueUrl).toBeDefined();
|
||||
|
||||
// Verify attributes in database
|
||||
const queue = await prismaService.sqsQueue.findFirst({
|
||||
where: { name: 'queue-with-attrs' },
|
||||
});
|
||||
const attributes = await prismaService.attribute.findMany({
|
||||
where: { arn: { contains: 'queue-with-attrs' } },
|
||||
});
|
||||
expect(attributes.some(a => a.name === 'VisibilityTimeout' && a.value === '60')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle duplicate queue name', async () => {
|
||||
// Create first queue
|
||||
await sqsClient.send(new CreateQueueCommand({ QueueName: 'duplicate-queue' }));
|
||||
|
||||
// Try to create duplicate
|
||||
await expect(sqsClient.send(new CreateQueueCommand({ QueueName: 'duplicate-queue' }))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ListQueues', () => {
|
||||
it('should list queues', async () => {
|
||||
// Create some queues
|
||||
await sqsClient.send(new CreateQueueCommand({ QueueName: 'queue-1' }));
|
||||
await sqsClient.send(new CreateQueueCommand({ QueueName: 'queue-2' }));
|
||||
await sqsClient.send(new CreateQueueCommand({ QueueName: 'queue-3' }));
|
||||
|
||||
const command = new ListQueuesCommand({});
|
||||
const response = await sqsClient.send(command);
|
||||
|
||||
expect(response.QueueUrls).toBeDefined();
|
||||
expect(response.QueueUrls!.length).toBeGreaterThanOrEqual(3);
|
||||
const queueNames = response.QueueUrls!.map(url => url.split('/').pop());
|
||||
expect(queueNames).toContain('queue-1');
|
||||
expect(queueNames).toContain('queue-2');
|
||||
expect(queueNames).toContain('queue-3');
|
||||
});
|
||||
|
||||
it('should handle empty queue list', async () => {
|
||||
const command = new ListQueuesCommand({});
|
||||
const response = await sqsClient.send(command);
|
||||
|
||||
expect(response.QueueUrls).toBeDefined();
|
||||
});
|
||||
|
||||
it('should filter queues by prefix', async () => {
|
||||
await sqsClient.send(new CreateQueueCommand({ QueueName: 'prod-queue-1' }));
|
||||
await sqsClient.send(new CreateQueueCommand({ QueueName: 'prod-queue-2' }));
|
||||
await sqsClient.send(new CreateQueueCommand({ QueueName: 'dev-queue-1' }));
|
||||
|
||||
const command = new ListQueuesCommand({
|
||||
QueueNamePrefix: 'prod',
|
||||
});
|
||||
const response = await sqsClient.send(command);
|
||||
|
||||
expect(response.QueueUrls).toBeDefined();
|
||||
const queueNames = response.QueueUrls!.map(url => url.split('/').pop());
|
||||
expect(queueNames.every(name => name?.startsWith('prod'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SendMessage and ReceiveMessage', () => {
|
||||
let queueUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const queueName = `message-test-queue-${Date.now()}-${Math.random()}`;
|
||||
const createResponse = await sqsClient.send(new CreateQueueCommand({ QueueName: queueName }));
|
||||
queueUrl = createResponse.QueueUrl!;
|
||||
});
|
||||
|
||||
it('should send and receive a message', async () => {
|
||||
// Send message
|
||||
const sendCommand = new SendMessageCommand({
|
||||
QueueUrl: queueUrl,
|
||||
MessageBody: 'Test message body',
|
||||
});
|
||||
const sendResponse = await sqsClient.send(sendCommand);
|
||||
|
||||
expect(sendResponse.MessageId).toBeDefined();
|
||||
expect(sendResponse.MD5OfMessageBody).toBeDefined();
|
||||
|
||||
// Receive message
|
||||
const receiveCommand = new ReceiveMessageCommand({
|
||||
QueueUrl: queueUrl,
|
||||
MaxNumberOfMessages: 1,
|
||||
});
|
||||
const receiveResponse = await sqsClient.send(receiveCommand);
|
||||
|
||||
expect(receiveResponse.Messages).toBeDefined();
|
||||
expect(receiveResponse.Messages!.length).toBe(1);
|
||||
expect(receiveResponse.Messages![0].Body).toBe('Test message body');
|
||||
expect(receiveResponse.Messages![0].MessageId).toBeDefined();
|
||||
expect(receiveResponse.Messages![0].ReceiptHandle).toBeDefined();
|
||||
});
|
||||
|
||||
it('should send multiple messages', async () => {
|
||||
// Send multiple messages
|
||||
await sqsClient.send(new SendMessageCommand({ QueueUrl: queueUrl, MessageBody: 'Message 1' }));
|
||||
await sqsClient.send(new SendMessageCommand({ QueueUrl: queueUrl, MessageBody: 'Message 2' }));
|
||||
await sqsClient.send(new SendMessageCommand({ QueueUrl: queueUrl, MessageBody: 'Message 3' }));
|
||||
|
||||
// Receive messages
|
||||
const receiveCommand = new ReceiveMessageCommand({
|
||||
QueueUrl: queueUrl,
|
||||
MaxNumberOfMessages: 10,
|
||||
});
|
||||
const receiveResponse = await sqsClient.send(receiveCommand);
|
||||
|
||||
expect(receiveResponse.Messages).toBeDefined();
|
||||
expect(receiveResponse.Messages!.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle empty queue when receiving', async () => {
|
||||
const receiveCommand = new ReceiveMessageCommand({
|
||||
QueueUrl: queueUrl,
|
||||
MaxNumberOfMessages: 1,
|
||||
});
|
||||
const receiveResponse = await sqsClient.send(receiveCommand);
|
||||
|
||||
expect(receiveResponse.Messages).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should respect MaxNumberOfMessages', async () => {
|
||||
// Send 5 messages
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await sqsClient.send(new SendMessageCommand({ QueueUrl: queueUrl, MessageBody: `Message ${i}` }));
|
||||
}
|
||||
|
||||
// Receive only 2 messages
|
||||
const receiveCommand = new ReceiveMessageCommand({
|
||||
QueueUrl: queueUrl,
|
||||
MaxNumberOfMessages: 2,
|
||||
});
|
||||
const receiveResponse = await sqsClient.send(receiveCommand);
|
||||
|
||||
expect(receiveResponse.Messages).toBeDefined();
|
||||
expect(receiveResponse.Messages!.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeleteMessage', () => {
|
||||
let queueUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const queueName = `delete-test-queue-${Date.now()}-${Math.random()}`;
|
||||
const createResponse = await sqsClient.send(new CreateQueueCommand({ QueueName: queueName }));
|
||||
queueUrl = createResponse.QueueUrl!;
|
||||
});
|
||||
|
||||
it('should delete a message', async () => {
|
||||
// Send message
|
||||
await sqsClient.send(new SendMessageCommand({ QueueUrl: queueUrl, MessageBody: 'Message to delete' }));
|
||||
|
||||
// Receive message
|
||||
const receiveResponse = await sqsClient.send(new ReceiveMessageCommand({ QueueUrl: queueUrl, MaxNumberOfMessages: 1 }));
|
||||
const receiptHandle = receiveResponse.Messages![0].ReceiptHandle!;
|
||||
|
||||
// Delete message
|
||||
const deleteCommand = new DeleteMessageCommand({
|
||||
QueueUrl: queueUrl,
|
||||
ReceiptHandle: receiptHandle,
|
||||
});
|
||||
await sqsClient.send(deleteCommand);
|
||||
|
||||
// Verify message is deleted from database
|
||||
const messages = await prismaService.sqsQueueMessage.findMany({});
|
||||
expect(messages.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should delete multiple messages in batch', async () => {
|
||||
// Send multiple messages
|
||||
await sqsClient.send(new SendMessageCommand({ QueueUrl: queueUrl, MessageBody: 'Message 1' }));
|
||||
await sqsClient.send(new SendMessageCommand({ QueueUrl: queueUrl, MessageBody: 'Message 2' }));
|
||||
|
||||
// Receive messages
|
||||
const receiveResponse = await sqsClient.send(new ReceiveMessageCommand({ QueueUrl: queueUrl, MaxNumberOfMessages: 10 }));
|
||||
|
||||
// Delete batch
|
||||
const deleteCommand = new DeleteMessageBatchCommand({
|
||||
QueueUrl: queueUrl,
|
||||
Entries: receiveResponse.Messages!.map((msg, idx) => ({
|
||||
Id: `${idx}`,
|
||||
ReceiptHandle: msg.ReceiptHandle!,
|
||||
})),
|
||||
});
|
||||
await sqsClient.send(deleteCommand);
|
||||
|
||||
// Verify messages are deleted
|
||||
const messages = await prismaService.sqsQueueMessage.findMany({});
|
||||
expect(messages.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PurgeQueue', () => {
|
||||
let queueUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const queueName = `purge-test-queue-${Date.now()}-${Math.random()}`;
|
||||
const createResponse = await sqsClient.send(new CreateQueueCommand({ QueueName: queueName }));
|
||||
queueUrl = createResponse.QueueUrl!;
|
||||
});
|
||||
|
||||
it('should purge all messages from queue', async () => {
|
||||
// Send multiple messages
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await sqsClient.send(new SendMessageCommand({ QueueUrl: queueUrl, MessageBody: `Message ${i}` }));
|
||||
}
|
||||
|
||||
// Verify messages exist
|
||||
let messages = await prismaService.sqsQueueMessage.findMany({});
|
||||
expect(messages.length).toBe(5);
|
||||
|
||||
// Purge queue
|
||||
const purgeCommand = new PurgeQueueCommand({
|
||||
QueueUrl: queueUrl,
|
||||
});
|
||||
await sqsClient.send(purgeCommand);
|
||||
|
||||
// Verify all messages are deleted
|
||||
messages = await prismaService.sqsQueueMessage.findMany({});
|
||||
expect(messages.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeleteQueue', () => {
|
||||
it('should delete a queue', async () => {
|
||||
// Create queue
|
||||
const createResponse = await sqsClient.send(new CreateQueueCommand({ QueueName: 'queue-to-delete' }));
|
||||
const queueUrl = createResponse.QueueUrl!;
|
||||
|
||||
// Verify queue exists
|
||||
let queue = await prismaService.sqsQueue.findFirst({
|
||||
where: { name: 'queue-to-delete' },
|
||||
});
|
||||
expect(queue).toBeDefined();
|
||||
|
||||
// Delete queue
|
||||
const deleteCommand = new DeleteQueueCommand({
|
||||
QueueUrl: queueUrl,
|
||||
});
|
||||
await sqsClient.send(deleteCommand);
|
||||
|
||||
// Verify queue is deleted
|
||||
queue = await prismaService.sqsQueue.findFirst({
|
||||
where: { name: 'queue-to-delete' },
|
||||
});
|
||||
expect(queue).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Queue Attributes', () => {
|
||||
let queueUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const queueName = `attr-test-queue-${Date.now()}-${Math.random()}`;
|
||||
const createResponse = await sqsClient.send(new CreateQueueCommand({ QueueName: queueName }));
|
||||
queueUrl = createResponse.QueueUrl!;
|
||||
});
|
||||
|
||||
it('should get queue attributes', async () => {
|
||||
const command = new GetQueueAttributesCommand({
|
||||
QueueUrl: queueUrl,
|
||||
AttributeNames: ['All'],
|
||||
});
|
||||
|
||||
const response = await sqsClient.send(command);
|
||||
|
||||
expect(response.Attributes).toBeDefined();
|
||||
expect(response.Attributes!.QueueArn).toBeDefined();
|
||||
expect(response.Attributes!.QueueArn).toContain('attr-test-queue');
|
||||
});
|
||||
|
||||
it('should set queue attributes', async () => {
|
||||
const setCommand = new SetQueueAttributesCommand({
|
||||
QueueUrl: queueUrl,
|
||||
Attributes: {
|
||||
VisibilityTimeout: '120',
|
||||
MessageRetentionPeriod: '86400',
|
||||
},
|
||||
});
|
||||
|
||||
await sqsClient.send(setCommand);
|
||||
|
||||
// Verify attributes were set
|
||||
const getCommand = new GetQueueAttributesCommand({
|
||||
QueueUrl: queueUrl,
|
||||
AttributeNames: ['All'],
|
||||
});
|
||||
const response = await sqsClient.send(getCommand);
|
||||
|
||||
expect(response.Attributes!.VisibilityTimeout).toBe('120');
|
||||
expect(response.Attributes!.MessageRetentionPeriod).toBe('86400');
|
||||
});
|
||||
|
||||
it('should update existing queue attributes', async () => {
|
||||
// Set initial attributes
|
||||
await sqsClient.send(
|
||||
new SetQueueAttributesCommand({
|
||||
QueueUrl: queueUrl,
|
||||
Attributes: {
|
||||
VisibilityTimeout: '60',
|
||||
MessageRetentionPeriod: '345600',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Update the same attributes with new values
|
||||
await sqsClient.send(
|
||||
new SetQueueAttributesCommand({
|
||||
QueueUrl: queueUrl,
|
||||
Attributes: {
|
||||
VisibilityTimeout: '30',
|
||||
MessageRetentionPeriod: '86400',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify attributes were updated, not duplicated
|
||||
const getCommand = new GetQueueAttributesCommand({
|
||||
QueueUrl: queueUrl,
|
||||
AttributeNames: ['All'],
|
||||
});
|
||||
const response = await sqsClient.send(getCommand);
|
||||
|
||||
expect(response.Attributes!.VisibilityTimeout).toBe('30');
|
||||
expect(response.Attributes!.MessageRetentionPeriod).toBe('86400');
|
||||
});
|
||||
|
||||
it('should get specific queue attributes', async () => {
|
||||
// Set some attributes
|
||||
await sqsClient.send(
|
||||
new SetQueueAttributesCommand({
|
||||
QueueUrl: queueUrl,
|
||||
Attributes: {
|
||||
VisibilityTimeout: '90',
|
||||
DelaySeconds: '5',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Get specific attributes
|
||||
const command = new GetQueueAttributesCommand({
|
||||
QueueUrl: queueUrl,
|
||||
AttributeNames: ['VisibilityTimeout'],
|
||||
});
|
||||
const response = await sqsClient.send(command);
|
||||
|
||||
expect(response.Attributes!.VisibilityTimeout).toBe('90');
|
||||
});
|
||||
});
|
||||
|
||||
describe('End-to-End Workflow', () => {
|
||||
it('should complete full SQS workflow', async () => {
|
||||
// 1. Create queue
|
||||
const createQueueResponse = await sqsClient.send(new CreateQueueCommand({ QueueName: 'e2e-test-queue' }));
|
||||
const queueUrl = createQueueResponse.QueueUrl!;
|
||||
expect(queueUrl).toBeDefined();
|
||||
|
||||
// 2. Set queue attributes
|
||||
await sqsClient.send(
|
||||
new SetQueueAttributesCommand({
|
||||
QueueUrl: queueUrl,
|
||||
Attributes: {
|
||||
VisibilityTimeout: '30',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// 3. Send messages
|
||||
await sqsClient.send(
|
||||
new SendMessageCommand({
|
||||
QueueUrl: queueUrl,
|
||||
MessageBody: 'E2E Test Message 1',
|
||||
}),
|
||||
);
|
||||
await sqsClient.send(
|
||||
new SendMessageCommand({
|
||||
QueueUrl: queueUrl,
|
||||
MessageBody: 'E2E Test Message 2',
|
||||
}),
|
||||
);
|
||||
|
||||
// 4. Get queue attributes
|
||||
const getAttrsResponse = await sqsClient.send(
|
||||
new GetQueueAttributesCommand({
|
||||
QueueUrl: queueUrl,
|
||||
AttributeNames: ['All'],
|
||||
}),
|
||||
);
|
||||
expect(getAttrsResponse.Attributes!.VisibilityTimeout).toBe('30');
|
||||
expect(getAttrsResponse.Attributes!.ApproximateNumberOfMessages).toBe('2');
|
||||
|
||||
// 5. Receive messages
|
||||
const receiveResponse = await sqsClient.send(
|
||||
new ReceiveMessageCommand({
|
||||
QueueUrl: queueUrl,
|
||||
MaxNumberOfMessages: 10,
|
||||
}),
|
||||
);
|
||||
expect(receiveResponse.Messages!.length).toBe(2);
|
||||
|
||||
// 6. Delete one message
|
||||
await sqsClient.send(
|
||||
new DeleteMessageCommand({
|
||||
QueueUrl: queueUrl,
|
||||
ReceiptHandle: receiveResponse.Messages![0].ReceiptHandle!,
|
||||
}),
|
||||
);
|
||||
|
||||
// 7. Verify one message remains
|
||||
const verifyMessages = await prismaService.sqsQueueMessage.findMany({});
|
||||
expect(verifyMessages.length).toBe(1);
|
||||
|
||||
// 8. Purge queue
|
||||
await sqsClient.send(new PurgeQueueCommand({ QueueUrl: queueUrl }));
|
||||
|
||||
// 9. Verify queue is empty
|
||||
const afterPurge = await prismaService.sqsQueueMessage.findMany({});
|
||||
expect(afterPurge.length).toBe(0);
|
||||
|
||||
// 10. Delete queue
|
||||
await sqsClient.send(new DeleteQueueCommand({ QueueUrl: queueUrl }));
|
||||
|
||||
// 11. Verify queue is deleted
|
||||
const queue = await prismaService.sqsQueue.findFirst({
|
||||
where: { name: 'e2e-test-queue' },
|
||||
});
|
||||
expect(queue).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Visibility', () => {
|
||||
let queueUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const queueName = `visibility-test-queue-${Date.now()}-${Math.random()}`;
|
||||
const createResponse = await sqsClient.send(
|
||||
new CreateQueueCommand({
|
||||
QueueName: queueName,
|
||||
Attributes: {
|
||||
VisibilityTimeout: '30',
|
||||
},
|
||||
}),
|
||||
);
|
||||
queueUrl = createResponse.QueueUrl!;
|
||||
});
|
||||
|
||||
it('should not return message again during visibility timeout', async () => {
|
||||
// Send message
|
||||
await sqsClient.send(
|
||||
new SendMessageCommand({
|
||||
QueueUrl: queueUrl,
|
||||
MessageBody: 'Visibility test message',
|
||||
}),
|
||||
);
|
||||
|
||||
// First receive
|
||||
const firstReceive = await sqsClient.send(
|
||||
new ReceiveMessageCommand({
|
||||
QueueUrl: queueUrl,
|
||||
MaxNumberOfMessages: 1,
|
||||
}),
|
||||
);
|
||||
expect(firstReceive.Messages!.length).toBe(1);
|
||||
|
||||
// Second receive should not return the message (still in flight)
|
||||
const secondReceive = await sqsClient.send(
|
||||
new ReceiveMessageCommand({
|
||||
QueueUrl: queueUrl,
|
||||
MaxNumberOfMessages: 1,
|
||||
}),
|
||||
);
|
||||
expect(secondReceive.Messages).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,6 @@ export class DeleteMessageBatchHandler extends AbstractActionHandler<QueryParams
|
||||
super();
|
||||
}
|
||||
|
||||
audit = false;
|
||||
format = Format.Xml;
|
||||
action = Action.SqsDeleteMessageBatch;
|
||||
validator = Joi.object<QueryParams>({
|
||||
|
||||
@@ -21,7 +21,6 @@ export class DeleteMessageHandler extends AbstractActionHandler<QueryParams> {
|
||||
super();
|
||||
}
|
||||
|
||||
audit = false;
|
||||
format = Format.Xml;
|
||||
action = Action.SqsDeleteMessage;
|
||||
validator = Joi.object<QueryParams, true>({
|
||||
|
||||
@@ -1,80 +1,22 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as Joi from 'joi';
|
||||
|
||||
import { AbstractActionHandler, AwsProperties, Format } from '../abstract-action.handler';
|
||||
import { Format } from '../abstract-action.handler';
|
||||
import { Action } from '../action.enum';
|
||||
import { AttributesService } from '../aws-shared-entities/attributes.service';
|
||||
import { SqsQueueEntryService } from './sqs-queue-entry.service';
|
||||
import { SqsQueue } from './sqs-queue.entity';
|
||||
|
||||
type QueryParams = {
|
||||
QueueUrl?: string,
|
||||
'AttributeName.1'?: string;
|
||||
__path: string;
|
||||
} & Record<string, string>;
|
||||
import { V2GetQueueAttributesHandler } from './v2-get-queue-attributes.handler';
|
||||
|
||||
@Injectable()
|
||||
export class GetQueueAttributesHandler extends AbstractActionHandler<QueryParams> {
|
||||
|
||||
constructor(
|
||||
private readonly attributeService: AttributesService,
|
||||
private readonly sqsQueueEntryService: SqsQueueEntryService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
export class GetQueueAttributesHandler extends V2GetQueueAttributesHandler {
|
||||
|
||||
format = Format.Xml;
|
||||
action = Action.SqsGetQueueAttributes;
|
||||
validator = Joi.object<QueryParams, true>({
|
||||
QueueUrl: Joi.string(),
|
||||
'AttributeName.1': Joi.string(),
|
||||
__path: Joi.string().required(),
|
||||
});
|
||||
|
||||
protected async handle(params: QueryParams) {
|
||||
|
||||
const attributeNames = Object.keys(params).reduce((l, k) => {
|
||||
const [name, _] = k.split('.');
|
||||
if (name === 'AttributeName') {
|
||||
l.push(params[k]);
|
||||
}
|
||||
return l;
|
||||
}, [] as string[]);
|
||||
|
||||
const [accountId, name] = SqsQueue.tryGetAccountIdAndNameFromPathOrArn(params.QueueUrl ?? params.__path);
|
||||
const queue = await this.sqsQueueEntryService.findQueueByAccountIdAndName(accountId, name);
|
||||
|
||||
if(!queue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queueMetrics = await this.sqsQueueEntryService.metrics(queue.id);
|
||||
const attributes = await this.getAttributes(attributeNames, queue.arn);
|
||||
const attributeMap = attributes.reduce((m, a) => {
|
||||
m[a.name] = a.value;
|
||||
return m;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
const response: Record<string, string> = {
|
||||
...attributeMap,
|
||||
ApproximateNumberOfMessages: `${queueMetrics.total}`,
|
||||
ApproximateNumberOfMessagesNotVisible: `${queueMetrics.inFlight}`,
|
||||
CreatedTimestamp: `${new Date(queue.createdAt).getTime()}`,
|
||||
LastModifiedTimestamp: `${new Date(queue.updatedAt).getTime()}`,
|
||||
QueueArn: queue.arn,
|
||||
}
|
||||
protected override async handle(params: { QueueUrl?: string; 'AttributeName.1'?: string; __path: string; } & Record<string, string>): Promise<any> {
|
||||
const response = await super.handle(params);
|
||||
return {
|
||||
Attribute: Object.keys(response).map(k => ({
|
||||
Attribute: Object.keys(response!.Attributes).map(k => ({
|
||||
Name: k,
|
||||
Value: response[k],
|
||||
Value: response!.Attributes[k],
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
private async getAttributes(attributeNames: string[], queueArn: string) {
|
||||
if (attributeNames.length === 0 || attributeNames.length === 1 && attributeNames[0] === 'All') {
|
||||
return await this.attributeService.getByArn(queueArn);
|
||||
}
|
||||
return await this.attributeService.getByArnAndNames(queueArn, attributeNames);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { Prisma, SqsQueueMessage } from '@prisma/client';
|
||||
import { Attribute, Prisma, SqsQueueMessage } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { PrismaService } from '../_prisma/prisma.service';
|
||||
import { SqsQueue } from './sqs-queue.entity';
|
||||
import { QueueNameExists } from '../aws-shared-entities/aws-exceptions';
|
||||
import { AttributesService } from '../aws-shared-entities/attributes.service';
|
||||
|
||||
type QueueEntry = {
|
||||
id: string;
|
||||
@@ -13,24 +14,29 @@ type QueueEntry = {
|
||||
message: string;
|
||||
inFlightReleaseDate: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
};
|
||||
|
||||
type Metrics = { total: number, inFlight: number}
|
||||
type Metrics = { total: number; inFlight: number };
|
||||
|
||||
const FIFTEEN_SECONDS = 15 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class SqsQueueEntryService {
|
||||
|
||||
private queueObjectCache: Record<string, [Date, SqsQueue]> = {};
|
||||
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
) {}
|
||||
constructor(private readonly prismaService: PrismaService, private readonly attributeService: AttributesService) {}
|
||||
|
||||
async findQueueByAccountIdAndName(accountId: string, name: string): Promise<SqsQueue | null> {
|
||||
const prisma = await this.prismaService.sqsQueue.findFirst({ where: { accountId, name } });
|
||||
return prisma ? new SqsQueue(prisma) : null;
|
||||
const queue = prisma ? new SqsQueue(prisma) : null;
|
||||
|
||||
if (!queue) {
|
||||
return queue;
|
||||
}
|
||||
|
||||
const attributes = await this.attributeService.getByArn(queue.arn);
|
||||
queue.attributes = attributes;
|
||||
return queue;
|
||||
}
|
||||
|
||||
async createQueue(data: Prisma.SqsQueueCreateInput): Promise<SqsQueue> {
|
||||
@@ -43,74 +49,78 @@ export class SqsQueueEntryService {
|
||||
}
|
||||
|
||||
async deleteQueue(id: number): Promise<void> {
|
||||
await this.prismaService.sqsQueue.delete({ where: { id }});
|
||||
await this.prismaService.sqsQueue.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async metrics(queueId: number): Promise<Metrics> {
|
||||
|
||||
const now = new Date();
|
||||
const [total, inFlight] = await Promise.all([
|
||||
this.prismaService.sqsQueueMessage.count({ where: { queueId }}),
|
||||
this.prismaService.sqsQueueMessage.count({ where: { queueId, inFlightRelease: { gt: now } }}),
|
||||
this.prismaService.sqsQueueMessage.count({ where: { queueId } }),
|
||||
this.prismaService.sqsQueueMessage.count({ where: { queueId, inFlightRelease: { gt: now } } }),
|
||||
]);
|
||||
|
||||
return { total, inFlight }
|
||||
return { total, inFlight };
|
||||
}
|
||||
|
||||
async publish(accountId: string, queueName: string, message: string) {
|
||||
const prisma = await this.prismaService.sqsQueue.findFirst({ where: { accountId, name: queueName }});
|
||||
const prisma = await this.prismaService.sqsQueue.findFirst({ where: { accountId, name: queueName } });
|
||||
|
||||
if (!prisma) {
|
||||
console.warn(`Warning bad subscription to ${queueName}`);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
const queue = new SqsQueue(prisma);
|
||||
const messageId = randomUUID();
|
||||
|
||||
await this.prismaService.sqsQueueMessage.create({
|
||||
const created = await this.prismaService.sqsQueueMessage.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
id: messageId,
|
||||
queueId: queue.id,
|
||||
senderId: accountId,
|
||||
message,
|
||||
inFlightRelease: new Date(),
|
||||
}
|
||||
inFlightRelease: new Date(0), // Set to epoch so message is immediately available
|
||||
},
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async receiveMessages(accountId: string, queueName: string, maxNumberOfMessages = 10, visabilityTimeout = 0): Promise<SqsQueueMessage[]> {
|
||||
|
||||
const queue = await this.getQueueHelper(accountId, queueName);
|
||||
|
||||
const visTimeout =
|
||||
visabilityTimeout > 0 || !queue.attributes['VisibilityTimeout'] ? visabilityTimeout : Number(queue.attributes['VisibilityTimeout']);
|
||||
|
||||
const accessDate = new Date();
|
||||
const newInFlightReleaseDate = new Date(accessDate);
|
||||
newInFlightReleaseDate.setSeconds(accessDate.getSeconds() + visabilityTimeout);
|
||||
newInFlightReleaseDate.setSeconds(accessDate.getSeconds() + visTimeout);
|
||||
const records = await this.prismaService.sqsQueueMessage.findMany({
|
||||
where: {
|
||||
queueId: queue.id,
|
||||
inFlightRelease: {
|
||||
lte: accessDate,
|
||||
}
|
||||
},
|
||||
},
|
||||
take: maxNumberOfMessages,
|
||||
});
|
||||
|
||||
await this.prismaService.sqsQueueMessage.updateMany({
|
||||
data: {
|
||||
inFlightRelease: newInFlightReleaseDate
|
||||
inFlightRelease: newInFlightReleaseDate,
|
||||
},
|
||||
where: {
|
||||
id: {
|
||||
in: records.map(r => r.id)
|
||||
}
|
||||
}
|
||||
in: records.map(r => r.id),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return records.map(r => ({ ...r, inFlightRelease: newInFlightReleaseDate }));
|
||||
}
|
||||
|
||||
async deleteMessage(id: string): Promise<void> {
|
||||
await this.prismaService.sqsQueueMessage.delete({ where: { id }});
|
||||
await this.prismaService.sqsQueueMessage.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async purge(accountId: string, queueName: string) {
|
||||
@@ -120,7 +130,7 @@ export class SqsQueueEntryService {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.prismaService.sqsQueueMessage.deleteMany({ where: { queueId: queue.id }});
|
||||
await this.prismaService.sqsQueueMessage.deleteMany({ where: { queueId: queue.id } });
|
||||
}
|
||||
|
||||
private async getQueueHelper(accountId: string, queueName: string): Promise<SqsQueue> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SqsQueue as PrismaSqsQueue } from '@prisma/client';
|
||||
import { Attribute, SqsQueue as PrismaSqsQueue } from '@prisma/client';
|
||||
|
||||
import { getPathFromUrl } from '../util/get-path-from-url';
|
||||
|
||||
@@ -25,6 +25,15 @@ const attributeSlotMap = {
|
||||
this.updatedAt = p.updatedAt;
|
||||
}
|
||||
|
||||
private _attributes: Record<string, string> = {};
|
||||
|
||||
get attributes(): Record<string, string> {
|
||||
return this._attributes;
|
||||
}
|
||||
|
||||
set attributes(attributes: Attribute[]) {
|
||||
this._attributes = Object.fromEntries(attributes.map(a => [a.name, a.value]));
|
||||
}
|
||||
|
||||
get arn(): string {
|
||||
return `arn:aws:sqs:${this.region}:${this.accountId}:${this.name}`;
|
||||
|
||||
@@ -19,6 +19,14 @@ import { DeleteMessageBatchHandler } from './delete-message-batch.handler';
|
||||
import { PrismaModule } from '../_prisma/prisma.module';
|
||||
import { V2ListQueuesHandler } from './v2-list-queues.handler';
|
||||
import { V2CreateQueueHandler } from './v2-create-queue.handler';
|
||||
import { V2GetQueueAttributesHandler } from './v2-get-queue-attributes.handler';
|
||||
import { V2SendMessageHandler } from './v2-send-message.handler';
|
||||
import { V2PurgeQueueHandler } from './v2-purge-queue.handler';
|
||||
import { V2DeleteQueueHandler } from './v2-delete-queue.handler';
|
||||
import { V2SetQueueAttributesHandler } from './v2-set-queue-attributes.handler';
|
||||
import { V2ReceiveMessageHandler } from './v2-receive-message.handler';
|
||||
import { V2DeleteMessageHandler } from './v2-delete-message.handler';
|
||||
import { V2DeleteMessageBatchHandler } from './v2-delete-message-batch.handler';
|
||||
|
||||
const handlers = [
|
||||
CreateQueueHandler,
|
||||
@@ -31,8 +39,16 @@ const handlers = [
|
||||
ReceiveMessageHandler,
|
||||
SetQueueAttributesHandler,
|
||||
V2CreateQueueHandler,
|
||||
V2DeleteMessageBatchHandler,
|
||||
V2DeleteMessageHandler,
|
||||
V2DeleteQueueHandler,
|
||||
V2GetQueueAttributesHandler,
|
||||
V2ListQueuesHandler,
|
||||
]
|
||||
V2PurgeQueueHandler,
|
||||
V2ReceiveMessageHandler,
|
||||
V2SendMessageHandler,
|
||||
V2SetQueueAttributesHandler,
|
||||
];
|
||||
|
||||
const actions = [
|
||||
Action.SqsAddPermisson,
|
||||
@@ -75,22 +91,16 @@ const actions = [
|
||||
Action.V2_SqsSetQueueAttributes,
|
||||
Action.V2_SqsTagQueue,
|
||||
Action.V2_SqsUntagQueue,
|
||||
]
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AwsSharedEntitiesModule,
|
||||
PrismaModule,
|
||||
],
|
||||
imports: [AwsSharedEntitiesModule, PrismaModule],
|
||||
providers: [
|
||||
...handlers,
|
||||
SqsQueueEntryService,
|
||||
ExistingActionHandlersProvider(handlers),
|
||||
DefaultActionHandlerProvider(SqsHandlers, Format.Xml, actions),
|
||||
],
|
||||
exports: [
|
||||
SqsHandlers,
|
||||
SqsQueueEntryService,
|
||||
]
|
||||
exports: [SqsHandlers, SqsQueueEntryService],
|
||||
})
|
||||
export class SqsModule {}
|
||||
|
||||
55
src/sqs/v2-delete-message-batch.handler.ts
Normal file
55
src/sqs/v2-delete-message-batch.handler.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as Joi from 'joi';
|
||||
|
||||
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||
import { Action } from '../action.enum';
|
||||
import { SqsQueueEntryService } from './sqs-queue-entry.service';
|
||||
import { SqsQueue } from './sqs-queue.entity';
|
||||
|
||||
type QueryParams = {
|
||||
QueueUrl: string;
|
||||
Entries: Array<{
|
||||
Id: string;
|
||||
ReceiptHandle: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class V2DeleteMessageBatchHandler extends AbstractActionHandler<QueryParams> {
|
||||
constructor(private readonly sqsQueueEntryService: SqsQueueEntryService) {
|
||||
super();
|
||||
}
|
||||
|
||||
format = Format.Json;
|
||||
action = Action.V2_SqsDeleteMessageBatch;
|
||||
validator = Joi.object<QueryParams>({
|
||||
QueueUrl: Joi.string().required(),
|
||||
Entries: Joi.array()
|
||||
.items(
|
||||
Joi.object({
|
||||
Id: Joi.string().required(),
|
||||
ReceiptHandle: Joi.string().required(),
|
||||
}),
|
||||
)
|
||||
.required(),
|
||||
});
|
||||
|
||||
protected async handle({ QueueUrl, Entries }: QueryParams) {
|
||||
const [accountId, name] = SqsQueue.tryGetAccountIdAndNameFromPathOrArn(QueueUrl);
|
||||
const successful: Array<{ Id: string }> = [];
|
||||
|
||||
for (const entry of Entries) {
|
||||
try {
|
||||
await this.sqsQueueEntryService.deleteMessage(entry.ReceiptHandle);
|
||||
successful.push({ Id: entry.Id });
|
||||
} catch (error) {
|
||||
// In a real implementation, we would collect failed entries
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
Successful: successful,
|
||||
Failed: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
30
src/sqs/v2-delete-message.handler.ts
Normal file
30
src/sqs/v2-delete-message.handler.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as Joi from 'joi';
|
||||
|
||||
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||
import { Action } from '../action.enum';
|
||||
import { SqsQueueEntryService } from './sqs-queue-entry.service';
|
||||
|
||||
type QueryParams = {
|
||||
QueueUrl: string;
|
||||
ReceiptHandle: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class V2DeleteMessageHandler extends AbstractActionHandler<QueryParams> {
|
||||
constructor(private readonly sqsQueueEntryService: SqsQueueEntryService) {
|
||||
super();
|
||||
}
|
||||
|
||||
format = Format.Json;
|
||||
action = Action.V2_SqsDeleteMessage;
|
||||
validator = Joi.object<QueryParams, true>({
|
||||
QueueUrl: Joi.string().required(),
|
||||
ReceiptHandle: Joi.string().required(),
|
||||
});
|
||||
|
||||
protected async handle({ QueueUrl, ReceiptHandle }: QueryParams) {
|
||||
await this.sqsQueueEntryService.deleteMessage(ReceiptHandle);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
46
src/sqs/v2-delete-queue.handler.ts
Normal file
46
src/sqs/v2-delete-queue.handler.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import * as Joi from 'joi';
|
||||
|
||||
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||
import { Action } from '../action.enum';
|
||||
import { AttributesService } from '../aws-shared-entities/attributes.service';
|
||||
import { TagsService } from '../aws-shared-entities/tags.service';
|
||||
import { SqsQueueEntryService } from './sqs-queue-entry.service';
|
||||
import { SqsQueue } from './sqs-queue.entity';
|
||||
|
||||
type QueryParams = {
|
||||
QueueUrl: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class V2DeleteQueueHandler extends AbstractActionHandler<QueryParams> {
|
||||
constructor(
|
||||
private readonly tagsService: TagsService,
|
||||
private readonly attributeService: AttributesService,
|
||||
private readonly sqsQueueEntryService: SqsQueueEntryService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
format = Format.Json;
|
||||
action = Action.V2_SqsDeleteQueue;
|
||||
validator = Joi.object<QueryParams, true>({
|
||||
QueueUrl: Joi.string().required(),
|
||||
});
|
||||
|
||||
protected async handle({ QueueUrl }: QueryParams) {
|
||||
const [accountId, name] = SqsQueue.tryGetAccountIdAndNameFromPathOrArn(QueueUrl);
|
||||
const queue = await this.sqsQueueEntryService.findQueueByAccountIdAndName(accountId, name);
|
||||
|
||||
if (!queue) {
|
||||
throw new BadRequestException('ResourceNotFoundException');
|
||||
}
|
||||
|
||||
await this.sqsQueueEntryService.purge(accountId, name);
|
||||
await this.tagsService.deleteByArn(queue.arn);
|
||||
await this.attributeService.deleteByArn(queue.arn);
|
||||
await this.sqsQueueEntryService.deleteQueue(queue.id);
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
78
src/sqs/v2-get-queue-attributes.handler.ts
Normal file
78
src/sqs/v2-get-queue-attributes.handler.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as Joi from 'joi';
|
||||
|
||||
import { AbstractActionHandler, AwsProperties, Format } from '../abstract-action.handler';
|
||||
import { Action } from '../action.enum';
|
||||
import { AttributesService } from '../aws-shared-entities/attributes.service';
|
||||
import { SqsQueueEntryService } from './sqs-queue-entry.service';
|
||||
import { SqsQueue } from './sqs-queue.entity';
|
||||
|
||||
type QueryParams = {
|
||||
QueueUrl?: string,
|
||||
'AttributeName.1'?: string;
|
||||
__path: string;
|
||||
} & Record<string, string>;
|
||||
|
||||
@Injectable()
|
||||
export class V2GetQueueAttributesHandler extends AbstractActionHandler<QueryParams> {
|
||||
|
||||
constructor(
|
||||
private readonly attributeService: AttributesService,
|
||||
private readonly sqsQueueEntryService: SqsQueueEntryService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
format = Format.Json;
|
||||
action = Action.V2_SqsGetQueueAttributes;
|
||||
validator = Joi.object<QueryParams, true>({
|
||||
QueueUrl: Joi.string(),
|
||||
'AttributeName.1': Joi.string(),
|
||||
__path: Joi.string().required(),
|
||||
});
|
||||
|
||||
protected async handle(params: QueryParams) {
|
||||
|
||||
const attributeNames = Object.keys(params).reduce((l, k) => {
|
||||
const [name, _] = k.split('.');
|
||||
if (name === 'AttributeName') {
|
||||
l.push(params[k]);
|
||||
}
|
||||
return l;
|
||||
}, [] as string[]);
|
||||
|
||||
const [accountId, name] = SqsQueue.tryGetAccountIdAndNameFromPathOrArn(params.QueueUrl ?? params.__path);
|
||||
const queue = await this.sqsQueueEntryService.findQueueByAccountIdAndName(accountId, name);
|
||||
|
||||
if(!queue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queueMetrics = await this.sqsQueueEntryService.metrics(queue.id);
|
||||
const attributes = await this.getAttributes(attributeNames, queue.arn);
|
||||
const attributeMap = attributes.reduce((m, a) => {
|
||||
m[a.name] = a.value;
|
||||
return m;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
const response: Record<string, string> = {
|
||||
...attributeMap,
|
||||
ApproximateNumberOfMessages: `${queueMetrics.total}`,
|
||||
ApproximateNumberOfMessagesNotVisible: `${queueMetrics.inFlight}`,
|
||||
CreatedTimestamp: `${new Date(queue.createdAt).getTime()}`,
|
||||
LastModifiedTimestamp: `${new Date(queue.updatedAt).getTime()}`,
|
||||
QueueArn: queue.arn,
|
||||
}
|
||||
|
||||
return {
|
||||
Attributes: response,
|
||||
}
|
||||
}
|
||||
|
||||
private async getAttributes(attributeNames: string[], queueArn: string) {
|
||||
if (attributeNames.length === 0 || attributeNames.length === 1 && attributeNames[0] === 'All') {
|
||||
return await this.attributeService.getByArn(queueArn);
|
||||
}
|
||||
return await this.attributeService.getByArnAndNames(queueArn, attributeNames);
|
||||
}
|
||||
}
|
||||
@@ -7,34 +7,38 @@ import { Action } from '../action.enum';
|
||||
import { SqsQueue } from './sqs-queue.entity';
|
||||
import { RequestContext } from '../_context/request.context';
|
||||
|
||||
type QueryParams = {}
|
||||
type QueryParams = {
|
||||
QueueNamePrefix?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class V2ListQueuesHandler extends AbstractActionHandler<QueryParams> {
|
||||
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
) {
|
||||
constructor(private readonly prismaService: PrismaService) {
|
||||
super();
|
||||
}
|
||||
|
||||
format = Format.Json;
|
||||
action = Action.V2_SqsListQueues;
|
||||
validator = Joi.object<QueryParams, true>();
|
||||
validator = Joi.object<QueryParams, true>({
|
||||
QueueNamePrefix: Joi.string().optional(),
|
||||
});
|
||||
|
||||
protected async handle(params: QueryParams, context: RequestContext): Promise<{ QueueUrl: string[] } | { QueueUrls: string[] } > {
|
||||
|
||||
const rawQueues = await this.prismaService.sqsQueue.findMany({
|
||||
where: {
|
||||
protected async handle(params: QueryParams, context: RequestContext): Promise<{ QueueUrl: string[] } | { QueueUrls: string[] }> {
|
||||
const where: any = {
|
||||
accountId: context.awsProperties.accountId,
|
||||
region: context.awsProperties.region,
|
||||
};
|
||||
|
||||
if (params.QueueNamePrefix) {
|
||||
where.name = { startsWith: params.QueueNamePrefix };
|
||||
}
|
||||
});
|
||||
|
||||
const rawQueues = await this.prismaService.sqsQueue.findMany({ where });
|
||||
|
||||
const queues = rawQueues.map(q => new SqsQueue(q));
|
||||
|
||||
return {
|
||||
QueueUrls: queues.map((q) => q.getUrl(context.awsProperties.host))
|
||||
}
|
||||
QueueUrls: queues.map(q => q.getUrl(context.awsProperties.host)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
32
src/sqs/v2-purge-queue.handler.ts
Normal file
32
src/sqs/v2-purge-queue.handler.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as Joi from 'joi';
|
||||
|
||||
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||
import { Action } from '../action.enum';
|
||||
import { RequestContext } from '../_context/request.context';
|
||||
import { SqsQueueEntryService } from './sqs-queue-entry.service';
|
||||
import { SqsQueue } from './sqs-queue.entity';
|
||||
|
||||
type QueryParams = {
|
||||
QueueUrl: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class V2PurgeQueueHandler extends AbstractActionHandler<QueryParams> {
|
||||
constructor(private readonly sqsQueueEntryService: SqsQueueEntryService) {
|
||||
super();
|
||||
}
|
||||
|
||||
format = Format.Json;
|
||||
action = Action.V2_SqsPurgeQueue;
|
||||
validator = Joi.object<QueryParams, true>({
|
||||
QueueUrl: Joi.string().required(),
|
||||
});
|
||||
|
||||
protected async handle({ QueueUrl }: QueryParams, { awsProperties }: RequestContext) {
|
||||
const [accountId, name] = SqsQueue.tryGetAccountIdAndNameFromPathOrArn(QueueUrl);
|
||||
await this.sqsQueueEntryService.purge(accountId, name);
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
60
src/sqs/v2-receive-message.handler.ts
Normal file
60
src/sqs/v2-receive-message.handler.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as Joi from 'joi';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||
import { Action } from '../action.enum';
|
||||
import { SqsQueue } from './sqs-queue.entity';
|
||||
import { SqsQueueEntryService } from './sqs-queue-entry.service';
|
||||
|
||||
type QueryParams = {
|
||||
QueueUrl: string;
|
||||
MaxNumberOfMessages?: number;
|
||||
VisibilityTimeout?: number;
|
||||
AttributeNames?: string[];
|
||||
MessageAttributeNames?: string[];
|
||||
WaitTimeSeconds?: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class V2ReceiveMessageHandler extends AbstractActionHandler<QueryParams> {
|
||||
constructor(private readonly sqsQueueEntryService: SqsQueueEntryService) {
|
||||
super();
|
||||
}
|
||||
|
||||
audit = false;
|
||||
format = Format.Json;
|
||||
action = Action.V2_SqsReceiveMessage;
|
||||
validator = Joi.object<QueryParams, true>({
|
||||
QueueUrl: Joi.string().required(),
|
||||
MaxNumberOfMessages: Joi.number().optional(),
|
||||
VisibilityTimeout: Joi.number().optional(),
|
||||
AttributeNames: Joi.array().items(Joi.string()).optional(),
|
||||
MessageAttributeNames: Joi.array().items(Joi.string()).optional(),
|
||||
WaitTimeSeconds: Joi.number().optional(),
|
||||
});
|
||||
|
||||
protected async handle({ QueueUrl, MaxNumberOfMessages, VisibilityTimeout }: QueryParams) {
|
||||
const [accountId, name] = SqsQueue.tryGetAccountIdAndNameFromPathOrArn(QueueUrl);
|
||||
const records = await this.sqsQueueEntryService.receiveMessages(accountId, name, MaxNumberOfMessages, VisibilityTimeout);
|
||||
|
||||
if (records.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
Messages: records.map(r => ({
|
||||
MessageId: r.id,
|
||||
ReceiptHandle: r.id,
|
||||
MD5OfBody: crypto.createHash('md5').update(r.message).digest('hex'),
|
||||
Body: r.message,
|
||||
Attributes: {
|
||||
SenderId: r.senderId,
|
||||
SentTimestamp: r.createdAt.valueOf().toString(),
|
||||
ApproximateReceiveCount: '1',
|
||||
ApproximateFirstReceiveTimestamp: r.createdAt.valueOf().toString(),
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
57
src/sqs/v2-send-message.handler.ts
Normal file
57
src/sqs/v2-send-message.handler.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as Joi from 'joi';
|
||||
import * as crypto from 'crypto';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||
import { Action } from '../action.enum';
|
||||
import { RequestContext } from '../_context/request.context';
|
||||
import { SqsQueueEntryService } from './sqs-queue-entry.service';
|
||||
import { SqsQueue } from './sqs-queue.entity';
|
||||
|
||||
type QueryParams = {
|
||||
QueueUrl: string;
|
||||
MessageBody: string;
|
||||
DelaySeconds?: number;
|
||||
MessageAttributes?: Record<string, any>;
|
||||
MessageSystemAttributes?: Record<string, any>;
|
||||
MessageDeduplicationId?: string;
|
||||
MessageGroupId?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class V2SendMessageHandler extends AbstractActionHandler<QueryParams> {
|
||||
constructor(private readonly sqsQueueEntryService: SqsQueueEntryService) {
|
||||
super();
|
||||
}
|
||||
|
||||
audit = false;
|
||||
format = Format.Json;
|
||||
action = Action.V2_SqsSendMessage;
|
||||
validator = Joi.object<QueryParams, true>({
|
||||
QueueUrl: Joi.string().required(),
|
||||
MessageBody: Joi.string().required(),
|
||||
DelaySeconds: Joi.number().optional(),
|
||||
MessageAttributes: Joi.object().optional(),
|
||||
MessageSystemAttributes: Joi.object().optional(),
|
||||
MessageDeduplicationId: Joi.string().optional(),
|
||||
MessageGroupId: Joi.string().optional(),
|
||||
});
|
||||
|
||||
protected async handle({ QueueUrl, MessageBody }: QueryParams, context: RequestContext) {
|
||||
const [accountId, name] = SqsQueue.tryGetAccountIdAndNameFromPathOrArn(QueueUrl);
|
||||
|
||||
const message = await this.sqsQueueEntryService.publish(accountId, name, MessageBody);
|
||||
|
||||
if (!message) {
|
||||
throw new Error('Queue not found');
|
||||
}
|
||||
|
||||
const md5 = crypto.createHash('md5').update(MessageBody).digest('hex');
|
||||
|
||||
return {
|
||||
MessageId: message.id,
|
||||
MD5OfMessageBody: md5,
|
||||
};
|
||||
}
|
||||
}
|
||||
43
src/sqs/v2-set-queue-attributes.handler.ts
Normal file
43
src/sqs/v2-set-queue-attributes.handler.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import * as Joi from 'joi';
|
||||
|
||||
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||
import { Action } from '../action.enum';
|
||||
import { AttributesService } from '../aws-shared-entities/attributes.service';
|
||||
import { SqsQueueEntryService } from './sqs-queue-entry.service';
|
||||
import { SqsQueue } from './sqs-queue.entity';
|
||||
|
||||
type QueryParams = {
|
||||
QueueUrl: string;
|
||||
Attributes?: Record<string, string>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class V2SetQueueAttributesHandler extends AbstractActionHandler<QueryParams> {
|
||||
constructor(private readonly attributeService: AttributesService, private readonly sqsQueueEntryService: SqsQueueEntryService) {
|
||||
super();
|
||||
}
|
||||
|
||||
format = Format.Json;
|
||||
action = Action.V2_SqsSetQueueAttributes;
|
||||
validator = Joi.object<QueryParams, true>({
|
||||
QueueUrl: Joi.string().required(),
|
||||
Attributes: Joi.object().optional(),
|
||||
});
|
||||
|
||||
protected async handle({ QueueUrl, Attributes }: QueryParams) {
|
||||
const [accountId, name] = SqsQueue.tryGetAccountIdAndNameFromPathOrArn(QueueUrl);
|
||||
const queue = await this.sqsQueueEntryService.findQueueByAccountIdAndName(accountId, name);
|
||||
|
||||
if (!queue) {
|
||||
throw new BadRequestException('ResourceNotFoundException');
|
||||
}
|
||||
|
||||
if (Attributes) {
|
||||
const attributes = Object.entries(Attributes).map(([key, value]) => ({ key, value }));
|
||||
await this.attributeService.createMany(queue.arn, attributes);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
231
src/sts/__tests__/sts.spec.ts
Normal file
231
src/sts/__tests__/sts.spec.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts';
|
||||
import { AppModule } from '../../app.module';
|
||||
import { PrismaService } from '../../_prisma/prisma.service';
|
||||
import { AwsExceptionFilter } from '../../_context/exception.filter';
|
||||
|
||||
const bodyParser = require('body-parser');
|
||||
|
||||
describe('STS Integration Tests', () => {
|
||||
let app: INestApplication;
|
||||
let stsClient: STSClient;
|
||||
let prismaService: PrismaService;
|
||||
const testPort = 8087;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Set test environment variables
|
||||
process.env.PORT = testPort.toString();
|
||||
process.env.AWS_ACCOUNT_ID = '123456789012';
|
||||
process.env.AWS_REGION = 'us-east-1';
|
||||
process.env.DATABASE_URL = `file::memory:?cache=shared&unique=${Date.now()}-${Math.random()}`;
|
||||
process.env.PERSISTANCE = ':memory:';
|
||||
|
||||
// Create NestJS testing module
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.useGlobalFilters(new AwsExceptionFilter());
|
||||
app.use(bodyParser.urlencoded({ extended: true }));
|
||||
|
||||
await app.init();
|
||||
await app.listen(testPort);
|
||||
|
||||
// Configure STS client to point to local endpoint
|
||||
stsClient = new STSClient({
|
||||
region: 'us-east-1',
|
||||
endpoint: `http://localhost:${testPort}`,
|
||||
credentials: {
|
||||
accessKeyId: 'test',
|
||||
secretAccessKey: 'test',
|
||||
},
|
||||
});
|
||||
|
||||
prismaService = moduleFixture.get<PrismaService>(PrismaService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
stsClient.destroy();
|
||||
});
|
||||
|
||||
describe('GetCallerIdentity', () => {
|
||||
it('should return caller identity information', async () => {
|
||||
const command = new GetCallerIdentityCommand({});
|
||||
|
||||
const response = await stsClient.send(command);
|
||||
|
||||
expect(response.UserId).toBeDefined();
|
||||
expect(response.UserId).toBe('AIDASAMPLEUSERID');
|
||||
expect(response.Account).toBe('123456789012');
|
||||
expect(response.Arn).toBe('arn:aws:iam::123456789012:user/DevAdmin');
|
||||
});
|
||||
|
||||
it('should return consistent identity on multiple calls', async () => {
|
||||
const command = new GetCallerIdentityCommand({});
|
||||
|
||||
const response1 = await stsClient.send(command);
|
||||
const response2 = await stsClient.send(command);
|
||||
|
||||
expect(response1.UserId).toBe(response2.UserId);
|
||||
expect(response1.Account).toBe(response2.Account);
|
||||
expect(response1.Arn).toBe(response2.Arn);
|
||||
});
|
||||
|
||||
it('should return correct account ID from environment', async () => {
|
||||
const command = new GetCallerIdentityCommand({});
|
||||
|
||||
const response = await stsClient.send(command);
|
||||
|
||||
expect(response.Account).toBe(process.env.AWS_ACCOUNT_ID);
|
||||
});
|
||||
|
||||
it('should have valid ARN format', async () => {
|
||||
const command = new GetCallerIdentityCommand({});
|
||||
|
||||
const response = await stsClient.send(command);
|
||||
|
||||
expect(response.Arn).toMatch(/^arn:aws:iam::\d{12}:user\/[\w+=,.@-]+$/);
|
||||
});
|
||||
|
||||
it('should not require any parameters', async () => {
|
||||
// GetCallerIdentity takes no parameters
|
||||
const command = new GetCallerIdentityCommand({});
|
||||
|
||||
await expect(stsClient.send(command)).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('End-to-End Workflow', () => {
|
||||
it('should verify caller identity as part of authentication flow', async () => {
|
||||
// In a real scenario, you would first authenticate and then verify identity
|
||||
const identityCommand = new GetCallerIdentityCommand({});
|
||||
const identityResponse = await stsClient.send(identityCommand);
|
||||
|
||||
// Verify that the caller has a valid identity
|
||||
expect(identityResponse.Account).toBeTruthy();
|
||||
expect(identityResponse.UserId).toBeTruthy();
|
||||
expect(identityResponse.Arn).toBeTruthy();
|
||||
|
||||
// The ARN should contain the account ID
|
||||
expect(identityResponse.Arn).toContain(identityResponse.Account);
|
||||
});
|
||||
|
||||
it('should be usable for authorization decisions', async () => {
|
||||
const command = new GetCallerIdentityCommand({});
|
||||
const response = await stsClient.send(command);
|
||||
|
||||
// In a real application, you might use this to:
|
||||
// 1. Verify the caller's account
|
||||
const accountId = response.Account;
|
||||
expect(accountId).toMatch(/^\d{12}$/);
|
||||
|
||||
// 2. Extract the user from the ARN
|
||||
const arnParts = response.Arn!.split(':');
|
||||
expect(arnParts[0]).toBe('arn');
|
||||
expect(arnParts[1]).toBe('aws');
|
||||
expect(arnParts[2]).toBe('iam');
|
||||
expect(arnParts[4]).toBe(accountId);
|
||||
|
||||
// 3. Make authorization decisions based on the identity
|
||||
const resourceType = arnParts[5].split('/')[0]; // 'user', 'role', etc.
|
||||
expect(['user', 'role', 'federated-user', 'assumed-role']).toContain(resourceType);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle service availability', async () => {
|
||||
const command = new GetCallerIdentityCommand({});
|
||||
|
||||
// STS should always be available and never return errors for GetCallerIdentity
|
||||
await expect(stsClient.send(command)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('should work with different credential configurations', async () => {
|
||||
// Create a new client with different (but still test) credentials
|
||||
const testClient = new STSClient({
|
||||
region: 'us-west-2',
|
||||
endpoint: `http://localhost:${testPort}`,
|
||||
credentials: {
|
||||
accessKeyId: 'different-key',
|
||||
secretAccessKey: 'different-secret',
|
||||
},
|
||||
});
|
||||
|
||||
const command = new GetCallerIdentityCommand({});
|
||||
const response = await testClient.send(command);
|
||||
|
||||
// Should still return the same account (from environment)
|
||||
expect(response.Account).toBe('123456789012');
|
||||
|
||||
testClient.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Response Structure', () => {
|
||||
it('should have all required response fields', async () => {
|
||||
const command = new GetCallerIdentityCommand({});
|
||||
const response = await stsClient.send(command);
|
||||
|
||||
// Check all fields are present
|
||||
expect(response).toHaveProperty('UserId');
|
||||
expect(response).toHaveProperty('Account');
|
||||
expect(response).toHaveProperty('Arn');
|
||||
});
|
||||
|
||||
it('should return string values for all fields', async () => {
|
||||
const command = new GetCallerIdentityCommand({});
|
||||
const response = await stsClient.send(command);
|
||||
|
||||
expect(typeof response.UserId).toBe('string');
|
||||
expect(typeof response.Account).toBe('string');
|
||||
expect(typeof response.Arn).toBe('string');
|
||||
});
|
||||
|
||||
it('should have non-empty values', async () => {
|
||||
const command = new GetCallerIdentityCommand({});
|
||||
const response = await stsClient.send(command);
|
||||
|
||||
expect(response.UserId!.length).toBeGreaterThan(0);
|
||||
expect(response.Account!.length).toBeGreaterThan(0);
|
||||
expect(response.Arn!.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Concurrent Requests', () => {
|
||||
it('should handle multiple concurrent GetCallerIdentity requests', async () => {
|
||||
const requests = Array(10)
|
||||
.fill(null)
|
||||
.map(() => stsClient.send(new GetCallerIdentityCommand({})));
|
||||
|
||||
const responses = await Promise.all(requests);
|
||||
|
||||
// All responses should be identical
|
||||
responses.forEach(response => {
|
||||
expect(response.Account).toBe('123456789012');
|
||||
expect(response.UserId).toBe('AIDASAMPLEUSERID');
|
||||
expect(response.Arn).toBe('arn:aws:iam::123456789012:user/DevAdmin');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle rapid sequential requests', async () => {
|
||||
const responses = [];
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const command = new GetCallerIdentityCommand({});
|
||||
const response = await stsClient.send(command);
|
||||
responses.push(response);
|
||||
}
|
||||
|
||||
// All responses should be consistent
|
||||
const firstResponse = responses[0];
|
||||
responses.forEach(response => {
|
||||
expect(response.Account).toBe(firstResponse.Account);
|
||||
expect(response.UserId).toBe(firstResponse.UserId);
|
||||
expect(response.Arn).toBe(firstResponse.Arn);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user