cdk-rds-sql
Version:
A CDK construct that allows creating roles or users and databases an on Aurora Serverless Postgresql or Mysql/MariaDB cluster.
350 lines (296 loc) • 10.9 kB
text/typescript
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"
import { createConnection } from "mysql2/promise"
import { GenericContainer, StartedTestContainer } from "testcontainers"
import { handler } from "./handler"
import { createRequest, updateRequest, deleteRequest } from "./util"
jest.mock("@aws-sdk/client-secrets-manager")
const SecretsManagerClientMock = SecretsManagerClient as jest.MockedClass<
typeof SecretsManagerClient
>
const DB_PORT = 3306
const DB_MASTER_USERNAME = "root"
const DB_MASTER_PASSWORD = "masterpwd"
const DB_DEFAULT_DB = "dummy"
let mysqlContainer: StartedTestContainer
let mysqlHost: string
let mysqlPort: number
beforeAll(async () => {
process.env.LOGGER = "true"
process.env.SSL = "false"
process.env.CONNECTION_TIMEOUT = "5000"
mysqlContainer = await new GenericContainer("mysql:8")
.withExposedPorts(DB_PORT)
.withEnvironment({
MYSQL_ROOT_PASSWORD: DB_MASTER_PASSWORD,
MYSQL_DATABASE: DB_DEFAULT_DB,
MYSQL_INITDB_SKIP_TZINFO: "true",
})
.start()
mysqlHost = mysqlContainer.getHost()
mysqlPort = mysqlContainer.getMappedPort(DB_PORT)
// Set up the mock after we have the host and port values
SecretsManagerClientMock.prototype.send.mockImplementation(() => {
return {
SecretString: JSON.stringify({
host: mysqlHost,
port: mysqlPort,
username: DB_MASTER_USERNAME,
password: DB_MASTER_PASSWORD,
dbname: DB_DEFAULT_DB,
engine: "mysql",
dbClusterIdentifier: "dummy",
}),
}
})
}, 60000)
afterAll(async () => {
if (mysqlContainer) {
await mysqlContainer.stop()
}
})
beforeEach(async () => {
jest.clearAllMocks()
// Clean up databases and users created by tests
const connection = await newConnection()
try {
// Drop all databases except system ones and default
const [databases] = await connection.query(
"SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys', ?)",
[DB_DEFAULT_DB]
)
for (const db of databases) {
await connection.query(`DROP DATABASE IF EXISTS \`${db.SCHEMA_NAME}\``)
}
// Drop all users except system ones
const [users] = await connection.query(
"SELECT User FROM mysql.user WHERE User NOT IN ('mysql.sys', 'mysql.session', 'mysql.infoschema', 'root')"
)
for (const user of users) {
await connection.query(`DROP USER IF EXISTS '${user.User}'@'%'`)
}
} finally {
await connection.end()
}
})
// Helper functions for MySQL tests
async function databaseExists(connection: any, dbName: string): Promise<boolean> {
const [rows] = await connection.query(
"SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ?",
[dbName]
)
return rows.length > 0
}
async function userExists(connection: any, username: string): Promise<boolean> {
const [rows] = await connection.query("SELECT User FROM mysql.user WHERE User = ?", [
username,
])
return rows.length > 0
}
async function tableExists(
connection: any,
dbName: string,
tableName: string
): Promise<boolean> {
const [rows] = await connection.query(
"SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?",
[dbName, tableName]
)
return rows.length > 0
}
async function rowCount(connection: any, tableName: string): Promise<number> {
const [rows] = await connection.query(`SELECT COUNT(*) as count FROM ${tableName}`)
return rows[0].count
}
async function newConnection(config?: any): Promise<any> {
const conn = await createConnection({
host: mysqlHost,
port: mysqlPort,
database: config && config.database ? config.database : DB_DEFAULT_DB,
user: config && config.user ? config.user : DB_MASTER_USERNAME,
password: config && config.password ? config.password : DB_MASTER_PASSWORD,
})
return conn
}
test("database", async () => {
const oldDatabaseName = "mydb"
const newDatabaseName = "mydb2"
const create = createRequest("database", oldDatabaseName)
await handler(create)
expect(SecretsManagerClientMock.prototype.send).toHaveBeenCalledTimes(1)
const connection = await newConnection()
try {
expect(await databaseExists(connection, oldDatabaseName)).toEqual(true)
const update = updateRequest("database", oldDatabaseName, newDatabaseName)
await expect(handler(update)).rejects.toThrow(
"Renaming database is not supported in MySQL."
)
const remove = deleteRequest("database", oldDatabaseName)
await handler(remove)
expect(await databaseExists(connection, newDatabaseName)).toEqual(false)
} finally {
await connection.end()
}
})
test("database with owner", async () => {
const databaseName = "mydb"
const userName = "example"
// First create user
const create_role = createRequest("role", userName, {
PasswordArn: "arn:aws:secretsmanager:us-east-1:123456789:secret:dummy",
})
await handler(create_role)
// Then create database with owner
const create_db = createRequest("database", databaseName, { Owner: userName })
await handler(create_db)
const connection = await newConnection()
try {
expect(await databaseExists(connection, databaseName)).toEqual(true)
// Create a table in the new database
const create_table = createRequest("sql", "", {
DatabaseName: databaseName,
Statement: "CREATE TABLE t(i INT)",
})
await handler(create_table)
// Verify we can login as owner and access the table
const userConn = await newConnection({
user: userName,
database: databaseName,
})
try {
expect(await tableExists(userConn, databaseName, "t")).toEqual(true)
// Test we can insert data
await userConn.query("INSERT INTO t VALUES (1), (2), (3)")
expect(await rowCount(userConn, "t")).toEqual(3)
} finally {
await userConn.end()
}
// Test database rename
const oldDatabaseName = databaseName
const newDatabaseName = "mydb2"
const update = updateRequest("database", oldDatabaseName, newDatabaseName, {
Owner: userName,
})
await expect(handler(update)).rejects.toThrow(
"Renaming database is not supported in MySQL."
)
} finally {
await connection.end()
}
})
describe("User creation", () => {
it("can create a user", async () => {
const oldRoleName = "testuser"
const newRoleName = "testuser2"
// Create role. We must specify a database as in mysql you cannot
// connect without a database.
const create = createRequest("role", oldRoleName, {
PasswordArn: "arn:aws:secretsmanager:us-east-1:123456789:secret:dummy",
DatabaseName: DB_DEFAULT_DB,
})
await handler(create)
expect(SecretsManagerClientMock.prototype.send).toHaveBeenCalledTimes(2)
const connection = await newConnection()
try {
expect(await userExists(connection, oldRoleName)).toEqual(true)
// Attempt to connect as this user
const userConn = await newConnection({
user: oldRoleName,
})
await userConn.end()
// Update role - rename
const update = updateRequest("role", oldRoleName, newRoleName, {
PasswordArn: "arn:aws:secretsmanager:us-east-1:123456789:secret:dummy",
})
await handler(update)
expect(await userExists(connection, oldRoleName)).toEqual(false)
expect(await userExists(connection, newRoleName)).toEqual(true)
// Delete role
const remove = deleteRequest("role", newRoleName)
await handler(remove)
expect(await userExists(connection, newRoleName)).toEqual(false)
} finally {
await connection.end()
}
})
it("can change a user's password", async () => {
const userName = "pwduser"
// Create user
const create = createRequest("role", userName, {
PasswordArn: "arn:aws:secretsmanager:us-east-1:123456789:secret:dummy",
DatabaseName: DB_DEFAULT_DB,
})
await handler(create)
expect(SecretsManagerClientMock.prototype.send).toHaveBeenCalledTimes(2)
const connection = await newConnection()
try {
expect(await userExists(connection, userName)).toEqual(true)
// Test we can connect with the initial password
const userConn = await newConnection({
user: userName,
})
await userConn.end()
// Update role - change password
const update = updateRequest("role", userName, userName, {
PasswordArn: "arn:aws:secretsmanager:us-east-1:123456789:secret:dummy",
})
await handler(update)
// Password changed but should still be able to connect
const userConn2 = await newConnection({
user: userName,
})
await userConn2.end()
} finally {
await connection.end()
}
})
})
test("sql execution", async () => {
const databaseName = "sqltest"
// Create database
const create_db = createRequest("database", databaseName)
await handler(create_db)
// Execute SQL to create table
const create_table = createRequest("sql", "", {
DatabaseName: databaseName,
Statement: "CREATE TABLE test_table (id INT, name VARCHAR(50))",
})
await handler(create_table)
// Execute multiple SQL statements
const create_multiple_tables = createRequest("sql", "", {
DatabaseName: databaseName,
Statement: "CREATE TABLE t1 (id INT); CREATE TABLE t2 (id INT);",
})
await handler(create_multiple_tables)
const connection = await newConnection({ database: databaseName })
try {
expect(await tableExists(connection, databaseName, "test_table")).toEqual(true)
// Execute SQL to insert data
const insert_data = createRequest("sql", "", {
DatabaseName: databaseName,
Statement: "INSERT INTO test_table VALUES (1, 'Test 1'), (2, 'Test 2')",
})
await handler(insert_data)
expect(await rowCount(connection, "test_table")).toEqual(2)
// Execute SQL with rollback for deletion test
const update_sql = updateRequest("sql", "dummy", "dummy", {
DatabaseName: databaseName,
Statement: "UPDATE test_table SET name = 'Updated' WHERE id = 1",
Rollback: "UPDATE test_table SET name = 'Test 1' WHERE id = 1",
})
await handler(update_sql)
// Verify update happened
const [rows] = await connection.query("SELECT name FROM test_table WHERE id = 1")
expect(rows[0].name).toEqual("Updated")
// Test rollback on delete
const delete_sql = deleteRequest("sql", "dummy", {
DatabaseName: databaseName,
Rollback: "UPDATE test_table SET name = 'Rollback test' WHERE id = 1",
})
await handler(delete_sql)
// Verify rollback executed
const [updated] = await connection.query("SELECT name FROM test_table WHERE id = 1")
expect(updated[0].name).toEqual("Rollback test")
} finally {
await connection.end()
}
})