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