Adds unit tests for all services

This commit is contained in:
2026-01-14 16:54:06 -05:00
parent d8930a6a30
commit a3317dd46f
59 changed files with 12592 additions and 8683 deletions

2
.gitignore vendored
View File

@@ -2,3 +2,5 @@ node_modules
dist
data
.env
*.sqlite
.DS_Store

16
jest.config.js Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View 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");

View 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
);

View 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;

View File

@@ -0,0 +1,2 @@
-- Drop unique constraint on IamPolicy
DROP INDEX "IamPolicy_accountId_path_name_key";

View File

@@ -56,7 +56,6 @@ model IamPolicy {
updatedAt DateTime @updatedAt
@@id([id, version])
@@unique([accountId, path, name])
}
model IamRoleIamPolicyAttachment {

View File

@@ -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',

View File

@@ -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);

View File

@@ -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],
},
],
})

View File

@@ -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] })) } };
}
}

View File

@@ -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(

View File

@@ -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[]) {

View 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();
});
});
});

View File

@@ -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(),
},
};
}
}

View File

@@ -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(),
}
}
},
};
}
}

View File

@@ -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,
};
}
}

View File

@@ -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,

View File

@@ -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();

View File

@@ -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,
};
}
}

View File

@@ -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) || [],
},
};
}
}

View 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);
}
});
});
});

View File

@@ -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);
}
}
},
};
}

View File

@@ -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> {

View File

@@ -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,
}
}

View File

@@ -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,
}
};
}
}

View File

@@ -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,
}
};
}
}

View File

@@ -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);

View 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);
});
});
});

View File

@@ -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,
}
};
}
}

View File

@@ -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,

View 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');
});
});
});

View File

@@ -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 };
}

View File

@@ -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);

View File

@@ -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 });
}
}

View File

@@ -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 {}

View 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 {};
}
}

View 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 {};
}
}

View 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();
});
});
});

View File

@@ -20,7 +20,6 @@ export class DeleteMessageBatchHandler extends AbstractActionHandler<QueryParams
super();
}
audit = false;
format = Format.Xml;
action = Action.SqsDeleteMessageBatch;
validator = Joi.object<QueryParams>({

View File

@@ -21,7 +21,6 @@ export class DeleteMessageHandler extends AbstractActionHandler<QueryParams> {
super();
}
audit = false;
format = Format.Xml;
action = Action.SqsDeleteMessage;
validator = Joi.object<QueryParams, true>({

View File

@@ -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);
}
}

View File

@@ -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> {

View File

@@ -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}`;

View File

@@ -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 {}

View 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: [],
};
}
}

View 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 {};
}
}

View 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 {};
}
}

View 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);
}
}

View File

@@ -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)),
};
}
}

View 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 {};
}
}

View 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(),
},
})),
};
}
}

View 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,
};
}
}

View 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 {};
}
}

View 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);
});
});
});
});

7610
yarn.lock Normal file

File diff suppressed because it is too large Load Diff