Ghost/test/unit/server/data/migrations/utils.test.js
Fabien 'egg' O'Carroll b36d0cc1c4
🐛 Fixed idempotentcy of addPermissionToRole util (#13685)
refs https://github.com/TryGhost/Team/issues/1178

The "up" migration that this util generates correctly throws if the
pre-requisite data cannot be found in the database. The "down" migration
however was incorrectly mirroring this behaviour of throwing - which
meant that it wasn't idempotent, as it does not require a permission or
role to existing if it wants to move relations between them.
2021-11-01 09:27:50 +00:00

559 lines
21 KiB
JavaScript

const should = require('should');
const sinon = require('sinon');
const errors = require('@tryghost/errors');
const utils = require('../../../../../core/server/data/migrations/utils');
class Deferred {
constructor() {
this.promise = new Promise((resolve, reject) => {
this.resolve = async (val) => {
resolve(val);
return this.promise;
};
this.reject = async (val) => {
reject(val);
return this.promise;
};
});
}
}
describe('migrations/utils', function () {
describe('createTransactionalMigration', function () {
it('Accepts two functions and creates a transactional migration from them', function () {
const config = {
transacting: {}
};
const up = sinon.spy();
const down = sinon.spy();
const migration = utils.createTransactionalMigration(up, down);
const upResult = migration.up(config);
const downResult = migration.down(config);
should.ok(migration.config.transaction, 'Migration config should set transaction to true');
should.ok(upResult instanceof Promise, 'Migration up method should return a Promise');
should.ok(downResult instanceof Promise, 'Migration down method should return a Promise');
should.ok(up.calledOnceWith(config.transacting), 'up function should be called with transacting');
should.ok(down.calledOnceWith(config.transacting), 'down function should be called with transacting');
});
});
describe('combineTransactionalMigrations', function () {
it('Accepts a number of migrations and returns a single migration', async function () {
const config = {
transacting: {}
};
const upA = sinon.stub().resolves();
const downA = sinon.stub().resolves();
const upB = sinon.stub().resolves();
const downB = sinon.stub().resolves();
const migrationA = utils.createTransactionalMigration(upA, downA);
const migrationB = utils.createTransactionalMigration(upB, downB);
const combinedMigration = utils.combineTransactionalMigrations(migrationA, migrationB);
should.ok(combinedMigration.config.transaction, 'Migration config should set transaction to true');
const upResult = combinedMigration.up(config);
should.ok(upResult instanceof Promise, 'Migration up method should return a Promise');
await upResult;
should.ok(upA.calledOnceWith(config.transacting), 'first up fn should be called with transacting');
should.ok(upB.calledOnceWith(config.transacting), 'second up fn should be called with transacting');
should.ok(upA.calledBefore(upB), 'Migration up method should call child up methods in order');
const downResult = combinedMigration.down(config);
should.ok(downResult instanceof Promise, 'Migration down method should return a Promise');
await downResult;
should.ok(downA.calledOnceWith(config.transacting), 'first down fn should be called with transacting');
should.ok(downB.calledOnceWith(config.transacting), 'second down fn should be called with transacting');
should.ok(downB.calledBefore(downA), 'Migration down method should call child down methods in reverse order');
});
it('Waits for each migration to finish before executing the next', async function () {
const config = {
transacting: {}
};
const upDeferredA = new Deferred();
const upDeferredB = new Deferred();
const downDeferredA = new Deferred();
const downDeferredB = new Deferred();
const upA = sinon.stub().returns(upDeferredA.promise);
const downA = sinon.stub().returns(downDeferredA.promise);
const upB = sinon.stub().returns(upDeferredB.promise);
const downB = sinon.stub().returns(downDeferredB.promise);
const migrationA = utils.createTransactionalMigration(upA, downA);
const migrationB = utils.createTransactionalMigration(upB, downB);
const combinedMigration = utils.combineTransactionalMigrations(migrationA, migrationB);
combinedMigration.up(config);
should.ok(!upB.called, 'second up fn should not be called until first up fn has resolved');
await upDeferredA.resolve();
should.ok(upB.calledOnce, 'second up fn should be called once first up fn has resolved');
combinedMigration.down(config);
should.ok(!downA.called, 'penultimate down fn should not be called until last down fn has resolved');
await downDeferredB.resolve();
should.ok(downA.calledOnce, 'penultimate down fn should be called once last down fn has resolved');
});
it('Stops execution if a migration errors, and propagates error', async function () {
const config = {
transacting: {}
};
const upDeferredA = new Deferred();
const upDeferredB = new Deferred();
const downDeferredA = new Deferred();
const downDeferredB = new Deferred();
const upA = sinon.stub().returns(upDeferredA.promise);
const downA = sinon.stub().returns(downDeferredA.promise);
const upB = sinon.stub().returns(upDeferredB.promise);
const downB = sinon.stub().returns(downDeferredB.promise);
const migrationA = utils.createTransactionalMigration(upA, downA);
const migrationB = utils.createTransactionalMigration(upB, downB);
const combinedMigration = utils.combineTransactionalMigrations(migrationA, migrationB);
const upResult = combinedMigration.up(config);
should.ok(!upB.called, 'second up fn should not be called until first up fn has resolved');
try {
await upDeferredA.reject(new Error('some reason'));
} catch (err) {
//
} finally {
should.ok(!upB.called, 'second up fn should not be called if first up fn has rejected');
}
let upError = null;
try {
await upResult;
} catch (err) {
upError = true;
} finally {
should.ok(upError, 'Migration up should error if child errors');
}
const downResult = combinedMigration.down(config);
should.ok(!downA.called, 'penultimate down fn should not be called until last down fn has resolved');
try {
await downDeferredB.reject(new Error('some reason'));
} catch (err) {
//
} finally {
should.ok(!downA.called, 'penultimate down fn should not be called if last down fn has rejected');
}
let downErr = null;
try {
await downResult;
} catch (err) {
downErr = err;
} finally {
should.ok(downErr, 'Migration down should error if child errors');
}
});
});
});
const Knex = require('knex');
const ObjectId = require('bson-objectid').default;
async function setupDb() {
const knex = Knex({
client: 'sqlite',
connection: {
filename: ':memory:'
},
// Suppress warnings from knex
useNullAsDefault: true
});
await knex.raw(`
CREATE TABLE \`permissions\` (
\`id\` varchar(24) not null,
\`name\` varchar(50) not null,
\`object_type\` varchar(50) not null,
\`action_type\` varchar(50) not null,
\`object_id\` varchar(24) null,
\`created_at\` datetime not null,
\`created_by\` varchar(24) not null,
\`updated_at\` datetime null,
\`updated_by\` varchar(24) null,
primary key (\`id\`)
);
CREATE UNIQUE INDEX \`permissions_name_unique\` on \`permissions\` (\`name\`);\`);
`);
await knex.raw(`
CREATE TABLE \`permissions_roles\` (
\`id\` varchar(24) not null,
\`role_id\` varchar(24) not null,
\`permission_id\` varchar(24) not null,
primary key (\`id\`)
);
`);
await knex.raw(`
CREATE TABLE \`roles\` (
\`id\` varchar(24) not null,
\`name\` varchar(50) not null,
\`description\` varchar(2000) null,
\`created_at\` datetime not null,
\`created_by\` varchar(24) not null,
\`updated_at\` datetime null,
\`updated_by\` varchar(24) null,
primary key (\`id\`)
);
CREATE UNIQUE INDEX \`roles_name_unique\` on \`roles\` (\`name\`);
`);
const date = knex.raw('CURRENT_TIMESTAMP');
await knex('roles').insert({
id: ObjectId().toHexString(),
name: 'Role Name',
description: 'Role description',
created_at: date,
created_by: utils.meta.MIGRATION_USER,
updated_at: date,
updated_by: utils.meta.MIGRATION_USER
});
await knex('roles').insert({
id: ObjectId().toHexString(),
name: 'Other Role Name',
description: 'Other Role description',
created_at: date,
created_by: utils.meta.MIGRATION_USER,
updated_at: date,
updated_by: utils.meta.MIGRATION_USER
});
await knex('permissions').insert({
id: ObjectId().toHexString(),
name: 'Permission Name',
action_type: 'action',
object_type: 'object',
created_at: date,
created_by: utils.meta.MIGRATION_USER,
updated_at: date,
updated_by: utils.meta.MIGRATION_USER
});
return knex;
}
async function runUpMigration(knex, migration) {
{
const transacting = await knex.transaction();
await migration.up({transacting});
transacting.commit();
}
return async function runDownMigration() {
const transacting = await knex.transaction();
await migration.down({transacting});
transacting.commit();
};
}
describe('migrations/utils/permissions', function () {
describe('addPermission', function () {
it('Accepts a name, action and object and returns a migration which adds a permission to the database', async function () {
const knex = await setupDb();
const migration = utils.addPermission({
name: 'scarface',
object: 'my little friend',
action: 'say hello'
});
should.ok(migration.config.transaction, 'addPermission creates a transactional migration');
const runDownMigration = await runUpMigration(knex, migration);
const allPermissionsAfterUp = await knex('permissions').select();
const insertedPermissionsAfterUp = allPermissionsAfterUp.filter((row) => {
return row.name === 'scarface';
});
should.ok(insertedPermissionsAfterUp.length === 1, 'The permission was inserted into the database');
await runDownMigration();
const allPermissionsAfterDown = await knex('permissions').select();
const insertedPermissionsAfterDown = allPermissionsAfterDown.filter((row) => {
return row.name === 'scarface';
});
should.ok(insertedPermissionsAfterDown.length === 0, 'The permission was removed');
});
});
describe('addPermissionToRole', function () {
it('Accepts a permission name and role name and returns a migration which adds a permission to the database', async function () {
const knex = await setupDb();
const migration = utils.addPermissionToRole({
permission: 'Permission Name',
role: 'Role Name'
});
const runDownMigration = await runUpMigration(knex, migration);
const allPermissionsForRoleAfterUp = await knex.raw(`
SELECT
p.name
FROM
permissions p
INNER JOIN
permissions_roles pr
ON
pr.permission_id = p.id
INNER JOIN
roles r
ON
pr.role_id = r.id
WHERE
r.name = 'Role Name';
`);
const attachedPermissionAfterUp = allPermissionsForRoleAfterUp.find((row) => {
return row.name === 'Permission Name';
});
should.ok(attachedPermissionAfterUp, 'The permission was attached to the role.');
await runDownMigration();
const allPermissionsForRoleAfterDown = await knex.raw(`
SELECT
p.name
FROM
permissions p
INNER JOIN
permissions_roles pr
ON
pr.permission_id = p.id
INNER JOIN
roles r
ON
pr.role_id = r.id
WHERE
r.name = 'Role Name';
`);
const attachedPermissionAfterDown = allPermissionsForRoleAfterDown.find((row) => {
return row.name === 'Permission Name';
});
should.ok(!attachedPermissionAfterDown, 'The permission was removed from the role.');
});
describe('Throws errors', function () {
it('Throws when permission cannot be found in up migration', async function () {
const knex = await setupDb();
const migration = utils.addPermissionToRole({
permission: 'Unimaginable',
role: 'Not there'
});
let runDownMigration;
try {
runDownMigration = await runUpMigration(knex, migration);
should.fail('addPermissionToRole up migration did not throw');
} catch (err) {
should.equal(err instanceof errors.GhostError, true);
err.message.should.equal('Cannot add permission(Unimaginable) with role(Not there) - permission does not exist');
}
});
it('Does not throw when permission cannot be found in down migration', async function () {
const knex = await setupDb();
const migration = utils.addPermissionToRole({
permission: 'Permission Name',
role: 'Role Name'
});
const runDownMigration = await runUpMigration(knex, migration);
await knex('permissions')
.where('name', '=', 'Permission Name')
.del();
await runDownMigration(knex, migration);
});
it('Throws when role cannot be found', async function () {
const knex = await setupDb();
const migration = utils.addPermissionToRole({
permission: 'Permission Name',
role: 'Not there'
});
try {
await runUpMigration(knex, migration);
should.fail('addPermissionToRole did not throw');
} catch (err) {
should.equal(err instanceof errors.GhostError, true);
err.message.should.equal('Cannot add permission(Permission Name) with role(Not there) - role does not exist');
}
});
it('Does not throw when role cannot be found in down migration', async function () {
const knex = await setupDb();
const migration = utils.addPermissionToRole({
permission: 'Permission Name',
role: 'Role Name'
});
const runDownMigration = await runUpMigration(knex, migration);
await knex('roles')
.where('name', '=', 'Role Name')
.del();
await runDownMigration(knex, migration);
});
});
});
describe('addPermissionWithRoles', function () {
it('Accepts addPermission config and a list of roles, and creates the permission, linking it to roles', async function () {
const knex = await setupDb();
const migration = utils.addPermissionWithRoles({
name: 'scarface',
object: 'my little friend',
action: 'say hello'
}, [
'Role Name',
'Other Role Name'
]);
const runDownMigration = await runUpMigration(knex, migration);
const allPermissionsAfterUp = await knex('permissions').select();
const insertedPermissionsAfterUp = allPermissionsAfterUp.filter((row) => {
return row.name === 'scarface';
});
should.ok(insertedPermissionsAfterUp.length === 1, 'The permission was inserted into the database');
const allPermissionsForRoleAfterUp = await knex.raw(`
SELECT
p.name
FROM
permissions p
INNER JOIN
permissions_roles pr
ON
pr.permission_id = p.id
INNER JOIN
roles r
ON
pr.role_id = r.id
WHERE
r.name = 'Role Name';
`);
const permissionAttachedToRoleAfterUp = allPermissionsForRoleAfterUp.find((row) => {
return row.name === 'scarface';
});
should.ok(permissionAttachedToRoleAfterUp, 'The permission was attached to the role.');
const allPermissionsForOtherRoleAfterUp = await knex.raw(`
SELECT
p.name
FROM
permissions p
INNER JOIN
permissions_roles pr
ON
pr.permission_id = p.id
INNER JOIN
roles r
ON
pr.role_id = r.id
WHERE
r.name = 'Other Role Name';
`);
const permissionAttachedToOtherRoleAfterUp = allPermissionsForRoleAfterUp.find((row) => {
return row.name === 'scarface';
});
should.ok(permissionAttachedToOtherRoleAfterUp, 'The permission was attached to the other role.');
await runDownMigration();
const allPermissionsAfterDown = await knex('permissions').select();
const insertedPermissionsAfterDown = allPermissionsAfterDown.filter((row) => {
return row.name === 'scarface';
});
should.ok(insertedPermissionsAfterDown.length === 0, 'The permission was removed from the database');
const allPermissionsForRoleAfterDown = await knex.raw(`
SELECT
p.name
FROM
permissions p
INNER JOIN
permissions_roles pr
ON
pr.permission_id = p.id
INNER JOIN
roles r
ON
pr.role_id = r.id
WHERE
r.name = 'Role Name';
`);
const permissionAttachedToRoleAfterDown = allPermissionsForRoleAfterDown.find((row) => {
return row.name === 'scarface';
});
should.ok(!permissionAttachedToRoleAfterDown, 'The permission was removed from the role.');
const allPermissionsForOtherRoleAfterDown = await knex.raw(`
SELECT
p.name
FROM
permissions p
INNER JOIN
permissions_roles pr
ON
pr.permission_id = p.id
INNER JOIN
roles r
ON
pr.role_id = r.id
WHERE
r.name = 'Other Role Name';
`);
const permissionAttachedToOtherRoleAfterDown = allPermissionsForOtherRoleAfterDown.find((row) => {
return row.name === 'scarface';
});
should.ok(!permissionAttachedToOtherRoleAfterDown, 'The permission was removed from the other role.');
});
});
});