From 96bc41c07a982dcbb8d811ffde4f27895b151c1b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 15:14:50 +0000 Subject: [PATCH] test: add Parse Server + in-memory Mongo integration tests Add an opt-in integration suite (yarn test:integration) that runs against a real Parse Server backed by mongodb-memory-server, kept separate from the fast unit suite (yarn test) to avoid the heavy deps/binary download in the default path. - jest.integration.config.ts with globalSetup/globalTeardown that boot Mongo + Parse Server once in Jest's main process (parse-server's dynamic imports fail inside a test-file VM); workers get the URL/creds via env and a WebCrypto window shim so SecureObject/CryptoUtils run. - Query: CRUD, findBy/findOneBy, getObjectById (id/pointer/not-found), count, findAll, each, distinct, aggregate, findOrCreate incl. a concurrency test that proves the per-class mutex prevents duplicate creation, and CacheableQuery caching. - SecureObject: encrypt-at-rest + transparent decrypt-on-read round-trip, saveAll, and already-encrypted guard; CryptoUtils PBKDF2/encrypt/decrypt round-trip, random-IV, and wrong-key failure using real WebCrypto. Adds parse-server and mongodb-memory-server as devDependencies. Co-authored-by: Samuel Denis-D'Ortun --- .gitignore | 3 + jest.integration.config.ts | 32 +++++ package.json | 5 +- spec/integration/Query.itest.ts | 164 +++++++++++++++++++++++++ spec/integration/SecureObject.itest.ts | 95 ++++++++++++++ spec/integration/globalSetup.ts | 50 ++++++++ spec/integration/globalTeardown.ts | 16 +++ spec/integration/jestSetup.ts | 23 ++++ spec/integration/testUtils.ts | 2 + 9 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 jest.integration.config.ts create mode 100644 spec/integration/Query.itest.ts create mode 100644 spec/integration/SecureObject.itest.ts create mode 100644 spec/integration/globalSetup.ts create mode 100644 spec/integration/globalTeardown.ts create mode 100644 spec/integration/jestSetup.ts create mode 100644 spec/integration/testUtils.ts diff --git a/.gitignore b/.gitignore index a236c5a..f19d9a6 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ node_modules/ /.idea/workspace.xml /dist /yarn.lock + +# Parse Server test logs +/logs diff --git a/jest.integration.config.ts b/jest.integration.config.ts new file mode 100644 index 0000000..37963b2 --- /dev/null +++ b/jest.integration.config.ts @@ -0,0 +1,32 @@ +/* + * Jest configuration for integration tests that run against a real Parse Server + * backed by an in-memory MongoDB (mongodb-memory-server). + * + * These are intentionally separate from the unit specs (jest.config.ts): they are + * slower and pull in heavy dev dependencies, so they run via `yarn test:integration` + * and are excluded from the default `yarn test` run. + */ +import { pathsToModuleNameMapper } from 'ts-jest'; +import tsConfigFile from './tsconfig.json'; + +export default { + clearMocks: true, + collectCoverage: false, + coverageProvider: 'v8', + rootDir: process.cwd(), + roots: [''], + modulePaths: ['/src'], + moduleNameMapper: { + ...pathsToModuleNameMapper(tsConfigFile.compilerOptions.paths, { prefix: __dirname }), + }, + modulePathIgnorePatterns: ['/dist/'], + globalSetup: '/spec/integration/globalSetup.ts', + globalTeardown: '/spec/integration/globalTeardown.ts', + setupFiles: ['/spec/integration/jestSetup.ts'], + testMatch: ['/spec/integration/**/*.itest.ts'], + transform: { + '^.+\\.(ts|tsx)$': 'ts-jest', + }, + // A fresh MongoMemoryServer + Parse Server is started per suite; give it room. + testTimeout: 60000, +}; diff --git a/package.json b/package.json index cf28a85..e41aab1 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "pre-commit": "yarn run lint && yarn run prettier", "lint": "eslint --cache --fix .", "prettier": "prettier --write src/ spec/", - "test": "jest" + "test": "jest", + "test:integration": "jest --config jest.integration.config.ts --runInBand --forceExit" }, "pre-commit": "pre-commit", "dependencies": { @@ -54,7 +55,9 @@ "eslint-plugin-varspacing": "1.2.2", "eslint-plugin-vue": "9.29.1", "jest": "^29.7.0", + "mongodb-memory-server": "^11.2.0", "parse": "^7.1.2", + "parse-server": "^9.10.0", "pre-commit": "1.2.2", "prettier": "^3.0.0", "ts-jest": "^29.0.5", diff --git a/spec/integration/Query.itest.ts b/spec/integration/Query.itest.ts new file mode 100644 index 0000000..b88a6d1 --- /dev/null +++ b/spec/integration/Query.itest.ts @@ -0,0 +1,164 @@ +import { Query, CacheableQuery, BaseObject } from '@utils/index'; +import { MASTER } from './testUtils'; + +class Widget extends BaseObject { + static className = 'Widget'; + constructor() { + super('Widget'); + } +} +Widget.register(); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const makeWidget = (attrs: Record): Widget => { + const w = new Widget(); + w.set(attrs); + return w; +}; + +describe('Query (integration, real Parse Server + in-memory Mongo)', () => { + afterEach(async () => { + const existing = await Query.create(Widget).find(MASTER); + await Parse.Object.destroyAll(existing, MASTER); + }); + + describe('CRUD & finders', () => { + it('saves and retrieves via findBy', async () => { + const w = makeWidget({ name: 'alpha', color: 'red' }); + await w.save(null, MASTER); + + const found = await Query.create(Widget).findBy({ name: 'alpha' }, true); + expect(found).toHaveLength(1); + expect(found[0].id).toBe(w.id); + expect(found[0].get('color')).toBe('red'); + }); + + it('findOneBy returns a single matching record', async () => { + await makeWidget({ name: 'beta', color: 'blue' }).save(null, MASTER); + const one = await Query.create(Widget).findOneBy({ name: 'beta' } as Partial, true); + expect(one?.get('color')).toBe('blue'); + }); + + it('getObjectById resolves by id and by pointer, and throws when missing', async () => { + const w = makeWidget({ name: 'gamma' }); + await w.save(null, MASTER); + + const byId = await Query.create(Widget).getObjectById(w.id, true); + expect(byId.id).toBe(w.id); + + const pointer = BaseObject.createPointer.call(Widget, w.id); + const byPointer = await Query.create(Widget).getObjectById(pointer, true); + expect(byPointer.id).toBe(w.id); + + await expect(Query.create(Widget).getObjectById('missing-id', true)).rejects.toThrow(); + }); + + it('count reflects the number of stored objects', async () => { + await makeWidget({ name: 'a' }).save(null, MASTER); + await makeWidget({ name: 'b' }).save(null, MASTER); + expect(await Query.create(Widget).count(MASTER)).toBe(2); + }); + + it('findAll returns every object', async () => { + await Parse.Object.saveAll( + [makeWidget({ name: 'a' }), makeWidget({ name: 'b' }), makeWidget({ name: 'c' })], + MASTER + ); + const all = await Query.create(Widget).findAll(MASTER); + expect(all).toHaveLength(3); + }); + + it('each iterates over every object', async () => { + await Parse.Object.saveAll( + [makeWidget({ name: 'a' }), makeWidget({ name: 'b' })], + MASTER + ); + const seen: string[] = []; + await Query.create(Widget).each(w => { + seen.push(w.get('name')); + }, MASTER); + expect(seen.sort()).toStrictEqual(['a', 'b']); + }); + }); + + describe('distinct & aggregate', () => { + beforeEach(async () => { + await Parse.Object.saveAll( + [ + makeWidget({ name: 'a', color: 'red' }), + makeWidget({ name: 'b', color: 'red' }), + makeWidget({ name: 'c', color: 'green' }), + ], + MASTER + ); + }); + + it('distinct returns the unique values of a field', async () => { + const colors = await Query.create(Widget) + .useMasterKey(true) + .distinct('color' as never); + expect((colors as string[]).sort()).toStrictEqual(['green', 'red']); + }); + + it('aggregate runs a server-side pipeline', async () => { + const results = await Query.create(Widget) + .useMasterKey(true) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .aggregate([{ $group: { _id: null, total: { $sum: 1 } } }] as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rows = results as any[]; + expect(Array.isArray(rows)).toBe(true); + expect(rows[0].total).toBe(3); + }); + }); + + describe('findOrCreate', () => { + it('creates when missing and returns the existing object next time', async () => { + const created = await Query.create(Widget).findOrCreate( + { name: 'unique', color: 'red' } as Partial, + true + ); + expect(created.id).toBeDefined(); + expect(created.get('color')).toBe('red'); + + const second = await Query.create(Widget).findOrCreate( + { name: 'unique' } as Partial, + true + ); + expect(second.id).toBe(created.id); + }); + + it('serializes concurrent calls via the per-class mutex (no duplicates)', async () => { + const params = { name: 'race' } as Partial; + + const results = await Promise.all( + Array.from({ length: 6 }, () => Query.create(Widget).findOrCreate(params, true)) + ); + + const ids = new Set(results.map(r => r.id)); + expect(ids.size).toBe(1); + + const stored = await Query.create(Widget).findBy({ name: 'race' }, true); + expect(stored).toHaveLength(1); + }); + }); + + describe('CacheableQuery', () => { + it('caches results so identical queries do not re-hit the server', async () => { + await makeWidget({ name: 'cache-key' }).save(null, MASTER); + + const cq = CacheableQuery.create(Widget); + const first = await cq.findBy({ name: 'cache-key' }, true); + expect(first).toHaveLength(1); + + // Mutating the DB after the first (cached) call must not change the cached result. + await makeWidget({ name: 'cache-key' }).save(null, MASTER); + const second = await cq.findBy({ name: 'cache-key' }, true); + expect(second).toHaveLength(1); + + // A fresh (uncached) query observes both rows. + const fresh = await Query.create(Widget).findBy({ name: 'cache-key' }, true); + expect(fresh).toHaveLength(2); + }); + }); +}); diff --git a/spec/integration/SecureObject.itest.ts b/spec/integration/SecureObject.itest.ts new file mode 100644 index 0000000..3366bc6 --- /dev/null +++ b/spec/integration/SecureObject.itest.ts @@ -0,0 +1,95 @@ +import { Crypto, Query, SecureObject, BaseObject } from '@utils/index'; +import { MASTER } from './testUtils'; + +class Secret extends SecureObject { + static className = 'Secret'; + constructor() { + super('Secret', ['payload']); + } +} +Secret.register(); + +describe('SecureObject (integration, real Parse Server + WebCrypto)', () => { + beforeAll(async () => { + const salt = Crypto.randomSalt(); + const derived = await Crypto.PBKDF2('correct horse battery staple', salt); + SecureObject.setSessionDerivedKey(derived); + }); + + afterEach(async () => { + const existing = await Query.create(Secret).find(MASTER); + await Parse.Object.destroyAll(existing, MASTER); + }); + + it('encrypts secure fields at rest and decrypts them on read', async () => { + const obj = new Secret(); + obj.set('label', 'public-label'); // non-secure field, stored as-is + obj.set('payload', { ssn: '123-45-6789', note: 'sensitive' }); // secure field + await obj.save(null, MASTER); + + // At rest: read the raw document with a vanilla Parse query (no auto-decrypt). + // The stored payload must be an encrypted envelope, not the plaintext. + const raw = await new Parse.Query(Secret).first(MASTER); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rawPayload = (raw as any).toJSON().payload; + expect(Crypto.isEncrypted(rawPayload)).toBe(true); + expect(JSON.stringify(rawPayload)).not.toContain('123-45-6789'); + + // Through our Query, the object is transparently decrypted on read. + const fetched = await Query.create(Secret).first(MASTER); + expect(fetched?.get('label')).toBe('public-label'); + expect(fetched?.get('payload')).toStrictEqual({ ssn: '123-45-6789', note: 'sensitive' }); + }); + + it('round-trips several values, including via findBy', async () => { + const a = new Secret(); + a.set('label', 'a'); + a.set('payload', { v: 1 }); + const b = new Secret(); + b.set('label', 'b'); + b.set('payload', { v: 2 }); + + await SecureObject.saveAll([a, b] as unknown as BaseObject[], MASTER); + + const results = await Query.create(Secret).findBy({ label: 'b' }, true); + expect(results).toHaveLength(1); + expect(results[0].get('payload')).toStrictEqual({ v: 2 }); + }); + + it('rejects encrypting an already-encrypted value', async () => { + const encrypted = await SecureObject.encryptField({ v: 42 }); + await expect(SecureObject.encryptField(encrypted)).rejects.toBeDefined(); + }); +}); + +describe('Crypto (integration, WebCrypto)', () => { + it('PBKDF2 + encrypt/decrypt round-trips arbitrary JSON', async () => { + const salt = Crypto.randomSalt(); + const derived = await Crypto.PBKDF2('pw', salt); + + const payload = { a: 1, nested: { b: [1, 2, 3] }, s: 'hello' }; + const encrypted = await Crypto.encrypt(derived, payload); + + expect(Crypto.isEncrypted(encrypted)).toBe(true); + expect(typeof encrypted.ct).toBe('string'); + expect(typeof encrypted.iv).toBe('string'); + + const decrypted = await Crypto.decrypt(derived, encrypted); + expect(decrypted).toStrictEqual(payload); + }); + + it('produces different ciphertext for each encryption (random IV)', async () => { + const derived = await Crypto.PBKDF2('pw', Crypto.randomSalt()); + const one = await Crypto.encrypt(derived, 'same'); + const two = await Crypto.encrypt(derived, 'same'); + expect(one.ct).not.toBe(two.ct); + expect(one.iv).not.toBe(two.iv); + }); + + it('fails to decrypt with the wrong key', async () => { + const good = await Crypto.PBKDF2('pw', Crypto.randomSalt()); + const bad = await Crypto.PBKDF2('other', Crypto.randomSalt()); + const encrypted = await Crypto.encrypt(good, { secret: true }); + await expect(Crypto.decrypt(bad, encrypted)).rejects.toBeDefined(); + }); +}); diff --git a/spec/integration/globalSetup.ts b/spec/integration/globalSetup.ts new file mode 100644 index 0000000..e8a42ff --- /dev/null +++ b/spec/integration/globalSetup.ts @@ -0,0 +1,50 @@ +/* + * Jest globalSetup: boot an in-memory MongoDB + a real Parse Server ONCE for the whole + * integration run. This runs in Jest's main Node process (not the per-file VM sandbox), + * so parse-server's dynamic import()s work here — starting it inside a test file's VM + * fails with "dynamic import callback was invoked without --experimental-vm-modules". + * + * The server URL / credentials are handed to the test workers via env vars; the server + * handles are stashed on globalThis for globalTeardown to stop. + */ +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { ParseServer } from 'parse-server'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const express = require('express'); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const http = require('http'); + +const APP_ID = 'goplan-utils-test'; +const MASTER_KEY = 'test-master-key'; + +export default async function globalSetup(): Promise { + const mongod = await MongoMemoryServer.create(); + const databaseURI = mongod.getUri(); + + const app = express(); + const httpServer = http.createServer(app); + await new Promise(resolve => httpServer.listen(0, resolve)); + const port = httpServer.address().port; + const serverURL = `http://127.0.0.1:${port}/parse`; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ParseServerCtor = ParseServer as any; + const parseServer = new ParseServerCtor({ + databaseURI, + appId: APP_ID, + masterKey: MASTER_KEY, + serverURL, + allowClientClassCreation: true, + silent: true, + }); + await parseServer.start(); + app.use('/parse', parseServer.app); + + process.env.PARSE_TEST_SERVER_URL = serverURL; + process.env.PARSE_TEST_APP_ID = APP_ID; + process.env.PARSE_TEST_MASTER_KEY = MASTER_KEY; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).__PARSE_TEST__ = { mongod, httpServer }; +} diff --git a/spec/integration/globalTeardown.ts b/spec/integration/globalTeardown.ts new file mode 100644 index 0000000..b6b4c98 --- /dev/null +++ b/spec/integration/globalTeardown.ts @@ -0,0 +1,16 @@ +import type { Server } from 'http'; +import type { MongoMemoryServer } from 'mongodb-memory-server'; + +export default async function globalTeardown(): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handles = (globalThis as any).__PARSE_TEST__ as + | { mongod: MongoMemoryServer; httpServer: Server } + | undefined; + + if (!handles) { + return; + } + + await new Promise(resolve => handles.httpServer.close(() => resolve())); + await handles.mongod.stop(); +} diff --git a/spec/integration/jestSetup.ts b/spec/integration/jestSetup.ts new file mode 100644 index 0000000..9b2824a --- /dev/null +++ b/spec/integration/jestSetup.ts @@ -0,0 +1,23 @@ +// Integration per-file setup (runs in the test worker): +// - wire the Node build of Parse in as the global `Parse` the library expects; +// - expose Node's WebCrypto + perf_hooks under `window` so SecureObject/CryptoUtils run; +// - point the Parse client at the server booted in globalSetup. +import Parse from 'parse/node'; +import { performance } from 'perf_hooks'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(global as any).Parse = Parse; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(global as any).window = { + crypto: globalThis.crypto, + performance, +}; + +Parse.initialize( + process.env.PARSE_TEST_APP_ID as string, + undefined, + process.env.PARSE_TEST_MASTER_KEY as string +); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(Parse as any).serverURL = process.env.PARSE_TEST_SERVER_URL; diff --git a/spec/integration/testUtils.ts b/spec/integration/testUtils.ts new file mode 100644 index 0000000..89c4f31 --- /dev/null +++ b/spec/integration/testUtils.ts @@ -0,0 +1,2 @@ +// Shared option object for master-key operations in integration tests. +export const MASTER: { useMasterKey: true } = { useMasterKey: true };