diff --git a/src/v2/components/password/index.ts b/src/v2/components/password/index.ts new file mode 100644 index 0000000..4ddaab3 --- /dev/null +++ b/src/v2/components/password/index.ts @@ -0,0 +1,68 @@ +import * as aws from '@pulumi/aws'; +import * as pulumi from '@pulumi/pulumi'; +import * as random from '@pulumi/random'; +import { commonTags } from '../../../constants'; + +export namespace Password { + export type Args = { + value?: pulumi.Input; + }; +} + +export class Password extends pulumi.ComponentResource { + name: string; + value: pulumi.Output; + secret: aws.secretsmanager.Secret; + + constructor( + name: string, + args: Password.Args = {}, + opts: pulumi.ComponentResourceOptions = {}, + ) { + super('studion:Password', name, {}, opts); + + this.name = name; + if (args.value) { + this.value = pulumi.secret(args.value); + } else { + const password = new random.RandomPassword( + `${this.name}-random-password`, + { + length: 16, + overrideSpecial: '_$', + special: true, + }, + { parent: this }, + ); + this.value = password.result; + } + + this.secret = this.createPasswordSecret(this.value); + this.registerOutputs(); + } + + private createPasswordSecret(password: pulumi.Input) { + const project = pulumi.getProject(); + const stack = pulumi.getStack(); + + const passwordSecret = new aws.secretsmanager.Secret( + `${this.name}-password-secret`, + { + namePrefix: `${stack}/${project}/${this.name}-`, + tags: commonTags, + }, + { parent: this }, + ); + + const passwordSecretValue = new aws.secretsmanager.SecretVersion( + `${this.name}-password-secret-value`, + { + secretId: passwordSecret.id, + secretString: password, + }, + { parent: this, dependsOn: [passwordSecret] }, + ); + + return passwordSecret; + } +} diff --git a/src/v2/index.ts b/src/v2/index.ts index aaaabc7..807116b 100644 --- a/src/v2/index.ts +++ b/src/v2/index.ts @@ -5,6 +5,7 @@ export { WebServerLoadBalancer } from './components/web-server/load-balancer'; export { ElastiCacheRedis } from './components/redis/elasticache-redis'; export { UpstashRedis } from './components/redis/upstash-redis'; export { Vpc } from './components/vpc'; +export { Password } from './components/password'; import { OtelCollectorBuilder } from './otel/builder'; import { OtelCollector } from './otel'; diff --git a/tests/password/index.test.ts b/tests/password/index.test.ts new file mode 100644 index 0000000..8ace4b6 --- /dev/null +++ b/tests/password/index.test.ts @@ -0,0 +1,121 @@ +import * as assert from 'node:assert'; +import { InlineProgramArgs } from '@pulumi/pulumi/automation'; +import { SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; +import { + DescribeSecretCommand, + GetSecretValueCommand, +} from '@aws-sdk/client-secrets-manager'; +import { PasswordTestContext } from './test-context'; +import { after, before, describe, it } from 'node:test'; +import * as automation from '../automation'; + +const programArgs: InlineProgramArgs = { + stackName: 'dev', + projectName: 'icb-test-password', + program: () => import('./infrastructure'), +}; + +describe('Password component deployment', () => { + const region = process.env.AWS_REGION; + if (!region) { + throw new Error('AWS_REGION environment variable is required'); + } + + const ctx: PasswordTestContext = { + outputs: {}, + config: { + autoGeneratedPasswordName: 'password-test-auto', + }, + clients: { + secretsManager: new SecretsManagerClient({ region }), + }, + }; + + before(async () => { + ctx.outputs = await automation.deploy(programArgs); + }); + + after(() => automation.destroy(programArgs)); + + it('should create a password component with the correct configuration', async () => { + const password = ctx.outputs.autoGeneratedPassword.value; + + assert.ok( + password.secret, + 'Password component should have secret property', + ); + assert.ok(password.value, 'Password component should have value property'); + assert.strictEqual( + password.name, + ctx.config.autoGeneratedPasswordName, + 'Password name should match input', + ); + }); + + it('should create a secret with auto generated password', async () => { + const password = ctx.outputs.autoGeneratedPassword.value; + + const secretResult = await ctx.clients.secretsManager.send( + new DescribeSecretCommand({ + SecretId: password.secret.arn, + }), + ); + + assert.ok(secretResult.ARN, 'Secret should exist'); + assert.ok(secretResult.Name, 'Secret should have a name'); + assert.ok(secretResult.CreatedDate, 'Secret should have creation date'); + + const expectedPrefix = `${programArgs.stackName}/${programArgs.projectName}/${ctx.config.autoGeneratedPasswordName}-`; + assert.ok( + secretResult.Name?.startsWith(expectedPrefix), + `Secret name should start with ${expectedPrefix}`, + ); + }); + + it('should generate a random password with correct format', async () => { + const password = ctx.outputs.autoGeneratedPassword.value; + + const secretValue = await ctx.clients.secretsManager.send( + new GetSecretValueCommand({ + SecretId: password.secret.arn, + }), + ); + + const passwordValue = secretValue.SecretString; + assert.ok(passwordValue, 'Password value should exist'); + assert.strictEqual( + passwordValue.length, + 16, + 'Password should be 16 characters long', + ); + assert.ok(secretValue.VersionId, 'Secret should have a version ID'); + }); + + it('should create a secret with custom password value', async () => { + const password = ctx.outputs.customPassword.value; + + const secretValue = await ctx.clients.secretsManager.send( + new GetSecretValueCommand({ + SecretId: password.secret.arn, + }), + ); + + const passwordValue = secretValue.SecretString; + assert.strictEqual( + passwordValue, + 'customPass!', + 'Password should match custom value', + ); + }); + + it('should have password value as a secret', async () => { + const autoGeneratePasswordValue = ctx.outputs.autoGeneratePasswordValue; + assert.ok( + autoGeneratePasswordValue.secret, + 'Auto-generated password should be a secret', + ); + + const customPasswordValue = ctx.outputs.customPasswordValue; + assert.ok(customPasswordValue.secret, 'Custom password should be a secret'); + }); +}); diff --git a/tests/password/infrastructure/index.ts b/tests/password/infrastructure/index.ts new file mode 100644 index 0000000..ebe7bce --- /dev/null +++ b/tests/password/infrastructure/index.ts @@ -0,0 +1,18 @@ +import { next as studion } from '@studion/infra-code-blocks'; + +const appName = 'password-test'; + +const autoGeneratedPassword = new studion.Password(`${appName}-auto`); +const autoGeneratePasswordValue = autoGeneratedPassword.value; + +const customPassword = new studion.Password(`${appName}-custom`, { + value: 'customPass!', +}); +const customPasswordValue = customPassword.value; + +export { + autoGeneratedPassword, + customPassword, + autoGeneratePasswordValue, + customPasswordValue, +}; diff --git a/tests/password/test-context.ts b/tests/password/test-context.ts new file mode 100644 index 0000000..7607393 --- /dev/null +++ b/tests/password/test-context.ts @@ -0,0 +1,25 @@ +import { OutputMap } from '@pulumi/pulumi/automation'; +import { SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; + +interface PasswordTestConfig { + autoGeneratedPasswordName: string; +} + +interface ConfigContext { + config: PasswordTestConfig; +} + +interface PulumiProgramContext { + outputs: OutputMap; +} + +interface AwsContext { + clients: { + secretsManager: SecretsManagerClient; + }; +} + +export interface PasswordTestContext + extends ConfigContext, + PulumiProgramContext, + AwsContext {}