diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e4676f3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + workflow_dispatch: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: [18.x, 16.x] + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + - run: npm i -g @sap/cds-dk + - run: npm i + - run: cds v + - run: npm run lint + - run: npm run test diff --git a/cds-plugin.js b/cds-plugin.js index a4fa2e1..c246e91 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -4,11 +4,6 @@ const { auditAccess } = require('./lib/access') const { augmentContext, calcMods4Before, calcMods4After, emitMods } = require('./lib/modification') const { hasPersonalData } = require('./lib/utils') -// // UNCOMMENT THIS IF YOU CAN'T USE CDS 7 -// if (cds.version.match(/^6/)) { -// cds.env.requires['audit-log'] = cds.env.requires['audit-log'] || { impl: '@cap-js/audit-logging/srv/log2console' } -// } - // TODO: why does cds.requires.audit-log: false in sample package.json not work ootb?! /* diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..c5f698f --- /dev/null +++ b/jest.config.js @@ -0,0 +1,7 @@ +const config = { + testTimeout: 42222, + testMatch: ['**/*.test.js'], + setupFilesAfterEnv: ['./test/jest.setup.js'] +} + +module.exports = config diff --git a/lib/utils.js b/lib/utils.js index 2637372..de054b0 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -238,4 +238,4 @@ module.exports = { addDataSubject, addDataSubjectForDetailsEntity, resolveDataSubjectPromises -} \ No newline at end of file +} diff --git a/package.json b/package.json index 2bea9fe..fa1b5ce 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,20 @@ "lib", "srv" ], + "scripts": { + "lint": "npx eslint .", + "test": "npx jest --silent" + }, "peerDependencies": { "@sap/cds": "*" }, "devDependencies": { + "@sap/audit-logging": "^5.7.0", + "axios": "^1.4.0", "eslint": "^8", - "@sap/audit-logging": "^5.7.0" + "express": "^4.18.2", + "jest": "^29.5.0", + "sqlite3": "^5.1.6" }, "cds": { "requires": { diff --git a/srv/log2console.js b/srv/log2console.js index 341e548..cb724a8 100644 --- a/srv/log2console.js +++ b/srv/log2console.js @@ -6,9 +6,17 @@ module.exports = class AuditLog2Console extends AuditLogService { await super.init() this.on('*', function(req) { - const { event, data } = req.data + const { event, data } = req.data.event && req.data.data ? req.data : req - console.log(`[audit-log] - ${event}:\n${JSON.stringify(data, null, 2).split('\n').map(l => ` ${l}`).join('\n')}`) + console.log(`[audit-log] - ${event}:\n${_format(data)}`) }) } -} \ No newline at end of file +} + +/* + * utils + */ + +function _format(data) { + return JSON.stringify(data, null, 2).split('\n').map(l => ` ${l}`).join('\n') +} diff --git a/srv/log2library.js b/srv/log2library.js index b32e13c..844ce6b 100644 --- a/srv/log2library.js +++ b/srv/log2library.js @@ -18,10 +18,12 @@ module.exports = class AuditLog2Library extends AuditLogService { await super.init() this.on('*', function(req) { - const { event, data } = req.data + const { event, data } = req.data.event && req.data.data ? req.data : req if (event.match(/^dataAccess/)) return this._dataAccess(data) if (event.match(/^dataModification/)) return this._dataModification(data) + if (event.match(/^configChange/)) return this._configChange(data) + if (event.match(/^security/)) return this._securityEvent(data) LOG._info && LOG.info(`event ${event} not implemented`) }) @@ -49,15 +51,14 @@ module.exports = class AuditLog2Library extends AuditLogService { return client } - async _dataAccess(access) { - // REVISIT: previous model/impl supported bulk - const accesses = [access] + async _dataAccess(accesses) { + if (!Array.isArray(accesses)) accesses = [accesses] const client = this._client || await this._getClient() if (!client) return // build the logs - const { tenant, user } = this._oauth2 ? { tenant: '$PROVIDER', user: '$USER' } : { tenant: access.tenant, user: access.user } + const { tenant, user } = this._oauth2 ? { tenant: '$PROVIDER', user: '$USER' } : { tenant: accesses[0].tenant, user: accesses[0].user } const { entries, errors } = _buildDataAccessLogs(client, accesses, tenant, user) if (errors.length) throw errors.length === 1 ? errors[0] : Object.assign(new Error('MULTIPLE_ERRORS'), { details: errors }) @@ -66,15 +67,14 @@ module.exports = class AuditLog2Library extends AuditLogService { if (errors.length) throw errors.length === 1 ? errors[0] : Object.assign(new Error('MULTIPLE_ERRORS'), { details: errors }) } - async _dataModification(modification) { - // REVISIT: previous model/impl supported bulk - const modifications = [modification] + async _dataModification(modifications) { + if (!Array.isArray(modifications)) modifications = [modifications] const client = this._client || await this._getClient() if (!client) return // build the logs - const { tenant, user } = this._oauth2 ? { tenant: '$PROVIDER', user: '$USER' } : { tenant: modification.tenant, user: modification.user } + const { tenant, user } = this._oauth2 ? { tenant: '$PROVIDER', user: '$USER' } : { tenant: modifications[0].tenant, user: modifications[0].user } const { entries, errors } = _buildDataModificationLogs(client, modifications, tenant, user) if (errors.length) throw errors.length === 1 ? errors[0] : Object.assign(new Error('MULTIPLE_ERRORS'), { details: errors }) @@ -82,6 +82,38 @@ module.exports = class AuditLog2Library extends AuditLogService { await Promise.all(entries.map(entry => _sendDataModificationLog(entry).catch(err => errors.push(err)))) if (errors.length) throw errors.length === 1 ? errors[0] : Object.assign(new Error('MULTIPLE_ERRORS'), { details: errors }) } + + async _configChange(configurations) { + if (!Array.isArray(configurations)) configurations = [configurations] + + const client = this._client || await this._getClient() + if (!client) return + + // build the logs + const { tenant, user } = this._oauth2 ? { tenant: '$PROVIDER', user: '$USER' } : { tenant: configurations[0].tenant, user: configurations[0].user } + const { entries, errors } = _buildConfigChangeLogs(client, configurations, tenant, user) + if (errors.length) throw errors.length === 1 ? errors[0] : Object.assign(new Error('MULTIPLE_ERRORS'), { details: errors }) + + // write the logs + await Promise.all(entries.map(entry => _sendConfigChangeLog(entry).catch(err => errors.push(err)))) + if (errors.length) throw errors.length === 1 ? errors[0] : Object.assign(new Error('MULTIPLE_ERRORS'), { details: errors }) + } + + async _securityEvent(arg) { + const { action, data } = arg + + const client = this._client || await this._getClient() + if (!client) return + + // build the logs + const { tenant, user } = this._oauth2 ? { tenant: '$PROVIDER', user: '$USER' } : { tenant: arg.tenant, user: arg.user } + const { entries, errors } = _buildSecurityLog(client, action, data, tenant, user) + if (errors.length) throw errors.length === 1 ? errors[0] : Object.assign(new Error('MULTIPLE_ERRORS'), { details: errors }) + + // write the logs + await Promise.all(entries.map(entry => _sendSecurityLog(entry).catch(err => errors.push(err)))) + if (errors.length) throw errors.length === 1 ? errors[0] : Object.assign(new Error('MULTIPLE_ERRORS'), { details: errors }) + } } /* @@ -190,77 +222,77 @@ function _sendDataModificationLog(entry) { } /* - * security + * config */ -// function _buildSecurityLog(client, action, data, tenant, user) { -// let entry - -// try { -// entry = client.securityMessage('action: %s, data: %s', action, data) -// if (tenant) entry.tenant(tenant) -// if (user) entry.by(user) -// } catch (err) { -// err.message = `Building security log failed with error: ${err.message}` -// throw err -// } - -// return entry -// } - -// function _sendSecurityLog(entry) { -// return new Promise((resolve, reject) => { -// entry.log(function (err) { -// if (err) { -// err.message = `Writing security log failed with error: ${err.message}` -// return reject(err) -// } - -// resolve() -// }) -// }) -// } +function _buildConfigChangeLogs(client, configurations, tenant, user) { + const entries = [] + const errors = [] + + for (const configuration of configurations) { + try { + const { dataObject } = _getObjectAndDataSubject(configuration) + const entry = client.configurationChange(dataObject).by(user) + if (tenant) entry.tenant(tenant) + for (const each of configuration.attributes) entry.attribute(_getAttributeToLog(each)) + entries.push(entry) + } catch (err) { + err.message = `Building configuration change log failed with error: ${err.message}` + errors.push(err) + } + } + + return { entries, errors } +} + +function _sendConfigChangeLog(entry) { + return new Promise((resolve, reject) => { + entry.logPrepare(function (err) { + if (err) { + err.message = `Preparing configuration change log failed with error: ${err.message}` + return reject(err) + } + + entry.logSuccess(function (err) { + if (err) { + err.message = `Writing configuration change log failed with error: ${err.message}` + return reject(err) + } + + resolve() + }) + }) + }) +} /* - * config + * security */ -// function _buildConfigChangeLogs(client, configurations, tenant, user) { -// const entries = [] -// const errors = [] - -// for (const configuration of configurations) { -// try { -// const { dataObject } = getObjectAndDataSubject(configuration) -// const entry = client.configurationChange(dataObject).by(user) -// if (tenant) entry.tenant(tenant) -// for (const each of configuration.attributes) entry.attribute(getAttributeToLog(each)) -// entries.push(entry) -// } catch (err) { -// err.message = `Building configuration change log failed with error: ${err.message}` -// errors.push(err) -// } -// } - -// return { entries, errors } -// } - -// function _sendConfigChangeLog(entry) { -// return new Promise((resolve, reject) => { -// entry.logPrepare(function (err) { -// if (err) { -// err.message = `Preparing configuration change log failed with error: ${err.message}` -// return reject(err) -// } - -// entry.logSuccess(function (err) { -// if (err) { -// err.message = `Writing configuration change log failed with error: ${err.message}` -// return reject(err) -// } - -// resolve() -// }) -// }) -// }) -// } +function _buildSecurityLog(client, action, data, tenant, user) { + let entry + + try { + entry = client.securityMessage('action: %s, data: %s', action, data) + if (tenant) entry.tenant(tenant) + if (user) entry.by(user) + } catch (err) { + err.message = `Building security log failed with error: ${err.message}` + throw err + } + + return entry +} + +function _sendSecurityLog(entry) { + return new Promise((resolve, reject) => { + entry.log(function (err) { + if (err) { + err.message = `Writing security log failed with error: ${err.message}` + return reject(err) + } + + resolve() + }) + }) +} diff --git a/srv/service.js b/srv/service.js index 7b8f2ff..f86eb3c 100644 --- a/srv/service.js +++ b/srv/service.js @@ -15,35 +15,56 @@ const _augment = data => { module.exports = class AuditLogService extends OutboxService { async emit(first, second) { - const { event, data } = typeof first === 'object' ? first : { event: first, data: second } - if (!this.options.outbox) return this.send(event, data) + let { event, data } = typeof first === 'object' ? first : { event: first, data: second } + if (data.event && data.data) ({ event, data } = data) + data = _augment(data) - if (this[event]) { - try { - // this will open a new (detached!) tx -> preserve user - await this.tx(() => super.send(new cds.Request({ method: event, data }))) - } catch (e) { - if (e.code === 'ERR_ASSERTION') { - e.unrecoverable = true - } - throw e - } + // immediate or deferred? + if (!this.options.outbox) return this.send(event, data) + try { + // this will open a new (detached!) tx -> preserve user + await this.tx(() => super.send(new cds.Request({ event, data }))) + } catch (e) { + if (e.code === 'ERR_ASSERTION') e.unrecoverable = true + throw e } } async send(event, data) { - if (this[event]) return super.send(event, data) + if (data.event && data.data) ({ event, data } = data) + + return super.send(event, _augment(data)) } /* - * api (await audit.log/logSync(event, data)) + * new api (await audit.log/logSync(event, data)) */ log(event, data = {}) { - return this.emit('log', { event, data: _augment(data) }) + return this.emit('log', { event, data }) } logSync(event, data = {}) { - return this.send('logSync', { event, data: _augment(data) }) + return this.send('logSync', { event, data }) + } + + /* + * compat api (await audit.(data)) + */ + + dataAccessLog(data = {}) { + return this.emit('dataAccessLog', data) + } + + dataModificationLog(data = {}) { + return this.emit('dataModificationLog', data) + } + + configChangeLog(data = {}) { + return this.emit('configChangeLog', data) + } + + securityLog(data = {}) { + return this.emit('securityLog', data) } } diff --git a/test/api/log2console.test.js b/test/api/log2console.test.js new file mode 100644 index 0000000..fc49afd --- /dev/null +++ b/test/api/log2console.test.js @@ -0,0 +1,131 @@ +const cds = require('@sap/cds') + +cds.env.requires['audit-log'] = { + kind: 'audit-log-to-console', + impl: '../../srv/log2console', + outbox: true +} + +const { POST, GET } = cds.test(__dirname) + +const wait = require('util').promisify(setTimeout) + +describe('AuditLogService API with kind audit-log-to-console', () => { + let __log, _logs + const _log = (...args) => { + if (!(args.length === 1 && typeof args[0] === 'string' && args[0].match(/\[audit-log\]/i))) { + // > not an audit log (most likely, anyway) + return __log(...args) + } + + _logs.push(JSON.parse(args[0].split('\n').slice(1).join(''))) + } + + const ALICE = { username: 'alice', password: 'password' } + + beforeAll(async () => { + __log = global.console.log + global.console.log = _log + }) + + afterAll(() => { + global.console.log = __log + }) + + beforeEach(async () => { + await POST('/api/resetSequence', {}, { auth: ALICE }) + _logs = [] + }) + + describe('default', () => { + test('emit is deferred', async () => { + const response = await POST('/api/testEmit', {}, { auth: ALICE }) + expect(response).toMatchObject({ status: 204 }) + await wait(42) + const { data: { value: sequence }} = await GET('/api/getSequence()', { auth: ALICE }) + expect(sequence).toEqual(['request succeeded', 'audit log logged']) + expect(_logs.length).toBe(1) + expect(_logs).toContainMatchObject({ user: 'alice', bar: 'baz' }) + }) + + test('send is immediate', async () => { + const response = await POST('/api/testSend', {}, { auth: ALICE }) + expect(response).toMatchObject({ status: 204 }) + await wait(42) + const { data: { value: sequence }} = await GET('/api/getSequence()', { auth: ALICE }) + expect(sequence).toEqual(['audit log logged', 'request succeeded']) + expect(_logs.length).toBe(1) + expect(_logs).toContainMatchObject({ user: 'alice', bar: 'baz' }) + }) + }) + + describe('new', () => { + test('log is deferred', async () => { + const response = await POST('/api/testLog', {}, { auth: ALICE }) + expect(response).toMatchObject({ status: 204 }) + await wait(42) + const { data: { value: sequence }} = await GET('/api/getSequence()', { auth: ALICE }) + expect(sequence).toEqual(['request succeeded', 'audit log logged']) + expect(_logs.length).toBe(1) + expect(_logs).toContainMatchObject({ user: 'alice', bar: 'baz' }) + }) + + test('logSync is immediate', async () => { + const response = await POST('/api/testLogSync', {}, { auth: ALICE }) + expect(response).toMatchObject({ status: 204 }) + await wait(42) + const { data: { value: sequence }} = await GET('/api/getSequence()', { auth: ALICE }) + expect(sequence).toEqual(['audit log logged', 'request succeeded']) + expect(_logs.length).toBe(1) + expect(_logs).toContainMatchObject({ user: 'alice', bar: 'baz' }) + }) + }) + + describe('compat', () => { + test('dataAccessLog', async () => { + const response = await POST('/api/testDataAccessLog', {}, { auth: ALICE }) + expect(response).toMatchObject({ status: 204 }) + expect(_logs.length).toBe(1) + // REVISIT: data structure is not yet final + expect(_logs).toContainMatchObject({ + user: 'alice', + dataObject: { type: 'test', id: [{ keyName: 'test', value: 'test' }] }, + dataSubject: { type: 'test', role: 'test', id: [{ keyName: 'test', value: 'test' }] }, + attributes: [{ name: 'test' }] + }) + }) + + test('dataModificationLog', async () => { + const response = await POST('/api/testDataModificationLog', {}, { auth: ALICE }) + expect(response).toMatchObject({ status: 204 }) + expect(_logs.length).toBe(1) + // REVISIT: data structure is not yet final + expect(_logs).toContainMatchObject({ + user: 'alice', + dataObject: { type: 'test', id: [{ keyName: 'test', value: 'test' }] }, + dataSubject: { type: 'test', role: 'test', id: [{ keyName: 'test', value: 'test' }] }, + attributes: [{ name: 'test', oldValue: 'test', newValue: 'test' }] + }) + }) + + test('configChangeLog', async () => { + const response = await POST('/api/testConfigChangeLog', {}, { auth: ALICE }) + expect(response).toMatchObject({ status: 204 }) + expect(_logs.length).toBe(1) + // REVISIT: data structure is not yet final + expect(_logs).toContainMatchObject({ + user: 'alice', + dataObject: { type: 'test', id: [{ keyName: 'test', value: 'test' }] }, + attributes: [{ name: 'test', oldValue: 'test', newValue: 'test' }] + }) + }) + + test('testSecurityLog', async () => { + const response = await POST('/api/testSecurityLog', {}, { auth: ALICE }) + expect(response).toMatchObject({ status: 204 }) + expect(_logs.length).toBe(1) + // REVISIT: data structure is not yet final + expect(_logs).toContainMatchObject({ user: 'alice', action: 'dummy', data: 'dummy' }) + }) + }) +}) diff --git a/test/api/package.json b/test/api/package.json new file mode 100644 index 0000000..c4ca4df --- /dev/null +++ b/test/api/package.json @@ -0,0 +1,13 @@ +{ + "name": "api", + "version": "1.0.0", + "description": "A simple CAP project.", + "repository": "", + "license": "UNLICENSED", + "private": true, + "cds": { + "plugins": [ + "../.." + ] + } +} diff --git a/test/api/srv/api-service.cds b/test/api/srv/api-service.cds new file mode 100644 index 0000000..2a62e95 --- /dev/null +++ b/test/api/srv/api-service.cds @@ -0,0 +1,22 @@ +@path: '/api' +service APIService { + + // default + action testEmit(); + action testSend(); + + // new + action testLog(); + action testLogSync(); + + // compat + action testDataAccessLog(); + action testDataModificationLog(); + action testConfigChangeLog(); + action testSecurityLog(); + + // test helpers + function getSequence() returns many String; + action resetSequence(); + +} diff --git a/test/api/srv/api-service.js b/test/api/srv/api-service.js new file mode 100644 index 0000000..b47786e --- /dev/null +++ b/test/api/srv/api-service.js @@ -0,0 +1,71 @@ +module.exports = async function () { + const audit = await cds.connect.to('audit-log') + + /* + * default + */ + + this.on('testEmit', async function () { + await audit.emit('foo', { bar: 'baz' }) + }) + + this.on('testSend', async function () { + await audit.send('foo', { bar: 'baz' }) + }) + + /* + * new + */ + + this.on('testLog', async function () { + await audit.log('foo', { bar: 'baz' }) + }) + + this.on('testLogSync', async function () { + await audit.logSync('foo', { bar: 'baz' }) + }) + + /* + * compat + */ + + this.on('testDataAccessLog', async function () { + // REVISIT: data structure is not yet final + await audit.dataAccessLog({ + dataObject: { type: 'test', id: [{ keyName: 'test', value: 'test' }] }, + dataSubject: { type: 'test', id: [{ keyName: 'test', value: 'test' }], role: 'test' }, + attributes: [{ name: 'test' }] + }) + }) + + this.on('testDataModificationLog', async function () { + // REVISIT: data structure is not yet final + await audit.dataModificationLog({ + dataObject: { type: 'test', id: [{ keyName: 'test', value: 'test' }] }, + dataSubject: { type: 'test', id: [{ keyName: 'test', value: 'test' }], role: 'test' }, + attributes: [{ name: 'test', oldValue: 'test', newValue: 'test' }] + }) + }) + + this.on('testConfigChangeLog', async function () { + // REVISIT: data structure is not yet final + await audit.configChangeLog({ + dataObject: { type: 'test', id: [{ keyName: 'test', value: 'test' }] }, + attributes: [{ name: 'test', oldValue: 'test', newValue: 'test' }] + }) + }) + + this.on('testSecurityLog', async function () { + // REVISIT: data structure is not yet final + await audit.securityLog({ action: 'dummy', data: 'dummy' }) + }) + + /* + * test helpers + */ + let _sequence = [] + this.before('*', req => !req.event.match(/sequence/i) && req.on('succeeded', () => _sequence.push('request succeeded'))) + this.on('getSequence', req => req.reply(_sequence)) + this.on('resetSequence', () => _sequence = []) + audit.after('*', () => _sequence.push('audit log logged')) +} diff --git a/test/jest.setup.js b/test/jest.setup.js new file mode 100644 index 0000000..43905f3 --- /dev/null +++ b/test/jest.setup.js @@ -0,0 +1,22 @@ +function toContainMatchObject(received, expected) { + let pass = false + for (const each of received) { + try { + expect(each).toMatchObject(expected) + pass = true + } catch (e) { + // ignore + } + + if (pass) break + } + + const message = () => `expected +${JSON.stringify(received, null, 2)} +to include an object matching +${JSON.stringify(expected, null, 2)}` + + return { pass, message } +} + +expect.extend({ toContainMatchObject }) diff --git a/test/personal-data/crud2library.test.js b/test/personal-data/crud2library.test.js new file mode 100644 index 0000000..a230f20 --- /dev/null +++ b/test/personal-data/crud2library.test.js @@ -0,0 +1,1954 @@ +const cds = require('@sap/cds') + +cds.env.requires['audit-log'] = { + kind: 'audit-log-to-library', + impl: '../../srv/log2library', + credentials: { logToConsole: true } +} + +const _logger = require('../utils/logger')({ debug: true }) +cds.log.Logger = _logger + +const { POST, PATCH, GET, DELETE, data } = cds.test(__dirname) + +describe('personal data audit logging in CRUD with kind audit-log-to-library', () => { + let __log, _logs + const _log = (...args) => { + if (args.length !== 1 || !args[0].uuid) { + // > not an audit log (most likely, anyway) + return __log(...args) + } + + // do not add log preps + if (args[0].attributes && 'old' in args[0].attributes[0] && !args[0].success) return + + _logs.push(...args) + } + + const CUSTOMER_ID = 'bcd4a37a-6319-4d52-bb48-02fd06b9ffe9' + const DATA_SUBJECT = { + type: 'CRUD_1.Customers', + role: 'Customer', + id: { ID: CUSTOMER_ID } + } + + const ALICE = { username: 'alice', password: 'password' } + + beforeAll(async () => { + __log = global.console.log + global.console.log = _log + }) + + afterAll(() => { + global.console.log = __log + }) + + beforeEach(async () => { + await data.reset() + _logs = [] + _logger._resetLogs() + }) + + describe('data access logging', () => { + test('read with another data subject and sensitive data only in composition children', async () => { + const { data: customer } = await GET(`/crud-2/Customers(${CUSTOMER_ID})?$expand=addresses`, { auth: ALICE }) + const addressID1 = customer.addresses[0].ID + const addressID2 = customer.addresses[1].ID + expect(_logs.length).toBe(2) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_2.CustomerPostalAddress', + id: { ID: addressID1 } + }, + data_subject: { + type: 'CRUD_2.CustomerPostalAddress', + role: 'Address', + id: { + ID: addressID1, + street: 'moo', + town: 'shu' + } + }, + attributes: [{ name: 'someOtherField' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_2.CustomerPostalAddress', + id: { ID: addressID2 } + }, + data_subject: { + type: 'CRUD_2.CustomerPostalAddress', + role: 'Address', + id: { + ID: addressID2, + street: 'sue', + town: 'lou' + } + }, + attributes: [{ name: 'someOtherField' }] + }) + }) + + test('wrongly modeled entity must not be logged', async () => { + const response = await GET(`/crud-2/Customers(${CUSTOMER_ID})?$expand=status,addresses`, { auth: ALICE }) + + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(2) + expect(_logs).not.toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_2.CustomerStatus' + } + }) + }) + + test('read all Customers', async () => { + const response = await GET('/crud-1/Customers', { auth: ALICE }) + + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(1) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Customers', + id: { ID: CUSTOMER_ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'creditCardNo' }] + }) + }) + + test('read single Customer', async () => { + const response = await GET(`/crud-1/Customers(${CUSTOMER_ID})`, { auth: ALICE }) + + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(1) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Customers', + id: { ID: CUSTOMER_ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'creditCardNo' }] + }) + }) + + test('no log if sensitive data not selected', async () => { + const response = await GET(`/crud-1/Customers(${CUSTOMER_ID})?$select=ID`, { auth: ALICE }) + + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(0) + }) + + test('read non-existing Customer should not crash the app', async () => { + try { + await GET('/crud-1/Customers(ffffffff-6319-4d52-bb48-02fd06b9ffe9)', { auth: ALICE }) + } catch (error) { + expect(error.message).toMatch(/404/) + } + }) + + test('read Customer expanding addresses and comments - comp of many', async () => { + const response = await GET(`/crud-1/Customers(${CUSTOMER_ID})?$expand=addresses($expand=attachments),comments`, { + auth: ALICE + }) + + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(5) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Customers', + id: { ID: CUSTOMER_ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'creditCardNo' }] + }) + + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerPostalAddress', + id: { ID: '1ab71292-ef69-4571-8cfb-10b9d5d1459e' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'street' }] + }) + + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.AddressAttachment', + id: { ID: '3cd71292-ef69-4571-8cfb-10b9d5d1437e' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'description' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.AddressAttachment', + id: { ID: '595225db-6eeb-4b4f-9439-dbe5fcb4ce5a' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'description' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerPostalAddress', + id: { ID: '285225db-6eeb-4b4f-9439-dbe5fcb4ce82' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'street' }] + }) + }) + + test('read Customer expanding deep nested comp of one', async () => { + const response = await GET(`/crud-1/Customers(ID=${CUSTOMER_ID})?$expand=status($expand=change($expand=last))`, { + auth: ALICE + }) + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(4) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Customers', + id: { ID: CUSTOMER_ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'creditCardNo' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerStatus', + id: { ID: '23d4a37a-6319-4d52-bb48-02fd06b9ffa4' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'description' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.StatusChange', + id: { ID: '59d4a37a-6319-4d52-bb48-02fd06b9fbc2', secondKey: 'some value' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'description' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.LastOne', + id: { ID: '74d4a37a-6319-4d52-bb48-02fd06b9f3r4' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'lastOneField' }] + }) + }) + + test('read all CustomerStatus', async () => { + const response = await GET('/crud-1/CustomerStatus', { auth: ALICE }) + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(1) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerStatus', + id: { ID: '23d4a37a-6319-4d52-bb48-02fd06b9ffa4' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'description' }] + }) + }) + + test('read all CustomerPostalAddress', async () => { + const response = await GET('/crud-1/CustomerPostalAddress', { auth: ALICE }) + + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(2) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerPostalAddress', + id: { ID: '1ab71292-ef69-4571-8cfb-10b9d5d1459e' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'street' }] + }) + + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerPostalAddress', + id: { ID: '285225db-6eeb-4b4f-9439-dbe5fcb4ce82' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'street' }] + }) + }) + + test('read all CustomerPostalAddress expanding Customer', async () => { + const response = await GET('/crud-1/CustomerPostalAddress?$expand=customer', { auth: ALICE }) + + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(3) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Customers', + id: { ID: CUSTOMER_ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'creditCardNo' }] + }) + + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerPostalAddress', + id: { ID: '1ab71292-ef69-4571-8cfb-10b9d5d1459e' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'street' }] + }) + + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerPostalAddress', + id: { ID: '285225db-6eeb-4b4f-9439-dbe5fcb4ce82' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'street' }] + }) + }) + test('read all Pages with integer keys', async () => { + const response = await GET('/crud-1/Pages', { auth: ALICE }) + + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(1) + // Note: All values must be strings (as required by audit-log service APIs) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Pages', + id: { ID: '1' } + }, + data_subject: { + id: { + ID: '1' + }, + role: 'Page', + type: 'CRUD_1.Pages' + } + }) + }) + }) + + describe('modification logging', () => { + test('deep update customer with another data subject and sensitive data only in composition children', async () => { + const response = await PATCH( + `/crud-2/Customers(${CUSTOMER_ID})`, + { + addresses: [ + { + ID: '1ab71292-ef69-4571-8cfb-10b9d5d1459e', + customer_ID: CUSTOMER_ID, + street: 'updated', + town: 'updated town', + someOtherField: 'dummy' + }, + { + ID: '285225db-6eeb-4b4f-9439-dbe5fcb4ce82', + customer_ID: CUSTOMER_ID, + street: 'sue', + town: 'lou', + someOtherField: 'dummy' + } + ] + }, + { auth: ALICE } + ) + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(3) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_2.CustomerPostalAddress', + id: { ID: '1ab71292-ef69-4571-8cfb-10b9d5d1459e' } + }, + data_subject: { + type: 'CRUD_2.CustomerPostalAddress', + role: 'Address', + id: { + ID: '1ab71292-ef69-4571-8cfb-10b9d5d1459e', + street: 'updated', + town: 'updated town' + } + }, + attributes: [ + { name: 'street', new: 'updated', old: 'moo' }, + { name: 'town', new: 'updated town', old: 'shu' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_2.CustomerPostalAddress', + id: { ID: '1ab71292-ef69-4571-8cfb-10b9d5d1459e' } + }, + data_subject: { + type: 'CRUD_2.CustomerPostalAddress', + role: 'Address', + id: { + ID: '1ab71292-ef69-4571-8cfb-10b9d5d1459e', + street: 'updated', + town: 'updated town' + } + }, + attributes: [{ name: 'someOtherField' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_2.CustomerPostalAddress', + id: { ID: '285225db-6eeb-4b4f-9439-dbe5fcb4ce82' } + }, + data_subject: { + type: 'CRUD_2.CustomerPostalAddress', + role: 'Address', + id: { + ID: '285225db-6eeb-4b4f-9439-dbe5fcb4ce82', + street: 'sue', + town: 'lou' + } + }, + attributes: [{ name: 'someOtherField' }] + }) + }) + + test('create Customer - flat', async () => { + const customer = { + emailAddress: 'bla@blub.com', + firstName: 'bla', + lastName: 'blub', + creditCardNo: '98765', + someOtherField: 'dummy' + } + + const response = await POST('/crud-1/Customers', customer, { auth: ALICE }) + + expect(response).toMatchObject({ status: 201 }) + customer.ID = response.data.ID + expect(_logs.length).toBe(2) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Customers', + id: { ID: customer.ID } + }, + data_subject: { + type: 'CRUD_1.Customers', + role: 'Customer', + id: { ID: customer.ID } + }, + attributes: [ + { name: 'emailAddress', old: 'null', new: customer.emailAddress }, + { name: 'firstName', old: 'null', new: customer.firstName }, + { name: 'lastName', old: 'null', new: customer.lastName }, + { name: 'creditCardNo', old: 'null', new: customer.creditCardNo } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Customers', + id: { ID: customer.ID } + }, + data_subject: { + type: 'CRUD_1.Customers', + role: 'Customer', + id: { ID: customer.ID } + }, + attributes: [{ name: 'creditCardNo' }] + }) + }) + + test('create Customer - deep', async () => { + const customer = { + emailAddress: 'bla@blub.com', + firstName: 'bla', + lastName: 'blub', + creditCardNo: '98765', + someOtherField: 'dummy', + addresses: [ + { + street: 'A1', + town: 'Monnem', + someOtherField: 'Beschde' + }, + { + street: 'B2', + town: 'Monnem', + someOtherField: 'Ajo', + attachments: [{ description: 'new', todo: 'nothing', notAnnotated: 'not logged' }] + } + ], + comments: [{ text: 'foo' }, { text: 'bar' }], + status: { + ID: '23d4a37a-6319-4d52-bb48-02fd06b9ffa5', + description: 'new', + todo: 'activate' + } + } + + const response = await POST('/crud-1/Customers', customer, { auth: ALICE }) + + expect(response).toMatchObject({ status: 201 }) + + customer.ID = response.data.ID + const addresses = response.data.addresses + const attachments = response.data.addresses[1].attachments + const data_subject = { + type: 'CRUD_1.Customers', + role: 'Customer', + id: { ID: customer.ID } + } + + expect(_logs.length).toBe(10) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Customers', + id: { ID: customer.ID } + }, + data_subject, + attributes: [ + { name: 'emailAddress', old: 'null', new: customer.emailAddress }, + { name: 'firstName', old: 'null', new: customer.firstName }, + { name: 'lastName', old: 'null', new: customer.lastName }, + { name: 'creditCardNo', old: 'null', new: customer.creditCardNo } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerPostalAddress', + id: { ID: addresses[0].ID } + }, + data_subject, + attributes: [ + { name: 'street', old: 'null', new: addresses[0].street }, + { name: 'town', old: 'null', new: addresses[0].town } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerPostalAddress', + id: { ID: addresses[1].ID } + }, + data_subject, + attributes: [ + { name: 'street', old: 'null', new: addresses[1].street }, + { name: 'town', old: 'null', new: addresses[1].town } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.AddressAttachment', + id: { ID: attachments[0].ID } + }, + data_subject, + attributes: [ + { name: 'description', old: 'null', new: attachments[0].description }, + { name: 'todo', old: 'null', new: attachments[0].todo } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerStatus', + id: { ID: '23d4a37a-6319-4d52-bb48-02fd06b9ffa5' } + }, + data_subject, + attributes: [ + { name: 'description', old: 'null', new: 'new' }, + { name: 'todo', old: 'null', new: 'activate' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Customers', + id: { ID: customer.ID } + }, + data_subject, + attributes: [{ name: 'creditCardNo' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerPostalAddress', + id: { ID: addresses[0].ID } + }, + data_subject, + attributes: [{ name: 'street' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerPostalAddress', + id: { ID: addresses[1].ID } + }, + data_subject, + attributes: [{ name: 'street' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.AddressAttachment', + id: { ID: attachments[0].ID } + }, + data_subject, + attributes: [{ name: 'description' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerStatus', + id: { ID: '23d4a37a-6319-4d52-bb48-02fd06b9ffa5' } + }, + data_subject, + attributes: [{ name: 'description' }] + }) + }) + + test('create Pages with integers', async () => { + const page = { + ID: 123, + sensitive: 1337, + personal: 4711 + } + + const response = await POST('/crud-1/Pages', page, { auth: ALICE }) + + expect(response).toMatchObject({ status: 201 }) + expect(_logs.length).toBe(2) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Pages', + id: { ID: '123' } + }, + data_subject: { + type: 'CRUD_1.Pages', + role: 'Page', + id: { ID: '123' } + }, + attributes: [ + { name: 'personal', old: 'null', new: '4711' }, + { name: 'sensitive', old: 'null', new: '1337' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Pages', + id: { ID: '123' } + }, + data_subject: { + id: { + ID: '123' + }, + role: 'Page', + type: 'CRUD_1.Pages' + }, + attributes: [{ name: 'sensitive' }] + }) + }) + + test('update Customer - flat', async () => { + const customer = { + emailAddress: 'bla@blub.com', + creditCardNo: '98765', + someOtherField: 'also just a dummy' + } + + const response = await PATCH(`/crud-1/Customers(${CUSTOMER_ID})`, customer, { auth: ALICE }) + + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(2) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Customers', + id: { ID: CUSTOMER_ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'emailAddress', old: 'foo@bar.com', new: customer.emailAddress }, + { name: 'creditCardNo', old: '12345', new: customer.creditCardNo } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Customers', + id: { ID: 'bcd4a37a-6319-4d52-bb48-02fd06b9ffe9' } + }, + data_subject: { + type: 'CRUD_1.Customers', + role: 'Customer', + id: { ID: 'bcd4a37a-6319-4d52-bb48-02fd06b9ffe9' } + }, + attributes: [{ name: 'creditCardNo' }] + }) + }) + + test('update Pages with integers', async () => { + const page = { + sensitive: 999, + personal: 888 + } + + const response = await PATCH('/crud-1/Pages(1)', page, { auth: ALICE }) + + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(2) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Pages', + id: { ID: '1' } + }, + data_subject: { + id: { + ID: '1' + }, + role: 'Page', + type: 'CRUD_1.Pages' + }, + attributes: [ + { name: 'personal', old: '222', new: '888' }, + { name: 'sensitive', old: '111', new: '999' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Pages', + id: { ID: '1' } + }, + data_subject: { + id: { + ID: '1' + }, + role: 'Page', + type: 'CRUD_1.Pages' + }, + attributes: [{ name: 'sensitive' }] + }) + }) + + test('update non-existing Customer - flat', async () => { + const newCustomer = { + emailAddress: 'minim@ipsum.com', + creditCardNo: '96765', + someOtherField: 'minim ipsum eu id ea' + } + + const newUUID = '542ce505-73ae-4860-a7f5-00fbccf1dae9' + const response = await PATCH(`/crud-1/Customers(${newUUID})`, newCustomer, { auth: ALICE }) + + expect(response).toMatchObject({ status: 201 }) + expect(_logs.length).toBe(2) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Customers', + id: { ID: newUUID } + }, + data_subject: { + id: { ID: newUUID }, + role: 'Customer', + type: 'CRUD_1.Customers' + }, + attributes: [ + { name: 'emailAddress', old: 'null', new: newCustomer.emailAddress }, + { name: 'creditCardNo', old: 'null', new: newCustomer.creditCardNo } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Customers', + id: { ID: newUUID } + }, + data_subject: { + type: 'CRUD_1.Customers', + role: 'Customer', + id: { ID: newUUID } + }, + attributes: [{ name: 'creditCardNo' }] + }) + }) + + test('update non-existing Pages with integers', async () => { + const page = { + sensitive: 999, + personal: 888 + } + + const response = await PATCH('/crud-1/Pages(123)', page, { auth: ALICE }) + + expect(response).toMatchObject({ status: 201 }) + expect(_logs.length).toBe(2) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Pages', + id: { ID: '123' } + }, + data_subject: { + id: { + ID: '123' + }, + role: 'Page', + type: 'CRUD_1.Pages' + }, + attributes: [ + { name: 'personal', old: 'null', new: '888' }, + { name: 'sensitive', old: 'null', new: '999' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Pages', + id: { ID: '123' } + }, + data_subject: { + id: { + ID: '123' + }, + role: 'Page', + type: 'CRUD_1.Pages' + }, + attributes: [{ name: 'sensitive' }] + }) + }) + + test('update Customer - deep', async () => { + let response + + response = await GET(`/crud-1/Customers(${CUSTOMER_ID})?$expand=addresses,status`, { auth: ALICE }) + + const oldAddresses = response.data.addresses + + // reset logs + _logs = [] + + const customer = { + addresses: [ + { + street: 'A1', + town: 'Monnem', + someOtherField: 'Beschde' + }, + { + street: 'B2', + town: 'Monnem', + someOtherField: 'Ajo' + } + ], + status: { + ID: '23d4a37a-6319-4d52-bb48-02fd06b9ffa4', + description: 'inactive', + todo: 'delete' + } + } + + response = await PATCH(`/crud-1/Customers(${CUSTOMER_ID})`, customer, { auth: ALICE }) + + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(12) + + const newAddresses = response.data.addresses + const newStatus = response.data.status + + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerPostalAddress', + id: { ID: oldAddresses[0].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'street', old: oldAddresses[0].street, new: 'null' }, + { name: 'town', old: oldAddresses[0].town, new: 'null' } + ] + }) + + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerPostalAddress', + id: { ID: oldAddresses[1].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'street', old: oldAddresses[1].street, new: 'null' }, + { name: 'town', old: oldAddresses[1].town, new: 'null' } + ] + }) + + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerPostalAddress', + id: { ID: newAddresses[0].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'street', old: 'null', new: newAddresses[0].street }, + { name: 'town', old: 'null', new: newAddresses[0].town } + ] + }) + + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerPostalAddress', + id: { ID: newAddresses[1].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'street', old: 'null', new: newAddresses[1].street }, + { name: 'town', old: 'null', new: newAddresses[1].town } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerStatus', + id: { ID: newStatus.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'description', old: 'active', new: 'inactive' }, + { name: 'todo', old: 'send reminder', new: 'delete' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Customers', + id: { ID: 'bcd4a37a-6319-4d52-bb48-02fd06b9ffe9' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'creditCardNo' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerPostalAddress', + id: { ID: newAddresses[0].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'street' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerPostalAddress', + id: { ID: newAddresses[1].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'street' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerStatus', + id: { ID: newStatus.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'description' }] + }) + }) + + test('update Customer - deep with reusing notes', async () => { + let response + + response = await GET( + `/crud-1/Customers(${CUSTOMER_ID})?$expand=addresses($expand=attachments($expand=notes)),status($expand=notes)`, + { auth: ALICE } + ) + + const oldAddresses = response.data.addresses + const oldAttachments = response.data.addresses[0].attachments + const oldAttachmentNote = response.data.addresses[0].attachments[0].notes[0] + const oldStatus = response.data.status + const oldStatusNote = response.data.status.notes[0] + + const customer = { + addresses: [ + { + ID: '1ab71292-ef69-4571-8cfb-10b9d5d1459e', + someOtherField: 'no tdummy', + street: 'mu', + attachments: [ + { + ID: '3cd71292-ef69-4571-8cfb-10b9d5d1437e', + description: 'mu', + notAnnotated: 'no tdummy', + notes: [ + { + note: 'the end' + } + ] + } + ] + }, + { + street: 'B2', + town: 'Monnem', + someOtherField: 'Ajo' + } + ], + status: { + ID: '23d4a37a-6319-4d52-bb48-02fd06b9ffa4', + description: 'inactive', + todo: 'delete', + notes: [ + { + ID: oldStatusNote.ID, + note: 'status note' + } + ] + } + } + + // reset logs + _logs = [] + + response = await PATCH(`/crud-1/Customers(${CUSTOMER_ID})`, customer, { auth: ALICE }) + + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(16) + + const newAddresses = response.data.addresses + const newStatus = response.data.status + const newAttachments = response.data.addresses[0].attachments + const newAttachmentNote = response.data.addresses[0].attachments[0].notes[0] + const newStatusNote = response.data.status.notes[0] + + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Notes', + id: { ID: oldAttachmentNote.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'note', old: oldAttachmentNote.note, new: 'null' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Notes', + id: { ID: oldStatusNote.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'note', old: oldStatusNote.note, new: newStatusNote.note }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.AddressAttachment', + id: { ID: oldAttachments[1].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'description', old: oldAttachments[1].description, new: 'null' }, + { name: 'todo', old: oldAttachments[1].todo, new: 'null' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerPostalAddress', + id: { ID: oldAddresses[1].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'street', old: oldAddresses[1].street, new: 'null' }, + { name: 'town', old: oldAddresses[1].town, new: 'null' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerPostalAddress', + id: { ID: newAddresses[0].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'street', old: oldAddresses[0].street, new: newAddresses[0].street }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.AddressAttachment', + id: { ID: oldAttachments[0].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'description', old: oldAttachments[0].description, new: newAttachments[0].description }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Notes', + id: { ID: newAttachmentNote.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'note', old: 'null', new: newAttachmentNote.note }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerPostalAddress', + id: { ID: newAddresses[1].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'street', old: 'null', new: newAddresses[1].street }, + { name: 'town', old: 'null', new: newAddresses[1].town } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerStatus', + id: { ID: newStatus.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'description', old: oldStatus.description, new: newStatus.description }, + { name: 'todo', old: oldStatus.todo, new: newStatus.todo } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.AddressAttachment', + id: { ID: newAttachments[0].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'description' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Customers', + id: { ID: 'bcd4a37a-6319-4d52-bb48-02fd06b9ffe9' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'creditCardNo' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerPostalAddress', + id: { ID: newAddresses[0].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'street' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerPostalAddress', + id: { ID: newAddresses[1].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'street' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerStatus', + id: { ID: newStatus.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'description' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Notes', + id: { ID: newStatusNote.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'note' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Notes', + id: { ID: newAttachmentNote.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'note' }] + }) + }) + + test('delete Customer - flat', async () => { + let response + + response = await GET( + `/crud-1/Customers(${CUSTOMER_ID})?$expand=addresses($expand=attachments($expand=notes)),status($expand=change($expand=last),notes),comments`, + { auth: ALICE } + ) + + const oldAddresses = response.data.addresses + const oldAttachments = response.data.addresses[0].attachments + const oldStatus = response.data.status + const oldChange = response.data.status.change + const oldLast = response.data.status.change.last + const oldStatusNote = oldStatus.notes[0] + const oldAttachmentNote = oldAttachments[0].notes[0] + + // reset logs + _logs = [] + + // delete children + response = await PATCH( + `/crud-1/Customers(${CUSTOMER_ID})`, + { addresses: [], status: null, comments: [] }, + { auth: ALICE } + ) + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(10) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerPostalAddress', + id: { ID: oldAddresses[0].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'street', old: oldAddresses[0].street, new: 'null' }, + { name: 'town', old: oldAddresses[0].town, new: 'null' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.AddressAttachment', + id: { ID: oldAttachments[0].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'description', old: oldAttachments[0].description, new: 'null' }, + { name: 'todo', old: oldAttachments[0].todo, new: 'null' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.AddressAttachment', + id: { ID: oldAttachments[1].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'description', old: oldAttachments[1].description, new: 'null' }, + { name: 'todo', old: oldAttachments[1].todo, new: 'null' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerPostalAddress', + id: { ID: oldAddresses[1].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'street', old: oldAddresses[1].street, new: 'null' }, + { name: 'town', old: oldAddresses[1].town, new: 'null' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerStatus', + id: { ID: oldStatus.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'description', old: 'active', new: 'null' }, + { name: 'todo', old: 'send reminder', new: 'null' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.StatusChange', + id: { ID: oldChange.ID, secondKey: oldChange.secondKey } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'description', old: 'new change', new: 'null' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.LastOne', + id: { ID: oldLast.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'lastOneField', old: 'some last value', new: 'null' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Notes', + id: { ID: oldStatusNote.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'note', old: oldStatusNote.note, new: 'null' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Notes', + id: { ID: oldAttachmentNote.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'note', old: oldAttachmentNote.note, new: 'null' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Customers', + id: { ID: 'bcd4a37a-6319-4d52-bb48-02fd06b9ffe9' } + }, + data_subject: { + type: 'CRUD_1.Customers', + role: 'Customer', + id: { ID: 'bcd4a37a-6319-4d52-bb48-02fd06b9ffe9' } + }, + attributes: [{ name: 'creditCardNo' }] + }) + + // reset logs + _logs = [] + + response = await DELETE(`/crud-1/Customers(${CUSTOMER_ID})`, { auth: ALICE }) + + expect(response).toMatchObject({ status: 204 }) + expect(_logs.length).toBe(1) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Customers', + id: { ID: CUSTOMER_ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'emailAddress', old: 'foo@bar.com', new: 'null' }, + { name: 'firstName', old: 'foo', new: 'null' }, + { name: 'lastName', old: 'bar', new: 'null' }, + { name: 'creditCardNo', old: '12345', new: 'null' } + ] + }) + }) + + test('delete Pages with integers - flat', async () => { + await DELETE('/crud-1/Pages(1)', { auth: ALICE }) + + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Pages', + id: { ID: '1' } + }, + data_subject: { + id: { + ID: '1' + }, + role: 'Page', + type: 'CRUD_1.Pages' + }, + attributes: [ + { name: 'personal', old: '222', new: 'null' }, + { name: 'sensitive', old: '111', new: 'null' } + ] + }) + }) + + test('delete Customer - deep', async () => { + let response + + response = await GET( + `/crud-1/Customers(${CUSTOMER_ID})?$expand=addresses($expand=attachments($expand=notes)),status($expand=change($expand=last),notes)`, + { auth: ALICE } + ) + + const oldAddresses = response.data.addresses + const oldAttachments = response.data.addresses[0].attachments + const oldStatus = response.data.status + const oldChange = response.data.status.change + const oldLast = response.data.status.change.last + const oldStatusNote = oldStatus.notes[0] + const oldAttachmentNote = oldAttachments[0].notes[0] + + // reset logs + _logs = [] + _logger._resetLogs() + + response = await DELETE(`/crud-1/Customers(${CUSTOMER_ID})`, { auth: ALICE }) + + expect(response).toMatchObject({ status: 204 }) + expect(_logs.length).toBe(10) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Customers', + id: { ID: CUSTOMER_ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'emailAddress', old: 'foo@bar.com', new: 'null' }, + { name: 'firstName', old: 'foo', new: 'null' }, + { name: 'lastName', old: 'bar', new: 'null' }, + { name: 'creditCardNo', old: '12345', new: 'null' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerPostalAddress', + id: { ID: oldAddresses[0].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'street', old: oldAddresses[0].street, new: 'null' }, + { name: 'town', old: oldAddresses[0].town, new: 'null' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.AddressAttachment', + id: { ID: oldAttachments[0].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'description', old: oldAttachments[0].description, new: 'null' }, + { name: 'todo', old: oldAttachments[0].todo, new: 'null' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.AddressAttachment', + id: { ID: oldAttachments[1].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'description', old: oldAttachments[1].description, new: 'null' }, + { name: 'todo', old: oldAttachments[1].todo, new: 'null' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerPostalAddress', + id: { ID: oldAddresses[1].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'street', old: oldAddresses[1].street, new: 'null' }, + { name: 'town', old: oldAddresses[1].town, new: 'null' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerStatus', + id: { ID: oldStatus.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'description', old: 'active', new: 'null' }, + { name: 'todo', old: 'send reminder', new: 'null' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.StatusChange', + id: { ID: oldChange.ID, secondKey: oldChange.secondKey } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'description', old: 'new change', new: 'null' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.LastOne', + id: { ID: oldLast.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'lastOneField', old: 'some last value', new: 'null' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Notes', + id: { ID: oldStatusNote.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'note', old: oldStatusNote.note, new: 'null' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Notes', + id: { ID: oldAttachmentNote.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'note', old: oldAttachmentNote.note, new: 'null' }] + }) + + // check only one select used to look up data subject + const selects = _logger._logs.debug.filter( + l => typeof l === 'string' && l.match(/^SELECT/) && l.match(/SELECT [Customers.]*ID FROM CRUD_1_Customers/) + ) + expect(selects.length).toBe(1) + }) + + test('delete comp of one', async () => { + const response = await DELETE('/crud-1/CustomerStatus(23d4a37a-6319-4d52-bb48-02fd06b9ffa4)', { auth: ALICE }) + expect(response).toMatchObject({ status: 204 }) + expect(_logs.length).toBe(4) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerStatus', + id: { ID: '23d4a37a-6319-4d52-bb48-02fd06b9ffa4' } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'description', old: 'active', new: 'null' }, + { name: 'todo', old: 'send reminder', new: 'null' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.StatusChange', + id: { ID: '59d4a37a-6319-4d52-bb48-02fd06b9fbc2', secondKey: 'some value' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'description', old: 'new change', new: 'null' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.LastOne', + id: { ID: '74d4a37a-6319-4d52-bb48-02fd06b9f3r4' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'lastOneField', old: 'some last value', new: 'null' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Notes', + id: { ID: '35bdc8d0-dcaf-4727-9377-9ae693055555' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'note', old: 'initial status note', new: 'null' }] + }) + }) + + test('with atomicity group', async () => { + let response + + response = await GET( + `/crud-1/Customers(${CUSTOMER_ID})?$expand=addresses($expand=attachments($expand=notes)),status($expand=change($expand=last),notes)`, + { auth: ALICE } + ) + const oldAddresses = response.data.addresses + const oldAttachments = response.data.addresses[0].attachments + const oldStatus = response.data.status + const oldChange = response.data.status.change + const oldLast = response.data.status.change.last + const oldAttachmentNotes = response.data.addresses[0].attachments[0].notes + const oldStatusNote = response.data.status.notes[0] + + // reset logs + _logs = [] + + const body = { + requests: [ + { + method: 'DELETE', + url: `/Customers(bcd4a37a-6319-4d52-bb48-02fd06b9ffe9)/addresses(${oldAddresses[0].ID})`, + headers: { 'content-type': 'application/json', 'odata-version': '4.0' }, + id: 'r1', + atomicityGroup: 'g1' + }, + { + method: 'DELETE', + url: `/Customers(bcd4a37a-6319-4d52-bb48-02fd06b9ffe9)/addresses(${oldAddresses[1].ID})`, + headers: { 'content-type': 'application/json', 'odata-version': '4.0' }, + id: 'r2', + atomicityGroup: 'g1' + }, + { + method: 'PATCH', + url: `/Customers(${CUSTOMER_ID})`, + headers: { 'content-type': 'application/json', 'odata-version': '4.0' }, + id: 'r3', + atomicityGroup: 'g1', + body: { status: null } + } + ] + } + response = await POST('/crud-1/$batch', body, { auth: ALICE }) + expect(response).toMatchObject({ status: 200 }) + expect(response.data.responses.every(r => r.status >= 200 && r.status < 300)).toBeTruthy() + expect(_logs.length).toBe(10) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerPostalAddress', + id: { ID: oldAddresses[0].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'street', old: oldAddresses[0].street, new: 'null' }, + { name: 'town', old: oldAddresses[0].town, new: 'null' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.AddressAttachment', + id: { ID: oldAttachments[0].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'description', old: oldAttachments[0].description, new: 'null' }, + { name: 'todo', old: oldAttachments[0].todo, new: 'null' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.AddressAttachment', + id: { ID: oldAttachments[1].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'description', old: oldAttachments[1].description, new: 'null' }, + { name: 'todo', old: oldAttachments[1].todo, new: 'null' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerPostalAddress', + id: { ID: oldAddresses[1].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'street', old: oldAddresses[1].street, new: 'null' }, + { name: 'town', old: oldAddresses[1].town, new: 'null' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.CustomerStatus', + id: { ID: oldStatus.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'description', old: 'active', new: 'null' }, + { name: 'todo', old: 'send reminder', new: 'null' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.StatusChange', + id: { ID: oldChange.ID, secondKey: oldChange.secondKey } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'description', old: 'new change', new: 'null' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.LastOne', + id: { ID: oldLast.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'lastOneField', old: 'some last value', new: 'null' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Notes', + id: { ID: oldAttachmentNotes[0].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'note', old: 'start', new: 'null' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Notes', + id: { ID: oldStatusNote.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'note', old: oldStatusNote.note, new: 'null' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Customers', + id: { ID: 'bcd4a37a-6319-4d52-bb48-02fd06b9ffe9' } + }, + data_subject: { + type: 'CRUD_1.Customers', + role: 'Customer', + id: { ID: 'bcd4a37a-6319-4d52-bb48-02fd06b9ffe9' } + }, + attributes: [{ name: 'creditCardNo' }] + }) + }) + + test(`with entity semantics -Other- and downward lookup of data subject ID`, async () => { + const order = { + ID: 'bcd4a37a-6319-4d52-bb48-02fd06b9aaaa', + header: { + description: 'dummy', + sensitiveData: { + customer: { + ID: CUSTOMER_ID + }, + note: 'positive' + } + }, + items: [ + { + name: 'foo', + customer: { + ID: CUSTOMER_ID + } + } + ], + misc: 'abc' + } + const r1 = await POST(`/crud-1/Orders`, order, { auth: ALICE }) + expect(r1) + const { + data: { + header_ID, + header: { sensitiveData }, + items + } + } = await GET(`/crud-1/Orders(${order.ID})?$expand=header($expand=sensitiveData),items`, { auth: ALICE }) + items.push({ + name: 'bar', + customer: { + ID: CUSTOMER_ID + } + }) + const updatedOrder = { + misc: 'IISSEE 123', + header: { + ID: header_ID, + description: 'olala', + sensitiveData: { + ID: sensitiveData.ID, + note: 'negative' + } + }, + items + } + _logs = [] + await PATCH(`/crud-1/Orders(${order.ID})`, updatedOrder, { auth: ALICE }) + expect(_logs.length).toBe(6) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Orders', + id: { ID: order.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'misc', old: 'abc', new: 'IISSEE 123' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.OrderHeader', + id: { ID: header_ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'description', old: 'dummy', new: 'olala' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.OrderHeader.sensitiveData', + id: { ID: sensitiveData.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'note', old: 'positive', new: 'negative' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Orders', + id: { ID: order.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'misc' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.OrderHeader', + id: { ID: header_ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'description' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.OrderHeader.sensitiveData', + id: { ID: sensitiveData.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'note' }] + }) + const r2 = await DELETE(`/crud-1/Orders(${order.ID})`, { auth: ALICE }) + expect(r2).toMatchObject({ status: 204 }) + expect(_logs.length).toBe(9) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Orders', + id: { ID: order.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'misc', old: 'IISSEE 123', new: 'null' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.OrderHeader', + id: { ID: header_ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'description', old: 'olala', new: 'null' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.OrderHeader.sensitiveData', + id: { ID: sensitiveData.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'note', old: 'negative', new: 'null' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Orders', + id: { ID: order.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'misc', old: 'abc', new: 'IISSEE 123' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.OrderHeader', + id: { ID: header_ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'description', old: 'dummy', new: 'olala' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.OrderHeader.sensitiveData', + id: { ID: sensitiveData.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'note', old: 'positive', new: 'negative' }] + }) + }) + }) + + describe('avoid audit logs by prepending on', () => { + let _avoid + + beforeAll(async () => { + const als = cds.services['audit-log'] || (await cds.connect.to('audit-log')) + + als.prepend(srv => { + srv.on('dataAccessLog', function (req, next) { + if (!_avoid) return next() + }) + }) + }) + + afterAll(() => { + // hackily remove on handler + cds.services['audit-log']._handlers.on.shift() + }) + + beforeEach(() => { + _avoid = undefined + }) + + test('read all Customers with avoid = false', async () => { + const response = await GET('/crud-1/Customers', { auth: ALICE }) + + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(1) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'CRUD_1.Customers', + id: { ID: CUSTOMER_ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'creditCardNo' }] + }) + }) + + // TODO: compat api not yet implemented + xtest('read all Customers with avoid = true', async () => { + _avoid = true + + const response = await GET('/crud-1/Customers', { auth: ALICE }) + + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(0) + }) + }) +}) diff --git a/test/personal-data/db/data/sap.auditlog.test.personal_data.db.AddressAttachment.csv b/test/personal-data/db/data/sap.auditlog.test.personal_data.db.AddressAttachment.csv new file mode 100644 index 0000000..f9bf9e3 --- /dev/null +++ b/test/personal-data/db/data/sap.auditlog.test.personal_data.db.AddressAttachment.csv @@ -0,0 +1,3 @@ +ID;address_ID;description;todo;notAnnotated +3cd71292-ef69-4571-8cfb-10b9d5d1437e;1ab71292-ef69-4571-8cfb-10b9d5d1459e;moo;shu;dummy +595225db-6eeb-4b4f-9439-dbe5fcb4ce5a;1ab71292-ef69-4571-8cfb-10b9d5d1459e;sue;lou;dummy diff --git a/test/personal-data/db/data/sap.auditlog.test.personal_data.db.Comments.csv b/test/personal-data/db/data/sap.auditlog.test.personal_data.db.Comments.csv new file mode 100644 index 0000000..b8714b6 --- /dev/null +++ b/test/personal-data/db/data/sap.auditlog.test.personal_data.db.Comments.csv @@ -0,0 +1,3 @@ +ID;customer_ID;text +35bdc8d0-dcaf-4727-9377-9ae6930b0f2b;bcd4a37a-6319-4d52-bb48-02fd06b9ffe9;what +85c4ccdc-6e78-4671-a3c8-2bf10e6a85de;bcd4a37a-6319-4d52-bb48-02fd06b9ffe9;ever diff --git a/test/personal-data/db/data/sap.auditlog.test.personal_data.db.CustomerPostalAddress.csv b/test/personal-data/db/data/sap.auditlog.test.personal_data.db.CustomerPostalAddress.csv new file mode 100644 index 0000000..a9545f0 --- /dev/null +++ b/test/personal-data/db/data/sap.auditlog.test.personal_data.db.CustomerPostalAddress.csv @@ -0,0 +1,3 @@ +ID;customer_ID;street;town;someOtherField +1ab71292-ef69-4571-8cfb-10b9d5d1459e;bcd4a37a-6319-4d52-bb48-02fd06b9ffe9;moo;shu;dummy +285225db-6eeb-4b4f-9439-dbe5fcb4ce82;bcd4a37a-6319-4d52-bb48-02fd06b9ffe9;sue;lou;dummy diff --git a/test/personal-data/db/data/sap.auditlog.test.personal_data.db.CustomerStatus.csv b/test/personal-data/db/data/sap.auditlog.test.personal_data.db.CustomerStatus.csv new file mode 100644 index 0000000..5c1ce3e --- /dev/null +++ b/test/personal-data/db/data/sap.auditlog.test.personal_data.db.CustomerStatus.csv @@ -0,0 +1,2 @@ +ID;description;todo;change_ID;change_secondKey +23d4a37a-6319-4d52-bb48-02fd06b9ffa4;active;send reminder;59d4a37a-6319-4d52-bb48-02fd06b9fbc2;some value diff --git a/test/personal-data/db/data/sap.auditlog.test.personal_data.db.Customers.csv b/test/personal-data/db/data/sap.auditlog.test.personal_data.db.Customers.csv new file mode 100644 index 0000000..18ef047 --- /dev/null +++ b/test/personal-data/db/data/sap.auditlog.test.personal_data.db.Customers.csv @@ -0,0 +1,2 @@ +ID;emailAddress;firstName;lastName;creditCardNo;someOtherField;status_ID +bcd4a37a-6319-4d52-bb48-02fd06b9ffe9;foo@bar.com;foo;bar;12345;dummy;23d4a37a-6319-4d52-bb48-02fd06b9ffa4 diff --git a/test/personal-data/db/data/sap.auditlog.test.personal_data.db.LastOne.csv b/test/personal-data/db/data/sap.auditlog.test.personal_data.db.LastOne.csv new file mode 100644 index 0000000..92bd65f --- /dev/null +++ b/test/personal-data/db/data/sap.auditlog.test.personal_data.db.LastOne.csv @@ -0,0 +1,2 @@ +ID;lastOneField +74d4a37a-6319-4d52-bb48-02fd06b9f3r4;some last value diff --git a/test/personal-data/db/data/sap.auditlog.test.personal_data.db.Notes.csv b/test/personal-data/db/data/sap.auditlog.test.personal_data.db.Notes.csv new file mode 100644 index 0000000..d507191 --- /dev/null +++ b/test/personal-data/db/data/sap.auditlog.test.personal_data.db.Notes.csv @@ -0,0 +1,3 @@ +ID;attachment_ID;customerStatus_ID;note +35bdc8d0-dcaf-4727-9377-9ae6930b0f2c;3cd71292-ef69-4571-8cfb-10b9d5d1437e;;start +35bdc8d0-dcaf-4727-9377-9ae693055555;;23d4a37a-6319-4d52-bb48-02fd06b9ffa4;initial status note diff --git a/test/personal-data/db/data/sap.auditlog.test.personal_data.db.Pages.csv b/test/personal-data/db/data/sap.auditlog.test.personal_data.db.Pages.csv new file mode 100644 index 0000000..7ff10cf --- /dev/null +++ b/test/personal-data/db/data/sap.auditlog.test.personal_data.db.Pages.csv @@ -0,0 +1,2 @@ +ID;sensitive;personal +1;111;222 diff --git a/test/personal-data/db/data/sap.auditlog.test.personal_data.db.StatusChange.csv b/test/personal-data/db/data/sap.auditlog.test.personal_data.db.StatusChange.csv new file mode 100644 index 0000000..27fa864 --- /dev/null +++ b/test/personal-data/db/data/sap.auditlog.test.personal_data.db.StatusChange.csv @@ -0,0 +1,2 @@ +ID;secondKey;description;last_ID +59d4a37a-6319-4d52-bb48-02fd06b9fbc2;some value;new change;74d4a37a-6319-4d52-bb48-02fd06b9f3r4 diff --git a/test/personal-data/db/schema.cds b/test/personal-data/db/schema.cds new file mode 100644 index 0000000..61a3c46 --- /dev/null +++ b/test/personal-data/db/schema.cds @@ -0,0 +1,98 @@ +using {cuid} from '@sap/cds/common'; + +namespace sap.auditlog.test.personal_data.db; + +entity Orders : cuid { + header : Composition of one OrderHeader; + items : Composition of many OrderItems + on $self = items.order; + misc : String; +} + +entity OrderItems : cuid { + name : String; + order : Association to Orders; + customer : Association to Customers; +} + +entity OrderHeader : cuid { + description : String; + sensitiveData : Composition of one SensitiveData; +} + +aspect SensitiveData : cuid { + customer : Association to Customers; + note : String; +} + +entity Pages { + key ID : Integer; + personal : Integer; + sensitive : Integer; +} + +entity Customers : cuid { + emailAddress : String; + firstName : String; + lastName : String; + creditCardNo : String(16); + someOtherField : String(128); + addresses : Composition of many CustomerPostalAddress + on addresses.customer = $self; + comments : Composition of many Comments + on comments.customer = $self; + status : Composition of CustomerStatus; +} + +entity CustomerPostalAddress : cuid { + customer : Association to one Customers @assert.integrity: false; + street : String(128); + town : String(128); + someOtherField : String(128); + attachments : Composition of many AddressAttachment + on attachments.address = $self; +} + +entity Comments : cuid { + customer : Association to one Customers; + text : String; +} + +entity CustomerStatus : cuid { + description : String; + todo : String; + change : Composition of StatusChange; + notes : Composition of many Notes + on notes.customerStatus = $self; +} + +entity StatusChange { + key ID : UUID; + key secondKey : String; + description : String; + last : Composition of LastOne; +} + +entity LastOne : cuid { + lastOneField : String; +} + +entity AddressAttachment : cuid { + description : String; + todo : String; + notAnnotated : String; + address : Association to one CustomerPostalAddress; + notes : Composition of many Notes + on notes.attachment = $self; +} + +type dummies { + dummy : String; +} + +entity Notes : cuid { + note : String; + attachment : Association to AddressAttachment; + customerStatus : Association to CustomerStatus; + dummyArray : many dummies; +} diff --git a/test/personal-data/fiori2library.test.js b/test/personal-data/fiori2library.test.js new file mode 100644 index 0000000..a7a4899 --- /dev/null +++ b/test/personal-data/fiori2library.test.js @@ -0,0 +1,866 @@ +const cds = require('@sap/cds') + +cds.env.requires['audit-log'] = { + kind: 'audit-log-to-library', + impl: '../../srv/log2library', + credentials: { logToConsole: true } +} + +const _logger = require('../utils/logger')({ debug: true }) +cds.log.Logger = _logger + +const { POST, PATCH, GET, DELETE, data } = cds.test(__dirname) + +describe('personal data audit logging in Fiori with kind audit-log-to-library', () => { + let __log, _logs + const _log = (...args) => { + if (args.length !== 1 || !args[0].uuid) { + // > not an audit log (most likely, anyway) + return __log(...args) + } + + // do not add log preps + if (args[0].attributes && 'old' in args[0].attributes[0] && !args[0].success) return + + _logs.push(...args) + } + + const CUSTOMER_ID = 'bcd4a37a-6319-4d52-bb48-02fd06b9ffe9' + const DATA_SUBJECT = { + type: 'Fiori_1.Customers', + role: 'Customer', + id: { ID: CUSTOMER_ID } + } + + const ALICE = { username: 'alice', password: 'password' } + + beforeAll(async () => { + __log = global.console.log + global.console.log = _log + }) + + afterAll(() => { + global.console.log = __log + }) + + beforeEach(async () => { + await data.reset() + _logs = [] + _logger._resetLogs() + }) + + describe('data access logging for active draft enabled entities', () => { + test('read with another data subject and sensitive data only in composition children', async () => { + const { data: customer } = await GET( + `/fiori-2/Customers(ID=${CUSTOMER_ID},IsActiveEntity=true)?$expand=addresses`, + { auth: ALICE } + ) + const addressID1 = customer.addresses[0].ID + const addressID2 = customer.addresses[1].ID + expect(_logs.length).toBe(2) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_2.CustomerPostalAddress', + id: { ID: addressID1 } + }, + data_subject: { + type: 'Fiori_2.CustomerPostalAddress', + role: 'Address', + id: { + ID: addressID1, + street: 'moo', + town: 'shu' + } + }, + attributes: [{ name: 'someOtherField' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_2.CustomerPostalAddress', + id: { ID: addressID2 } + }, + data_subject: { + type: 'Fiori_2.CustomerPostalAddress', + role: 'Address', + id: { + ID: addressID2, + street: 'sue', + town: 'lou' + } + }, + attributes: [{ name: 'someOtherField' }] + }) + }) + + test('read all Customers', async () => { + const response = await GET('/fiori-1/Customers', { auth: ALICE }) + + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(1) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.Customers', + id: { ID: CUSTOMER_ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'creditCardNo' }] + }) + }) + + test('read single Customer', async () => { + const response = await GET(`/fiori-1/Customers(ID=${CUSTOMER_ID},IsActiveEntity=true)`, { auth: ALICE }) + + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(1) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.Customers', + id: { ID: CUSTOMER_ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'creditCardNo' }] + }) + }) + + test('read Customer expanding addresses and comments - comp of many', async () => { + const response = await GET( + `/fiori-1/Customers(ID=${CUSTOMER_ID},IsActiveEntity=true)?$expand=addresses($expand=attachments),comments`, + { auth: ALICE } + ) + + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(5) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.Customers', + id: { ID: CUSTOMER_ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'creditCardNo' }] + }) + + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.CustomerPostalAddress', + id: { ID: '1ab71292-ef69-4571-8cfb-10b9d5d1459e' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'street' }] + }) + + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.AddressAttachment', + id: { ID: '3cd71292-ef69-4571-8cfb-10b9d5d1437e' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'description' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.AddressAttachment', + id: { ID: '595225db-6eeb-4b4f-9439-dbe5fcb4ce5a' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'description' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.CustomerPostalAddress', + id: { ID: '285225db-6eeb-4b4f-9439-dbe5fcb4ce82' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'street' }] + }) + }) + + test('read Customer expanding deep nested comp of one', async () => { + const response = await GET( + `/fiori-1/Customers(ID=${CUSTOMER_ID},IsActiveEntity=true)?$expand=status($expand=change($expand=last))`, + { auth: ALICE } + ) + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(4) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.Customers', + id: { ID: CUSTOMER_ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'creditCardNo' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.CustomerStatus', + id: { ID: '23d4a37a-6319-4d52-bb48-02fd06b9ffa4' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'description' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.StatusChange', + id: { ID: '59d4a37a-6319-4d52-bb48-02fd06b9fbc2', secondKey: 'some value' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'description' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.LastOne', + id: { ID: '74d4a37a-6319-4d52-bb48-02fd06b9f3r4' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'lastOneField' }] + }) + }) + + test('read all CustomerStatus', async () => { + const response = await GET('/fiori-1/CustomerStatus', { auth: ALICE }) + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(1) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.CustomerStatus', + id: { ID: '23d4a37a-6319-4d52-bb48-02fd06b9ffa4' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'description' }] + }) + }) + + test('read all CustomerPostalAddress', async () => { + const response = await GET('/fiori-1/CustomerPostalAddress', { auth: ALICE }) + + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(2) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.CustomerPostalAddress', + id: { ID: '1ab71292-ef69-4571-8cfb-10b9d5d1459e' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'street' }] + }) + + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.CustomerPostalAddress', + id: { ID: '285225db-6eeb-4b4f-9439-dbe5fcb4ce82' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'street' }] + }) + }) + + test('read all CustomerPostalAddress expanding Customer', async () => { + const response = await GET('/fiori-1/CustomerPostalAddress?$expand=customer', { auth: ALICE }) + + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(3) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.Customers', + id: { ID: CUSTOMER_ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'creditCardNo' }] + }) + + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.CustomerPostalAddress', + id: { ID: '1ab71292-ef69-4571-8cfb-10b9d5d1459e' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'street' }] + }) + + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.CustomerPostalAddress', + id: { ID: '285225db-6eeb-4b4f-9439-dbe5fcb4ce82' } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'street' }] + }) + }) + + test('draft union', async () => { + const response = await GET( + '/fiori-1/Customers?$filter=(IsActiveEntity eq false or SiblingEntity/IsActiveEntity eq null)', + { auth: ALICE } + ) + + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(1) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.Customers', + id: { ID: CUSTOMER_ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'creditCardNo' }] + }) + }) + }) + + describe('modification and read draft logging', () => { + test('draft edit, patch and activate with another data subject and sensitive data only in composition children', async () => { + await POST(`/fiori-2/Customers(ID=${CUSTOMER_ID},IsActiveEntity=true)/draftEdit`, {}, { auth: ALICE }) + const { data: customer } = await GET( + `/fiori-2/Customers(ID=${CUSTOMER_ID},IsActiveEntity=false)?$expand=addresses`, + { auth: ALICE } + ) + const addressID = customer.addresses[0].ID + await PATCH( + `/fiori-2/Customers(ID=${CUSTOMER_ID},IsActiveEntity=false)/addresses(ID=${addressID},IsActiveEntity=false)`, + { + street: 'updated', + town: 'updated town' + }, + { auth: ALICE } + ) + const response = await POST( + `/fiori-2/Customers(ID=${CUSTOMER_ID},IsActiveEntity=false)/draftActivate`, + {}, + { auth: ALICE } + ) + + expect(response).toMatchObject({ status: 200 }) + // TODO: check if this is correct + expect(_logs.length).toBe(1) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_2.CustomerPostalAddress', + id: { ID: addressID } + }, + data_subject: { + type: 'Fiori_2.CustomerPostalAddress', + role: 'Address', + id: { + ID: addressID, + street: 'updated', + town: 'updated town' + } + }, + attributes: [ + { name: 'street', new: 'updated', old: 'moo' }, + { name: 'town', new: 'updated town', old: 'shu' } + ] + }) + }) + + test('create, patch, read and activate', async () => { + const customer = { + emailAddress: 'bla@blub.com', + firstName: 'bla', + lastName: 'blub', + creditCardNo: '98765', + someOtherField: 'dummy' + } + + let response = await POST('/fiori-1/Customers', {}, { auth: ALICE }) + + expect(response).toMatchObject({ status: 201 }) + customer.ID = response.data.ID + expect(_logs.length).toBe(0) + + response = await PATCH(`/fiori-1/Customers(ID=${customer.ID},IsActiveEntity=false)`, customer, { auth: ALICE }) + + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(0) + + response = await GET(`/fiori-1/Customers(ID=${customer.ID},IsActiveEntity=false)`, { auth: ALICE }) + + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(0) + + response = await POST( + `/fiori-1/Customers(ID=${customer.ID},IsActiveEntity=false)/Fiori_1.draftActivate`, + {}, + { auth: ALICE } + ) + + expect(_logs.length).toBe(2) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.Customers', + id: { ID: customer.ID } + }, + data_subject: { + type: 'Fiori_1.Customers', + role: 'Customer', + id: { ID: customer.ID } + }, + attributes: [ + { name: 'emailAddress', old: 'null', new: customer.emailAddress }, + { name: 'firstName', old: 'null', new: customer.firstName }, + { name: 'lastName', old: 'null', new: customer.lastName }, + { name: 'creditCardNo', old: 'null', new: customer.creditCardNo } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.Customers', + id: { ID: customer.ID } + }, + data_subject: { + type: 'Fiori_1.Customers', + role: 'Customer', + id: { ID: customer.ID } + }, + attributes: [{ name: 'creditCardNo' }] + }) + }) + + test('draft edit, read union, delete draft', async () => { + let response = await POST( + `/fiori-1/Customers(ID=bcd4a37a-6319-4d52-bb48-02fd06b9ffe9,IsActiveEntity=true)/Fiori_1.draftEdit`, + { PreserveChanges: true }, + { auth: ALICE } + ) + + expect(response).toMatchObject({ status: 201 }) + // TODO: check if this is correct + expect(_logs.length).toBe(0) // REVISIT: Read active personal data will be logged after using expand ** in edit.js + + response = await GET( + '/fiori-1/Customers?$filter=(IsActiveEntity eq false or SiblingEntity/IsActiveEntity eq null)', + { auth: ALICE } + ) + + expect(response).toMatchObject({ status: 200 }) + // TODO: check if this is correct + expect(_logs.length).toBe(1) + + response = await DELETE(`/fiori-1/Customers(ID=${CUSTOMER_ID},IsActiveEntity=false)`, { auth: ALICE }) + + expect(response).toMatchObject({ status: 204 }) + // TODO: check if this is correct + expect(_logs.length).toBe(1) + }) + + test('draft edit, patch and activate', async () => { + let response = await POST( + `/fiori-1/Customers(ID=bcd4a37a-6319-4d52-bb48-02fd06b9ffe9,IsActiveEntity=true)/Fiori_1.draftEdit`, + { PreserveChanges: true }, + { auth: ALICE } + ) + + expect(response).toMatchObject({ status: 201 }) + // TODO: check if this is correct + expect(_logs.length).toBe(0) // REVISIT: Read active personal data will be logged after using expand ** in edit.js + + const customer = { + ID: response.data.ID, + emailAddress: 'bla@blub.com', + firstName: 'bla', + lastName: 'blub', + creditCardNo: '98765', + someOtherField: 'dummy' + } + + response = await PATCH(`/fiori-1/Customers(ID=${customer.ID},IsActiveEntity=false)`, customer, { auth: ALICE }) + + expect(response).toMatchObject({ status: 200 }) + // TODO: check if this is correct + expect(_logs.length).toBe(0) + + response = await POST( + `/fiori-1/Customers(ID=${customer.ID},IsActiveEntity=false)/Fiori_1.draftActivate`, + {}, + { auth: ALICE } + ) + + // TODO: check if this is correct + expect(_logs.length).toBe(2) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.Customers', + id: { ID: customer.ID } + }, + data_subject: { + type: 'Fiori_1.Customers', + role: 'Customer', + id: { ID: customer.ID } + }, + attributes: [ + { name: 'emailAddress', old: 'foo@bar.com', new: customer.emailAddress }, + { name: 'firstName', old: 'foo', new: customer.firstName }, + { name: 'lastName', old: 'bar', new: customer.lastName }, + { name: 'creditCardNo', old: '12345', new: customer.creditCardNo } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.Customers', + id: { ID: customer.ID } + }, + data_subject: { + type: 'Fiori_1.Customers', + role: 'Customer', + id: { ID: customer.ID } + }, + attributes: [{ name: 'creditCardNo' }] + }) + }) + + test('create, patch, and activate - deep', async () => { + let response = await POST('/fiori-1/Customers', {}, { auth: ALICE }) + + expect(response).toMatchObject({ status: 201 }) + expect(_logs.length).toBe(0) + + const customer = { + ID: response.data.ID, + emailAddress: 'bla@blub.com', + firstName: 'bla', + lastName: 'blub', + creditCardNo: '98765', + someOtherField: 'dummy' + } + response = await PATCH(`/fiori-1/Customers(ID=${customer.ID},IsActiveEntity=false)`, customer, { auth: ALICE }) + + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(0) + + response = await POST(`/fiori-1/Customers(ID=${customer.ID},IsActiveEntity=false)/addresses`, {}, { auth: ALICE }) + + expect(response).toMatchObject({ status: 201 }) + expect(_logs.length).toBe(0) + + const address = { + ID: response.data.ID, + street: 'A1', + town: 'Monnem', + someOtherField: 'Beschde' + } + + response = await PATCH( + `/fiori-1/Customers(ID=${customer.ID},IsActiveEntity=false)/addresses(ID=${address.ID},IsActiveEntity=false)`, + address, + { auth: ALICE } + ) + + expect(response).toMatchObject({ status: 200 }) + expect(_logs.length).toBe(0) + + response = await POST( + `/fiori-1/Customers(ID=${customer.ID},IsActiveEntity=false)/Fiori_1.draftActivate`, + {}, + { auth: ALICE } + ) + + const data_subject = { + type: 'Fiori_1.Customers', + role: 'Customer', + id: { ID: customer.ID } + } + + expect(_logs.length).toBe(3) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.Customers', + id: { ID: customer.ID } + }, + data_subject, + attributes: [ + { name: 'emailAddress', old: 'null', new: customer.emailAddress }, + { name: 'firstName', old: 'null', new: customer.firstName }, + { name: 'lastName', old: 'null', new: customer.lastName }, + { name: 'creditCardNo', old: 'null', new: customer.creditCardNo } + ] + }) + + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.Customers', + id: { ID: customer.ID } + }, + data_subject: { + type: 'Fiori_1.Customers', + role: 'Customer', + id: { ID: customer.ID } + }, + attributes: [{ name: 'creditCardNo' }] + }) + + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.CustomerPostalAddress', + id: { ID: address.ID } + }, + data_subject, + attributes: [ + { name: 'street', old: 'null', new: address.street }, + { name: 'town', old: 'null', new: address.town } + ] + }) + }) + + test('delete active Customer - deep', async () => { + let response = await GET( + `/fiori-1/Customers(ID=${CUSTOMER_ID},IsActiveEntity=true)?$expand=addresses($expand=attachments),status($expand=change($expand=last)),comments`, + { auth: ALICE } + ) + + const oldAddresses = response.data.addresses + const oldAttachments = response.data.addresses[0].attachments + const oldStatus = response.data.status + const oldChange = response.data.status.change + const oldLast = response.data.status.change.last + + // reset logs + _logs = [] + _logger._resetLogs() + + response = await DELETE(`/fiori-1/Customers(ID=${CUSTOMER_ID},IsActiveEntity=true)`, { auth: ALICE }) + + expect(response).toMatchObject({ status: 204 }) + expect(_logs.length).toBe(10) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.Customers', + id: { ID: CUSTOMER_ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'emailAddress', old: 'foo@bar.com', new: 'null' }, + { name: 'firstName', old: 'foo', new: 'null' }, + { name: 'lastName', old: 'bar', new: 'null' }, + { name: 'creditCardNo', old: '12345', new: 'null' } + ] + }) + + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.CustomerPostalAddress', + id: { ID: oldAddresses[0].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'street', old: oldAddresses[0].street, new: 'null' }, + { name: 'town', old: oldAddresses[0].town, new: 'null' } + ] + }) + + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.AddressAttachment', + id: { ID: oldAttachments[0].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'description', old: oldAttachments[0].description, new: 'null' }, + { name: 'todo', old: oldAttachments[0].todo, new: 'null' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.AddressAttachment', + id: { ID: oldAttachments[1].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'description', old: oldAttachments[1].description, new: 'null' }, + { name: 'todo', old: oldAttachments[1].todo, new: 'null' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.CustomerPostalAddress', + id: { ID: oldAddresses[1].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'street', old: oldAddresses[1].street, new: 'null' }, + { name: 'town', old: oldAddresses[1].town, new: 'null' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.CustomerStatus', + id: { ID: oldStatus.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'description', old: 'active', new: 'null' }, + { name: 'todo', old: 'send reminder', new: 'null' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.StatusChange', + id: { ID: oldChange.ID, secondKey: oldChange.secondKey } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'description', old: 'new change', new: 'null' }] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.LastOne', + id: { ID: oldLast.ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'lastOneField', old: 'some last value', new: 'null' }] + }) + + const selects = _logger._logs.debug.filter( + l => typeof l === 'string' && l.match(/^SELECT/) && l.match(/SELECT [Customers.]*ID FROM Fiori_1_Customers/) + ) + expect(selects.length).toBe(1) + }) + + test('with atomicity group', async () => { + let response = await GET( + `/fiori-1/Customers(ID=${CUSTOMER_ID},IsActiveEntity=true)?$expand=addresses($expand=attachments($expand=notes)),status($expand=change($expand=last),notes)`, + { auth: ALICE } + ) + const oldAddresses = response.data.addresses + const oldAttachments = response.data.addresses[0].attachments + const oldAttachmentNotes = response.data.addresses[0].attachments[0].notes + + // reset logs + _logs = [] + + response = await POST( + `/fiori-1/Customers(ID=bcd4a37a-6319-4d52-bb48-02fd06b9ffe9,IsActiveEntity=true)/Fiori_1.draftEdit`, + { PreserveChanges: true }, + { auth: ALICE } + ) + + expect(response).toMatchObject({ status: 201 }) + // TODO: check if this is correct + expect(_logs.length).toBe(0) // REVISIT: Read active personal data will be logged after using expand ** in edit.js + + response = await PATCH( + `/fiori-1/Customers(ID=bcd4a37a-6319-4d52-bb48-02fd06b9ffe9,IsActiveEntity=false)`, + { status: null }, + { auth: ALICE } + ) + + expect(response).toMatchObject({ status: 200 }) + // TODO: check if this is correct + expect(_logs.length).toBe(0) + + const body = { + requests: [ + { + method: 'POST', + url: `/Customers(ID=bcd4a37a-6319-4d52-bb48-02fd06b9ffe9,IsActiveEntity=false)/Fiori_1.draftActivate`, + headers: { 'content-type': 'application/json', 'odata-version': '4.0' }, + id: 'r1', + atomicityGroup: 'g1' + }, + { + method: 'DELETE', + url: `/Customers(ID=bcd4a37a-6319-4d52-bb48-02fd06b9ffe9,IsActiveEntity=true)`, + headers: { 'content-type': 'application/json', 'odata-version': '4.0' }, + id: 'r2', + atomicityGroup: 'g1', + dependsOn: ['r1'] + } + ] + } + response = await POST('/fiori-1/$batch', body, { auth: ALICE }) + expect(response).toMatchObject({ status: 200 }) + expect(response.data.responses.every(r => r.status >= 200 && r.status < 300)).toBeTruthy() + // TODO: check if this is correct + expect(_logs.length).toBe(11) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.CustomerPostalAddress', + id: { ID: oldAddresses[0].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'street', old: oldAddresses[0].street, new: 'null' }, + { name: 'town', old: oldAddresses[0].town, new: 'null' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.AddressAttachment', + id: { ID: oldAttachments[0].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'description', old: oldAttachments[0].description, new: 'null' }, + { name: 'todo', old: oldAttachments[0].todo, new: 'null' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.AddressAttachment', + id: { ID: oldAttachments[1].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'description', old: oldAttachments[1].description, new: 'null' }, + { name: 'todo', old: oldAttachments[1].todo, new: 'null' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.CustomerPostalAddress', + id: { ID: oldAddresses[1].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [ + { name: 'street', old: oldAddresses[1].street, new: 'null' }, + { name: 'town', old: oldAddresses[1].town, new: 'null' } + ] + }) + expect(_logs).toContainMatchObject({ + user: 'alice', + object: { + type: 'Fiori_1.Notes', + id: { ID: oldAttachmentNotes[0].ID } + }, + data_subject: DATA_SUBJECT, + attributes: [{ name: 'note', old: 'start', new: 'null' }] + }) + }) + }) +}) diff --git a/test/personal-data/package.json b/test/personal-data/package.json new file mode 100644 index 0000000..a5e36cb --- /dev/null +++ b/test/personal-data/package.json @@ -0,0 +1,13 @@ +{ + "name": "personal-data", + "version": "1.0.0", + "description": "A simple CAP project.", + "repository": "", + "license": "UNLICENSED", + "private": true, + "cds": { + "plugins": [ + "../.." + ] + } +} diff --git a/test/personal-data/srv/crud-service.cds b/test/personal-data/srv/crud-service.cds new file mode 100644 index 0000000..60d38d1 --- /dev/null +++ b/test/personal-data/srv/crud-service.cds @@ -0,0 +1,157 @@ +using {sap.auditlog.test.personal_data.db as db} from '../db/schema'; + +@path : '/crud-1' +@requires: 'admin' +service CRUD_1 { + + entity Orders as projection on db.Orders; + entity OrderHeader as projection on db.OrderHeader; + entity OrderItems as projection on db.OrderItems; + entity Pages as projection on db.Pages; + entity Customers as projection on db.Customers; + entity CustomerPostalAddress as projection on db.CustomerPostalAddress; + entity Comments as projection on db.Comments; + entity CustomerStatus as projection on db.CustomerStatus; + entity StatusChange as projection on db.StatusChange; + entity LastOne as projection on db.LastOne; + entity Notes as projection on db.Notes; + + entity AddressAttachment as projection on db.AddressAttachment { + *, + address.customer as customer + } + + annotate Orders with @PersonalData: { + DataSubjectRole: 'Customer', + EntitySemantics: 'Other' + } { + misc @PersonalData.IsPotentiallySensitive; + } + + annotate OrderHeader with @PersonalData: { + DataSubjectRole: 'Customer', + EntitySemantics: 'Other' + } { + description @PersonalData.IsPotentiallySensitive; + } + + annotate OrderHeader.sensitiveData with @PersonalData: { + DataSubjectRole: 'Customer', + EntitySemantics: 'Other' + } { + note @PersonalData.IsPotentiallySensitive; + } + + annotate Pages with @PersonalData : { + DataSubjectRole: 'Page', + EntitySemantics: 'DataSubject' + } { + ID @PersonalData.FieldSemantics: 'DataSubjectID'; + sensitive @PersonalData.IsPotentiallySensitive; + personal @PersonalData.IsPotentiallyPersonal; + } + + annotate Customers with @PersonalData : { + DataSubjectRole: 'Customer', + EntitySemantics: 'DataSubject' + } { + ID @PersonalData.FieldSemantics: 'DataSubjectID'; + emailAddress @PersonalData.IsPotentiallyPersonal; + firstName @PersonalData.IsPotentiallyPersonal; + lastName @PersonalData.IsPotentiallyPersonal; + creditCardNo @PersonalData.IsPotentiallySensitive; + } + + annotate CustomerPostalAddress with @PersonalData: { + DataSubjectRole: 'Customer', + EntitySemantics: 'DataSubjectDetails' + } { + customer @PersonalData.FieldSemantics : 'DataSubjectID'; + street @PersonalData.IsPotentiallySensitive; + town @PersonalData.IsPotentiallyPersonal; + } + + annotate CustomerStatus with @PersonalData: { + DataSubjectRole: 'Customer', + EntitySemantics: 'DataSubjectDetails' + } { + description @PersonalData.IsPotentiallySensitive; + todo @PersonalData.IsPotentiallyPersonal; + } + + annotate StatusChange with @PersonalData: { + DataSubjectRole: 'Customer', + EntitySemantics: 'DataSubjectDetails' + } { + description @PersonalData.IsPotentiallySensitive; + secondKey @PersonalData.IsPotentiallyPersonal; + } + + annotate LastOne with @PersonalData: { + DataSubjectRole: 'Customer', + EntitySemantics: 'DataSubjectDetails' + } { + lastOneField @PersonalData.IsPotentiallySensitive; + } + + annotate AddressAttachment with @PersonalData: { + DataSubjectRole: 'Customer', + EntitySemantics: 'DataSubjectDetails' + } { + customer @PersonalData.FieldSemantics : 'DataSubjectID'; + description @PersonalData.IsPotentiallySensitive; + todo @PersonalData.IsPotentiallyPersonal; + } + + annotate Notes with @PersonalData: { + DataSubjectRole: 'Customer', + EntitySemantics: 'Other' + } { + note @PersonalData.IsPotentiallySensitive; + dummyArray @PersonalData.IsPotentiallyPersonal; + } + + // currently this generates no annotations in CSN + annotate dummies with @PersonalData: { + DataSubjectRole: 'Customer', + EntitySemantics: 'DataSubjectDetails' + } { + dummy @PersonalData.IsPotentiallyPersonal; + } +} + +@path : '/crud-2' +@requires: 'admin' +service CRUD_2 { + entity Customers as projection on db.Customers; + entity CustomerPostalAddress as projection on db.CustomerPostalAddress; + entity CustomerStatus as projection on db.CustomerStatus; + + entity AddressAttachment as projection on db.AddressAttachment { + *, + address.customer as customer + } + + annotate Customers with @PersonalData : { + DataSubjectRole: 'Address', + EntitySemantics: 'Other' + } { + addresses @PersonalData.FieldSemantics: 'DataSubjectID'; + } + + annotate CustomerPostalAddress with @PersonalData: { + DataSubjectRole: 'Address', + EntitySemantics: 'DataSubject' + } { + ID @PersonalData.FieldSemantics : 'DataSubjectID'; + street @PersonalData.IsPotentiallyPersonal @PersonalData.FieldSemantics: 'DataSubjectID'; + town @PersonalData.IsPotentiallyPersonal @PersonalData.FieldSemantics: 'DataSubjectID'; + someOtherField @PersonalData.IsPotentiallySensitive; + } + + // invalid modeling, must have no effect + annotate CustomerStatus with @PersonalData: {EntitySemantics: 'Other'} { + description @PersonalData.IsPotentiallySensitive; + todo @PersonalData.IsPotentiallyPersonal; + } +} diff --git a/test/personal-data/srv/fiori-service.cds b/test/personal-data/srv/fiori-service.cds new file mode 100644 index 0000000..e6dcb95 --- /dev/null +++ b/test/personal-data/srv/fiori-service.cds @@ -0,0 +1,148 @@ +using {sap.auditlog.test.personal_data.db as db} from '../db/schema'; + +@path : '/fiori-1' +@requires: 'admin' +service Fiori_1 { + @odata.draft.enabled + entity Orders as projection on db.Orders; + + entity OrderHeader as projection on db.OrderHeader; + entity OrderItems as projection on db.OrderItems; + entity Pages as projection on db.Pages; + + @odata.draft.enabled + entity Customers as projection on db.Customers; + + entity CustomerPostalAddress as projection on db.CustomerPostalAddress; + entity Comments as projection on db.Comments; + entity CustomerStatus as projection on db.CustomerStatus; + entity StatusChange as projection on db.StatusChange; + entity LastOne as projection on db.LastOne; + entity Notes as projection on db.Notes; + + entity AddressAttachment as projection on db.AddressAttachment { + *, + address.customer as customer + } + + annotate Orders with @PersonalData: { + DataSubjectRole: 'Customer', + EntitySemantics: 'Other' + } { + misc @PersonalData.IsPotentiallySensitive; + } + + annotate OrderHeader with @PersonalData: { + DataSubjectRole: 'Customer', + EntitySemantics: 'Other' + } { + description @PersonalData.IsPotentiallySensitive; + } + + annotate OrderHeader.sensitiveData with @PersonalData: { + DataSubjectRole: 'Customer', + EntitySemantics: 'Other' + } { + note @PersonalData.IsPotentiallySensitive; + } + + annotate Pages with @PersonalData : { + DataSubjectRole: 'Page', + EntitySemantics: 'DataSubject' + } { + ID @PersonalData.FieldSemantics: 'DataSubjectID'; + sensitive @PersonalData.IsPotentiallySensitive; + personal @PersonalData.IsPotentiallyPersonal; + } + + annotate Customers with @PersonalData : { + DataSubjectRole: 'Customer', + EntitySemantics: 'DataSubject' + } { + ID @PersonalData.FieldSemantics: 'DataSubjectID'; + emailAddress @PersonalData.IsPotentiallyPersonal @PersonalData.FieldSemantics: 'DataSubjectID'; + firstName @PersonalData.IsPotentiallyPersonal; + lastName @PersonalData.IsPotentiallyPersonal; + creditCardNo @PersonalData.IsPotentiallySensitive; + } + + annotate CustomerPostalAddress with @PersonalData: { + DataSubjectRole: 'Customer', + EntitySemantics: 'DataSubjectDetails' + } { + customer @PersonalData.FieldSemantics : 'DataSubjectID'; + street @PersonalData.IsPotentiallySensitive; + town @PersonalData.IsPotentiallyPersonal; + } + + annotate CustomerStatus with @PersonalData: { + DataSubjectRole: 'Customer', + EntitySemantics: 'DataSubjectDetails' + } { + description @PersonalData.IsPotentiallySensitive; + todo @PersonalData.IsPotentiallyPersonal; + } + + annotate StatusChange with @PersonalData: { + DataSubjectRole: 'Customer', + EntitySemantics: 'DataSubjectDetails' + } { + description @PersonalData.IsPotentiallySensitive; + secondKey @PersonalData.IsPotentiallyPersonal; + } + + annotate LastOne with @PersonalData: { + DataSubjectRole: 'Customer', + EntitySemantics: 'DataSubjectDetails' + } { + lastOneField @PersonalData.IsPotentiallySensitive; + } + + annotate AddressAttachment with @PersonalData: { + DataSubjectRole: 'Customer', + EntitySemantics: 'DataSubjectDetails' + } { + customer @PersonalData.FieldSemantics : 'DataSubjectID'; + description @PersonalData.IsPotentiallySensitive; + todo @PersonalData.IsPotentiallyPersonal; + } + + annotate Notes with @PersonalData: { + DataSubjectRole: 'Customer', + EntitySemantics: 'Other' + } { + note @PersonalData.IsPotentiallySensitive; + dummyArray @PersonalData.IsPotentiallyPersonal; + } +} + +@path : '/fiori-2' +@requires: 'admin' +service Fiori_2 { + @odata.draft.enabled + entity Customers as projection on db.Customers; + + entity CustomerPostalAddress as projection on db.CustomerPostalAddress; + + entity AddressAttachment as projection on db.AddressAttachment { + *, + address.customer as customer + } + + annotate Customers with @PersonalData : { + DataSubjectRole: 'Address', + EntitySemantics: 'Other' + } { + addresses @PersonalData.FieldSemantics: 'DataSubjectID'; + } + + annotate CustomerPostalAddress with @PersonalData: { + DataSubjectRole: 'Address', + EntitySemantics: 'DataSubject' + } { + ID @PersonalData.FieldSemantics : 'DataSubjectID'; + street @PersonalData.IsPotentiallyPersonal @PersonalData.FieldSemantics: 'DataSubjectID'; + town @PersonalData.IsPotentiallyPersonal @PersonalData.FieldSemantics: 'DataSubjectID'; + someOtherField @PersonalData.IsPotentiallySensitive; + } +} diff --git a/test/utils/logger.js b/test/utils/logger.js new file mode 100644 index 0000000..59f1f24 --- /dev/null +++ b/test/utils/logger.js @@ -0,0 +1,67 @@ +const _deepCopy = arg => { + if (Buffer.isBuffer(arg)) return Buffer.from(arg) + if (Array.isArray(arg)) return _deepCopyArray(arg) + if (typeof arg === 'object') return _deepCopyObject(arg) + return arg +} + +const _deepCopyArray = arr => { + if (!arr) return arr + const clone = [] + for (const item of arr) clone.push(_deepCopy(item)) + return clone +} + +const _deepCopyObject = obj => { + if (!obj) return obj + const clone = {} + for (const key in obj) clone[key] = _deepCopy(obj[key]) + return clone +} + +const deepCopy = data => { + if (Array.isArray(data)) return _deepCopyArray(data) + return _deepCopyObject(data) +} + +module.exports = (levels = {}) => { + const _logs = {} + + const _push = (level, ...args) => { + if (args.length > 1 || typeof args[0] !== 'object') return _logs[level].push(...args) + const copy = deepCopy(args[0]) + args[0].message && (copy.message = args[0].message) + // args[0].stack && (copy.stack = args[0].stack) + _logs[level].push(copy) + } + + const fn = () => { + return { + trace: (...args) => _push('trace', ...args), + debug: (...args) => _push('debug', ...args), + log: (...args) => _push('log', ...args), + info: (...args) => _push('info', ...args), + warn: (...args) => _push('warn', ...args), + error: (...args) => _push('error', ...args), + _trace: levels.trace || false, + _debug: levels.debug || false, + _info: levels.info || false, + _warn: levels.warn || false, + _error: levels.error || false + } + } + + fn._logs = _logs + fn._resetLogs = () => { + _logs.trace = [] + _logs.debug = [] + _logs.log = [] + _logs.info = [] + _logs.warn = [] + _logs.error = [] + } + + fn._resetLogs() + + return fn +}