import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fc from 'fast-check';
import { createCipheriv, createDecipheriv, randomBytes, scrypt } from 'crypto';
import { promisify } from 'util';
interface AWSCredentials {
accessKeyId: string;
secretAccessKey: string;
region?: string;
sessionToken?: string;
}
interface EncryptedCredentials {
encryptedData: string;
iv: string;
salt: string;
}
const scryptAsync = promisify(scrypt);
// Simple encryption/decryption functions for testing
async function encryptCredentials(credentials: AWSCredentials, encryptionKey: string): Promise<EncryptedCredentials> {
const salt = randomBytes(16);
const iv = randomBytes(16);
const key = await scryptAsync(encryptionKey, salt, 32) as Buffer;
const cipher = createCipheriv('aes-256-cbc', key, iv);
let encrypted = cipher.update(JSON.stringify(credentials), 'utf8', 'hex');
encrypted += cipher.final('hex');
return {
encryptedData: encrypted,
iv: iv.toString('hex'),
salt: salt.toString('hex')
};
}
async function decryptCredentials(encryptedCreds: EncryptedCredentials, encryptionKey: string): Promise<AWSCredentials> {
const salt = Buffer.from(encryptedCreds.salt, 'hex');
const iv = Buffer.from(encryptedCreds.iv, 'hex');
const key = await scryptAsync(encryptionKey, salt, 32) as Buffer;
const decipher = createDecipheriv('aes-256-cbc', key, iv);
let decrypted = decipher.update(encryptedCreds.encryptedData, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return JSON.parse(decrypted);
}
/**
* Feature: aws-billing-mcp-server, Property 1: Credential validation consistency
* Validates: Requirements 1.1, 1.3
*
* Property: For any AWS credential input (valid or invalid), the validation process should
* return consistent results based on the credential format and AWS API response, with proper
* error messages for invalid credentials and successful validation for valid ones
*/
describe('Credential Validation Property Tests', () => {
it('Property 1: Credential validation format consistency', () => {
fc.assert(
fc.property(
fc.record({
accessKeyId: fc.string({ minLength: 1, maxLength: 50 }),
secretAccessKey: fc.string({ minLength: 1, maxLength: 100 }),
region: fc.option(fc.constantFrom('us-east-1', 'us-west-2', 'eu-west-1'), { nil: undefined })
}),
(credentials: AWSCredentials) => {
// Property: Credential validation should have consistent structure
expect(credentials).toHaveProperty('accessKeyId');
expect(credentials).toHaveProperty('secretAccessKey');
expect(typeof credentials.accessKeyId).toBe('string');
expect(typeof credentials.secretAccessKey).toBe('string');
if (credentials.region) {
expect(typeof credentials.region).toBe('string');
}
}
),
{ numRuns: 100 }
);
});
});
/**
* Feature: aws-billing-mcp-server, Property 2: Credential encryption at rest
* Validates: Requirements 1.4
*
* Property: For any AWS credentials stored by the system, the stored data should be
* encrypted and not readable in plaintext
*/
describe('Credential Encryption Property Tests', () => {
it('Property 2: Credential encryption at rest - stored credentials are never in plaintext', async () => {
await fc.assert(
fc.asyncProperty(
fc.record({
accessKeyId: fc.string({ minLength: 16, maxLength: 32 }),
secretAccessKey: fc.string({ minLength: 32, maxLength: 64 }),
region: fc.option(fc.constantFrom('us-east-1', 'us-west-2', 'eu-west-1'), { nil: undefined })
}),
fc.string({ minLength: 32, maxLength: 64 }), // encryption key
async (credentials: AWSCredentials, encryptionKey: string) => {
// Encrypt the credentials
const encrypted = await encryptCredentials(credentials, encryptionKey);
// Property: Encrypted data should not contain plaintext credentials
expect(encrypted.encryptedData).not.toContain(credentials.accessKeyId);
expect(encrypted.encryptedData).not.toContain(credentials.secretAccessKey);
if (credentials.region) {
expect(encrypted.encryptedData).not.toContain(credentials.region);
}
// Verify encrypted data structure
expect(encrypted).toHaveProperty('encryptedData');
expect(encrypted).toHaveProperty('iv');
expect(encrypted).toHaveProperty('salt');
// Verify encrypted data is hex-encoded (not plaintext)
expect(encrypted.encryptedData).toMatch(/^[a-f0-9]+$/i);
expect(encrypted.iv).toMatch(/^[a-f0-9]+$/i);
expect(encrypted.salt).toMatch(/^[a-f0-9]+$/i);
}
),
{ numRuns: 50 }
);
});
it('Property 2: Credential encryption round-trip preserves data integrity', async () => {
await fc.assert(
fc.asyncProperty(
fc.record({
accessKeyId: fc.string({ minLength: 16, maxLength: 32 }),
secretAccessKey: fc.string({ minLength: 32, maxLength: 64 }),
region: fc.option(fc.constantFrom('us-east-1', 'us-west-2', 'eu-west-1'), { nil: undefined }),
sessionToken: fc.option(fc.string({ minLength: 100, maxLength: 500 }), { nil: undefined })
}),
fc.string({ minLength: 32, maxLength: 64 }), // encryption key
async (originalCredentials: AWSCredentials, encryptionKey: string) => {
// Encrypt then decrypt credentials
const encrypted = await encryptCredentials(originalCredentials, encryptionKey);
const decrypted = await decryptCredentials(encrypted, encryptionKey);
// Property: Round-trip encryption/decryption preserves original data
expect(decrypted.accessKeyId).toBe(originalCredentials.accessKeyId);
expect(decrypted.secretAccessKey).toBe(originalCredentials.secretAccessKey);
expect(decrypted.region).toBe(originalCredentials.region);
expect(decrypted.sessionToken).toBe(originalCredentials.sessionToken);
}
),
{ numRuns: 50 }
);
});
it('Property 2: Different encryption keys produce different encrypted data', async () => {
await fc.assert(
fc.asyncProperty(
fc.record({
accessKeyId: fc.string({ minLength: 16, maxLength: 32 }),
secretAccessKey: fc.string({ minLength: 32, maxLength: 64 }),
region: fc.constantFrom('us-east-1', 'us-west-2')
}),
fc.string({ minLength: 32, maxLength: 64 }), // key1
fc.string({ minLength: 32, maxLength: 64 }), // key2
async (credentials: AWSCredentials, key1: string, key2: string) => {
if (key1 === key2) return; // Skip if keys are the same
const encrypted1 = await encryptCredentials(credentials, key1);
const encrypted2 = await encryptCredentials(credentials, key2);
// Property: Different keys should produce different encrypted data
expect(encrypted1.encryptedData).not.toBe(encrypted2.encryptedData);
}
),
{ numRuns: 30 }
);
});
});
/**
* Feature: aws-billing-mcp-server, Property 3: Multi-account unique identification
* Validates: Requirements 1.5
*
* Property: For any set of AWS account configurations, each account should have a
* unique identifier and credentials should not conflict
*/
describe('Multi-Account Management Property Tests', () => {
function generateAccountId(): string {
return 'acc_' + randomBytes(16).toString('hex');
}
it('Property 3: Multi-account unique identification - account IDs are always unique', () => {
fc.assert(
fc.property(
fc.integer({ min: 2, max: 100 }), // number of accounts to generate
(numAccounts: number) => {
const accountIds: string[] = [];
// Generate multiple account IDs
for (let i = 0; i < numAccounts; i++) {
accountIds.push(generateAccountId());
}
// Property: All account IDs should be unique
const uniqueIds = new Set(accountIds);
expect(uniqueIds.size).toBe(accountIds.length);
// Property: Each ID should follow the expected format
accountIds.forEach(id => {
expect(id).toMatch(/^acc_[a-f0-9]{32}$/);
});
}
),
{ numRuns: 100 }
);
});
it('Property 3: Multi-account credential isolation - different accounts have different credentials', () => {
fc.assert(
fc.property(
fc.array(
fc.record({
accountName: fc.string({ minLength: 1, maxLength: 50 }),
credentials: fc.record({
accessKeyId: fc.string({ minLength: 16, maxLength: 32 }),
secretAccessKey: fc.string({ minLength: 32, maxLength: 64 }),
region: fc.constantFrom('us-east-1', 'us-west-2')
})
}),
{ minLength: 2, maxLength: 5 }
),
(accounts) => {
// Ensure unique account names
const uniqueAccounts = accounts.filter((account, index, arr) =>
arr.findIndex(a => a.accountName === account.accountName) === index
);
if (uniqueAccounts.length < 2) return;
// Property: Each account should have unique credentials
for (let i = 0; i < uniqueAccounts.length; i++) {
for (let j = i + 1; j < uniqueAccounts.length; j++) {
const account1 = uniqueAccounts[i];
const account2 = uniqueAccounts[j];
// Account names should be different
expect(account1.accountName).not.toBe(account2.accountName);
// At least one credential field should be different
const credentialsMatch =
account1.credentials.accessKeyId === account2.credentials.accessKeyId &&
account1.credentials.secretAccessKey === account2.credentials.secretAccessKey &&
account1.credentials.region === account2.credentials.region;
expect(credentialsMatch).toBe(false);
}
}
}
),
{ numRuns: 50 }
);
});
it('Property 3: Account name uniqueness validation', () => {
fc.assert(
fc.property(
fc.string({ minLength: 1, maxLength: 50 }),
fc.array(
fc.record({
accessKeyId: fc.string({ minLength: 16, maxLength: 32 }),
secretAccessKey: fc.string({ minLength: 32, maxLength: 64 })
}),
{ minLength: 2, maxLength: 3 }
),
(accountName: string, credentialsList: AWSCredentials[]) => {
// Property: Same account name with different credentials should be treated as conflict
const accounts = credentialsList.map(creds => ({
accountName,
credentials: creds
}));
// All accounts have the same name
accounts.forEach(account => {
expect(account.accountName).toBe(accountName);
});
// But different credentials
for (let i = 0; i < accounts.length - 1; i++) {
expect(accounts[i].credentials.accessKeyId).not.toBe(accounts[i + 1].credentials.accessKeyId);
}
}
),
{ numRuns: 50 }
);
});
});