diff --git a/package.json b/package.json index de52e5b5..25b4cb36 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "build": "nuxt build --modern", "start:inspect": "NODE_ENV=production node --inspect node_modules/.bin/nuxt start --modern", "dev": "nuxt dev", - "test": "cd tests/seeds; jest ; cd ../..", + "test-sqlite": "NODE_ENV=test; DB='sqlite'; jest", + "test-mariadb": "NODE_ENV=test; DB='mariadb'; jest", + "test-postgresql": "NODE_ENV=test; DB='postgresql'; jest", "start": "nuxt start --modern", "doc": "cd docs && bundle exec jekyll b", "doc:dev": "cd docs && bundle exec jekyll s --drafts", @@ -28,10 +30,10 @@ "yarn.lock" ], "engines": { - "node": ">=12 <=16" + "node": ">=14 <=16" }, "dependencies": { - "@mdi/js": "^6.9.96", + "@mdi/js": "^7.0.96", "@nuxtjs/auth": "^4.9.1", "@nuxtjs/axios": "^5.13.5", "@nuxtjs/sitemap": "^2.4.0", @@ -41,7 +43,7 @@ "body-parser": "^1.20.0", "cookie-parser": "^1.4.6", "cors": "^2.8.5", - "dayjs": "^1.11.3", + "dayjs": "^1.11.4", "dompurify": "^2.3.10", "email-templates": "^8.0.9", "express": "^4.18.1", @@ -50,7 +52,7 @@ "https-proxy-agent": "^5.0.1", "ical.js": "^1.5.0", "ics": "^2.37.0", - "jsdom": "^19.0.0", + "jsdom": "^20.0.0", "jsonwebtoken": "^8.5.1", "linkify-html": "^3.0.4", "linkifyjs": "3.0.5", @@ -60,19 +62,19 @@ "minify-css-string": "^1.0.0", "mkdirp": "^1.0.4", "multer": "^1.4.5-lts.1", - "nuxt-edge": "2.16.0-27358576.777a4b7f", + "nuxt-edge": "2.16.0-27616340.013f051b", "pg": "^8.6.0", "sequelize": "^6.21.3", "sequelize-slugify": "^1.6.1", "sharp": "^0.27.2", - "sqlite3": "^5.0.9", + "sqlite3": "^5.0.10", "tiptap": "^1.32.0", "tiptap-extensions": "^1.35.0", "umzug": "^2.3.0", "v-calendar": "^2.4.1", - "vue": "^2.6.14", + "vue": "2.7.8", "vue-i18n": "^8.26.7", - "vue-template-compiler": "^2.6.14", + "vue-template-compiler": "2.7.8", "vuetify": "2.6.7", "winston": "^3.8.1", "winston-daily-rotate-file": "^4.7.1", diff --git a/server/api/controller/collection.js b/server/api/controller/collection.js index a186a13c..065c85ed 100644 --- a/server/api/controller/collection.js +++ b/server/api/controller/collection.js @@ -14,8 +14,7 @@ const collectionController = { const withFilters = req.query.withFilters let collections if (withFilters) { - collections = await Collection.findAll({ include: [Filter] }) - + collections = await Collection.findAll({ include: [ Filter ] }) } else { collections = await Collection.findAll() } @@ -109,9 +108,14 @@ const collectionController = { } // TODO: validation - log.info('Create collection: ' + req.body.name) - const collection = await Collection.create(collectionDetail) - res.json(collection) + log.info(`Create collection: ${req.body.name}`) + try { + const collection = await Collection.create(collectionDetail) + res.json(collection) + } catch (e) { + log.error(`Create collection failed ${e}`) + res.sendStatus(400) + } }, async remove (req, res) { @@ -122,7 +126,7 @@ const collectionController = { await collection.destroy() res.sendStatus(200) } catch (e) { - log.error('Remove collection failed:', e) + log.error('Remove collection failed:' + String(e)) res.sendStatus(404) } }, @@ -148,9 +152,12 @@ const collectionController = { async removeFilter (req, res) { const filter_id = req.params.id - log.info('Remove filter', filter_id) + log.info(`Remove filter ${filter_id}`) try { const filter = await Filter.findByPk(filter_id) + if (!filter) { + return res.sendStatus(404) + } await filter.destroy() res.sendStatus(200) } catch (e) { diff --git a/server/api/controller/event.js b/server/api/controller/event.js index 06de9e1f..52338200 100644 --- a/server/api/controller/event.js +++ b/server/api/controller/event.js @@ -391,7 +391,13 @@ const eventController = { let place if (body.place_id) { place = await Place.findByPk(body.place_id) + if (!place) { + return res.status(400).send(`Place not found`) + } } else { + if (!body.place_name) { + return res.status(400).send(`Place not found`) + } place = await Place.findOne({ where: Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('name')), Op.eq, body.place_name.trim().toLocaleLowerCase() )}) if (!place) { if (!body.place_address || !body.place_name) { @@ -639,11 +645,11 @@ const eventController = { if (tags && places) { where[Op.and] = [ { placeId: places ? places.split(',') : []}, - Sequelize.fn('EXISTS', Sequelize.literal(`SELECT 1 FROM event_tags WHERE ${Col('event_tags.eventId')}=event.id AND LOWER(${Col('tagTag')}) in (?)`)) + Sequelize.fn('EXISTS', Sequelize.literal(`SELECT 1 FROM event_tags WHERE ${Col('event_tags.eventId')}=${Col('event.id')} AND LOWER(${Col('tagTag')}) in (?)`)) ] replacements.push(tags) } else if (tags) { - where[Op.and] = Sequelize.fn('EXISTS', Sequelize.literal(`SELECT 1 FROM event_tags WHERE ${Col('event_tags.eventId')}=event.id AND LOWER(${Col('tagTag')}) in (?)`)) + where[Op.and] = Sequelize.fn('EXISTS', Sequelize.literal(`SELECT 1 FROM event_tags WHERE ${Col('event_tags.eventId')}=${Col('event.id')} AND LOWER(${Col('tagTag')}) in (?)`)) replacements.push(tags) } else if (places) { where.placeId = places.split(',') diff --git a/server/api/models/collection.js b/server/api/models/collection.js index 16a38b87..4df48141 100644 --- a/server/api/models/collection.js +++ b/server/api/models/collection.js @@ -3,6 +3,7 @@ const sequelize = require('./index').sequelize class Collection extends Model {} +// TODO: slugify! Collection.init({ id: { type: DataTypes.INTEGER, diff --git a/server/helpers.js b/server/helpers.js index 876fa3e4..dba90b3a 100644 --- a/server/helpers.js +++ b/server/helpers.js @@ -111,6 +111,8 @@ module.exports = { col (field) { if (config.db.dialect === 'postgres') { return '"' + field.split('.').join('"."') + '"' + } else if (config.db.dialect === 'mariadb') { + return '`' + field.split('.').join('`.`') + '`' } else { return field } diff --git a/server/routes.js b/server/routes.js index c129b750..a7ed8458 100644 --- a/server/routes.js +++ b/server/routes.js @@ -1,90 +1,100 @@ const express = require('express') const cookieParser = require('cookie-parser') - -const initialize = require('./initialize.server') -initialize.start() - -// const metricsController = require('./metrics') -// const promBundle = require('express-prom-bundle') -// const metricsMiddleware = promBundle({ includeMethod: true }) -const config = require('./config') - -const helpers = require('./helpers') -const log = require('./log') -const api = require('./api') - - const app = express() -app.enable('trust proxy') -app.use(helpers.logRequest) +const initialize = require('./initialize.server') -app.use(helpers.initSettings) -app.use(helpers.setUserLocale) -app.use(helpers.serveStatic()) +async function main () { -app.use(cookieParser()) + await initialize.start() + + // const metricsController = require('./metrics') + // const promBundle = require('express-prom-bundle') + // const metricsMiddleware = promBundle({ includeMethod: true }) + + const config = require('./config') + + const helpers = require('./helpers') + const log = require('./log') + const api = require('./api') + + app.enable('trust proxy') + app.use(helpers.logRequest) + + app.use(helpers.initSettings) + app.use(helpers.setUserLocale) + app.use(helpers.serveStatic()) + + app.use(cookieParser()) -// do not handle all routes on setup -if (config.status === 'READY') { - const cors = require('cors') - const { spamFilter } = require('./federation/helpers') - const oauth = require('./api/oauth') - const auth = require('./api/auth') - const federation = require('./federation') - const webfinger = require('./federation/webfinger') - const exportController = require('./api/controller/export') - const tagController = require('./api/controller/tag') - const placeController = require('./api/controller/place') - const collectionController = require('./api/controller/collection') + // do not handle all routes on setup + if (config.status === 'READY') { + const cors = require('cors') + const { spamFilter } = require('./federation/helpers') + const oauth = require('./api/oauth') + const auth = require('./api/auth') + const federation = require('./federation') + const webfinger = require('./federation/webfinger') + const exportController = require('./api/controller/export') + const tagController = require('./api/controller/tag') + const placeController = require('./api/controller/place') + const collectionController = require('./api/controller/collection') - // rss / ics feed - app.use(helpers.feedRedirect) - app.get('/feed/:format/tag/:tag', cors(), tagController.getEvents) - app.get('/feed/:format/place/:placeName', cors(), placeController.getEvents) - app.get('/feed/:format/collection/:name', cors(), collectionController.getEvents) - app.get('/feed/:format', cors(), exportController.export) + // rss / ics feed + app.use(helpers.feedRedirect) + app.get('/feed/:format/tag/:tag', cors(), tagController.getEvents) + app.get('/feed/:format/place/:placeName', cors(), placeController.getEvents) + app.get('/feed/:format/collection/:name', cors(), collectionController.getEvents) + app.get('/feed/:format', cors(), exportController.export) - - app.use('/event/:slug', helpers.APRedirect) - - // federation api / activitypub / webfinger / nodeinfo - app.use('/federation', federation) - app.use('/.well-known', webfinger) + + app.use('/event/:slug', helpers.APRedirect) + + // federation api / activitypub / webfinger / nodeinfo + app.use('/federation', federation) + app.use('/.well-known', webfinger) - // ignore unimplemented ping url from fediverse - app.use(spamFilter) + // ignore unimplemented ping url from fediverse + app.use(spamFilter) - // fill res.locals.user if request is authenticated - app.use(auth.fillUser) + // fill res.locals.user if request is authenticated + app.use(auth.fillUser) - app.use('/oauth', oauth) - // app.use(metricsMiddleware) + app.use('/oauth', oauth) + // app.use(metricsMiddleware) + } + + // api! + app.use('/api', api) + + // // Handle 500 + app.use((error, _req, res, _next) => { + log.error('[ERROR]' + error) + return res.status(500).send('500: Internal Server Error') + }) + + // remaining request goes to nuxt + // first nuxt component is ./pages/index.vue (with ./layouts/default.vue) + // prefill current events, tags, places and announcements (used in every path) + app.use(async (_req, res, next) => { + if (config.status === 'READY') { + + const announceController = require('./api/controller/announce') + res.locals.announcements = await announceController._getVisible() + } + res.locals.status = config.status + next() + }) + + return app } -// api! -app.use('/api', api) - -// // Handle 500 -app.use((error, _req, res, _next) => { - log.error('[ERROR]' + error) - return res.status(500).send('500: Internal Server Error') -}) - -// remaining request goes to nuxt -// first nuxt component is ./pages/index.vue (with ./layouts/default.vue) -// prefill current events, tags, places and announcements (used in every path) -app.use(async (_req, res, next) => { - if (config.status === 'READY') { - - const announceController = require('./api/controller/announce') - res.locals.announcements = await announceController._getVisible() - } - res.locals.status = config.status - next() -}) +if (process.env.NODE_ENV !== 'test') { + main() +} module.exports = { + main, handler: app, unload: () => initialize.shutdown(false) } diff --git a/tests/app.test.js b/tests/app.test.js index 635b4de6..db5c8295 100644 --- a/tests/app.test.js +++ b/tests/app.test.js @@ -1,21 +1,38 @@ const request = require('supertest') -const fs = require('fs') const dayjs = require('dayjs') +const path = require('path') + +const admin = { username: 'admin', password: 'test', grant_type: 'password', client_id: 'self' } -const admin = { username: 'admin', password: 'JqFuXEnkTyOR', grant_type: 'password', client_id: 'self' } let token -// - event list should be empty -// - try to write without auth -// - registration should be not allowed when disabled -// - registration should create a new user (not active) when enabled -// - unconfirmed user cannot login -// - should not login without auth data -// - should login with correct authentication let app +let places = [] + beforeAll( async () => { - fs.copyFileSync('./starter.sqlite', './testdb.sqlite') - await require('../server/initialize.server.js').start() - app = require('../server/routes.js').handler + switch (process.env.DB) { + case 'mariadb': + process.env.config_path = path.resolve(__dirname, './seeds/config.mariadb.json') + break + case 'postgresql': + process.env.config_path = path.resolve(__dirname, './seeds/config.postgres.json') + break + case 'sqlite': + default: + process.env.config_path = path.resolve(__dirname, './seeds/config.sqlite.json') + } + app = await require('../server/routes.js').main() + const { sequelize } = require('../server/api/models/index') + await sequelize.query('DELETE FROM settings') + await sequelize.query('DELETE FROM events') + await sequelize.query('DELETE FROM users') + await sequelize.query('DELETE FROM ap_users') + await sequelize.query('DELETE FROM tags') + await sequelize.query('DELETE FROM places') + await sequelize.query('DELETE FROM collections') +}) + +afterAll( async () => { + await require('../server/initialize.server.js').shutdown(false) }) describe('Basic', () => { @@ -35,9 +52,18 @@ describe('Authentication / Authorization', () => { test('should not authenticate with wrong user/password', () => { return request(app).post('/oauth/login') + .set('Content-Type', 'application/x-www-form-urlencoded') .expect(500) }) + test('shoud register an admin as first user', async () => { + const response = await request(app) + .post('/api/user/register') + .send({ email: 'admin', password: 'test' }) + .expect(200) + expect(response.body.id).toBeDefined() + }) + test('should authenticate with correct user/password', async () => { const response = await request(app) .post('/oauth/login') @@ -72,7 +98,7 @@ describe('Events', () => { const required_fields = { 'title': {}, 'start_datetime': { title: 'test title' }, - 'place_id or place_name and place_address': { title: 'test title', start_datetime: new Date().getTime() * 1000, place_name: 'test place name'}, + 'place_id or place_name and place_address': { title: 'test title', start_datetime: dayjs().unix()+1000, place_name: 'test place name'}, } const promises = Object.keys(required_fields).map(async field => { @@ -80,47 +106,51 @@ describe('Events', () => { .expect(400) expect(response.text).toBe(`${field} required`) }) - + return Promise.all(promises) }) test('should create anon event only when allowed', async () => { - + await request(app).post('/api/settings') .send({ key: 'allow_anon_event', value: false }) .auth(token.access_token, { type: 'bearer' }) .expect(200) - + await request(app).post('/api/event') .expect(403) - await request(app).post('/api/event') - .send({ title: 'test title', place_name: 'place name', place_address: 'address', tags: ['test'], start_datetime: new Date().getTime() * 1000 }) + let response = await request(app).post('/api/event') + .send({ title: 'test title 2', place_name: 'place name', place_address: 'address', tags: ['test'], start_datetime: dayjs().unix()+1000 }) .auth(token.access_token, { type: 'bearer' }) .expect(200) + expect(response.body.place.id).toBeDefined() + places.push(response.body.place.id) + await request(app).post('/api/settings') .send({ key: 'allow_anon_event', value: true }) .auth(token.access_token, { type: 'bearer' }) .expect(200) - - return request(app).post('/api/event') - .send({ title: 'test title', place_name: 'place name 2', place_address: 'address 2', tags: ['test'], start_datetime: new Date().getTime() * 1000 }) + + response = await request(app).post('/api/event') + .send({ title: 'test title 3', place_name: 'place name 2', place_address: 'address 2', tags: ['test'], start_datetime: dayjs().unix()+1000 }) .expect(200) + expect(response.body.place.id).toBeDefined() + places.push(response.body.place.id) + }) - - test('should trim tags', async () => { const event = { - title: 'test title', - place_id: 1, - start_datetime: dayjs().unix(), + title: 'test title 4', + place_id: places[0], + start_datetime: dayjs().unix()+1000, tags: [' test tag '] } - + const response = await request(app).post('/api/event') .send(event) .expect(200) @@ -133,7 +163,7 @@ describe('Events', () => { describe('Tags', () => { test('should create event with tags', async () => { const event = await request(app).post('/api/event') - .send({ title: 'test tags', place_id: 2, start_datetime: new Date().getTime() * 1000, tags: ['tag1', 'Tag2', 'tAg3'] }) + .send({ title: 'test tags', place_id: places[1], start_datetime: dayjs().unix()+1000 , tags: ['tag1', 'Tag2', 'tAg3'] }) .auth(token.access_token, { type: 'bearer' }) .expect(200) @@ -142,7 +172,7 @@ describe('Tags', () => { test('should create event trimming tags / ignore sensitiviness', async () => { const event = await request(app).post('/api/event') - .send({ title: 'test trimming tags', place_id: 2, start_datetime: new Date().getTime() * 1000, tags: ['Tag1', 'taG2 '] }) + .send({ title: 'test trimming tags', place_id: places[1], start_datetime: dayjs().unix()+1000, tags: ['Tag1', 'taG2 '] }) .auth(token.access_token, { type: 'bearer' }) .expect(200) @@ -155,8 +185,6 @@ describe('Tags', () => { const response = await request(app).get('/api/events?tags=tAg3') .expect(200) - // console.error(response.body) - // console.error(response.body[0].tags) expect(response.body.length).toBe(1) // expect(response.body[0].title).toBe('test tags') expect(response.body[0].tags.length).toBe(3) @@ -194,6 +222,8 @@ describe('Place', () => { }) +let collections = [] +let filters = [] describe ('Collection', () => { test('should not create a new collection if not allowed', () => { return request(app).post('/api/collections') @@ -206,7 +236,8 @@ describe ('Collection', () => { .send({ name: 'test collection' }) .auth(token.access_token, { type: 'bearer' }) .expect(200) - expect(response.body.id).toBe(1) + expect(response.body.id).toBeDefined() + collections.push(response.body.id) }) test('should do not have any event when no filters', async () => { @@ -220,14 +251,16 @@ describe ('Collection', () => { test('should add a new filter', async () => { await request(app) .post('/api/filter') + .send({ collectionId: collections[0], tags: ['test'] }) .expect(403) const response = await request(app).post('/api/filter') - .send({ collectionId: 1, tags: ['test'] }) + .send({ collectionId: collections[0], tags: ['test'] }) .auth(token.access_token, { type: 'bearer' }) .expect(200) - expect(response.body.id).toBe(1) + expect(response.body.id).toBeDefined() + filters.push(response.body.id) }) @@ -241,36 +274,36 @@ describe ('Collection', () => { test('should remove filter', async () => { await request(app) - .delete('/api/filter/1') + .delete(`/api/filter/${filters[0]}`) .expect(403) await request(app) - .delete('/api/filter/1') + .delete(`/api/filter/${filters[0]}`) .auth(token.access_token, { type: 'bearer' }) .expect(200) const response = await request(app) - .get('/api/filter/1') + .get(`/api/filter/${filters[0]}`) .auth(token.access_token, { type: 'bearer' }) .expect(200) - + expect(response.body.length).toBe(0) }) test('shoud filter for tags', async () => { - await request(app) + let response = await request(app) .post('/api/filter') - .send({ collectionId: 1, tags: ['test'] }) + .send({ collectionId: collections[0], tags: ['test'] }) .auth(token.access_token, { type: 'bearer' }) .expect(200) - let response = await request(app) - .get('/api/filter/1') - .auth(token.access_token, { type: 'bearer' }) - .expect(200) - - expect(response.body.length).toBe(1) + // response = await request(app) + // .get(`/api/filter/${response.body.id}`) + // .auth(token.access_token, { type: 'bearer' }) + // .expect(200) + // expect(response.body.length).toBe(1) + response = await request(app) .get(`/api/collections/test collection`) .expect(200) @@ -279,4 +312,4 @@ describe ('Collection', () => { }) -}) \ No newline at end of file +}) diff --git a/tests/seeds/config.mariadb.json b/tests/seeds/config.mariadb.json new file mode 100644 index 00000000..9de0f29f --- /dev/null +++ b/tests/seeds/config.mariadb.json @@ -0,0 +1,19 @@ +{ + "baseurl": "http://localhost:13120", + "hostname": "localhost", + "server": { + "host": "0.0.0.0", + "port": 13120 + }, + "log_level": "warn", + "log_path": "./logs", + "db": { + "dialect": "mariadb", + "host": "localhost", + "database": "gancio", + "username": "gancio", + "password": "gancio", + "logging": false + }, + "upload_path": "./uploads" +} diff --git a/tests/seeds/config.postgres.json b/tests/seeds/config.postgres.json new file mode 100644 index 00000000..b980178d --- /dev/null +++ b/tests/seeds/config.postgres.json @@ -0,0 +1,19 @@ +{ + "baseurl": "http://localhost:13120", + "hostname": "localhost", + "server": { + "host": "0.0.0.0", + "port": 13120 + }, + "log_level": "warn", + "log_path": "./logs", + "db": { + "dialect": "postgres", + "host": "localhost", + "database": "gancio", + "username": "gancio", + "password": "gancio", + "logging": false + }, + "upload_path": "./uploads" +} diff --git a/tests/seeds/config.sqlite.json b/tests/seeds/config.sqlite.json new file mode 100644 index 00000000..0e2eabf0 --- /dev/null +++ b/tests/seeds/config.sqlite.json @@ -0,0 +1,16 @@ +{ + "baseurl": "http://localhost:13120", + "hostname": "127.0.0.1", + "server": { + "host": "0.0.0.0", + "port": 13120 + }, + "log_level": "warn", + "log_path": "./logs", + "db": { + "dialect": "sqlite", + "storage": "./tests/seeds/testdb.sqlite", + "logging": false + }, + "upload_path": "./uploads" +}