From 3127aac47c50ed143f45b3e47d7737d7ad52dffe Mon Sep 17 00:00:00 2001 From: Thibaut Patel Date: Thu, 21 Jan 2021 10:51:40 +0100 Subject: [PATCH] Added regression tests for the v3 endpoints refs https://github.com/TryGhost/Team/issues/221 --- .../api/v3/admin/authentication_spec.js | 398 ++++++++++ test/regression/api/v3/admin/db_spec.js | 172 +++++ .../api/v3/admin/identities_spec.js | 102 +++ test/regression/api/v3/admin/images_spec.js | 86 +++ test/regression/api/v3/admin/labels_spec.js | 70 ++ .../api/v3/admin/members_signin_url_spec.js | 119 +++ test/regression/api/v3/admin/members_spec.js | 645 ++++++++++++++++ .../api/v3/admin/notifications_spec.js | 198 +++++ test/regression/api/v3/admin/pages_spec.js | 47 ++ test/regression/api/v3/admin/posts_spec.js | 635 ++++++++++++++++ .../regression/api/v3/admin/redirects_spec.js | 429 +++++++++++ .../regression/api/v3/admin/schedules_spec.js | 184 +++++ test/regression/api/v3/admin/settings_spec.js | 717 ++++++++++++++++++ test/regression/api/v3/admin/slack_spec.js | 48 ++ test/regression/api/v3/admin/users_spec.js | 295 +++++++ test/regression/api/v3/admin/utils.js | 134 ++++ test/regression/api/v3/admin/webhooks_spec.js | 187 +++++ .../regression/api/v3/content/authors_spec.js | 56 ++ test/regression/api/v3/content/pages_spec.js | 68 ++ test/regression/api/v3/content/posts_spec.js | 407 ++++++++++ test/regression/api/v3/content/tags_spec.js | 87 +++ test/regression/api/v3/content/utils.js | 94 +++ test/regression/site/site_spec.js | 2 +- 23 files changed, 5179 insertions(+), 1 deletion(-) create mode 100644 test/regression/api/v3/admin/authentication_spec.js create mode 100644 test/regression/api/v3/admin/db_spec.js create mode 100644 test/regression/api/v3/admin/identities_spec.js create mode 100644 test/regression/api/v3/admin/images_spec.js create mode 100644 test/regression/api/v3/admin/labels_spec.js create mode 100644 test/regression/api/v3/admin/members_signin_url_spec.js create mode 100644 test/regression/api/v3/admin/members_spec.js create mode 100644 test/regression/api/v3/admin/notifications_spec.js create mode 100644 test/regression/api/v3/admin/pages_spec.js create mode 100644 test/regression/api/v3/admin/posts_spec.js create mode 100644 test/regression/api/v3/admin/redirects_spec.js create mode 100644 test/regression/api/v3/admin/schedules_spec.js create mode 100644 test/regression/api/v3/admin/settings_spec.js create mode 100644 test/regression/api/v3/admin/slack_spec.js create mode 100644 test/regression/api/v3/admin/users_spec.js create mode 100644 test/regression/api/v3/admin/utils.js create mode 100644 test/regression/api/v3/admin/webhooks_spec.js create mode 100644 test/regression/api/v3/content/authors_spec.js create mode 100644 test/regression/api/v3/content/pages_spec.js create mode 100644 test/regression/api/v3/content/posts_spec.js create mode 100644 test/regression/api/v3/content/tags_spec.js create mode 100644 test/regression/api/v3/content/utils.js diff --git a/test/regression/api/v3/admin/authentication_spec.js b/test/regression/api/v3/admin/authentication_spec.js new file mode 100644 index 0000000000..98056d3a1a --- /dev/null +++ b/test/regression/api/v3/admin/authentication_spec.js @@ -0,0 +1,398 @@ +const should = require('should'); +const sinon = require('sinon'); +const supertest = require('supertest'); +const localUtils = require('./utils'); +const testUtils = require('../../../../utils/index'); +const models = require('../../../../../core/server/models/index'); +const security = require('@tryghost/security'); +const settingsCache = require('../../../../../core/server/services/settings/cache'); +const config = require('../../../../../core/shared/config/index'); +const mailService = require('../../../../../core/server/services/mail/index'); + +let ghost = testUtils.startGhost; +let request; + +describe('Authentication API v3', function () { + let ghostServer; + + describe('Blog setup', function () { + before(function () { + return ghost({forceStart: true}) + .then(function (_ghostServer) { + ghostServer = _ghostServer; + request = supertest.agent(config.get('url')); + }); + }); + + beforeEach(function () { + sinon.stub(mailService.GhostMailer.prototype, 'send').resolves('Mail is disabled'); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('is setup? no', function () { + return request + .get(localUtils.API.getApiQuery('authentication/setup')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect(200) + .then((res) => { + res.body.setup[0].status.should.be.false(); + }); + }); + + it('complete setup', function () { + return request + .post(localUtils.API.getApiQuery('authentication/setup')) + .set('Origin', config.get('url')) + .send({ + setup: [{ + name: 'test user', + email: 'test@example.com', + password: 'thisissupersafe', + blogTitle: 'a test blog' + }] + }) + .expect('Content-Type', /json/) + .expect(201) + .then((res) => { + const jsonResponse = res.body; + should.exist(jsonResponse.users); + should.not.exist(jsonResponse.meta); + + jsonResponse.users.should.have.length(1); + localUtils.API.checkResponse(jsonResponse.users[0], 'user'); + + const newUser = jsonResponse.users[0]; + newUser.id.should.equal(testUtils.DataGenerator.Content.users[0].id); + newUser.name.should.equal('test user'); + newUser.email.should.equal('test@example.com'); + + mailService.GhostMailer.prototype.send.called.should.be.true(); + mailService.GhostMailer.prototype.send.args[0][0].to.should.equal('test@example.com'); + }); + }); + + it('is setup? yes', function () { + return request + .get(localUtils.API.getApiQuery('authentication/setup')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect(200) + .then((res) => { + res.body.setup[0].status.should.be.true(); + }); + }); + + it('complete setup again', function () { + return request + .post(localUtils.API.getApiQuery('authentication/setup')) + .set('Origin', config.get('url')) + .send({ + setup: [{ + name: 'test user', + email: 'test-leo@example.com', + password: 'thisissupersafe', + blogTitle: 'a test blog' + }] + }) + .expect('Content-Type', /json/) + .expect(403); + }); + + it('update setup', function () { + return localUtils.doAuth(request) + .then(() => { + return request + .put(localUtils.API.getApiQuery('authentication/setup')) + .set('Origin', config.get('url')) + .send({ + setup: [{ + name: 'test user edit', + email: 'test-edit@example.com', + password: 'thisissupersafe', + blogTitle: 'a test blog' + }] + }) + .expect('Content-Type', /json/) + .expect(200); + }) + .then((res) => { + const jsonResponse = res.body; + should.exist(jsonResponse.users); + should.not.exist(jsonResponse.meta); + + jsonResponse.users.should.have.length(1); + localUtils.API.checkResponse(jsonResponse.users[0], 'user'); + + const newUser = jsonResponse.users[0]; + newUser.id.should.equal(testUtils.DataGenerator.Content.users[0].id); + newUser.name.should.equal('test user edit'); + newUser.email.should.equal('test-edit@example.com'); + }); + }); + }); + + describe('Invitation', function () { + before(function () { + return ghost() + .then(function (_ghostServer) { + ghostServer = _ghostServer; + request = supertest.agent(config.get('url')); + + // simulates blog setup (initialises the owner) + return localUtils.doAuth(request, 'invites'); + }); + }); + + it('check invite with invalid email', function () { + return request + .get(localUtils.API.getApiQuery('authentication/invitation?email=invalidemail')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect(400); + }); + + it('check valid invite', function () { + return request + .get(localUtils.API.getApiQuery(`authentication/invitation?email=${testUtils.DataGenerator.forKnex.invites[0].email}`)) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect(200) + .then((res) => { + res.body.invitation[0].valid.should.equal(true); + }); + }); + + it('check invalid invite', function () { + return request + .get(localUtils.API.getApiQuery(`authentication/invitation?email=notinvited@example.org`)) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect(200) + .then((res) => { + res.body.invitation[0].valid.should.equal(false); + }); + }); + + it('try to accept without invite', function () { + return request + .post(localUtils.API.getApiQuery('authentication/invitation')) + .set('Origin', config.get('url')) + .send({ + invitation: [{ + token: 'lul11111', + password: 'lel123456', + email: 'not-invited@example.org', + name: 'not invited' + }] + }) + .expect('Content-Type', /json/) + .expect(404); + }); + + it('try to accept with invite and existing email address', function () { + return request + .post(localUtils.API.getApiQuery('authentication/invitation')) + .set('Origin', config.get('url')) + .send({ + invitation: [{ + token: testUtils.DataGenerator.forKnex.invites[0].token, + password: '12345678910', + email: testUtils.DataGenerator.forKnex.users[0].email, + name: 'invited' + }] + }) + .expect('Content-Type', /json/) + .expect(422); + }); + + it('try to accept with invite', function () { + return request + .post(localUtils.API.getApiQuery('authentication/invitation')) + .set('Origin', config.get('url')) + .send({ + invitation: [{ + token: testUtils.DataGenerator.forKnex.invites[0].token, + password: '12345678910', + email: testUtils.DataGenerator.forKnex.invites[0].email, + name: 'invited' + }] + }) + .expect('Content-Type', /json/) + .expect(200) + .then((res) => { + res.body.invitation[0].message.should.equal('Invitation accepted.'); + }); + }); + }); + + describe('Password reset', function () { + const user = testUtils.DataGenerator.forModel.users[0]; + + before(function () { + return ghost({forceStart: true}) + .then(() => { + request = supertest.agent(config.get('url')); + }) + .then(() => { + return localUtils.doAuth(request); + }); + }); + + beforeEach(function () { + sinon.stub(mailService.GhostMailer.prototype, 'send').resolves('Mail is disabled'); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('reset password', function (done) { + models.User.getOwnerUser(testUtils.context.internal) + .then(function (ownerUser) { + const token = security.tokens.resetToken.generateHash({ + expires: Date.now() + (1000 * 60), + email: user.email, + dbHash: settingsCache.get('db_hash'), + password: ownerUser.get('password') + }); + + request.put(localUtils.API.getApiQuery('authentication/passwordreset')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .send({ + passwordreset: [{ + token: token, + newPassword: 'thisissupersafe', + ne2Password: 'thisissupersafe' + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err, res) { + if (err) { + return done(err); + } + + const jsonResponse = res.body; + should.exist(jsonResponse.passwordreset[0].message); + jsonResponse.passwordreset[0].message.should.equal('Password changed successfully.'); + done(); + }); + }) + .catch(done); + }); + + it('reset password: invalid token', function () { + return request + .put(localUtils.API.getApiQuery('authentication/passwordreset')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .send({ + passwordreset: [{ + token: 'invalid', + newPassword: 'thisissupersafe', + ne2Password: 'thisissupersafe' + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(401) + .then((res) => { + should.exist(res.body.errors); + res.body.errors[0].type.should.eql('UnauthorizedError'); + res.body.errors[0].message.should.eql('Cannot reset password.'); + res.body.errors[0].context.should.eql('Invalid password reset link.'); + }); + }); + + it('reset password: expired token', function () { + return models.User.getOwnerUser(testUtils.context.internal) + .then(function (ownerUser) { + const dateInThePast = Date.now() - (1000 * 60); + const token = security.tokens.resetToken.generateHash({ + expires: dateInThePast, + email: user.email, + dbHash: settingsCache.get('db_hash'), + password: ownerUser.get('password') + }); + + return request + .put(localUtils.API.getApiQuery('authentication/passwordreset')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .send({ + passwordreset: [{ + token: token, + newPassword: 'thisissupersafe', + ne2Password: 'thisissupersafe' + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(400); + }) + .then((res) => { + should.exist(res.body.errors); + res.body.errors[0].type.should.eql('BadRequestError'); + res.body.errors[0].message.should.eql('Cannot reset password.'); + res.body.errors[0].context.should.eql('Password reset link expired.'); + }); + }); + + it('reset password: unmatched token', function () { + const token = security.tokens.resetToken.generateHash({ + expires: Date.now() + (1000 * 60), + email: user.email, + dbHash: settingsCache.get('db_hash'), + password: 'invalid_password' + }); + + return request + .put(localUtils.API.getApiQuery('authentication/passwordreset')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .send({ + passwordreset: [{ + token: token, + newPassword: 'thisissupersafe', + ne2Password: 'thisissupersafe' + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(400) + .then((res) => { + should.exist(res.body.errors); + res.body.errors[0].type.should.eql('BadRequestError'); + res.body.errors[0].message.should.eql('Cannot reset password.'); + res.body.errors[0].context.should.eql('Password reset link has already been used.'); + }); + }); + + it('reset password: generate reset token', function () { + return request + .post(localUtils.API.getApiQuery('authentication/passwordreset')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .send({ + passwordreset: [{ + email: user.email + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + const jsonResponse = res.body; + should.exist(jsonResponse.passwordreset[0].message); + jsonResponse.passwordreset[0].message.should.equal('Check your email for further instructions.'); + mailService.GhostMailer.prototype.send.args[0][0].to.should.equal(user.email); + }); + }); + }); +}); diff --git a/test/regression/api/v3/admin/db_spec.js b/test/regression/api/v3/admin/db_spec.js new file mode 100644 index 0000000000..2a5ea061ef --- /dev/null +++ b/test/regression/api/v3/admin/db_spec.js @@ -0,0 +1,172 @@ +const path = require('path'); +const _ = require('lodash'); +const os = require('os'); +const fs = require('fs-extra'); +const uuid = require('uuid'); +const should = require('should'); +const supertest = require('supertest'); +const sinon = require('sinon'); +const config = require('../../../../../core/shared/config'); +const {events} = require('../../../../../core/server/lib/common'); +const testUtils = require('../../../../utils'); +const localUtils = require('./utils'); + +let ghost = testUtils.startGhost; +let request; +let eventsTriggered; + +describe('DB API', function () { + let backupKey; + let schedulerKey; + + before(function () { + return ghost() + .then(() => { + request = supertest.agent(config.get('url')); + }) + .then(() => { + return localUtils.doAuth(request); + }) + .then(() => { + backupKey = _.find(testUtils.existingData.apiKeys, {integration: {slug: 'ghost-backup'}}); + schedulerKey = _.find(testUtils.existingData.apiKeys, {integration: {slug: 'ghost-scheduler'}}); + }); + }); + + beforeEach(function () { + eventsTriggered = {}; + + sinon.stub(events, 'emit').callsFake((eventName, eventObj) => { + if (!eventsTriggered[eventName]) { + eventsTriggered[eventName] = []; + } + + eventsTriggered[eventName].push(eventObj); + }); + }); + + afterEach(function () { + sinon.restore(); + }); + + // SKIPPED: we no longer have the "extra" clients and client_trusted_domains tables + it.skip('can export the database with more tables', function () { + return request.get(localUtils.API.getApiQuery('db/?include=clients,client_trusted_domains')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect(200) + .then((res) => { + const jsonResponse = res.body; + should.exist(jsonResponse.db); + jsonResponse.db.should.have.length(1); + Object.keys(jsonResponse.db[0].data).length.should.eql(29); + }); + }); + + it('can export & import', function () { + const exportFolder = path.join(os.tmpdir(), uuid.v4()); + const exportPath = path.join(exportFolder, 'export.json'); + + return request.put(localUtils.API.getApiQuery('settings/')) + .set('Origin', config.get('url')) + .send({ + settings: [ + { + key: 'is_private', + value: true + } + ] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then(() => { + return request.get(localUtils.API.getApiQuery('db/')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect(200); + }) + .then((res) => { + const jsonResponse = res.body; + should.exist(jsonResponse.db); + + fs.ensureDirSync(exportFolder); + fs.writeJSONSync(exportPath, jsonResponse); + + return request.post(localUtils.API.getApiQuery('db/')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .attach('importfile', exportPath) + .expect(200); + }) + .then((res) => { + res.body.problems.length.should.eql(3); + fs.removeSync(exportFolder); + }); + }); + + it('fails when triggering an export from unknown filename ', function () { + return request.get(localUtils.API.getApiQuery('db/?filename=this_file_is_not_here.json')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(404); + }); + + it('import should fail without file', function () { + return request.post(localUtils.API.getApiQuery('db/')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(422); + }); + + it('import should fail with unsupported file', function () { + return request.post(localUtils.API.getApiQuery('db/')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .attach('importfile', path.join(__dirname, '/../../../../utils/fixtures/csv/single-column-with-header.csv')) + .expect(415); + }); + + it('export can be triggered by backup integration', function () { + const backupQuery = `?filename=test`; + const fsStub = sinon.stub(fs, 'writeFile').resolves(); + + return request.post(localUtils.API.getApiQuery(`db/backup${backupQuery}`)) + .set('Authorization', `Ghost ${localUtils.getValidAdminToken('/v3/admin/', backupKey)}`) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect(200) + .then((res) => { + res.body.should.be.Object(); + res.body.db[0].filename.should.match(/test\.json/); + fsStub.calledOnce.should.eql(true); + }); + }); + + it('export can not be triggered by integration other than backup', function () { + const fsStub = sinon.stub(fs, 'writeFile').resolves(); + + return request.post(localUtils.API.getApiQuery(`db/backup`)) + .set('Authorization', `Ghost ${localUtils.getValidAdminToken('/v3/admin/', schedulerKey)}`) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect(403) + .then((res) => { + should.exist(res.body.errors); + res.body.errors[0].type.should.eql('NoPermissionError'); + fsStub.called.should.eql(false); + }); + }); + + it('export can be triggered by Admin authentication', function () { + const fsStub = sinon.stub(fs, 'writeFile').resolves(); + + return request.post(localUtils.API.getApiQuery(`db/backup`)) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect(200); + }); +}); diff --git a/test/regression/api/v3/admin/identities_spec.js b/test/regression/api/v3/admin/identities_spec.js new file mode 100644 index 0000000000..9d91d801b0 --- /dev/null +++ b/test/regression/api/v3/admin/identities_spec.js @@ -0,0 +1,102 @@ +const should = require('should'); +const supertest = require('supertest'); +const jwt = require('jsonwebtoken'); +const jwksClient = require('jwks-rsa'); +const testUtils = require('../../../../utils'); +const localUtils = require('./utils'); +const config = require('../../../../../core/shared/config'); + +const ghost = testUtils.startGhost; + +let request; + +const verifyJWKS = (endpoint, token) => { + return new Promise((resolve, reject) => { + const client = jwksClient({ + jwksUri: endpoint + }); + + function getKey(header, callback){ + client.getSigningKey(header.kid, (err, key) => { + let signingKey = key.publicKey || key.rsaPublicKey; + callback(null, signingKey); + }); + } + + jwt.verify(token, getKey, {}, (err, decoded) => { + if (err) { + reject(err); + } + + resolve(decoded); + }); + }); +}; + +describe('Identities API', function () { + describe('As Owner', function () { + before(function () { + return ghost() + .then(function () { + request = supertest.agent(config.get('url')); + }) + .then(function () { + return localUtils.doAuth(request); + }); + }); + + it('Can create JWT token and verify it afterwards with public jwks', function () { + let identity; + + return request + .get(localUtils.API.getApiQuery(`identities/`)) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse); + should.exist(jsonResponse.identities); + + identity = jsonResponse.identities[0]; + }) + .then(() => { + return verifyJWKS(`${request.app}/ghost/.well-known/jwks.json`, identity.token); + }) + .then((decoded) => { + decoded.sub.should.equal('jbloggs@example.com'); + }); + }); + }); + + describe('As non-Owner', function () { + before(function () { + return ghost() + .then(function (_ghostServer) { + request = supertest.agent(config.get('url')); + }) + .then(function () { + return testUtils.createUser({ + user: testUtils.DataGenerator.forKnex.createUser({email: 'admin+1@ghost.org'}), + role: testUtils.DataGenerator.Content.roles[0].name + }); + }) + .then(function (admin) { + request.user = admin; + + return localUtils.doAuth(request); + }); + }); + + it('Cannot read', function () { + return request + .get(localUtils.API.getApiQuery(`identities/`)) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(403); + }); + }); +}); diff --git a/test/regression/api/v3/admin/images_spec.js b/test/regression/api/v3/admin/images_spec.js new file mode 100644 index 0000000000..7c63149e8f --- /dev/null +++ b/test/regression/api/v3/admin/images_spec.js @@ -0,0 +1,86 @@ +const path = require('path'); +const fs = require('fs-extra'); +const should = require('should'); +const supertest = require('supertest'); +const localUtils = require('./utils'); +const testUtils = require('../../../../utils'); +const config = require('../../../../../core/shared/config'); + +const ghost = testUtils.startGhost; + +describe('Images API', function () { + const images = []; + let request; + + before(function () { + return ghost() + .then(function () { + request = supertest.agent(config.get('url')); + }) + .then(function () { + return localUtils.doAuth(request); + }); + }); + + after(function () { + images.forEach(function (image) { + fs.removeSync(config.get('paths').appRoot + image); + }); + }); + + it('Can\'t import fail without file', function () { + return request + .post(localUtils.API.getApiQuery('images/upload')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(422); + }); + + it('Can\'t import with unsupported file', function (done) { + request.post(localUtils.API.getApiQuery('images/upload')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .attach('file', path.join(__dirname, '/../../../../utils/fixtures/csv/single-column-with-header.csv')) + .expect(415) + .end(function (err) { + if (err) { + return done(err); + } + + done(); + }); + }); + + it('Can\'t upload incorrect extension', function (done) { + request.post(localUtils.API.getApiQuery('images/upload')) + .set('Origin', config.get('url')) + .set('content-type', 'image/png') + .expect('Content-Type', /json/) + .attach('file', path.join(__dirname, '/../../../../utils/fixtures/images/ghost-logo.pngx')) + .expect(415) + .end(function (err) { + if (err) { + return done(err); + } + + done(); + }); + }); + + it('Can\'t import if profile image is not square', function (done) { + request.post(localUtils.API.getApiQuery('images/upload')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .field('purpose', 'profile_image') + .attach('file', path.join(__dirname, '/../../../../utils/fixtures/images/favicon_not_square.png')) + .expect(422) + .end(function (err) { + if (err) { + return done(err); + } + + done(); + }); + }); +}); diff --git a/test/regression/api/v3/admin/labels_spec.js b/test/regression/api/v3/admin/labels_spec.js new file mode 100644 index 0000000000..d604072ec6 --- /dev/null +++ b/test/regression/api/v3/admin/labels_spec.js @@ -0,0 +1,70 @@ +const path = require('path'); +const should = require('should'); +const supertest = require('supertest'); +const sinon = require('sinon'); +const testUtils = require('../../../../utils'); +const localUtils = require('./utils'); +const config = require('../../../../../core/shared/config'); + +const ghost = testUtils.startGhost; + +let request; + +describe('Labels API', function () { + after(function () { + sinon.restore(); + }); + + before(function () { + return ghost() + .then(function () { + request = supertest.agent(config.get('url')); + }) + .then(function () { + return localUtils.doAuth(request); + }); + }); + + it('Errors when adding label with the same name', function () { + const label = { + name: 'test' + }; + + return request + .post(localUtils.API.getApiQuery(`labels/`)) + .send({labels: [label]}) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(201) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse); + should.exist(jsonResponse.labels); + jsonResponse.labels.should.have.length(1); + + jsonResponse.labels[0].name.should.equal(label.name); + jsonResponse.labels[0].slug.should.equal(label.name); + }) + .then(() => { + return request + .post(localUtils.API.getApiQuery(`labels/`)) + .send({labels: [label]}) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(422); + }) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse); + should.exist(jsonResponse.errors); + jsonResponse.errors.should.have.length(1); + + jsonResponse.errors[0].type.should.equal('ValidationError'); + jsonResponse.errors[0].context.should.equal('Label already exists'); + }); + }); +}); diff --git a/test/regression/api/v3/admin/members_signin_url_spec.js b/test/regression/api/v3/admin/members_signin_url_spec.js new file mode 100644 index 0000000000..bb951d1d56 --- /dev/null +++ b/test/regression/api/v3/admin/members_signin_url_spec.js @@ -0,0 +1,119 @@ +const path = require('path'); +const should = require('should'); +const supertest = require('supertest'); +const sinon = require('sinon'); +const testUtils = require('../../../../utils'); +const localUtils = require('./utils'); +const config = require('../../../../../core/shared/config'); +const labs = require('../../../../../core/server/services/labs'); + +const ghost = testUtils.startGhost; + +let request; + +describe('Members Sigin URL API', function () { + before(function () { + sinon.stub(labs, 'isSet').withArgs('members').returns(true); + }); + + after(function () { + sinon.restore(); + }); + + describe('As Owner', function () { + before(function () { + return ghost() + .then(function () { + request = supertest.agent(config.get('url')); + }) + .then(function () { + return localUtils.doAuth(request, 'member'); + }); + }); + + it('Can read', function () { + return request + .get(localUtils.API.getApiQuery(`members/${testUtils.DataGenerator.Content.members[0].id}/signin_urls/`)) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse); + should.exist(jsonResponse.member_signin_urls); + jsonResponse.member_signin_urls.should.have.length(1); + localUtils.API.checkResponse(jsonResponse.member_signin_urls[0], 'member_signin_url'); + }); + }); + }); + + describe('As Admin', function () { + before(function () { + return ghost() + .then(function () { + request = supertest.agent(config.get('url')); + }) + .then(function () { + return testUtils.createUser({ + user: testUtils.DataGenerator.forKnex.createUser({email: 'admin+1@ghost.org'}), + role: testUtils.DataGenerator.Content.roles[0].name + }); + }) + .then(function (admin) { + request.user = admin; + + return localUtils.doAuth(request, 'member'); + }); + }); + + it('Can read', function () { + return request + .get(localUtils.API.getApiQuery(`members/${testUtils.DataGenerator.Content.members[0].id}/signin_urls/`)) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse); + should.exist(jsonResponse.member_signin_urls); + jsonResponse.member_signin_urls.should.have.length(1); + localUtils.API.checkResponse(jsonResponse.member_signin_urls[0], 'member_signin_url'); + }); + }); + }); + + describe('As non-Owner and non-Admin', function () { + before(function () { + return ghost() + .then(function (_ghostServer) { + request = supertest.agent(config.get('url')); + }) + .then(function () { + return testUtils.createUser({ + user: testUtils.DataGenerator.forKnex.createUser({ + email: 'test+editor@ghost.org' + }), + role: testUtils.DataGenerator.Content.roles[1].name + }); + }) + .then((user) => { + request.user = user; + + return localUtils.doAuth(request, 'member'); + }); + }); + + it('Cannot read', function () { + return request + .get(localUtils.API.getApiQuery(`members/${testUtils.DataGenerator.Content.members[0].id}/signin_urls/`)) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(403); + }); + }); +}); diff --git a/test/regression/api/v3/admin/members_spec.js b/test/regression/api/v3/admin/members_spec.js new file mode 100644 index 0000000000..a4eb43579d --- /dev/null +++ b/test/regression/api/v3/admin/members_spec.js @@ -0,0 +1,645 @@ +const path = require('path'); +const should = require('should'); +const supertest = require('supertest'); +const sinon = require('sinon'); +const testUtils = require('../../../../utils'); +const localUtils = require('./utils'); +const config = require('../../../../../core/shared/config'); +const labs = require('../../../../../core/server/services/labs'); + +const ghost = testUtils.startGhost; + +let request; + +describe('Members API', function () { + before(function () { + sinon.stub(labs, 'isSet').withArgs('members').returns(true); + }); + + after(function () { + sinon.restore(); + }); + + before(function () { + return ghost() + .then(function () { + request = supertest.agent(config.get('url')); + }) + .then(function () { + return localUtils.doAuth(request, 'members'); + }); + }); + + it('Can order by email_open_rate', async function () { + await request + .get(localUtils.API.getApiQuery('members/?order=email_open_rate%20desc')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse.members); + localUtils.API.checkResponse(jsonResponse, 'members'); + jsonResponse.members.should.have.length(4); + + jsonResponse.members[0].email.should.equal('paid@test.com'); + jsonResponse.members[0].email_open_rate.should.equal(80); + jsonResponse.members[1].email.should.equal('member2@test.com'); + jsonResponse.members[1].email_open_rate.should.equal(50); + jsonResponse.members[2].email.should.equal('member1@test.com'); + should.equal(null, jsonResponse.members[2].email_open_rate); + jsonResponse.members[3].email.should.equal('trialing@test.com'); + should.equal(null, jsonResponse.members[3].email_open_rate); + }); + + await request + .get(localUtils.API.getApiQuery('members/?order=email_open_rate%20asc')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + const jsonResponse = res.body; + localUtils.API.checkResponse(jsonResponse, 'members'); + jsonResponse.members.should.have.length(4); + + jsonResponse.members[0].email.should.equal('member2@test.com'); + jsonResponse.members[0].email_open_rate.should.equal(50); + jsonResponse.members[1].email.should.equal('paid@test.com'); + jsonResponse.members[1].email_open_rate.should.equal(80); + jsonResponse.members[2].email.should.equal('member1@test.com'); + should.equal(null, jsonResponse.members[2].email_open_rate); + jsonResponse.members[3].email.should.equal('trialing@test.com'); + should.equal(null, jsonResponse.members[3].email_open_rate); + }); + }); + + it('Can search by case-insensitive name', function () { + return request + .get(localUtils.API.getApiQuery('members/?search=egg')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse); + should.exist(jsonResponse.members); + jsonResponse.members.should.have.length(1); + jsonResponse.members[0].email.should.equal('member1@test.com'); + localUtils.API.checkResponse(jsonResponse, 'members'); + localUtils.API.checkResponse(jsonResponse.members[0], 'member', 'stripe'); + localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination'); + }); + }); + + it('Can search by case-insensitive email', function () { + return request + .get(localUtils.API.getApiQuery('members/?search=MEMBER2')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse); + should.exist(jsonResponse.members); + jsonResponse.members.should.have.length(1); + jsonResponse.members[0].email.should.equal('member2@test.com'); + localUtils.API.checkResponse(jsonResponse, 'members'); + localUtils.API.checkResponse(jsonResponse.members[0], 'member', 'stripe'); + localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination'); + }); + }); + + it('Can search for paid members', function () { + return request + .get(localUtils.API.getApiQuery('members/?search=egon&paid=true')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse); + should.exist(jsonResponse.members); + jsonResponse.members.should.have.length(1); + jsonResponse.members[0].email.should.equal('paid@test.com'); + localUtils.API.checkResponse(jsonResponse, 'members'); + localUtils.API.checkResponse(jsonResponse.members[0], 'member', 'stripe'); + localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination'); + }); + }); + + it('Search for non existing member returns empty result set', function () { + return request + .get(localUtils.API.getApiQuery('members/?search=do_not_exist')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse); + should.exist(jsonResponse.members); + jsonResponse.members.should.have.length(0); + }); + }); + + it('Add should fail when passing incorrect email_type query parameter', function () { + const member = { + name: 'test', + email: 'memberTestAdd@test.com' + }; + + return request + .post(localUtils.API.getApiQuery(`members/?send_email=true&email_type=lel`)) + .send({members: [member]}) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(422); + }); + + it('Add should fail when comped flag is passed in but Stripe is not enabled', function () { + const member = { + email: 'memberTestAdd@test.com', + comped: true + }; + + return request + .post(localUtils.API.getApiQuery(`members/`)) + .send({members: [member]}) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(422) + .then((res) => { + const jsonResponse = res.body; + + should.exist(jsonResponse); + should.exist(jsonResponse.errors); + + jsonResponse.errors[0].message.should.eql('Validation error, cannot save member.'); + jsonResponse.errors[0].context.should.match(/Missing Stripe connection./); + }); + }); + + // NOTE: this test should be enabled and expanded once test suite fully supports Stripe mocking + it.skip('Can set a "Complimentary" subscription', function () { + const memberToChange = { + name: 'Comped Member', + email: 'member2comp@test.com' + }; + + const memberChanged = { + comped: true + }; + + return request + .post(localUtils.API.getApiQuery(`members/`)) + .send({members: [memberToChange]}) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(201) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse); + should.exist(jsonResponse.members); + jsonResponse.members.should.have.length(1); + + return jsonResponse.members[0]; + }) + .then((newMember) => { + return request + .put(localUtils.API.getApiQuery(`members/${newMember.id}/`)) + .send({members: [memberChanged]}) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + + const jsonResponse = res.body; + + should.exist(jsonResponse); + should.exist(jsonResponse.members); + jsonResponse.members.should.have.length(1); + localUtils.API.checkResponse(jsonResponse.members[0], 'member', 'stripe'); + jsonResponse.members[0].name.should.equal(memberToChange.name); + jsonResponse.members[0].email.should.equal(memberToChange.email); + jsonResponse.members[0].comped.should.equal(memberToChange.comped); + }); + }); + }); + + it('Can delete a member without cancelling Stripe Subscription', async function () { + const member = { + name: 'Member 2 Delete', + email: 'Member2Delete@test.com' + }; + + const createdMember = await request.post(localUtils.API.getApiQuery(`members/`)) + .send({members: [member]}) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(201) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse); + should.exist(jsonResponse.members); + jsonResponse.members.should.have.length(1); + + return jsonResponse.members[0]; + }); + + await request.delete(localUtils.API.getApiQuery(`members/${createdMember.id}/`)) + .set('Origin', config.get('url')) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(204) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + + const jsonResponse = res.body; + + should.exist(jsonResponse); + }); + }); + + // NOTE: this test should be enabled and expanded once test suite fully supports Stripe mocking + it.skip('Can delete a member and cancel Stripe Subscription', async function () { + const member = { + name: 'Member 2 Delete', + email: 'Member2Delete@test.com', + comped: true + }; + + const createdMember = await request.post(localUtils.API.getApiQuery(`members/`)) + .send({members: [member]}) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(201) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse); + should.exist(jsonResponse.members); + jsonResponse.members.should.have.length(1); + + return jsonResponse.members[0]; + }); + + await request.delete(localUtils.API.getApiQuery(`members/${createdMember.id}/?cancel=true`)) + .set('Origin', config.get('url')) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(204) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + + const jsonResponse = res.body; + + should.exist(jsonResponse); + }); + }); + + // NOTE: this test should be enabled and expanded once test suite fully supports Stripe mocking + it.skip('Does not cancel Stripe Subscription if cancel_subscriptions is not set to "true"', async function () { + const member = { + name: 'Member 2 Delete', + email: 'Member2Delete@test.com', + comped: true + }; + + const createdMember = await request.post(localUtils.API.getApiQuery(`members/`)) + .send({members: [member]}) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(201) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse); + should.exist(jsonResponse.members); + jsonResponse.members.should.have.length(1); + + return jsonResponse.members[0]; + }); + + await request.delete(localUtils.API.getApiQuery(`members/${createdMember.id}/?cancel=false`)) + .set('Origin', config.get('url')) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(204) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + + const jsonResponse = res.body; + + should.exist(jsonResponse); + }); + }); + + it('Can import CSV with minimum one field and labels', function () { + let importLabel; + + return request + .post(localUtils.API.getApiQuery(`members/upload/`)) + .field('labels', ['global-label-1', 'global-label-1']) + .attach('membersfile', path.join(__dirname, '/../../../../utils/fixtures/csv/valid-members-labels.csv')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(201) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + + should.exist(jsonResponse); + should.exist(jsonResponse.meta); + should.exist(jsonResponse.meta.stats); + + should.exist(jsonResponse.meta.import_label); + jsonResponse.meta.import_label.slug.should.match(/^import-/); + jsonResponse.meta.stats.imported.should.equal(2); + jsonResponse.meta.stats.invalid.length.should.equal(0); + + importLabel = jsonResponse.meta.import_label.slug; + return request + .get(localUtils.API.getApiQuery(`members/?&filter=label:${importLabel}`)) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200); + }) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + + should.exist(jsonResponse); + should.exist(jsonResponse.members); + should.equal(jsonResponse.members.length, 2); + + const importedMember1 = jsonResponse.members.find(m => m.email === 'member+labels_1@example.com'); + should.exist(importedMember1); + should(importedMember1.name).equal(null); + should(importedMember1.note).equal(null); + importedMember1.subscribed.should.equal(true); + importedMember1.comped.should.equal(false); + importedMember1.stripe.should.not.be.undefined(); + importedMember1.stripe.subscriptions.length.should.equal(0); + + // check label order + // 1 unique global + 1 record labels + 1 auto generated label + importedMember1.labels.length.should.equal(3); + should.exist(importedMember1.labels.find(({slug}) => slug === 'label')); + should.exist(importedMember1.labels.find(({slug}) => slug === 'global-label-1')); + should.exist(importedMember1.labels.find(({slug}) => slug.match(/^import-/))); + + const importedMember2 = jsonResponse.members.find(m => m.email === 'member+labels_2@example.com'); + should.exist(importedMember2); + // 1 unique global + 2 record labels + importedMember2.labels.length.should.equal(4); + should.exist(importedMember2.labels.find(({slug}) => slug === 'another-label')); + should.exist(importedMember2.labels.find(({slug}) => slug === 'and-one-more')); + should.exist(importedMember2.labels.find(({slug}) => slug === 'global-label-1')); + should.exist(importedMember2.labels.find(({slug}) => slug.match(/^import-/))); + }); + }); + + it('Can import CSV with mapped fields', function () { + return request + .post(localUtils.API.getApiQuery(`members/upload/`)) + .field('mapping[correo_electrpnico]', 'email') + .field('mapping[nombre]', 'name') + .attach('membersfile', path.join(__dirname, '/../../../../utils/fixtures/csv/members-with-mappings.csv')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(201) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + + should.exist(jsonResponse); + should.exist(jsonResponse.meta); + should.exist(jsonResponse.meta.stats); + + jsonResponse.meta.stats.imported.should.equal(1); + jsonResponse.meta.stats.invalid.length.should.equal(0); + + should.exist(jsonResponse.meta.import_label); + jsonResponse.meta.import_label.slug.should.match(/^import-/); + }) + .then(() => { + return request + .get(localUtils.API.getApiQuery(`members/?search=${encodeURIComponent('member+mapped_1@example.com')}`)) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200); + }) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + + should.exist(jsonResponse); + should.exist(jsonResponse.members); + should.exist(jsonResponse.members[0]); + + const importedMember1 = jsonResponse.members[0]; + should(importedMember1.email).equal('member+mapped_1@example.com'); + should(importedMember1.name).equal('Hannah'); + should(importedMember1.note).equal('no need to map me'); + importedMember1.subscribed.should.equal(true); + importedMember1.comped.should.equal(false); + importedMember1.stripe.should.not.be.undefined(); + importedMember1.stripe.subscriptions.length.should.equal(0); + importedMember1.labels.length.should.equal(1); // auto-generated import label + }); + }); + + it('Can import CSV with labels and provide additional labels', function () { + return request + .post(localUtils.API.getApiQuery(`members/upload/`)) + .attach('membersfile', path.join(__dirname, '/../../../../utils/fixtures/csv/valid-members-defaults.csv')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(201) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + + should.exist(jsonResponse); + should.exist(jsonResponse.meta); + should.exist(jsonResponse.meta.stats); + + jsonResponse.meta.stats.imported.should.equal(2); + jsonResponse.meta.stats.invalid.length.should.equal(0); + }) + .then(() => { + return request + .get(localUtils.API.getApiQuery(`members/`)) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200); + }) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + + should.exist(jsonResponse); + should.exist(jsonResponse.members); + + const defaultMember1 = jsonResponse.members.find(member => (member.email === 'member+defaults_1@example.com')); + should(defaultMember1.name).equal(null); + should(defaultMember1.note).equal(null); + defaultMember1.subscribed.should.equal(true); + defaultMember1.comped.should.equal(false); + defaultMember1.stripe.should.not.be.undefined(); + defaultMember1.stripe.subscriptions.length.should.equal(0); + defaultMember1.labels.length.should.equal(1); // auto-generated import label + + const defaultMember2 = jsonResponse.members.find(member => (member.email === 'member+defaults_2@example.com')); + should(defaultMember2).not.be.undefined(); + }); + }); + + it('Runs imports with stripe_customer_id as background job', function () { + return request + .post(localUtils.API.getApiQuery(`members/upload/`)) + .attach('membersfile', path.join(__dirname, '/../../../../utils/fixtures/csv/members-with-stripe-ids.csv')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(202) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + + should.exist(jsonResponse); + should.not.exist(jsonResponse.meta); + }); + }); + + it('Fails to import memmber with invalid values', function () { + return request + .post(localUtils.API.getApiQuery(`members/upload/`)) + .field('labels', ['new-global-label']) + .attach('membersfile', path.join(__dirname, '/../../../../utils/fixtures/csv/members-invalid-values.csv')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(201) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + + should.exist(jsonResponse); + should.exist(jsonResponse.meta); + should.exist(jsonResponse.meta.stats); + + jsonResponse.meta.stats.imported.should.equal(1); + jsonResponse.meta.stats.invalid.length.should.equal(1); + + jsonResponse.meta.stats.invalid[0].error.should.match(/Validation \(isEmail\) failed for email/); + + should.exist(jsonResponse.meta.import_label); + jsonResponse.meta.import_label.slug.should.match(/^import-/); + }); + }); + + it('Can fetch stats with no ?days param', function () { + return request + .get(localUtils.API.getApiQuery('members/stats/')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + // .expect(200) - doesn't surface underlying errors in tests + .then((res) => { + res.status.should.equal(200, JSON.stringify(res.body)); + + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + + should.exist(jsonResponse); + should.exist(jsonResponse.total); + should.exist(jsonResponse.total_in_range); + should.exist(jsonResponse.total_on_date); + should.exist(jsonResponse.new_today); + + // 3 from fixtures and 6 imported in previous tests + jsonResponse.total.should.equal(10); + }); + }); + + it('Can fetch stats with ?days=90', function () { + return request + .get(localUtils.API.getApiQuery('members/stats/?days=90')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + // .expect(200) - doesn't surface underlying errors in tests + .then((res) => { + res.status.should.equal(200, JSON.stringify(res.body)); + + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + + should.exist(jsonResponse); + should.exist(jsonResponse.total); + should.exist(jsonResponse.total_in_range); + should.exist(jsonResponse.total_on_date); + should.exist(jsonResponse.new_today); + + // 3 from fixtures and 6 imported in previous tests + jsonResponse.total.should.equal(10); + }); + }); + + it('Can fetch stats with ?days=all-time', function () { + return request + .get(localUtils.API.getApiQuery('members/stats/?days=all-time')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + // .expect(200) - doesn't surface underlying errors in tests + .then((res) => { + res.status.should.equal(200, JSON.stringify(res.body)); + + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + + should.exist(jsonResponse); + should.exist(jsonResponse.total); + should.exist(jsonResponse.total_in_range); + should.exist(jsonResponse.total_on_date); + should.exist(jsonResponse.new_today); + + // 3 from fixtures and 6 imported in previous tests + jsonResponse.total.should.equal(10); + }); + }); + + it('Errors when fetching stats with unknown days param value', function () { + return request + .get(localUtils.API.getApiQuery('members/stats/?days=nope')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(422); + }); +}); diff --git a/test/regression/api/v3/admin/notifications_spec.js b/test/regression/api/v3/admin/notifications_spec.js new file mode 100644 index 0000000000..2e469908e7 --- /dev/null +++ b/test/regression/api/v3/admin/notifications_spec.js @@ -0,0 +1,198 @@ +const should = require('should'); +const supertest = require('supertest'); +const testUtils = require('../../../../utils'); +const config = require('../../../../../core/shared/config'); +const localUtils = require('./utils'); +const ghost = testUtils.startGhost; + +describe('Notifications API', function () { + describe('As Editor', function () { + let request; + + before(function () { + return ghost() + .then(function (_ghostServer) { + request = supertest.agent(config.get('url')); + }) + .then(function () { + return testUtils.createUser({ + user: testUtils.DataGenerator.forKnex.createUser({ + email: 'test+editor@ghost.org' + }), + role: testUtils.DataGenerator.Content.roles[1].name + }); + }) + .then((user) => { + request.user = user; + return localUtils.doAuth(request); + }); + }); + + it('Add notification', function () { + const newNotification = { + type: 'info', + message: 'test notification', + custom: true + }; + + return request.post(localUtils.API.getApiQuery('notifications/')) + .set('Origin', config.get('url')) + .send({notifications: [newNotification]}) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(201) + .then((res) => { + const jsonResponse = res.body; + + should.exist(jsonResponse); + should.exist(jsonResponse.notifications); + should.equal(jsonResponse.notifications.length, 1); + }); + }); + + it('Read notifications', function () { + return request.get(localUtils.API.getApiQuery('notifications/')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + const jsonResponse = res.body; + + should.exist(jsonResponse); + should.exist(jsonResponse.notifications); + should.equal(jsonResponse.notifications.length, 1); + }); + }); + }); + + describe('As Author', function () { + let request; + + before(function () { + return ghost() + .then(function (_ghostServer) { + request = supertest.agent(config.get('url')); + }) + .then(function () { + return testUtils.createUser({ + user: testUtils.DataGenerator.forKnex.createUser({ + email: 'test+author@ghost.org' + }), + role: testUtils.DataGenerator.Content.roles[2].name + }); + }) + .then((user) => { + request.user = user; + return localUtils.doAuth(request); + }); + }); + + it('Add notification', function () { + const newNotification = { + type: 'info', + message: 'test notification', + custom: true + }; + + return request.post(localUtils.API.getApiQuery('notifications/')) + .set('Origin', config.get('url')) + .send({notifications: [newNotification]}) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(403); + }); + + it('Read notifications', function () { + return request.get(localUtils.API.getApiQuery('notifications/')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(403); + }); + }); + + describe('Can view by multiple users', function () { + let requestEditor1; + let requestEditor2; + let notification; + + before(function () { + return ghost() + .then(function (_ghostServer) { + requestEditor1 = supertest.agent(config.get('url')); + requestEditor2 = supertest.agent(config.get('url')); + }) + .then(function () { + return testUtils.createUser({ + user: testUtils.DataGenerator.forKnex.createUser({ + email: 'test+editor1@ghost.org' + }), + role: testUtils.DataGenerator.Content.roles[1].name + }); + }) + .then((user) => { + requestEditor1.user = user; + return localUtils.doAuth(requestEditor1); + }) + .then(function () { + return testUtils.createUser({ + user: testUtils.DataGenerator.forKnex.createUser({ + email: 'test+editor2@ghost.org' + }), + role: testUtils.DataGenerator.Content.roles[1].name + }); + }) + .then((user) => { + requestEditor2.user = user; + return localUtils.doAuth(requestEditor2); + }) + .then(() => { + const newNotification = { + type: 'info', + message: 'multiple views', + custom: true + }; + + return requestEditor1.post(localUtils.API.getApiQuery('notifications/')) + .set('Origin', config.get('url')) + .send({notifications: [newNotification]}) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(201) + .then((res) => { + notification = res.body.notifications[0]; + }); + }); + }); + + it('notification is visible and dismissible by other user', function () { + return requestEditor1.del(localUtils.API.getApiQuery(`notifications/${notification.id}`)) + .set('Origin', config.get('url')) + .expect(204) + .then(() => { + return requestEditor2.get(localUtils.API.getApiQuery(`notifications/`)) + .set('Origin', config.get('url')) + .expect(200) + .then(function (res) { + const deleted = res.body.notifications.filter(n => n.id === notification.id); + deleted.should.not.be.empty(); + }); + }) + .then(() => { + return requestEditor2.del(localUtils.API.getApiQuery(`notifications/${notification.id}`)) + .set('Origin', config.get('url')) + .expect(204); + }) + .then(() => { + return requestEditor2.get(localUtils.API.getApiQuery(`notifications/`)) + .set('Origin', config.get('url')) + .expect(200) + .then(function (res) { + const deleted = res.body.notifications.filter(n => n.id === notification.id); + deleted.should.be.empty(); + }); + }); + }); + }); +}); diff --git a/test/regression/api/v3/admin/pages_spec.js b/test/regression/api/v3/admin/pages_spec.js new file mode 100644 index 0000000000..f269746cb6 --- /dev/null +++ b/test/regression/api/v3/admin/pages_spec.js @@ -0,0 +1,47 @@ +const should = require('should'); +const supertest = require('supertest'); +const testUtils = require('../../../../utils'); +const config = require('../../../../../core/shared/config'); +const localUtils = require('./utils'); +const ghost = testUtils.startGhost; +let request; + +describe('Pages API', function () { + before(function () { + return ghost() + .then(function (_ghostServer) { + request = supertest.agent(config.get('url')); + }) + .then(function () { + return localUtils.doAuth(request, 'posts'); + }); + }); + + describe('Edit', function () { + it('accepts html source', function () { + return request + .get(localUtils.API.getApiQuery(`pages/${testUtils.DataGenerator.Content.posts[5].id}/`)) + .set('Origin', config.get('url')) + .expect(200) + .then((res) => { + res.body.pages[0].slug.should.equal('static-page-test'); + + return request + .put(localUtils.API.getApiQuery('pages/' + testUtils.DataGenerator.Content.posts[5].id + '/?source=html')) + .set('Origin', config.get('url')) + .send({ + pages: [{ + html: '

HTML Ipsum presents

', + updated_at: res.body.pages[0].updated_at + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200); + }) + .then((res) => { + res.body.pages[0].mobiledoc.should.equal('{"version":"0.3.1","atoms":[],"cards":[],"markups":[],"sections":[[1,"p",[[0,[],0,"HTML Ipsum presents"]]]]}'); + }); + }); + }); +}); diff --git a/test/regression/api/v3/admin/posts_spec.js b/test/regression/api/v3/admin/posts_spec.js new file mode 100644 index 0000000000..afbda23035 --- /dev/null +++ b/test/regression/api/v3/admin/posts_spec.js @@ -0,0 +1,635 @@ +const _ = require('lodash'); +const should = require('should'); +const supertest = require('supertest'); +const ObjectId = require('bson-objectid'); +const moment = require('moment-timezone'); +const testUtils = require('../../../../utils'); +const config = require('../../../../../core/shared/config'); +const models = require('../../../../../core/server/models'); +const localUtils = require('./utils'); +const ghost = testUtils.startGhost; +let request; + +describe('Posts API', function () { + let ghostServer; + let ownerCookie; + + before(function () { + return ghost() + .then(function (_ghostServer) { + ghostServer = _ghostServer; + request = supertest.agent(config.get('url')); + }) + .then(function () { + return localUtils.doAuth(request, 'users:extra', 'posts', 'emails'); + }) + .then(function (cookie) { + ownerCookie = cookie; + }); + }); + + describe('Browse', function () { + it('fields & formats combined', function (done) { + request.get(localUtils.API.getApiQuery('posts/?formats=mobiledoc,html&fields=id,title')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err, res) { + if (err) { + return done(err); + } + + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse.posts); + localUtils.API.checkResponse(jsonResponse, 'posts'); + jsonResponse.posts.should.have.length(13); + + localUtils.API.checkResponse( + jsonResponse.posts[0], + 'post', + null, + null, + ['mobiledoc', 'id', 'title', 'html'] + ); + + localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination'); + + done(); + }); + }); + + it('combined fields, formats, include and non existing', function (done) { + request.get(localUtils.API.getApiQuery('posts/?formats=mobiledoc,html,plaintext&fields=id,title,primary_tag,doesnotexist&include=authors,tags,email')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err, res) { + if (err) { + return done(err); + } + + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse.posts); + localUtils.API.checkResponse(jsonResponse, 'posts'); + jsonResponse.posts.should.have.length(13); + + localUtils.API.checkResponse( + jsonResponse.posts[0], + 'post', + null, + null, + ['mobiledoc', 'plaintext', 'id', 'title', 'html', 'authors', 'tags', 'primary_tag', 'email'] + ); + + localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination'); + + done(); + }); + }); + + it('can filter by fields coming from posts_meta table non null meta_description', function (done) { + request.get(localUtils.API.getApiQuery(`posts/?filter=meta_description:-null`)) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err, res) { + if (err) { + return done(err); + } + + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse.posts); + localUtils.API.checkResponse(jsonResponse, 'posts'); + jsonResponse.posts.should.have.length(2); + jsonResponse.posts.forEach((post) => { + should.notEqual(post.meta_description, null); + }); + + localUtils.API.checkResponse( + jsonResponse.posts[0], + 'post' + ); + + localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination'); + + done(); + }); + }); + + it('can filter by fields coming from posts_meta table by value', function (done) { + request.get(localUtils.API.getApiQuery(`posts/?filter=meta_description:'meta description for short and sweet'`)) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err, res) { + if (err) { + return done(err); + } + + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse.posts); + localUtils.API.checkResponse(jsonResponse, 'posts'); + jsonResponse.posts.should.have.length(1); + jsonResponse.posts[0].id.should.equal(testUtils.DataGenerator.Content.posts[2].id); + jsonResponse.posts[0].meta_description.should.equal('meta description for short and sweet'); + + localUtils.API.checkResponse( + jsonResponse.posts[0], + 'post' + ); + + localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination'); + + done(); + }); + }); + + it('can order by fields coming from posts_meta table', function (done) { + request.get(localUtils.API.getApiQuery('posts/?order=meta_description%20ASC')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err, res) { + if (err) { + return done(err); + } + + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse.posts); + localUtils.API.checkResponse(jsonResponse, 'posts'); + jsonResponse.posts.should.have.length(13); + + should.equal(jsonResponse.posts[0].meta_description, null); + jsonResponse.posts[12].slug.should.equal('short-and-sweet'); + jsonResponse.posts[12].meta_description.should.equal('meta description for short and sweet'); + + localUtils.API.checkResponse( + jsonResponse.posts[0], + 'post' + ); + + localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination'); + + done(); + }); + }); + + it('can order by email open rate', async function () { + try { + await testUtils.createEmailedPost({ + postOptions: { + post: { + slug: '80-open-rate' + } + }, + emailOptions: { + email: { + email_count: 100, + opened_count: 80, + track_opens: true + } + } + }); + + await testUtils.createEmailedPost({ + postOptions: { + post: { + slug: '60-open-rate' + } + }, + emailOptions: { + email: { + email_count: 100, + opened_count: 60, + track_opens: true + } + } + }); + } catch (err) { + if (_.isArray(err)) { + throw err[0]; + } + throw err; + } + + await request.get(localUtils.API.getApiQuery('posts/?order=email.open_rate%20DESC')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse.posts); + localUtils.API.checkResponse(jsonResponse, 'posts'); + jsonResponse.posts.should.have.length(15); + + jsonResponse.posts[0].slug.should.equal('80-open-rate', 'DESC 1st'); + jsonResponse.posts[1].slug.should.equal('60-open-rate', 'DESC 2nd'); + + localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination'); + }); + + await request.get(localUtils.API.getApiQuery('posts/?order=email.open_rate%20ASC')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + const jsonResponse = res.body; + jsonResponse.posts[0].slug.should.equal('60-open-rate', 'ASC 1st'); + jsonResponse.posts[1].slug.should.equal('80-open-rate', 'ASC 2nd'); + }); + }); + }); + + describe('Read', function () { + it('can\'t retrieve non existent post', function (done) { + request.get(localUtils.API.getApiQuery(`posts/${ObjectId.generate()}/`)) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(404) + .end(function (err, res) { + if (err) { + return done(err); + } + + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse); + should.exist(jsonResponse.errors); + testUtils.API.checkResponseValue(jsonResponse.errors[0], [ + 'message', + 'context', + 'type', + 'details', + 'property', + 'help', + 'code', + 'id' + ]); + done(); + }); + }); + }); + + describe('Add', function () { + it('adds default title when it is missing', function () { + return request + .post(localUtils.API.getApiQuery('posts/')) + .set('Origin', config.get('url')) + .send({ + posts: [{ + title: '' + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(201) + .then((res) => { + should.exist(res.body.posts); + should.exist(res.body.posts[0].title); + res.body.posts[0].title.should.equal('(Untitled)'); + + should.exist(res.headers.location); + res.headers.location.should.equal(`http://127.0.0.1:2369${localUtils.API.getApiQuery('posts/')}${res.body.posts[0].id}/`); + }); + }); + }); + + describe('Edit', function () { + it('published_at = null', function () { + return request + .get(localUtils.API.getApiQuery(`posts/${testUtils.DataGenerator.Content.posts[0].id}/`)) + .set('Origin', config.get('url')) + .expect(200) + .then((res) => { + return request + .put(localUtils.API.getApiQuery('posts/' + testUtils.DataGenerator.Content.posts[0].id + '/')) + .set('Origin', config.get('url')) + .send({ + posts: [{ + published_at: null, + updated_at: res.body.posts[0].updated_at + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200); + }) + .then((res) => { + // @NOTE: if you set published_at to null and the post is published, we set it to NOW in model layer + should.exist(res.headers['x-cache-invalidate']); + should.exist(res.body.posts); + should.exist(res.body.posts[0].published_at); + }); + }); + + it('html to plaintext', function () { + return request + .get(localUtils.API.getApiQuery(`posts/${testUtils.DataGenerator.Content.posts[0].id}/`)) + .set('Origin', config.get('url')) + .expect(200) + .then((res) => { + return request + .put(localUtils.API.getApiQuery('posts/' + testUtils.DataGenerator.Content.posts[0].id + '/?source=html&formats=html,plaintext')) + .set('Origin', config.get('url')) + .send({ + posts: [{ + html: '

HTML Ipsum presents

', + updated_at: res.body.posts[0].updated_at + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200); + }) + .then((res) => { + return models.Post.findOne({ + id: res.body.posts[0].id + }, testUtils.context.internal); + }) + .then((model) => { + model.get('plaintext').should.equal('HTML Ipsum presents'); + }); + }); + + it('canonical_url', function () { + return request + .get(localUtils.API.getApiQuery(`posts/${testUtils.DataGenerator.Content.posts[0].id}/`)) + .set('Origin', config.get('url')) + .expect(200) + .then((res) => { + return request + .put(localUtils.API.getApiQuery('posts/' + testUtils.DataGenerator.Content.posts[0].id + '/')) + .set('Origin', config.get('url')) + .send({ + posts: [{ + canonical_url: `/canonical/url`, + updated_at: res.body.posts[0].updated_at + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200); + }) + .then((res) => { + should.exist(res.body.posts); + should.exist(res.body.posts[0].canonical_url); + res.body.posts[0].canonical_url.should.equal(`${config.get('url')}/canonical/url`); + }); + }); + + it('update dates & x_by', function () { + const post = { + created_by: ObjectId.generate(), + updated_by: ObjectId.generate(), + created_at: moment().add(2, 'days').format(), + updated_at: moment().add(2, 'days').format() + }; + + return request + .put(localUtils.API.getApiQuery('posts/' + testUtils.DataGenerator.Content.posts[0].id + '/')) + .set('Origin', config.get('url')) + .send({posts: [post]}) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + // @NOTE: you cannot modify these fields above manually, that's why the resource won't change. + should.not.exist(res.headers['x-cache-invalidate']); + + return models.Post.findOne({ + id: res.body.posts[0].id + }, testUtils.context.internal); + }) + .then((model) => { + // We expect that the changed properties aren't changed, they are still the same than before. + model.get('created_at').toISOString().should.not.eql(post.created_at); + model.get('updated_by').should.not.eql(post.updated_by); + model.get('created_by').should.not.eql(post.created_by); + + // `updated_at` is automatically set, but it's not the date we send to override. + model.get('updated_at').toISOString().should.not.eql(post.updated_at); + }); + }); + + it('Can change scheduled post', function () { + return request + .get(localUtils.API.getApiQuery(`posts/${testUtils.DataGenerator.Content.posts[7].id}/`)) + .set('Origin', config.get('url')) + .expect(200) + .then((res) => { + res.body.posts[0].status.should.eql('scheduled'); + + return request + .put(localUtils.API.getApiQuery('posts/' + testUtils.DataGenerator.Content.posts[7].id + '/')) + .set('Origin', config.get('url')) + .send({ + posts: [{ + title: 'change scheduled post', + updated_at: res.body.posts[0].updated_at + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200); + }) + .then((res) => { + should.exist(res.headers['x-cache-invalidate']); + }); + }); + + it('trims title', function () { + const untrimmedTitle = ' test trimmed update title '; + + return request + .get(localUtils.API.getApiQuery(`posts/${testUtils.DataGenerator.Content.posts[0].id}/`)) + .set('Origin', config.get('url')) + .expect(200) + .then((res) => { + return request + .put(localUtils.API.getApiQuery('posts/' + testUtils.DataGenerator.Content.posts[0].id + '/')) + .set('Origin', config.get('url')) + .send({ + posts: [{ + title: untrimmedTitle, + updated_at: res.body.posts[0].updated_at + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200); + }) + .then((res) => { + should.exist(res.body.posts); + should.exist(res.body.posts[0].title); + res.body.posts[0].title.should.equal(untrimmedTitle.trim()); + }); + }); + + it('strips invisible unicode from slug', function () { + const slug = 'this-is\u0008-invisible'; + + return request + .get(localUtils.API.getApiQuery(`posts/${testUtils.DataGenerator.Content.posts[0].id}/`)) + .set('Origin', config.get('url')) + .expect(200) + .then((res) => { + return request + .put(localUtils.API.getApiQuery('posts/' + testUtils.DataGenerator.Content.posts[0].id + '/')) + .set('Origin', config.get('url')) + .send({ + posts: [{ + slug: slug, + updated_at: res.body.posts[0].updated_at + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200); + }) + .then((res) => { + should.exist(res.body.posts); + should.exist(res.body.posts[0].slug); + res.body.posts[0].slug.should.equal('this-is-invisible'); + }); + }); + + it('accepts visibility parameter', function () { + return request + .get(localUtils.API.getApiQuery(`posts/${testUtils.DataGenerator.Content.posts[0].id}/`)) + .set('Origin', config.get('url')) + .expect(200) + .then((res) => { + return request + .put(localUtils.API.getApiQuery('posts/' + testUtils.DataGenerator.Content.posts[0].id + '/')) + .set('Origin', config.get('url')) + .send({ + posts: [{ + visibility: 'members', + updated_at: res.body.posts[0].updated_at + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200); + }) + .then((res) => { + should.exist(res.body.posts); + should.exist(res.body.posts[0].visibility); + res.body.posts[0].visibility.should.equal('members'); + }); + }); + + it('changes to post_meta fields triggers a cache invalidation', function () { + return request + .get(localUtils.API.getApiQuery(`posts/${testUtils.DataGenerator.Content.posts[0].id}/`)) + .set('Origin', config.get('url')) + .expect(200) + .then((res) => { + return request + .put(localUtils.API.getApiQuery('posts/' + testUtils.DataGenerator.Content.posts[0].id + '/')) + .set('Origin', config.get('url')) + .send({ + posts: [{ + meta_title: 'changed meta title', + updated_at: res.body.posts[0].updated_at + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200); + }) + .then((res) => { + should.exist(res.headers['x-cache-invalidate']); + + should.exist(res.body.posts); + should.equal(res.body.posts[0].meta_title, 'changed meta title'); + }); + }); + + it('saving post with no modbiledoc content doesn\t trigger cache invalidation', function () { + return request + .post(localUtils.API.getApiQuery('posts/')) + .set('Origin', config.get('url')) + .send({ + posts: [{ + title: 'Has a title by no other content', + status: 'published' + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(201) + .then((res) => { + should.exist(res.body.posts); + should.exist(res.body.posts[0].title); + res.body.posts[0].title.should.equal('Has a title by no other content'); + should.equal(res.body.posts[0].html, undefined); + should.equal(res.body.posts[0].plaintext, undefined); + + return request + .put(localUtils.API.getApiQuery(`posts/${res.body.posts[0].id}/`)) + .set('Origin', config.get('url')) + .send({ + posts: [{ + title: res.body.posts[0].title, + mobilecdoc: res.body.posts[0].mobilecdoc, + updated_at: res.body.posts[0].updated_at + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200); + }) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + + should.exist(res.body.posts); + res.body.posts[0].title.should.equal('Has a title by no other content'); + should.equal(res.body.posts[0].html, undefined); + should.equal(res.body.posts[0].plaintext, undefined); + }); + }); + }); + + describe('Destroy', function () { + it('non existent post', function () { + return request + .del(localUtils.API.getApiQuery('posts/' + ObjectId.generate() + '/')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(404) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + should.exist(res.body); + should.exist(res.body.errors); + testUtils.API.checkResponseValue(res.body.errors[0], [ + 'message', + 'context', + 'type', + 'details', + 'property', + 'help', + 'code', + 'id' + ]); + }); + }); + }); +}); diff --git a/test/regression/api/v3/admin/redirects_spec.js b/test/regression/api/v3/admin/redirects_spec.js new file mode 100644 index 0000000000..3e8b302fd7 --- /dev/null +++ b/test/regression/api/v3/admin/redirects_spec.js @@ -0,0 +1,429 @@ +const should = require('should'); +const supertest = require('supertest'); +const fs = require('fs-extra'); +const Promise = require('bluebird'); +const path = require('path'); +const testUtils = require('../../../../utils'); +const localUtils = require('./utils'); +const configUtils = require('../../../../utils/configUtils'); +const config = require('../../../../../core/shared/config'); + +const ghost = testUtils.startGhost; +let request; + +describe('Redirects API', function () { + let originalContentPath; + + before(function () { + return ghost({redirectsFile: true}) + .then(() => { + request = supertest.agent(config.get('url')); + }) + .then(() => { + return localUtils.doAuth(request); + }) + .then(() => { + originalContentPath = configUtils.config.get('paths:contentPath'); + }); + }); + + const startGhost = (options) => { + return ghost(options) + .then(() => { + request = supertest.agent(config.get('url')); + }) + .then(() => { + return localUtils.doAuth(request); + }); + }; + + describe('Download', function () { + afterEach(function () { + configUtils.config.set('paths:contentPath', originalContentPath); + }); + + it('file does not exist', function () { + // Just set any content folder, which does not contain a redirects file. + configUtils.set('paths:contentPath', path.join(__dirname, '../../../utils/fixtures/data')); + + return request + .get(localUtils.API.getApiQuery('redirects/json/')) + .set('Origin', config.get('url')) + .expect(200) + .then((res) => { + res.headers['content-disposition'].should.eql('Attachment; filename="redirects.json"'); + res.headers['content-type'].should.eql('application/json; charset=utf-8'); + should.not.exist(res.headers['x-cache-invalidate']); + + should.deepEqual(res.body, []); + }); + }); + + it('file exists', function () { + return request + .get(localUtils.API.getApiQuery('redirects/json/')) + .set('Origin', config.get('url')) + .expect('Content-Type', /application\/json/) + .expect('Content-Disposition', 'Attachment; filename="redirects.json"') + .expect(200) + .then((res) => { + res.headers['content-disposition'].should.eql('Attachment; filename="redirects.json"'); + res.headers['content-type'].should.eql('application/json; charset=utf-8'); + + should.deepEqual(res.body, require('../../../../utils/fixtures/data/redirects.json')); + }); + }); + }); + + describe('Download yaml', function () { + beforeEach(function () { + testUtils.setupRedirectsFile(config.get('paths:contentPath'), '.yaml'); + }); + + afterEach(function () { + testUtils.setupRedirectsFile(config.get('paths:contentPath'), '.json'); + }); + + // 'file does not exist' doesn't have to be tested because it always returns .json file. + // TODO: But it should be written when the default redirects file type is changed to yaml. + + it('file exists', function () { + return request + .get(localUtils.API.getApiQuery('redirects/json/')) + .set('Origin', config.get('url')) + .expect('Content-Type', /text\/html/) + .expect('Content-Disposition', 'Attachment; filename="redirects.yaml"') + .expect(200) + .then((res) => { + res.headers['content-disposition'].should.eql('Attachment; filename="redirects.yaml"'); + res.headers['content-type'].should.eql('text/html; charset=utf-8'); + + should.deepEqual(res.text, fs.readFileSync(path.join(__dirname, '../../../../utils/fixtures/data/redirects.yaml')).toString()); + }); + }); + }); + + describe('Upload', function () { + describe('Error cases', function () { + it('syntax error', function () { + fs.writeFileSync(path.join(config.get('paths:contentPath'), 'redirects.json'), 'something'); + + return request + .post(localUtils.API.getApiQuery('redirects/json/')) + .set('Origin', config.get('url')) + .attach('redirects', path.join(config.get('paths:contentPath'), 'redirects.json')) + .expect('Content-Type', /application\/json/) + .expect(400); + }); + + it('wrong format: no array', function () { + fs.writeFileSync(path.join(config.get('paths:contentPath'), 'redirects.json'), JSON.stringify({ + from: 'c', + to: 'd' + })); + + return request + .post(localUtils.API.getApiQuery('redirects/json/')) + .set('Origin', config.get('url')) + .attach('redirects', path.join(config.get('paths:contentPath'), 'redirects.json')) + .expect('Content-Type', /application\/json/) + .expect(422); + }); + + it('wrong format: no from/to', function () { + fs.writeFileSync(path.join(config.get('paths:contentPath'), 'redirects.json'), JSON.stringify([{to: 'd'}])); + + return request + .post(localUtils.API.getApiQuery('redirects/json/')) + .set('Origin', config.get('url')) + .attach('redirects', path.join(config.get('paths:contentPath'), 'redirects.json')) + .expect('Content-Type', /application\/json/) + .expect(422); + }); + }); + + describe('Ensure re-registering redirects works', function () { + it('no redirects file exists', function () { + return startGhost({redirectsFile: false, forceStart: true}) + .then(() => { + return request + .get('/my-old-blog-post/') + .expect(404); + }) + .then(() => { + // Provide a redirects file in the root directory of the content test folder + fs.writeFileSync(path.join(config.get('paths:contentPath'), 'redirects-init.json'), JSON.stringify([{ + from: 'k', + to: 'l' + }])); + }) + .then(() => { + return request + .post(localUtils.API.getApiQuery('redirects/json/')) + .set('Origin', config.get('url')) + .attach('redirects', path.join(config.get('paths:contentPath'), 'redirects-init.json')) + .expect('Content-Type', /application\/json/) + .expect(200); + }) + .then((res) => { + res.headers['x-cache-invalidate'].should.eql('/*'); + + return request + .get('/k/') + .expect(302); + }) + .then((response) => { + response.headers.location.should.eql('/l'); + + const dataFiles = fs.readdirSync(config.getContentPath('data')); + dataFiles.join(',').match(/(redirects)/g).length.should.eql(1); + }); + }); + + it('override', function () { + return startGhost({forceStart: true}) + .then(() => { + return request + .get('/my-old-blog-post/') + .expect(301); + }) + .then((response) => { + response.headers.location.should.eql('/revamped-url/'); + }) + .then(() => { + // Provide a second redirects file in the root directory of the content test folder + fs.writeFileSync(path.join(config.get('paths:contentPath'), 'redirects.json'), JSON.stringify([{ + from: 'c', + to: 'd' + }])); + }) + .then(() => { + // Override redirects file + return request + .post(localUtils.API.getApiQuery('redirects/json/')) + .set('Origin', config.get('url')) + .attach('redirects', path.join(config.get('paths:contentPath'), 'redirects.json')) + .expect('Content-Type', /application\/json/) + .expect(200); + }) + .then((res) => { + res.headers['x-cache-invalidate'].should.eql('/*'); + + return request + .get('/my-old-blog-post/') + .expect(404); + }) + .then(() => { + return request + .get('/c/') + .expect(302); + }) + .then((response) => { + response.headers.location.should.eql('/d'); + + // check backup of redirects files + const dataFiles = fs.readdirSync(config.getContentPath('data')); + dataFiles.join(',').match(/(redirects)/g).length.should.eql(2); + + // Provide another redirects file in the root directory of the content test folder + fs.writeFileSync(path.join(config.get('paths:contentPath'), 'redirects-something.json'), JSON.stringify([{ + from: 'e', + to: 'b' + }])); + }) + .then(() => { + // the backup is in the format HH:mm:ss, we have to wait minimum a second + return new Promise((resolve) => { + setTimeout(resolve, 1100); + }); + }) + .then(() => { + // Override redirects file again and ensure the backup file works twice + return request + .post(localUtils.API.getApiQuery('redirects/json/')) + .set('Origin', config.get('url')) + .attach('redirects', path.join(config.get('paths:contentPath'), 'redirects-something.json')) + .expect('Content-Type', /application\/json/) + .expect(200); + }) + .then(() => { + const dataFiles = fs.readdirSync(config.getContentPath('data')); + dataFiles.join(',').match(/(redirects)/g).length.should.eql(3); + }); + }); + }); + }); + + describe('Upload yaml', function () { + describe('Error cases', function () { + it('syntax error', function () { + fs.writeFileSync(path.join(config.get('paths:contentPath'), 'redirects.yaml'), 'x'); + + return request + .post(localUtils.API.getApiQuery('redirects/json/')) + .set('Origin', config.get('url')) + .attach('redirects', path.join(config.get('paths:contentPath'), 'redirects.yaml')) + .expect('Content-Type', /application\/json/) + .expect(400); + }); + }); + + describe('Ensure re-registering redirects works', function () { + it('no redirects file exists', function () { + return startGhost({redirectsFile: false, forceStart: true}) + .then(() => { + return request + .get('/my-old-blog-post/') + .expect(404); + }) + .then(() => { + // Provide a redirects file in the root directory of the content test folder + fs.writeFileSync(path.join(config.get('paths:contentPath'), 'redirects-init.yaml'), '302:\n k: l'); + }) + .then(() => { + return request + .post(localUtils.API.getApiQuery('redirects/json/')) + .set('Origin', config.get('url')) + .attach('redirects', path.join(config.get('paths:contentPath'), 'redirects-init.yaml')) + .expect('Content-Type', /application\/json/) + .expect(200); + }) + .then((res) => { + res.headers['x-cache-invalidate'].should.eql('/*'); + + return request + .get('/k/') + .expect(302); + }) + .then((response) => { + response.headers.location.should.eql('/l'); + + const dataFiles = fs.readdirSync(config.getContentPath('data')); + dataFiles.join(',').match(/(redirects)/g).length.should.eql(1); + }); + }); + + it('override', function () { + // We want to test if we can override old redirects.json with new redirects.yaml + // That's why we start with .json. + return startGhost({forceStart: true, redirectsFileExt: '.json'}) + .then(() => { + return request + .get('/my-old-blog-post/') + .expect(301); + }) + .then((response) => { + response.headers.location.should.eql('/revamped-url/'); + }) + .then(() => { + // Provide a second redirects file in the root directory of the content test folder + fs.writeFileSync(path.join(config.get('paths:contentPath'), 'redirects.yaml'), '302:\n c: d'); + }) + .then(() => { + // Override redirects file + return request + .post(localUtils.API.getApiQuery('redirects/json/')) + .set('Origin', config.get('url')) + .attach('redirects', path.join(config.get('paths:contentPath'), 'redirects.yaml')) + .expect('Content-Type', /application\/json/) + .expect(200); + }) + .then((res) => { + res.headers['x-cache-invalidate'].should.eql('/*'); + + return request + .get('/my-old-blog-post/') + .expect(404); + }) + .then(() => { + return request + .get('/c/') + .expect(302); + }) + .then((response) => { + response.headers.location.should.eql('/d'); + + // check backup of redirects files + const dataFiles = fs.readdirSync(config.getContentPath('data')); + dataFiles.join(',').match(/(redirects)/g).length.should.eql(2); + + // Provide another redirects file in the root directory of the content test folder + fs.writeFileSync(path.join(config.get('paths:contentPath'), 'redirects-something.json'), JSON.stringify([{ + from: 'e', + to: 'b' + }])); + }) + .then(() => { + // the backup is in the format HH:mm:ss, we have to wait minimum a second + return new Promise((resolve) => { + setTimeout(resolve, 1100); + }); + }) + .then(() => { + // Override redirects file again and ensure the backup file works twice + return request + .post(localUtils.API.getApiQuery('redirects/json/')) + .set('Origin', config.get('url')) + .attach('redirects', path.join(config.get('paths:contentPath'), 'redirects-something.json')) + .expect('Content-Type', /application\/json/) + .expect(200); + }) + .then(() => { + return request + .get('/e/') + .expect(302); + }) + .then((response) => { + response.headers.location.should.eql('/b'); + + const dataFiles = fs.readdirSync(config.getContentPath('data')); + dataFiles.join(',').match(/(redirects)/g).length.should.eql(3); + }); + }); + }); + }); + + // https://github.com/TryGhost/Ghost/issues/10898 + describe('Merge querystring', function () { + it('toURL param takes precedence, other params pass through', function () { + return startGhost({forceStart: true, redirectsFileExt: '.json'}) + .then(function () { + return request + .get('/test-params/?q=123&lang=js') + .expect(301) + .then(function (res) { + res.headers.location.should.eql('/result?q=abc&lang=js'); + }); + }); + }); + }); + + // TODO: For backward compatibility, we only check if download, upload endpoints work here. + // when updating to v4, the old endpoints should be updated to the new ones. + // And the tests below should be removed. + describe('New endpoints work', function () { + it('download', function () { + return request + .get(localUtils.API.getApiQuery('redirects/download/')) + .set('Origin', config.get('url')) + .expect('Content-Type', /application\/json/) + .expect('Content-Disposition', 'Attachment; filename="redirects.json"') + .expect(200); + }); + + it('upload', function () { + // Provide a redirects file in the root directory of the content test folder + fs.writeFileSync(path.join(config.get('paths:contentPath'), 'redirects-init.json'), JSON.stringify([{ + from: 'k', + to: 'l' + }])); + + return request + .post(localUtils.API.getApiQuery('redirects/upload/')) + .set('Origin', config.get('url')) + .attach('redirects', path.join(config.get('paths:contentPath'), 'redirects-init.json')) + .expect('Content-Type', /application\/json/) + .expect(200); + }); + }); +}); diff --git a/test/regression/api/v3/admin/schedules_spec.js b/test/regression/api/v3/admin/schedules_spec.js new file mode 100644 index 0000000000..e08c4133bc --- /dev/null +++ b/test/regression/api/v3/admin/schedules_spec.js @@ -0,0 +1,184 @@ +const _ = require('lodash'); +const should = require('should'); +const supertest = require('supertest'); +const Promise = require('bluebird'); +const sinon = require('sinon'); +const moment = require('moment-timezone'); +const SchedulingDefault = require('../../../../../core/server/adapters/scheduling/SchedulingDefault'); +const models = require('../../../../../core/server/models/index'); +const config = require('../../../../../core/shared/config/index'); +const testUtils = require('../../../../utils/index'); +const localUtils = require('./utils'); + +const ghost = testUtils.startGhost; + +// TODO: Fix with token in URL +describe.skip('v3 Schedules API', function () { + const resources = []; + let request; + + before(function () { + models.init(); + + // @NOTE: mock the post scheduler, otherwise it will auto publish the post + sinon.stub(SchedulingDefault.prototype, '_pingUrl').resolves(); + }); + + after(function () { + sinon.restore(); + }); + + before(function () { + return ghost() + .then(() => { + request = supertest.agent(config.get('url')); + }); + }); + + before(function () { + return ghost() + .then(function () { + resources.push(testUtils.DataGenerator.forKnex.createPost({ + created_by: testUtils.existingData.users[0].id, + author_id: testUtils.existingData.users[0].id, + published_by: testUtils.existingData.users[0].id, + published_at: moment().add(30, 'seconds').toDate(), + status: 'scheduled', + slug: 'first' + })); + + resources.push(testUtils.DataGenerator.forKnex.createPost({ + created_by: testUtils.existingData.users[0].id, + author_id: testUtils.existingData.users[0].id, + published_by: testUtils.existingData.users[0].id, + published_at: moment().subtract(30, 'seconds').toDate(), + status: 'scheduled', + slug: 'second' + })); + + resources.push(testUtils.DataGenerator.forKnex.createPost({ + created_by: testUtils.existingData.users[0].id, + author_id: testUtils.existingData.users[0].id, + published_by: testUtils.existingData.users[0].id, + published_at: moment().add(10, 'minute').toDate(), + status: 'scheduled', + slug: 'third' + })); + + resources.push(testUtils.DataGenerator.forKnex.createPost({ + created_by: testUtils.existingData.users[0].id, + author_id: testUtils.existingData.users[0].id, + published_by: testUtils.existingData.users[0].id, + published_at: moment().subtract(10, 'minute').toDate(), + status: 'scheduled', + slug: 'fourth' + })); + + resources.push(testUtils.DataGenerator.forKnex.createPost({ + created_by: testUtils.existingData.users[0].id, + author_id: testUtils.existingData.users[0].id, + published_by: testUtils.existingData.users[0].id, + published_at: moment().add(30, 'seconds').toDate(), + status: 'scheduled', + slug: 'fifth', + type: 'page' + })); + + return Promise.mapSeries(resources, function (post) { + return models.Post.add(post, {context: {internal: true}}); + }).then(function (result) { + result.length.should.eql(5); + }); + }); + }); + + describe('publish', function () { + let schedulerKey; + + before(function () { + schedulerKey = _.find(testUtils.existingData.apiKeys, {integration: {slug: 'ghost-scheduler'}}); + }); + + it('publishes posts', function () { + return request + .put(localUtils.API.getApiQuery(`schedules/posts/${resources[0].id}/`)) + .set('Authorization', `Ghost ${localUtils.getValidAdminToken('/v3/admin/', schedulerKey)}`) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + should.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse); + jsonResponse.posts[0].id.should.eql(resources[0].id); + jsonResponse.posts[0].status.should.eql('published'); + }); + }); + + it('publishes page', function () { + return request + .put(localUtils.API.getApiQuery(`schedules/pages/${resources[4].id}/`)) + .set('Authorization', `Ghost ${localUtils.getValidAdminToken('/v3/admin/', schedulerKey)}`) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + should.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse); + jsonResponse.pages[0].id.should.eql(resources[4].id); + jsonResponse.pages[0].status.should.eql('published'); + }); + }); + + it('no access', function () { + const zapierKey = _.find(testUtils.existingData.apiKeys, {integration: {slug: 'ghost-backup'}}); + return request + .put(localUtils.API.getApiQuery(`schedules/posts/${resources[0].id}/`)) + .set('Authorization', `Ghost ${localUtils.getValidAdminToken('/v3/admin/', zapierKey)}`) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(403); + }); + + it('should fail with invalid resource type', function () { + return request + .put(localUtils.API.getApiQuery(`schedules/this_is_invalid/${resources[0].id}/`)) + .set('Authorization', `Ghost ${localUtils.getValidAdminToken('/v3/admin/', schedulerKey)}`) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(422); + }); + + it('published_at is x seconds in past, but still in tolerance', function () { + return request + .put(localUtils.API.getApiQuery(`schedules/posts/${resources[1].id}/`)) + .set('Authorization', `Ghost ${localUtils.getValidAdminToken('/v3/admin/', schedulerKey)}`) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200); + }); + + it('not found', function () { + return request + .put(localUtils.API.getApiQuery(`schedules/posts/${resources[2].id}/`)) + .set('Authorization', `Ghost ${localUtils.getValidAdminToken('/v3/admin/', schedulerKey)}`) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(404); + }); + + it('force publish', function () { + return request + .put(localUtils.API.getApiQuery(`schedules/posts/${resources[3].id}/`)) + .send({ + force: true + }) + .set('Authorization', `Ghost ${localUtils.getValidAdminToken('/v3/admin/', schedulerKey)}`) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200); + }); + }); +}); diff --git a/test/regression/api/v3/admin/settings_spec.js b/test/regression/api/v3/admin/settings_spec.js new file mode 100644 index 0000000000..52025233aa --- /dev/null +++ b/test/regression/api/v3/admin/settings_spec.js @@ -0,0 +1,717 @@ +const _ = require('lodash'); +const should = require('should'); +const supertest = require('supertest'); +const config = require('../../../../../core/shared/config'); +const testUtils = require('../../../../utils'); +const localUtils = require('./utils'); +const ghost = testUtils.startGhost; + +// NOTE: in future iterations these fields should be fetched from a central module. +// Have put a list as is here for the lack of better place for it. +const defaultSettingsKeyTypes = [ + {key: 'title', type: 'blog'}, + {key: 'description', type: 'blog'}, + {key: 'logo', type: 'blog'}, + {key: 'cover_image', type: 'blog'}, + {key: 'icon', type: 'blog'}, + {key: 'lang', type: 'blog'}, + {key: 'timezone', type: 'blog'}, + {key: 'codeinjection_head', type: 'blog'}, + {key: 'codeinjection_foot', type: 'blog'}, + {key: 'facebook', type: 'blog'}, + {key: 'twitter', type: 'blog'}, + {key: 'navigation', type: 'blog'}, + {key: 'secondary_navigation', type: 'blog'}, + {key: 'meta_title', type: 'blog'}, + {key: 'meta_description', type: 'blog'}, + {key: 'og_image', type: 'blog'}, + {key: 'og_title', type: 'blog'}, + {key: 'og_description', type: 'blog'}, + {key: 'twitter_image', type: 'blog'}, + {key: 'twitter_title', type: 'blog'}, + {key: 'twitter_description', type: 'blog'}, + {key: 'active_theme', type: 'theme'}, + {key: 'is_private', type: 'private'}, + {key: 'password', type: 'private'}, + {key: 'public_hash', type: 'private'}, + {key: 'default_content_visibility', type: 'members'}, + {key: 'members_allow_free_signup', type: 'members'}, + {key: 'members_from_address', type: 'members'}, + {key: 'members_support_address', type: 'members'}, + {key: 'members_reply_address', type: 'members'}, + {key: 'members_paid_signup_redirect', type: 'members'}, + {key: 'members_free_signup_redirect', type: 'members'}, + {key: 'stripe_product_name', type: 'members'}, + {key: 'stripe_plans', type: 'members'}, + {key: 'stripe_secret_key', type: 'members'}, + {key: 'stripe_publishable_key', type: 'members'}, + {key: 'stripe_connect_secret_key', type: 'members'}, + {key: 'stripe_connect_publishable_key', type: 'members'}, + {key: 'stripe_connect_account_id', type: 'members'}, + {key: 'stripe_connect_display_name', type: 'members'}, + {key: 'stripe_connect_livemode', type: 'members'}, + {key: 'portal_name', type: 'portal'}, + {key: 'portal_button', type: 'portal'}, + {key: 'portal_plans', type: 'portal'}, + {key: 'portal_button_style', type: 'portal'}, + {key: 'portal_button_icon', type: 'portal'}, + {key: 'portal_button_signup_text', type: 'portal'}, + {key: 'mailgun_api_key', type: 'bulk_email'}, + {key: 'mailgun_domain', type: 'bulk_email'}, + {key: 'mailgun_base_url', type: 'bulk_email'}, + {key: 'email_track_opens', type: 'bulk_email'}, + {key: 'amp', type: 'blog'}, + {key: 'amp_gtag_id', type: 'blog'}, + {key: 'labs', type: 'blog'}, + {key: 'slack', type: 'blog'}, + {key: 'unsplash', type: 'blog'}, + {key: 'shared_views', type: 'blog'}, + {key: 'active_timezone', type: 'blog'}, + {key: 'default_locale', type: 'blog'}, + {key: 'accent_color', type: 'blog'}, + {key: 'newsletter_show_badge', type: 'newsletter'}, + {key: 'newsletter_show_header', type: 'newsletter'}, + {key: 'newsletter_body_font_category', type: 'newsletter'}, + {key: 'newsletter_footer_content', type: 'newsletter'}, + {key: 'firstpromoter', type: 'firstpromoter'}, + {key: 'firstpromoter_id', type: 'firstpromoter'} +]; + +describe('Settings API (v3)', function () { + let ghostServer; + let request; + + describe('As Owner', function () { + before(function () { + return ghost() + .then(function (_ghostServer) { + ghostServer = _ghostServer; + request = supertest.agent(config.get('url')); + }) + .then(function () { + return localUtils.doAuth(request); + }); + }); + + it('Can request all settings', function () { + return request.get(localUtils.API.getApiQuery(`settings/`)) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + + const jsonResponse = res.body; + should.exist(jsonResponse.settings); + should.exist(jsonResponse.meta); + + jsonResponse.settings.should.be.an.Object(); + const settings = jsonResponse.settings; + should.equal(settings.length, defaultSettingsKeyTypes.length); + for (const defaultSetting of defaultSettingsKeyTypes) { + should.exist(settings.find((setting) => { + return setting.key === defaultSetting.key && setting.type === defaultSetting.type; + }), `Expected to find a setting with key ${defaultSetting.key} and type ${defaultSetting.type}`); + } + + localUtils.API.checkResponse(jsonResponse, 'settings'); + }); + }); + + it('Can request settings by type', function () { + return request.get(localUtils.API.getApiQuery(`settings/?type=theme`)) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + + const jsonResponse = res.body; + should.exist(jsonResponse.settings); + should.exist(jsonResponse.meta); + + jsonResponse.settings.should.be.an.Object(); + const settings = jsonResponse.settings; + + Object.keys(settings).length.should.equal(1); + settings[0].key.should.equal('active_theme'); + settings[0].value.should.equal('casper'); + settings[0].type.should.equal('theme'); + + localUtils.API.checkResponse(jsonResponse, 'settings'); + }); + }); + + it('Can request settings by group', function () { + return request.get(localUtils.API.getApiQuery(`settings/?group=theme`)) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + + const jsonResponse = res.body; + should.exist(jsonResponse.settings); + should.exist(jsonResponse.meta); + + jsonResponse.settings.should.be.an.Object(); + const settings = jsonResponse.settings; + + Object.keys(settings).length.should.equal(1); + settings[0].key.should.equal('active_theme'); + settings[0].value.should.equal('casper'); + settings[0].type.should.equal('theme'); + + localUtils.API.checkResponse(jsonResponse, 'settings'); + }); + }); + + it('Can request settings by group and by deprecated type', function () { + return request.get(localUtils.API.getApiQuery(`settings/?group=theme&type=private`)) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + + const jsonResponse = res.body; + should.exist(jsonResponse.settings); + should.exist(jsonResponse.meta); + + jsonResponse.settings.should.be.an.Object(); + const settings = jsonResponse.settings; + + Object.keys(settings).length.should.equal(4); + settings[0].key.should.equal('active_theme'); + settings[0].value.should.equal('casper'); + settings[0].type.should.equal('theme'); + + localUtils.API.checkResponse(jsonResponse, 'settings'); + }); + }); + + it('Requesting core settings type returns no results', function () { + return request.get(localUtils.API.getApiQuery(`settings/?type=core`)) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + + const jsonResponse = res.body; + should.exist(jsonResponse.settings); + should.exist(jsonResponse.meta); + + jsonResponse.settings.should.be.an.Object(); + const settings = jsonResponse.settings; + + Object.keys(settings).length.should.equal(0); + + localUtils.API.checkResponse(jsonResponse, 'settings'); + }); + }); + + it('Can\'t read core setting', function () { + return request + .get(localUtils.API.getApiQuery('settings/db_hash/')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(403); + }); + + it('Can\'t read permalinks', function (done) { + request.get(localUtils.API.getApiQuery('settings/permalinks/')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(404) + .end(function (err, res) { + if (err) { + return done(err); + } + + done(); + }); + }); + + it('Can read deprecated default_locale', function (done) { + request.get(localUtils.API.getApiQuery('settings/default_locale/')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err, res) { + if (err) { + return done(err); + } + + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + + should.exist(jsonResponse); + should.exist(jsonResponse.settings); + + jsonResponse.settings.length.should.eql(1); + + testUtils.API.checkResponseValue(jsonResponse.settings[0], ['id', 'group', 'key', 'value', 'type', 'flags', 'created_at', 'updated_at']); + jsonResponse.settings[0].key.should.eql('default_locale'); + done(); + }); + }); + + it('can edit deprecated default_locale setting', function () { + return request.get(localUtils.API.getApiQuery('settings/default_locale/')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .then(function (res) { + let jsonResponse = res.body; + const newValue = 'new value'; + should.exist(jsonResponse); + should.exist(jsonResponse.settings); + jsonResponse.settings = [{key: 'default_locale', value: 'ua'}]; + + return jsonResponse; + }) + .then((editedSetting) => { + return request.put(localUtils.API.getApiQuery('settings/')) + .set('Origin', config.get('url')) + .send(editedSetting) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then(function (res) { + should.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + + should.exist(jsonResponse); + should.exist(jsonResponse.settings); + + jsonResponse.settings.length.should.eql(1); + + testUtils.API.checkResponseValue(jsonResponse.settings[0], ['id', 'group', 'key', 'value', 'type', 'flags', 'created_at', 'updated_at']); + jsonResponse.settings[0].key.should.eql('default_locale'); + jsonResponse.settings[0].value.should.eql('ua'); + }); + }); + }); + + it('Can read timezone', function (done) { + request.get(localUtils.API.getApiQuery('settings/timezone/')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err, res) { + if (err) { + return done(err); + } + + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + + should.exist(jsonResponse); + should.exist(jsonResponse.settings); + + jsonResponse.settings.length.should.eql(1); + + testUtils.API.checkResponseValue(jsonResponse.settings[0], ['id', 'group', 'key', 'value', 'type', 'flags', 'created_at', 'updated_at']); + jsonResponse.settings[0].key.should.eql('timezone'); + done(); + }); + }); + + it('Can read active_timezone', function (done) { + request.get(localUtils.API.getApiQuery('settings/active_timezone/')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err, res) { + if (err) { + return done(err); + } + + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + + should.exist(jsonResponse); + should.exist(jsonResponse.settings); + + jsonResponse.settings.length.should.eql(1); + + testUtils.API.checkResponseValue(jsonResponse.settings[0], ['id', 'group', 'key', 'value', 'type', 'flags', 'created_at', 'updated_at']); + jsonResponse.settings[0].key.should.eql('active_timezone'); + done(); + }); + }); + + it('Can read deprecated active_timezone', function (done) { + request.get(localUtils.API.getApiQuery('settings/active_timezone/')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err, res) { + if (err) { + return done(err); + } + + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + + should.exist(jsonResponse); + should.exist(jsonResponse.settings); + + jsonResponse.settings.length.should.eql(1); + + testUtils.API.checkResponseValue(jsonResponse.settings[0], ['id', 'group', 'key', 'value', 'type', 'flags', 'created_at', 'updated_at']); + jsonResponse.settings[0].key.should.eql('active_timezone'); + done(); + }); + }); + + it('can\'t read non existent setting', function (done) { + request.get(localUtils.API.getApiQuery('settings/testsetting/')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(404) + .end(function (err, res) { + if (err) { + return done(err); + } + + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse); + should.exist(jsonResponse.errors); + testUtils.API.checkResponseValue(jsonResponse.errors[0], [ + 'message', + 'context', + 'type', + 'details', + 'property', + 'help', + 'code', + 'id' + ]); + done(); + }); + }); + + it('can toggle member setting', function () { + return request.get(localUtils.API.getApiQuery('settings/')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .then(function (res) { + const jsonResponse = res.body; + + const settingToChange = { + settings: [ + { + key: 'labs', + value: '{"subscribers":false,"members":false}' + } + ] + }; + + should.exist(jsonResponse); + should.exist(jsonResponse.settings); + + return request.put(localUtils.API.getApiQuery('settings/')) + .set('Origin', config.get('url')) + .send(settingToChange) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then(function ({body, headers}) { + const putBody = body; + headers['x-cache-invalidate'].should.eql('/*'); + should.exist(putBody); + + putBody.settings[0].key.should.eql('labs'); + putBody.settings[0].value.should.eql(JSON.stringify({subscribers: false, members: false})); + }); + }); + }); + + it('can\'t edit permalinks', function (done) { + const settingToChange = { + settings: [{key: 'permalinks', value: '/:primary_author/:slug/'}] + }; + + request.put(localUtils.API.getApiQuery('settings/')) + .set('Origin', config.get('url')) + .send(settingToChange) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(404) + .end(function (err, res) { + if (err) { + return done(err); + } + + done(); + }); + }); + + it('can\'t edit non existent setting', function () { + return request.get(localUtils.API.getApiQuery('settings/')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .then(function (res) { + let jsonResponse = res.body; + const newValue = 'new value'; + should.exist(jsonResponse); + should.exist(jsonResponse.settings); + jsonResponse.settings = [{key: 'testvalue', value: newValue}]; + + return jsonResponse; + }) + .then((jsonResponse) => { + return request.put(localUtils.API.getApiQuery('settings/')) + .set('Origin', config.get('url')) + .send(jsonResponse) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(404) + .then(function (res) { + jsonResponse = res.body; + should.not.exist(res.headers['x-cache-invalidate']); + should.exist(jsonResponse.errors); + testUtils.API.checkResponseValue(jsonResponse.errors[0], [ + 'message', + 'context', + 'type', + 'details', + 'property', + 'help', + 'code', + 'id' + ]); + }); + }); + }); + + it('Will transform "1"', function () { + return request.get(localUtils.API.getApiQuery('settings/')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .then(function (res) { + const jsonResponse = res.body; + + const settingToChange = { + settings: [ + { + key: 'is_private', + value: '1' + } + ] + }; + + should.exist(jsonResponse); + should.exist(jsonResponse.settings); + + return request.put(localUtils.API.getApiQuery('settings/')) + .set('Origin', config.get('url')) + .send(settingToChange) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then(function ({body, headers}) { + const putBody = body; + headers['x-cache-invalidate'].should.eql('/*'); + should.exist(putBody); + + putBody.settings[0].key.should.eql('is_private'); + putBody.settings[0].value.should.eql(true); + + localUtils.API.checkResponse(putBody, 'settings'); + }); + }); + }); + }); + + describe('As Admin', function () { + before(function () { + return ghost() + .then(function (_ghostServer) { + ghostServer = _ghostServer; + request = supertest.agent(config.get('url')); + }) + .then(function () { + // create admin + return testUtils.createUser({ + user: testUtils.DataGenerator.forKnex.createUser({email: 'admin+1@ghost.org'}), + role: testUtils.DataGenerator.Content.roles[0].name + }); + }) + .then(function (admin) { + request.user = admin; + + // by default we login with the owner + return localUtils.doAuth(request); + }); + }); + + it('cannot toggle member setting', function (done) { + const settingToChange = { + settings: [ + { + key: 'labs', + value: '{"subscribers":false,"members":true}' + } + ] + }; + + request.put(localUtils.API.getApiQuery('settings/')) + .set('Origin', config.get('url')) + .send(settingToChange) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(403) + .end(function (err, res) { + if (err) { + return done(err); + } + + done(); + }); + }); + }); + + describe('As Editor', function () { + let editor; + + before(function () { + return ghost() + .then(function (_ghostServer) { + ghostServer = _ghostServer; + request = supertest.agent(config.get('url')); + }) + .then(function () { + // create editor + return testUtils.createUser({ + user: testUtils.DataGenerator.forKnex.createUser({email: 'test+1@ghost.org'}), + role: testUtils.DataGenerator.Content.roles[1].name + }); + }) + .then(function (_user1) { + editor = _user1; + request.user = editor; + + // by default we login with the owner + return localUtils.doAuth(request); + }); + }); + + it('should not be able to edit settings', function () { + return request.get(localUtils.API.getApiQuery('settings/')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .then(function (res) { + let jsonResponse = res.body; + const newValue = 'new value'; + should.exist(jsonResponse); + should.exist(jsonResponse.settings); + jsonResponse.settings = [{key: 'visibility', value: 'public'}]; + + return request.put(localUtils.API.getApiQuery('settings/')) + .set('Origin', config.get('url')) + .send(jsonResponse) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(403) + .then(function ({body, headers}) { + jsonResponse = body; + should.not.exist(headers['x-cache-invalidate']); + should.exist(jsonResponse.errors); + testUtils.API.checkResponseValue(jsonResponse.errors[0], [ + 'message', + 'context', + 'type', + 'details', + 'property', + 'help', + 'code', + 'id' + ]); + }); + }); + }); + }); + + describe('As Author', function () { + before(function () { + return ghost() + .then(function (_ghostServer) { + ghostServer = _ghostServer; + request = supertest.agent(config.get('url')); + }) + .then(function () { + // create author + return testUtils.createUser({ + user: testUtils.DataGenerator.forKnex.createUser({email: 'test+2@ghost.org'}), + role: testUtils.DataGenerator.Content.roles[2].name + }); + }) + .then(function (author) { + request.user = author; + + // by default we login with the owner + return localUtils.doAuth(request); + }); + }); + + it('should not be able to edit settings', function () { + return request.get(localUtils.API.getApiQuery('settings/')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .then(function (res) { + let jsonResponse = res.body; + should.exist(jsonResponse); + should.exist(jsonResponse.settings); + jsonResponse.settings = [{key: 'visibility', value: 'public'}]; + + return request.put(localUtils.API.getApiQuery('settings/')) + .set('Origin', config.get('url')) + .send(jsonResponse) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(403) + .then(function ({body, headers}) { + jsonResponse = body; + should.not.exist(headers['x-cache-invalidate']); + should.exist(jsonResponse.errors); + testUtils.API.checkResponseValue(jsonResponse.errors[0], [ + 'message', + 'context', + 'type', + 'details', + 'property', + 'help', + 'code', + 'id' + ]); + }); + }); + }); + }); +}); diff --git a/test/regression/api/v3/admin/slack_spec.js b/test/regression/api/v3/admin/slack_spec.js new file mode 100644 index 0000000000..829c96da59 --- /dev/null +++ b/test/regression/api/v3/admin/slack_spec.js @@ -0,0 +1,48 @@ +const should = require('should'); +const supertest = require('supertest'); +const sinon = require('sinon'); +const testUtils = require('../../../../utils'); +const localUtils = require('./utils'); +const config = require('../../../../../core/shared/config'); +const {events} = require('../../../../../core/server/lib/common'); +const ghost = testUtils.startGhost; + +let request; + +describe('Slack API', function () { + let ghostServer; + + before(function () { + return ghost() + .then(function (_ghostServer) { + ghostServer = _ghostServer; + request = supertest.agent(config.get('url')); + }) + .then(function () { + return localUtils.doAuth(request); + }); + }); + after(function () { + sinon.restore(); + }); + + it('Can post slack test', function (done) { + const eventSpy = sinon.spy(events, 'emit'); + request.post(localUtils.API.getApiQuery('slack/test/')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err, res) { + if (err) { + return done(err); + } + + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse); + eventSpy.calledWith('slack.test').should.be.true(); + done(); + }); + }); +}); diff --git a/test/regression/api/v3/admin/users_spec.js b/test/regression/api/v3/admin/users_spec.js new file mode 100644 index 0000000000..d4aef10335 --- /dev/null +++ b/test/regression/api/v3/admin/users_spec.js @@ -0,0 +1,295 @@ +const should = require('should'); +const supertest = require('supertest'); +const ObjectId = require('bson-objectid'); +const testUtils = require('../../../../utils'); +const config = require('../../../../../core/shared/config'); +const localUtils = require('./utils'); +const ghost = testUtils.startGhost; +let request; + +describe('User API', function () { + let editor; + let author; + let ghostServer; + let otherAuthor; + let admin; + + describe('As Owner', function () { + before(function () { + return ghost() + .then(function (_ghostServer) { + ghostServer = _ghostServer; + request = supertest.agent(config.get('url')); + }) + .then(function () { + // create inactive user + return testUtils.createUser({ + user: testUtils.DataGenerator.forKnex.createUser({email: 'test+3@ghost.org'}), + role: testUtils.DataGenerator.Content.roles[2].name + }); + }) + .then(function (_user) { + otherAuthor = _user; + + // create admin user + return testUtils.createUser({ + user: testUtils.DataGenerator.forKnex.createUser({email: 'test+admin@ghost.org', slug: 'owner'}), + role: testUtils.DataGenerator.Content.roles[3].name + }); + }) + .then(function (_user) { + admin = _user; + + // by default we login with the owner + return localUtils.doAuth(request); + }); + }); + + describe('Read', function () { + it('can\'t retrieve non existent user by id', function (done) { + request.get(localUtils.API.getApiQuery('users/' + ObjectId.generate() + '/')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(404) + .end(function (err, res) { + if (err) { + return done(err); + } + + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse); + should.exist(jsonResponse.errors); + testUtils.API.checkResponseValue(jsonResponse.errors[0], [ + 'message', + 'context', + 'type', + 'details', + 'property', + 'help', + 'code', + 'id' + ]); + done(); + }); + }); + + it('can\'t retrieve non existent user by slug', function (done) { + request.get(localUtils.API.getApiQuery('users/slug/blargh/')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(404) + .end(function (err, res) { + if (err) { + return done(err); + } + + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse); + should.exist(jsonResponse.errors); + testUtils.API.checkResponseValue(jsonResponse.errors[0], [ + 'message', + 'context', + 'type', + 'details', + 'property', + 'help', + 'code', + 'id' + ]); + done(); + }); + }); + }); + + describe('Edit', function () { + it('can change the other users password', function (done) { + request.put(localUtils.API.getApiQuery('users/password/')) + .set('Origin', config.get('url')) + .send({ + password: [{ + newPassword: 'superSecure', + ne2Password: 'superSecure', + user_id: otherAuthor.id + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err) { + if (err) { + return done(err); + } + + done(); + }); + }); + }); + + describe('Destroy', function () { + it('[failure] Destroy unknown user id', function (done) { + request.delete(localUtils.API.getApiQuery('users/' + ObjectId.generate())) + .set('Origin', config.get('url')) + .expect(404) + .end(function (err) { + if (err) { + return done(err); + } + + done(); + }); + }); + }); + }); + + describe('As Editor', function () { + before(function () { + return ghost() + .then(function (_ghostServer) { + ghostServer = _ghostServer; + request = supertest.agent(config.get('url')); + }) + .then(function () { + // create editor + return testUtils.createUser({ + user: testUtils.DataGenerator.forKnex.createUser({email: 'test+1@ghost.org'}), + role: testUtils.DataGenerator.Content.roles[1].name + }); + }) + .then(function (_user1) { + editor = _user1; + request.user = editor; + + // by default we login with the owner + return localUtils.doAuth(request); + }); + }); + + describe('success cases', function () { + it('can edit himself', function (done) { + request.put(localUtils.API.getApiQuery('users/' + editor.id + '/')) + .set('Origin', config.get('url')) + .send({ + users: [{id: editor.id, name: 'test'}] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err) { + if (err) { + return done(err); + } + + done(); + }); + }); + }); + + describe('error cases', function () { + it('can\'t edit the owner', function (done) { + request.put(localUtils.API.getApiQuery('users/' + testUtils.DataGenerator.Content.users[0].id + '/')) + .set('Origin', config.get('url')) + .send({ + users: [{ + id: testUtils.DataGenerator.Content.users[0].id + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(403) + .end(function (err) { + if (err) { + return done(err); + } + + done(); + }); + }); + + it('Cannot transfer ownership to any other user', function () { + return request + .put(localUtils.API.getApiQuery('users/owner')) + .set('Origin', config.get('url')) + .send({ + owner: [{ + id: testUtils.existingData.users[1].id + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(403); + }); + }); + }); + + describe('As Author', function () { + before(function () { + return ghost() + .then(function (_ghostServer) { + ghostServer = _ghostServer; + request = supertest.agent(config.get('url')); + }) + .then(function () { + // create author + return testUtils.createUser({ + user: testUtils.DataGenerator.forKnex.createUser({email: 'test+2@ghost.org'}), + role: testUtils.DataGenerator.Content.roles[2].name + }); + }) + .then(function (_user2) { + author = _user2; + request.user = author; + + // by default we login with the owner + return localUtils.doAuth(request); + }); + }); + + describe('success cases', function () { + it('can edit himself', function (done) { + request.put(localUtils.API.getApiQuery('users/' + author.id + '/')) + .set('Origin', config.get('url')) + .send({ + users: [{id: author.id, name: 'test'}] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err) { + if (err) { + return done(err); + } + + done(); + }); + }); + }); + + describe('error cases', function () { + it('can\'t edit the owner', function (done) { + request.put(localUtils.API.getApiQuery('users/' + testUtils.DataGenerator.Content.users[0].id + '/')) + .set('Origin', config.get('url')) + .send({ + users: [{ + id: testUtils.DataGenerator.Content.users[0].id + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(403) + .end(function (err) { + if (err) { + return done(err); + } + + done(); + }); + }); + }); + }); +}); diff --git a/test/regression/api/v3/admin/utils.js b/test/regression/api/v3/admin/utils.js new file mode 100644 index 0000000000..af4fdfc5fb --- /dev/null +++ b/test/regression/api/v3/admin/utils.js @@ -0,0 +1,134 @@ +const url = require('url'); +const _ = require('lodash'); +const testUtils = require('../../../../utils'); +const schema = require('../../../../../core/server/data/schema').tables; +const API_URL = '/ghost/api/v3/admin/'; + +const expectedProperties = { + // API top level + posts: ['posts', 'meta'], + tags: ['tags', 'meta'], + users: ['users', 'meta'], + settings: ['settings', 'meta'], + subscribers: ['subscribers', 'meta'], + roles: ['roles'], + pagination: ['page', 'limit', 'pages', 'total', 'next', 'prev'], + slugs: ['slugs'], + slug: ['slug'], + invites: ['invites', 'meta'], + themes: ['themes'], + members: ['members', 'meta'], + + post: _(schema.posts) + .keys() + // by default we only return mobiledoc + .without('html', 'plaintext') + .without('locale') + .without('page') + .without('author_id', 'author') + .without('type') + // always returns computed properties + // primary_tag and primary_author properties are included + // only because authors and tags are always included + .concat('url', 'primary_tag', 'primary_author', 'excerpt') + .concat('authors', 'tags', 'email') + // returns meta fields from `posts_meta` schema + .concat( + ..._(schema.posts_meta).keys().without('post_id', 'id') + ) + .concat('send_email_when_published') + , + user: _(schema.users) + .keys() + .without('visibility') + .without('password') + .without('locale') + .concat('url') + , + tag: _(schema.tags) + .keys() + // unused field + .without('parent_id') + , + setting: _(schema.settings) + .keys() + , + subscriber: _(schema.subscribers) + .keys() + , + member: _(schema.members) + .keys() + .concat('avatar_image') + .concat('comped') + .concat('labels') + , + member_signin_url: ['member_id', 'url'], + role: _(schema.roles) + .keys() + , + permission: _(schema.permissions) + .keys() + , + notification: ['type', 'message', 'status', 'id', 'dismissible', 'location', 'custom'], + theme: ['name', 'package', 'active'], + invite: _(schema.invites) + .keys() + .without('token') + , + webhook: _(schema.webhooks) + .keys() + , + email_preview: ['html', 'subject', 'plaintext'] +}; + +_.each(expectedProperties, (value, key) => { + if (!value.__wrapped__) { + return; + } + + /** + * @deprecated: x_by + */ + expectedProperties[key] = value + .without( + 'created_by', + 'updated_by', + 'published_by' + ) + .value(); +}); + +module.exports = { + API: { + getApiQuery(route) { + return url.resolve(API_URL, route); + }, + + checkResponse(...args) { + this.expectedProperties = expectedProperties; + return testUtils.API.checkResponse.call(this, ...args); + } + }, + + doAuth(...args) { + return testUtils.API.doAuth(`${API_URL}session/`, ...args); + }, + + getValidAdminToken(endpoint, key) { + const jwt = require('jsonwebtoken'); + key = key || testUtils.DataGenerator.Content.api_keys[0]; + + const JWT_OPTIONS = { + keyid: key.id, + algorithm: 'HS256', + expiresIn: '5m', + audience: endpoint + }; + + return jwt.sign( + {}, + Buffer.from(key.secret, 'hex'), + JWT_OPTIONS + ); + } +}; diff --git a/test/regression/api/v3/admin/webhooks_spec.js b/test/regression/api/v3/admin/webhooks_spec.js new file mode 100644 index 0000000000..be9dffb5e6 --- /dev/null +++ b/test/regression/api/v3/admin/webhooks_spec.js @@ -0,0 +1,187 @@ +const should = require('should'); +const supertest = require('supertest'); +const testUtils = require('../../../../utils'); +const config = require('../../../../../core/shared/config'); +const localUtils = require('./utils'); + +const ghost = testUtils.startGhost; + +describe('Webhooks API (v3)', function () { + let request; + + before(function () { + return ghost() + .then(function () { + request = supertest.agent(config.get('url')); + }) + .then(function () { + return localUtils.doAuth(request, 'api_keys', 'webhooks'); + }); + }); + + it('Can create a webhook using integration', function () { + let webhookData = { + event: 'test.create', + target_url: 'http://example.com/webhooks/test/extra/v3', + integration_id: 'ignore_me', + name: 'test', + secret: 'thisissecret', + api_version: 'v3' + }; + + return request.post(localUtils.API.getApiQuery('webhooks/')) + .set('Authorization', `Ghost ${localUtils.getValidAdminToken('/v3/admin/', testUtils.DataGenerator.Content.api_keys[0])}`) + .send({webhooks: [webhookData]}) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(201) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + + should.exist(jsonResponse); + should.exist(jsonResponse.webhooks); + should.exist(jsonResponse.webhooks[0].event); + should.exist(jsonResponse.webhooks[0].target_url); + + jsonResponse.webhooks[0].event.should.eql('test.create'); + jsonResponse.webhooks[0].target_url.should.eql('http://example.com/webhooks/test/extra/v3'); + jsonResponse.webhooks[0].integration_id.should.eql(testUtils.DataGenerator.Content.api_keys[0].integration_id); + jsonResponse.webhooks[0].name.should.eql('test'); + jsonResponse.webhooks[0].secret.should.eql('thisissecret'); + jsonResponse.webhooks[0].api_version.should.eql('v3'); + + localUtils.API.checkResponse(jsonResponse.webhooks[0], 'webhook'); + }); + }); + + it('Fails validation for when integration_id is missing', function () { + let webhookData = { + event: 'test.create', + target_url: 'http://example.com/webhooks/test/extra/1', + name: 'test', + secret: 'thisissecret', + api_version: 'v2' + }; + + return request.post(localUtils.API.getApiQuery('webhooks/')) + .set('Origin', config.get('url')) + .send({webhooks: [webhookData]}) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(422); + }); + + it('Fails validation for non-lowercase event name', function () { + let webhookData = { + event: 'tEst.evenT', + target_url: 'http://example.com/webhooks/test/extra/1', + name: 'test', + secret: 'thisissecret', + api_version: 'v2' + }; + + return request.post(localUtils.API.getApiQuery('webhooks/')) + .set('Origin', config.get('url')) + .send({webhooks: [webhookData]}) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(422); + }); + + it('Fails validation when required fields are not present', function () { + let webhookData = { + api_version: 'v2', + integration_id: 'dummy' + }; + + return request.post(localUtils.API.getApiQuery('webhooks/')) + .set('Origin', config.get('url')) + .send({webhooks: [webhookData]}) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(422); + }); + + it('Integration cannot edit or delete other integration\'s webhook', function () { + let createdIntegration; + let createdWebhook; + + return Promise.resolve() + .then(() => { + return request.post(localUtils.API.getApiQuery('integrations/')) + .set('Origin', config.get('url')) + .send({ + integrations: [{ + name: 'Rubbish Integration Name' + }] + }) + .expect(201) + .then(({body}) => { + [createdIntegration] = body.integrations; + + return request.post(localUtils.API.getApiQuery('webhooks/')) + .set('Origin', config.get('url')) + .send({ + webhooks: [{ + name: 'Testing', + event: 'site.changed', + target_url: 'https://example.com/rebuild', + integration_id: createdIntegration.id + }] + }) + .expect(201); + }); + }) + .then(({body}) => { + [createdWebhook] = body.webhooks; + + return request.put(localUtils.API.getApiQuery(`webhooks/${createdWebhook.id}/`)) + .set('Authorization', `Ghost ${localUtils.getValidAdminToken('/v3/admin/', testUtils.DataGenerator.Content.api_keys[0])}`) + .send({ + webhooks: [{ + name: 'Edit Test', + event: 'subscriber.added', + target_url: 'https://example.com/new-subscriber' + }] + }) + .expect(403); + }) + .then(() => { + return request.del(localUtils.API.getApiQuery(`webhooks/${createdWebhook.id}/`)) + .set('Authorization', `Ghost ${localUtils.getValidAdminToken('/v3/admin/', testUtils.DataGenerator.Content.api_keys[0])}`) + .expect(403); + }); + }); + + it('Integration editing non-existing webhook returns 404', function () { + return request.put(localUtils.API.getApiQuery(`webhooks/5f27d0287c75da744d8615da/`)) + .set('Authorization', `Ghost ${localUtils.getValidAdminToken('/v3/admin/', testUtils.DataGenerator.Content.api_keys[0])}`) + .send({ + webhooks: [{ + name: 'Edit Test' + }] + }) + .expect(404); + }); + + it('Integration deleting non-existing webhook returns 404', function () { + return request.delete(localUtils.API.getApiQuery(`webhooks/5f27d0287c75da744d8615db/`)) + .set('Authorization', `Ghost ${localUtils.getValidAdminToken('/v3/admin/', testUtils.DataGenerator.Content.api_keys[0])}`) + .expect(404); + }); + + it('Cannot edit webhooks using content api keys', function () { + let webhookData = { + event: 'post.create', + target_url: 'http://example.com/webhooks/test/extra/2' + }; + + return request.post(localUtils.API.getApiQuery('webhooks/')) + .set('Authorization', `Ghost ${localUtils.getValidAdminToken('/v3/admin/', testUtils.DataGenerator.Content.api_keys[1])}`) + .send({webhooks: [webhookData]}) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(401); + }); +}); diff --git a/test/regression/api/v3/content/authors_spec.js b/test/regression/api/v3/content/authors_spec.js new file mode 100644 index 0000000000..dcb36da7be --- /dev/null +++ b/test/regression/api/v3/content/authors_spec.js @@ -0,0 +1,56 @@ +const should = require('should'); +const supertest = require('supertest'); +const localUtils = require('./utils'); +const testUtils = require('../../../../utils'); +const configUtils = require('../../../../utils/configUtils'); +const config = require('../../../../../core/shared/config'); + +const ghost = testUtils.startGhost; + +describe('Authors Content API', function () { + const validKey = localUtils.getValidKey(); + let request; + + before(function () { + return ghost() + .then(function (_ghostServer) { + request = supertest.agent(config.get('url')); + }) + .then(function () { + return testUtils.initFixtures('owner:post', 'users:no-owner', 'user:inactive', 'posts', 'api_keys'); + }); + }); + + afterEach(function () { + configUtils.restore(); + }); + + it('can read authors with fields', function () { + return request.get(localUtils.API.getApiQuery(`authors/1/?key=${validKey}&fields=name`)) + .set('Origin', testUtils.API.getURL()) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + + // We don't expose any other attrs. + localUtils.API.checkResponse(res.body.authors[0], 'author', null, null, ['id', 'name']); + }); + }); + + it('browse authors with slug filter, should order in slug order', function () { + return request.get(localUtils.API.getApiQuery(`authors/?key=${validKey}&filter=slug:[joe-bloggs,ghost,slimer-mcectoplasm]`)) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + const jsonResponse = res.body; + + jsonResponse.authors.should.be.an.Array().with.lengthOf(3); + jsonResponse.authors[0].slug.should.equal('joe-bloggs'); + jsonResponse.authors[1].slug.should.equal('ghost'); + jsonResponse.authors[2].slug.should.equal('slimer-mcectoplasm'); + }); + }); +}); diff --git a/test/regression/api/v3/content/pages_spec.js b/test/regression/api/v3/content/pages_spec.js new file mode 100644 index 0000000000..dc071ed8e9 --- /dev/null +++ b/test/regression/api/v3/content/pages_spec.js @@ -0,0 +1,68 @@ +const should = require('should'); +const supertest = require('supertest'); +const testUtils = require('../../../../utils'); +const localUtils = require('./utils'); +const configUtils = require('../../../../utils/configUtils'); +const config = require('../../../../../core/shared/config'); + +const ghost = testUtils.startGhost; +let request; + +describe('api/v3/content/pages', function () { + const key = localUtils.getValidKey(); + + before(function () { + return ghost() + .then(function () { + request = supertest.agent(config.get('url')); + }) + .then(function () { + return testUtils.initFixtures('users:no-owner', 'user:inactive', 'posts', 'tags:extra', 'api_keys'); + }); + }); + + afterEach(function () { + configUtils.restore(); + }); + + it('Can browse pages with page:false', function () { + return request.get(localUtils.API.getApiQuery(`pages/?key=${key}&filter=page:false`)) + .set('Origin', testUtils.API.getURL()) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + res.headers.vary.should.eql('Accept-Encoding'); + should.exist(res.headers['access-control-allow-origin']); + should.not.exist(res.headers['x-cache-invalidate']); + + const jsonResponse = res.body; + should.exist(jsonResponse.pages); + should.exist(jsonResponse.meta); + + jsonResponse.pages.should.have.length(0); + }); + }); + + it('browse pages with slug filter, should order in slug order', function () { + return request.get(localUtils.API.getApiQuery(`pages/?key=${key}&filter=slug:[static-page-test]`)) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + const jsonResponse = res.body; + + jsonResponse.pages.should.be.an.Array().with.lengthOf(1); + jsonResponse.pages[0].slug.should.equal('static-page-test'); + }); + }); + + it('can\'t read post', function () { + return request + .get(localUtils.API.getApiQuery(`pages/${testUtils.DataGenerator.Content.posts[0].id}/?key=${key}`)) + .set('Origin', testUtils.API.getURL()) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(404); + }); +}); diff --git a/test/regression/api/v3/content/posts_spec.js b/test/regression/api/v3/content/posts_spec.js new file mode 100644 index 0000000000..bc206462a9 --- /dev/null +++ b/test/regression/api/v3/content/posts_spec.js @@ -0,0 +1,407 @@ +const should = require('should'); +const sinon = require('sinon'); +const moment = require('moment'); +const supertest = require('supertest'); +const _ = require('lodash'); +const labs = require('../../../../../core/server/services/labs'); +const testUtils = require('../../../../utils'); +const localUtils = require('./utils'); +const configUtils = require('../../../../utils/configUtils'); +const urlUtils = require('../../../../utils/urlUtils'); +const config = require('../../../../../core/shared/config'); + +const ghost = testUtils.startGhost; +let request; + +describe('api/v3/content/posts', function () { + before(function () { + return ghost() + .then(function () { + request = supertest.agent(config.get('url')); + }) + .then(function () { + return testUtils.initFixtures('users:no-owner', 'user:inactive', 'posts', 'tags:extra', 'api_keys'); + }); + }); + + afterEach(function () { + configUtils.restore(); + urlUtils.restore(); + }); + + const validKey = localUtils.getValidKey(); + + it('browse posts', function (done) { + request.get(localUtils.API.getApiQuery(`posts/?key=${validKey}`)) + .set('Origin', testUtils.API.getURL()) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err, res) { + if (err) { + return done(err); + } + + res.headers.vary.should.eql('Accept-Encoding'); + should.exist(res.headers['access-control-allow-origin']); + should.not.exist(res.headers['x-cache-invalidate']); + + const jsonResponse = res.body; + should.exist(jsonResponse.posts); + localUtils.API.checkResponse(jsonResponse, 'posts'); + jsonResponse.posts.should.have.length(11); + localUtils.API.checkResponse(jsonResponse.posts[0], 'post'); + localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination'); + _.isBoolean(jsonResponse.posts[0].featured).should.eql(true); + + // Default order 'published_at desc' check + jsonResponse.posts[0].slug.should.eql('welcome'); + jsonResponse.posts[6].slug.should.eql('themes'); + + // check meta response for this test + jsonResponse.meta.pagination.page.should.eql(1); + jsonResponse.meta.pagination.limit.should.eql(15); + jsonResponse.meta.pagination.pages.should.eql(1); + jsonResponse.meta.pagination.total.should.eql(11); + jsonResponse.meta.pagination.hasOwnProperty('next').should.be.true(); + jsonResponse.meta.pagination.hasOwnProperty('prev').should.be.true(); + should.not.exist(jsonResponse.meta.pagination.next); + should.not.exist(jsonResponse.meta.pagination.prev); + + done(); + }); + }); + + it('browse posts with related authors/tags also returns primary_author/primary_tag', function (done) { + request.get(localUtils.API.getApiQuery(`posts/?key=${validKey}&include=authors,tags`)) + .set('Origin', testUtils.API.getURL()) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err, res) { + if (err) { + return done(err); + } + + res.headers.vary.should.eql('Accept-Encoding'); + should.exist(res.headers['access-control-allow-origin']); + should.not.exist(res.headers['x-cache-invalidate']); + + const jsonResponse = res.body; + should.exist(jsonResponse.posts); + localUtils.API.checkResponse(jsonResponse, 'posts'); + jsonResponse.posts.should.have.length(11); + localUtils.API.checkResponse( + jsonResponse.posts[0], + 'post', + ['authors', 'tags', 'primary_tag', 'primary_author'], + null + ); + + localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination'); + _.isBoolean(jsonResponse.posts[0].featured).should.eql(true); + + // Default order 'published_at desc' check + jsonResponse.posts[0].slug.should.eql('welcome'); + jsonResponse.posts[6].slug.should.eql('themes'); + + // check meta response for this test + jsonResponse.meta.pagination.page.should.eql(1); + jsonResponse.meta.pagination.limit.should.eql(15); + jsonResponse.meta.pagination.pages.should.eql(1); + jsonResponse.meta.pagination.total.should.eql(11); + jsonResponse.meta.pagination.hasOwnProperty('next').should.be.true(); + jsonResponse.meta.pagination.hasOwnProperty('prev').should.be.true(); + should.not.exist(jsonResponse.meta.pagination.next); + should.not.exist(jsonResponse.meta.pagination.prev); + + done(); + }); + }); + + it('browse posts with basic page filter should not return pages', function (done) { + request.get(localUtils.API.getApiQuery(`posts/?key=${validKey}&filter=page:true`)) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err, res) { + if (err) { + return done(err); + } + const jsonResponse = res.body; + + should.not.exist(res.headers['x-cache-invalidate']); + should.exist(jsonResponse.posts); + localUtils.API.checkResponse(jsonResponse, 'posts'); + localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination'); + jsonResponse.posts.should.have.length(0); + + done(); + }); + }); + + it('browse posts with basic page filter should not return pages', function (done) { + request.get(localUtils.API.getApiQuery(`posts/?key=${validKey}&filter=page:true,featured:true`)) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err, res) { + if (err) { + return done(err); + } + const jsonResponse = res.body; + + should.not.exist(res.headers['x-cache-invalidate']); + should.exist(jsonResponse.posts); + localUtils.API.checkResponse(jsonResponse, 'posts'); + localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination'); + jsonResponse.posts.should.have.length(2); + jsonResponse.posts.filter(p => (p.page === true)).should.have.length(0); + + done(); + }); + }); + + it('browse posts with published and draft status, should not return drafts', function (done) { + request.get(localUtils.API.getApiQuery(`posts/?key=${validKey}&filter=status:published,status:draft`)) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err, res) { + if (err) { + return done(err); + } + const jsonResponse = res.body; + + jsonResponse.posts.should.be.an.Array().with.lengthOf(11); + + done(); + }); + }); + + it('browse posts with slug filter, should order in slug order', function () { + return request.get(localUtils.API.getApiQuery(`posts/?key=${validKey}&filter=slug:[themes,ghostly-kitchen-sink,the-editor]`)) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + const jsonResponse = res.body; + + jsonResponse.posts.should.be.an.Array().with.lengthOf(3); + jsonResponse.posts[0].slug.should.equal('themes'); + jsonResponse.posts[1].slug.should.equal('ghostly-kitchen-sink'); + jsonResponse.posts[2].slug.should.equal('the-editor'); + }); + }); + + it('browse posts with slug filter should order taking order parameter into account', function () { + return request.get(localUtils.API.getApiQuery(`posts/?key=${validKey}&order=slug%20DESC&filter=slug:[themes,ghostly-kitchen-sink,the-editor]`)) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + const jsonResponse = res.body; + + jsonResponse.posts.should.be.an.Array().with.lengthOf(3); + jsonResponse.posts[0].slug.should.equal('themes'); + jsonResponse.posts[1].slug.should.equal('the-editor'); + jsonResponse.posts[2].slug.should.equal('ghostly-kitchen-sink'); + }); + }); + + it('ensure origin header on redirect is not getting lost', function (done) { + // NOTE: force a redirect to the admin url + configUtils.set('admin:url', 'http://localhost:9999'); + urlUtils.stubUrlUtilsFromConfig(); + + request.get(localUtils.API.getApiQuery(`posts?key=${validKey}`)) + .set('Origin', 'https://example.com') + // 301 Redirects _should_ be cached + .expect('Cache-Control', testUtils.cacheRules.year) + .expect(301) + .end(function (err, res) { + if (err) { + return done(err); + } + + res.headers.vary.should.eql('Accept, Accept-Encoding'); + res.headers.location.should.eql(`http://localhost:9999/ghost/api/v3/content/posts/?key=${validKey}`); + should.exist(res.headers['access-control-allow-origin']); + should.not.exist(res.headers['x-cache-invalidate']); + done(); + }); + }); + + it('can\'t read page', function () { + return request + .get(localUtils.API.getApiQuery(`posts/${testUtils.DataGenerator.Content.posts[5].id}/?key=${validKey}`)) + .set('Origin', testUtils.API.getURL()) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(404); + }); + + it('can read post with fields', function () { + return request + .get(localUtils.API.getApiQuery(`posts/${testUtils.DataGenerator.Content.posts[0].id}/?key=${validKey}&fields=title,slug`)) + .set('Origin', testUtils.API.getURL()) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + localUtils.API.checkResponse(res.body.posts[0], 'post', null, null, ['id', 'title', 'slug']); + }); + }); + + describe('content gating', function () { + let publicPost; + let membersPost; + let paidPost; + + before(function () { + // NOTE: ideally this would be set through Admin API request not a stub + sinon.stub(labs, 'isSet').withArgs('members').returns(true); + }); + + before (function () { + publicPost = testUtils.DataGenerator.forKnex.createPost({ + slug: 'free-to-see', + visibility: 'public', + published_at: moment().add(15, 'seconds').toDate() // here to ensure sorting is not modified + }); + + membersPost = testUtils.DataGenerator.forKnex.createPost({ + slug: 'thou-shalt-not-be-seen', + visibility: 'members', + published_at: moment().add(45, 'seconds').toDate() // here to ensure sorting is not modified + }); + + paidPost = testUtils.DataGenerator.forKnex.createPost({ + slug: 'thou-shalt-be-paid-for', + visibility: 'paid', + published_at: moment().add(30, 'seconds').toDate() // here to ensure sorting is not modified + }); + + return testUtils.fixtures.insertPosts([ + publicPost, + membersPost, + paidPost + ]); + }); + + it('public post fields are always visible', function () { + return request + .get(localUtils.API.getApiQuery(`posts/${publicPost.id}/?key=${validKey}&fields=slug,html,plaintext&formats=html,plaintext`)) + .set('Origin', testUtils.API.getURL()) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + const jsonResponse = res.body; + should.exist(jsonResponse.posts); + const post = jsonResponse.posts[0]; + + localUtils.API.checkResponse(post, 'post', null, null, ['id', 'slug', 'html', 'plaintext']); + post.slug.should.eql('free-to-see'); + post.html.should.not.eql(''); + post.plaintext.should.not.eql(''); + }); + }); + + it('cannot read members only post content', function () { + return request + .get(localUtils.API.getApiQuery(`posts/${membersPost.id}/?key=${validKey}`)) + .set('Origin', testUtils.API.getURL()) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + const jsonResponse = res.body; + should.exist(jsonResponse.posts); + const post = jsonResponse.posts[0]; + + localUtils.API.checkResponse(post, 'post', null, null); + post.slug.should.eql('thou-shalt-not-be-seen'); + post.html.should.eql(''); + }); + }); + + it('cannot read paid only post content', function () { + return request + .get(localUtils.API.getApiQuery(`posts/${paidPost.id}/?key=${validKey}`)) + .set('Origin', testUtils.API.getURL()) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + const jsonResponse = res.body; + should.exist(jsonResponse.posts); + const post = jsonResponse.posts[0]; + + localUtils.API.checkResponse(post, 'post', null, null); + post.slug.should.eql('thou-shalt-be-paid-for'); + post.html.should.eql(''); + }); + }); + + it('cannot read members only post plaintext', function () { + return request + .get(localUtils.API.getApiQuery(`posts/${membersPost.id}/?key=${validKey}&formats=html,plaintext&fields=html,plaintext`)) + .set('Origin', testUtils.API.getURL()) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + const jsonResponse = res.body; + should.exist(jsonResponse.posts); + const post = jsonResponse.posts[0]; + + localUtils.API.checkResponse(post, 'post', null, null, ['id', 'html', 'plaintext']); + post.html.should.eql(''); + post.plaintext.should.eql(''); + }); + }); + + it('cannot browse members only posts content', function () { + return request.get(localUtils.API.getApiQuery(`posts/?key=${validKey}`)) + .set('Origin', testUtils.API.getURL()) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + res.headers.vary.should.eql('Accept-Encoding'); + should.exist(res.headers['access-control-allow-origin']); + should.not.exist(res.headers['x-cache-invalidate']); + + const jsonResponse = res.body; + should.exist(jsonResponse.posts); + localUtils.API.checkResponse(jsonResponse, 'posts'); + jsonResponse.posts.should.have.length(14); + localUtils.API.checkResponse(jsonResponse.posts[0], 'post', null, null); + localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination'); + _.isBoolean(jsonResponse.posts[0].featured).should.eql(true); + + // Default order 'published_at desc' check + jsonResponse.posts[0].slug.should.eql('thou-shalt-not-be-seen'); + jsonResponse.posts[1].slug.should.eql('thou-shalt-be-paid-for'); + jsonResponse.posts[2].slug.should.eql('free-to-see'); + jsonResponse.posts[7].slug.should.eql('organising-content'); + + jsonResponse.posts[0].html.should.eql(''); + jsonResponse.posts[1].html.should.eql(''); + jsonResponse.posts[2].html.should.not.eql(''); + jsonResponse.posts[7].html.should.not.eql(''); + + // check meta response for this test + jsonResponse.meta.pagination.page.should.eql(1); + jsonResponse.meta.pagination.limit.should.eql(15); + jsonResponse.meta.pagination.pages.should.eql(1); + jsonResponse.meta.pagination.total.should.eql(14); + jsonResponse.meta.pagination.hasOwnProperty('next').should.be.true(); + jsonResponse.meta.pagination.hasOwnProperty('prev').should.be.true(); + should.not.exist(jsonResponse.meta.pagination.next); + should.not.exist(jsonResponse.meta.pagination.prev); + }); + }); + }); +}); diff --git a/test/regression/api/v3/content/tags_spec.js b/test/regression/api/v3/content/tags_spec.js new file mode 100644 index 0000000000..94e7b81921 --- /dev/null +++ b/test/regression/api/v3/content/tags_spec.js @@ -0,0 +1,87 @@ +const should = require('should'); +const supertest = require('supertest'); +const _ = require('lodash'); +const localUtils = require('./utils'); +const testUtils = require('../../../../utils'); +const configUtils = require('../../../../utils/configUtils'); +const config = require('../../../../../core/shared/config'); + +const ghost = testUtils.startGhost; +let request; + +describe('api/v3/content/tags', function () { + const validKey = localUtils.getValidKey(); + + before(function () { + return ghost() + .then(function () { + request = supertest.agent(config.get('url')); + }) + .then(function () { + return testUtils.initFixtures('users:no-owner', 'user:inactive', 'posts', 'tags:extra', 'api_keys'); + }); + }); + + afterEach(function () { + configUtils.restore(); + }); + + it('Can read tags with fields', function () { + return request + .get(localUtils.API.getApiQuery(`tags/${testUtils.DataGenerator.Content.tags[0].id}/?key=${validKey}&fields=name,slug`)) + .set('Origin', testUtils.API.getURL()) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + localUtils.API.checkResponse(res.body.tags[0], 'tag', null, null, ['id', 'name', 'slug']); + }); + }); + + it('Can request all tags with count.posts field', function () { + return request + .get(localUtils.API.getApiQuery(`tags/?key=${validKey}&include=count.posts`)) + .set('Origin', testUtils.API.getURL()) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + should.not.exist(res.headers['x-cache-invalidate']); + const jsonResponse = res.body; + should.exist(jsonResponse); + should.exist(jsonResponse.tags); + jsonResponse.tags.should.have.length(4); + localUtils.API.checkResponse(jsonResponse.tags[0], 'tag', ['count', 'url']); + + jsonResponse.meta.pagination.should.have.property('page', 1); + jsonResponse.meta.pagination.should.have.property('limit', 15); + jsonResponse.meta.pagination.should.have.property('pages', 4); + jsonResponse.meta.pagination.should.have.property('total', 56); + jsonResponse.meta.pagination.should.have.property('next', 2); + jsonResponse.meta.pagination.should.have.property('prev', null); + + should.exist(jsonResponse.tags[0].count.posts); + // Each tag should have the correct count + _.find(jsonResponse.tags, {name: 'Getting Started'}).count.posts.should.eql(7); + _.find(jsonResponse.tags, {name: 'kitchen sink'}).count.posts.should.eql(2); + _.find(jsonResponse.tags, {name: 'bacon'}).count.posts.should.eql(2); + _.find(jsonResponse.tags, {name: 'chorizo'}).count.posts.should.eql(1); + }); + }); + + it('Browse tags with slug filter, should order in slug order', function () { + return request.get(localUtils.API.getApiQuery(`tags/?key=${validKey}&filter=slug:[kitchen-sink,bacon,chorizo]`)) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + const jsonResponse = res.body; + + jsonResponse.tags.should.be.an.Array().with.lengthOf(3); + jsonResponse.tags[0].slug.should.equal('kitchen-sink'); + jsonResponse.tags[1].slug.should.equal('bacon'); + jsonResponse.tags[2].slug.should.equal('chorizo'); + }); + }); +}); diff --git a/test/regression/api/v3/content/utils.js b/test/regression/api/v3/content/utils.js new file mode 100644 index 0000000000..956bf64432 --- /dev/null +++ b/test/regression/api/v3/content/utils.js @@ -0,0 +1,94 @@ +const url = require('url'); +const _ = require('lodash'); +const testUtils = require('../../../../utils'); +const schema = require('../../../../../core/server/data/schema').tables; +const API_URL = '/ghost/api/v3/content/'; + +const expectedProperties = { + // API top level + posts: ['posts', 'meta'], + tags: ['tags', 'meta'], + authors: ['authors', 'meta'], + pagination: ['page', 'limit', 'pages', 'total', 'next', 'prev'], + + post: _(schema.posts) + .keys() + // by default we only return html + .without('mobiledoc', 'plaintext') + // v3 doesn't return author_id OR author + .without('author_id', 'author') + // and always returns computed properties: url, primary_tag, primary_author + .concat('url') + // v3 API doesn't return unused fields + .without('locale') + // These fields aren't useful as they always have known values + .without('status') + // @TODO: https://github.com/TryGhost/Ghost/issues/10335 + // .without('page') + .without('type') + // v3 returns a calculated excerpt field + .concat('excerpt') + // Access is a calculated property in >= v3 + .concat('access') + // returns meta fields from `posts_meta` schema + .concat( + ..._(schema.posts_meta).keys().without('post_id', 'id') + ) + .concat('reading_time') + .concat('send_email_when_published') + , + author: _(schema.users) + .keys() + .without( + 'password', + 'email', + 'created_at', + 'created_by', + 'updated_at', + 'updated_by', + 'last_seen', + 'status' + ) + // v3 API doesn't return unused fields + .without('accessibility', 'locale', 'tour', 'visibility') + , + tag: _(schema.tags) + .keys() + // v3 Tag API doesn't return parent_id or parent + .without('parent_id', 'parent') + // v3 Tag API doesn't return date fields + .without('created_at', 'updated_at') +}; + +_.each(expectedProperties, (value, key) => { + if (!value.__wrapped__) { + return; + } + + /** + * @deprecated: x_by + */ + expectedProperties[key] = value + .without( + 'created_by', + 'updated_by', + 'published_by' + ) + .value(); +}); + +module.exports = { + API: { + getApiQuery(route) { + return url.resolve(API_URL, route); + }, + + checkResponse(...args) { + this.expectedProperties = expectedProperties; + return testUtils.API.checkResponse.call(this, ...args); + } + }, + getValidKey() { + return testUtils.DataGenerator.Content.api_keys[1].secret; + } +}; diff --git a/test/regression/site/site_spec.js b/test/regression/site/site_spec.js index 5a773e6776..af06831867 100644 --- a/test/regression/site/site_spec.js +++ b/test/regression/site/site_spec.js @@ -3444,7 +3444,7 @@ describe('Integration - Web - Site', function () { }); beforeEach(function () { - const postsAPI = require('../../../core/server/api/canary/posts-public'); + const postsAPI = require('../../../core/server/api/v3/posts-public'); postSpy = sinon.spy(postsAPI.browse, 'query'); });