UNPKG

apialize

Version:

Turn a database model into a production ready REST(ish) CRUD API in a few lines.

209 lines (183 loc) 6.93 kB
const express = require('express'); const request = require('supertest'); const bodyParser = require('body-parser'); function randId() { return ( 'u-' + Math.random().toString(16).slice(2, 10) + Date.now().toString(16) + Math.random().toString(16).slice(2, 6) ); } const { crud } = require('../src'); const { Sequelize, DataTypes } = require('sequelize'); /** * Multi-user ownership test * Table: records * - id (auto increment PK) * - external_id (uuid string, unique) * - user_id (string) user owner * - data (string) arbitrary payload * * Requirements: * - external_id returned for all operations; clients never send it on create (server-generated) * - Users can only see and mutate their own records * - Attempts to access another user's record return 404 (not found) */ describe('multi-user ownership with default numeric id (bearer auth)', () => { let sequelize; let Record; let Session; let app; let tokens; beforeAll(async () => { sequelize = new Sequelize('sqlite::memory:', { logging: false }); Record = sequelize.define( 'Record', { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, external_id: { type: DataTypes.STRING, unique: true }, user_id: { type: DataTypes.STRING, allowNull: false }, data: { type: DataTypes.STRING }, }, { tableName: 'records', timestamps: false } ); Session = sequelize.define( 'Session', { token: { type: DataTypes.STRING, primaryKey: true }, user_id: { type: DataTypes.STRING, allowNull: false }, }, { tableName: 'sessions', timestamps: false } ); await sequelize.sync({ force: true }); // Hook to always generate external_id if not provided Record.beforeCreate((instance) => { if (!instance.external_id) instance.external_id = randId(); }); }); afterAll(async () => { await sequelize.close(); }); beforeEach(async () => { await Record.destroy({ where: {}, truncate: true, restartIdentity: true }); await Session.destroy({ where: {}, truncate: true }); // Seed two sessions (bearer tokens) tokens = { userA: `token-A-${randId()}`, userB: `token-B-${randId()}`, }; await Session.bulkCreate([ { token: tokens.userA, user_id: 'userA' }, { token: tokens.userB, user_id: 'userB' }, ]); // Build single app with auth middleware app = express(); app.use(bodyParser.json()); const authOwnership = async (req, res, next) => { const header = req.get('Authorization') || ''; const m = header.match(/^Bearer (.+)$/i); if (!m) return res.status(401).json({ error: 'Unauthorized' }); const token = m[1]; const session = await Session.findOne({ where: { token } }); if (!session) return res.status(401).json({ error: 'Unauthorized' }); const userId = session.user_id; req.apialize.applyWhere({ user_id: userId }); if (['POST', 'PUT', 'PATCH'].includes(req.method)) { req.body.user_id = userId; req.apialize.values = { ...(req.apialize.values || {}), user_id: userId, }; } next(); }; const opts = { middleware: [authOwnership] }; app.use('/records', crud(Record, opts)); // crud adapts to new operation signatures app.use((err, _req, res, _next) => res.status(500).json({ error: err.message }) ); }); test('users can only CRUD their own records via bearer token', async () => { const authA = { Authorization: `Bearer ${tokens.userA}` }; const authB = { Authorization: `Bearer ${tokens.userB}` }; // User A creates two records (no external_id supplied) const createA1 = await request(app) .post('/records') .set(authA) .send({ data: 'A1' }); const createA2 = await request(app) .post('/records') .set(authA) .send({ data: 'A2' }); if (createA1.status !== 201) { // eslint-disable-next-line no-console console.log('createA1 error body', createA1.body); } expect(createA1.status).toBe(201); expect(createA2.status).toBe(201); expect(createA1.body).toHaveProperty('id'); // internal numeric id const a1 = createA1.body.id; const a2 = createA2.body.id; // User B creates one record const createB1 = await request(app) .post('/records') .set(authB) .send({ data: 'B1' }); expect(createB1.status).toBe(201); const b1 = createB1.body.id; // List endpoints only show each user's own data const listA = await request(app).get('/records').set(authA); expect(listA.status).toBe(200); expect(listA.body.meta.count).toBe(2); expect(listA.body.data.map((r) => r.data).sort()).toEqual(['A1', 'A2']); const listB = await request(app).get('/records').set(authB); expect(listB.status).toBe(200); expect(listB.body.meta.count).toBe(1); expect(listB.body.data[0].data).toBe('B1'); // User A can read own record const getA1 = await request(app).get(`/records/${a1}`).set(authA); expect(getA1.status).toBe(200); expect(getA1.body.record.data).toBe('A1'); // User B cannot read User A's record -> 404 (filtered out by ownership) const getA1ByB = await request(app).get(`/records/${a1}`).set(authB); expect(getA1ByB.status).toBe(404); // Patch own record (User A) const patchA1 = await request(app) .patch(`/records/${a1}`) .set(authA) .send({ data: 'A1-updated' }); expect(patchA1.status).toBe(200); const verifyA1 = await request(app).get(`/records/${a1}`).set(authA); expect(verifyA1.body.record.data).toBe('A1-updated'); // Patch other user's record (User B tries to patch A1) -> 404 const patchA1ByB = await request(app) .patch(`/records/${a1}`) .set(authB) .send({ data: 'hack' }); expect(patchA1ByB.status).toBe(404); // PUT replace own record (User B) const putB1 = await request(app) .put(`/records/${b1}`) .set(authB) .send({ data: 'B1-replaced' }); expect(putB1.status).toBe(200); expect(putB1.body).toMatchObject({ success: true }); // PUT other user's record -> 404 const putA2ByB = await request(app) .put(`/records/${a2}`) .set(authB) .send({ data: 'takeover' }); expect(putA2ByB.status).toBe(404); // Delete own record const delA2 = await request(app).delete(`/records/${a2}`).set(authA); expect(delA2.status).toBe(200); // Delete other user's (User B deleting A1) -> 404 const delA1ByB = await request(app).delete(`/records/${a1}`).set(authB); expect(delA1ByB.status).toBe(404); // Final list userA should have 1 remaining const finalListA = await request(app).get('/records').set(authA); expect(finalListA.body.meta.count).toBe(1); expect(finalListA.body.data[0].id).toBe(a1); }); });