Ghost/test/unit/server/models/base/crud.test.js
Fabien 'egg' O'Carroll 022c8c8e69
Added support for 'FOR UPDATE' lock (#14433)
refs https://github.com/TryGhost/Team/issues/1248

This is the underlying cause of the problems we've seen whilst handling
Stripe webhooks. A transaction ensures that the operations are atomic,
but not that they can run concurrently.

If you have some code which does this, concurrently:
1. Starts a transaction
2. Reads a value
3. Changes the values
4. Ends the transaction

Without applying the `FOR UPDATE` lock - you will have both sequenes
read the same value at step 2.

With the `FOR UPDATE` lock - one of the sequences will hang at step 2,
waiting for the other transaction to end, at which point it will resume
and read the _changed_ value.

Because the `edit` method explicitly does a read followed by a write, we
have also add the `FOR UPDATE` lock to this by default, to avoid any race
conditions. This does however require that `edit` is called within a
transaction. An issue here https://github.com/TryGhost/Team/issues/1503
considers running in a transaction by default.
2022-04-08 12:52:33 +01:00

287 lines
11 KiB
JavaScript

const errors = require('@tryghost/errors');
const should = require('should');
const sinon = require('sinon');
const models = require('../../../../../core/server/models');
describe('Models: crud', function () {
before(function () {
models.init();
});
afterEach(function () {
sinon.restore();
});
describe('destroy', function () {
it('forges model using destroyBy, fetches it, and calls destroy, passing filtered options', function () {
const unfilteredOptions = {
destroyBy: {
prop: 'whatever'
}
};
const model = models.Base.Model.forge({});
const filterOptionsSpy = sinon.spy(models.Base.Model, 'filterOptions');
const forgeStub = sinon.stub(models.Base.Model, 'forge')
.returns(model);
const fetchStub = sinon.stub(model, 'fetch')
.resolves(model);
const destroyStub = sinon.stub(model, 'destroy');
return models.Base.Model.destroy(unfilteredOptions).then(() => {
should.equal(filterOptionsSpy.args[0][0], unfilteredOptions);
should.equal(filterOptionsSpy.args[0][1], 'destroy');
should.deepEqual(forgeStub.args[0][0], {
prop: 'whatever'
});
const filteredOptions = filterOptionsSpy.returnValues[0];
should.equal(fetchStub.args[0][0], filteredOptions);
should.equal(destroyStub.args[0][0], filteredOptions);
});
});
it('uses options.id to forge model, if no destroyBy is provided', function () {
const unfilteredOptions = {
id: 23
};
const model = models.Base.Model.forge({});
const filterOptionsSpy = sinon.spy(models.Base.Model, 'filterOptions');
const forgeStub = sinon.stub(models.Base.Model, 'forge')
.returns(model);
const fetchStub = sinon.stub(model, 'fetch')
.resolves(model);
const destroyStub = sinon.stub(model, 'destroy');
return models.Base.Model.destroy(unfilteredOptions).then(() => {
should.equal(filterOptionsSpy.args[0][0], unfilteredOptions);
should.equal(filterOptionsSpy.args[0][1], 'destroy');
should.deepEqual(forgeStub.args[0][0], {
id: 23
});
const filteredOptions = filterOptionsSpy.returnValues[0];
should.equal(fetchStub.args[0][0], filteredOptions);
should.equal(destroyStub.args[0][0], filteredOptions);
});
});
});
describe('findOne', function () {
it('forges model using filtered data, fetches it passing filtered options and resolves with the fetched model', function () {
const data = {
id: 670
};
const unfilteredOptions = {
donny: 'donson'
};
const model = models.Base.Model.forge({});
const fetchedModel = models.Base.Model.forge({});
const filterOptionsSpy = sinon.spy(models.Base.Model, 'filterOptions');
const filterDataSpy = sinon.spy(models.Base.Model, 'filterData');
const forgeStub = sinon.stub(models.Base.Model, 'forge')
.returns(model);
const fetchStub = sinon.stub(model, 'fetch')
.resolves(fetchedModel);
const findOneReturnValue = models.Base.Model.findOne(data, unfilteredOptions);
return findOneReturnValue.then((result) => {
should.equal(result, fetchedModel);
should.equal(filterOptionsSpy.args[0][0], unfilteredOptions);
should.equal(filterOptionsSpy.args[0][1], 'findOne');
should.equal(filterDataSpy.args[0][0], data);
const filteredData = filterDataSpy.returnValues[0];
should.deepEqual(forgeStub.args[0][0], filteredData);
const filteredOptions = filterOptionsSpy.returnValues[0];
should.equal(fetchStub.args[0][0], filteredOptions);
});
});
it('Sets the `lock` option to "forUpdate" when the `forUpdate` and `transacting` options are passed', function () {
const data = {
id: 670
};
const unfilteredOptions = {
donny: 'donson',
forUpdate: true,
transacting: {}
};
const model = models.Base.Model.forge({});
const fetchedModel = models.Base.Model.forge({});
sinon.spy(models.Base.Model, 'filterOptions');
sinon.spy(models.Base.Model, 'filterData');
sinon.stub(models.Base.Model, 'forge')
.returns(model);
const fetchStub = sinon.stub(model, 'fetch')
.resolves(fetchedModel);
const findOneReturnValue = models.Base.Model.findOne(data, unfilteredOptions);
return findOneReturnValue.then((result) => {
should.equal(fetchStub.args[0][0].lock, 'forUpdate');
});
});
});
describe('edit', function () {
it('resolves with the savedModel after forges model w/ id, fetches w/ filtered options, saves w/ filtered data and options and method=update', function () {
const data = {
life: 'suffering'
};
const unfilteredOptions = {
id: 'something real special'
};
const model = models.Base.Model.forge({});
const savedModel = models.Base.Model.forge({});
const filterOptionsSpy = sinon.spy(models.Base.Model, 'filterOptions');
const filterDataSpy = sinon.spy(models.Base.Model, 'filterData');
const forgeStub = sinon.stub(models.Base.Model, 'forge')
.returns(model);
const fetchStub = sinon.stub(model, 'fetch')
.resolves(model);
const saveStub = sinon.stub(model, 'save')
.resolves(savedModel);
return models.Base.Model.edit(data, unfilteredOptions).then((result) => {
should.equal(result, savedModel);
should.equal(filterOptionsSpy.args[0][0], unfilteredOptions);
should.equal(filterOptionsSpy.args[0][1], 'edit');
should.equal(filterDataSpy.args[0][0], data);
const filteredOptions = filterOptionsSpy.returnValues[0];
should.deepEqual(forgeStub.args[0][0], {id: filteredOptions.id});
should.equal(fetchStub.args[0][0], filteredOptions);
should.equal(fetchStub.args[0][0].lock, undefined);
const filteredData = filterDataSpy.returnValues[0];
should.equal(saveStub.args[0][0], filteredData);
should.equal(saveStub.args[0][1].method, 'update');
should.deepEqual(saveStub.args[0][1], filteredOptions);
});
});
it('sets options.lock to "forUpdate" if options.transacting is present', function () {
const data = {
base: 'cannon'
};
const unfilteredOptions = {
transacting: {}
};
const model = models.Base.Model.forge({});
sinon.stub(models.Base.Model, 'forge')
.returns(model);
const fetchStub = sinon.stub(model, 'fetch')
.resolves();
return models.Base.Model.findOne(data, unfilteredOptions).then(() => {
should.equal(fetchStub.args[0][0].lock, undefined);
});
});
it('sets model.hasTimestamps to false if options.importing is truthy', function () {
const data = {
base: 'cannon'
};
const unfilteredOptions = {
importing: true
};
const model = models.Base.Model.forge({});
const forgeStub = sinon.stub(models.Base.Model, 'forge')
.returns(model);
const fetchStub = sinon.stub(model, 'fetch')
.resolves();
return models.Base.Model.findOne(data, unfilteredOptions).then(() => {
should.equal(model.hasTimestamps, true);
});
});
it('throws an error if model cannot be found on edit', function () {
const data = {
db: 'cooper'
};
const unfilteredOptions = {
id: 'something real special'
};
const model = models.Base.Model.forge({});
const filterOptionsSpy = sinon.spy(models.Base.Model, 'filterOptions');
const filterDataSpy = sinon.spy(models.Base.Model, 'filterData');
const forgeStub = sinon.stub(models.Base.Model, 'forge')
.returns(model);
const fetchStub = sinon.stub(model, 'fetch')
.resolves();
const saveSpy = sinon.stub(model, 'save');
return models.Base.Model.edit(data, unfilteredOptions).then(() => {
throw new Error('That should not happen');
}).catch((err) => {
(err instanceof errors.NotFoundError).should.be.true();
});
});
});
describe('add', function () {
it('forges model w/ filtered data, saves w/ null and options and method=insert', function () {
const data = {
rum: 'ham'
};
const unfilteredOptions = {};
const model = models.Base.Model.forge({});
const savedModel = models.Base.Model.forge({});
const filterOptionsSpy = sinon.spy(models.Base.Model, 'filterOptions');
const filterDataSpy = sinon.spy(models.Base.Model, 'filterData');
const forgeStub = sinon.stub(models.Base.Model, 'forge')
.returns(model);
const saveStub = sinon.stub(model, 'save')
.resolves(savedModel);
return models.Base.Model.add(data, unfilteredOptions).then((result) => {
should.equal(result, savedModel);
should.equal(filterOptionsSpy.args[0][0], unfilteredOptions);
should.equal(filterOptionsSpy.args[0][1], 'add');
should.equal(filterDataSpy.args[0][0], data);
const filteredData = filterDataSpy.returnValues[0];
should.deepEqual(forgeStub.args[0][0], filteredData);
const filteredOptions = filterOptionsSpy.returnValues[0];
should.equal(saveStub.args[0][0], null);
should.equal(saveStub.args[0][1].method, 'insert');
should.deepEqual(saveStub.args[0][1], filteredOptions);
});
});
it('sets model.hasTimestamps to false if options.importing is truthy', function () {
const data = {
newham: 'generals'
};
const unfilteredOptions = {
importing: true
};
const model = models.Base.Model.forge({});
const forgeStub = sinon.stub(models.Base.Model, 'forge')
.returns(model);
const saveStub = sinon.stub(model, 'save')
.resolves();
return models.Base.Model.add(data, unfilteredOptions).then(() => {
should.equal(model.hasTimestamps, false);
});
});
});
});