Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ node_modules/
/.idea/workspace.xml
/dist
/yarn.lock

# Parse Server test logs
/logs
32 changes: 32 additions & 0 deletions jest.integration.config.ts
Original file line number Diff line number Diff line change
@@ -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: ['<rootDir>'],
modulePaths: ['<rootDir>/src'],
moduleNameMapper: {
...pathsToModuleNameMapper(tsConfigFile.compilerOptions.paths, { prefix: __dirname }),
},
modulePathIgnorePatterns: ['<rootDir>/dist/'],
globalSetup: '<rootDir>/spec/integration/globalSetup.ts',
globalTeardown: '<rootDir>/spec/integration/globalTeardown.ts',
setupFiles: ['<rootDir>/spec/integration/jestSetup.ts'],
testMatch: ['<rootDir>/spec/integration/**/*.itest.ts'],
transform: {
'^.+\\.(ts|tsx)$': 'ts-jest',
},
// A fresh MongoMemoryServer + Parse Server is started per suite; give it room.
testTimeout: 60000,
};
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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",
Expand Down
164 changes: 164 additions & 0 deletions spec/integration/Query.itest.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>): 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<Widget>, 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<Widget>,
true
);
expect(created.id).toBeDefined();
expect(created.get('color')).toBe('red');

const second = await Query.create(Widget).findOrCreate(
{ name: 'unique' } as Partial<Widget>,
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<Widget>;

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);
});
});
});
95 changes: 95 additions & 0 deletions spec/integration/SecureObject.itest.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
50 changes: 50 additions & 0 deletions spec/integration/globalSetup.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const mongod = await MongoMemoryServer.create();
const databaseURI = mongod.getUri();

const app = express();
const httpServer = http.createServer(app);
await new Promise<void>(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 };
}
16 changes: 16 additions & 0 deletions spec/integration/globalTeardown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Server } from 'http';
import type { MongoMemoryServer } from 'mongodb-memory-server';

export default async function globalTeardown(): Promise<void> {
// 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<void>(resolve => handles.httpServer.close(() => resolve()));
await handles.mongod.stop();
}
Loading
Loading