diff --git a/src/app-sync.js b/src/app-sync.js index 839f5e6c51..7d2b55714b 100644 --- a/src/app-sync.js +++ b/src/app-sync.js @@ -12,8 +12,8 @@ import { SyncProtoBuf } from '@actual-app/crdt'; const app = express(); app.use(errorMiddleware); -app.use(express.json()); app.use(express.raw({ type: 'application/actual-sync' })); +app.use(express.json()); app.use(validateUserMiddleware); export { app as handlers }; @@ -31,6 +31,7 @@ app.post('/sync', async (req, res) => { try { requestPb = SyncProtoBuf.SyncRequest.deserializeBinary(req.body); } catch (e) { + console.log('Error parsing sync request', e); res.status(500); res.send({ status: 'error', reason: 'internal-error' }); return; @@ -44,7 +45,11 @@ app.post('/sync', async (req, res) => { let messages = requestPb.getMessagesList(); if (!since) { - throw new Error('`since` is required'); + return res.status(422).send({ + details: 'since-required', + reason: 'unprocessable-entity', + status: 'error', + }); } let currentFiles = accountDb.all( diff --git a/src/app-sync.test.js b/src/app-sync.test.js index c99acf85f2..4e145c1f42 100644 --- a/src/app-sync.test.js +++ b/src/app-sync.test.js @@ -3,6 +3,8 @@ import request from 'supertest'; import { handlers as app } from './app-sync.js'; import getAccountDb from './account-db.js'; import { getPathForUserFile } from './util/paths.js'; +import { SyncProtoBuf } from '@actual-app/crdt'; +import crypto from 'node:crypto'; describe('/download-user-file', () => { describe('default version', () => { @@ -137,3 +139,169 @@ describe('/delete-user-file', () => { expect(rows[0].deleted).toBe(1); }); }); + +describe('/sync', () => { + it('returns 401 if the user is not authenticated', async () => { + const res = await request(app).post('/sync'); + + expect(res.statusCode).toEqual(401); + expect(res.body).toEqual({ + details: 'token-not-found', + reason: 'unauthorized', + status: 'error', + }); + }); + + it('returns 200 and syncs successfully with correct file attributes', async () => { + const fileId = crypto.randomBytes(16).toString('hex'); + const groupId = 'group-id'; + const keyId = 'key-id'; + const syncVersion = 2; + const encryptMeta = JSON.stringify({ keyId }); + + addMockFile(fileId, groupId, keyId, encryptMeta, syncVersion); + + const syncRequest = createMinimalSyncRequest(fileId, groupId, keyId); + + const res = await sendSyncRequest(syncRequest); + + expect(res.statusCode).toEqual(200); + expect(res.headers['content-type']).toEqual('application/actual-sync'); + expect(res.headers['x-actual-sync-method']).toEqual('simple'); + }); + + it('returns 500 if the request body is invalid', async () => { + const res = await request(app) + .post('/sync') + .set('x-actual-token', 'valid-token') + // Content-Type is set correctly, but the body cannot be deserialized + .set('Content-Type', 'application/actual-sync') + .send('invalid-body'); + + expect(res.statusCode).toEqual(500); + expect(res.body).toEqual({ + status: 'error', + reason: 'internal-error', + }); + }); + + it('returns 422 if since is not provided', async () => { + const syncRequest = createMinimalSyncRequest( + 'file-id', + 'group-id', + 'key-id', + ); + syncRequest.setSince(undefined); + + const res = await sendSyncRequest(syncRequest); + + expect(res.statusCode).toEqual(422); + expect(res.body).toEqual({ + status: 'error', + reason: 'unprocessable-entity', + details: 'since-required', + }); + }); + + it('returns 400 if the file does not exist in the database', async () => { + const syncRequest = createMinimalSyncRequest( + 'non-existant-file-id', + 'group-id', + 'key-id', + ); + + // We do not insert the file into the database, so it does not exist + + const res = await sendSyncRequest(syncRequest); + + expect(res.statusCode).toEqual(400); + expect(res.text).toEqual('file-not-found'); + }); + + it('returns 400 if the file sync version is old', async () => { + const fileId = crypto.randomBytes(16).toString('hex'); + const groupId = 'group-id'; + const keyId = 'key-id'; + const oldSyncVersion = 1; // Assuming SYNC_FORMAT_VERSION is 2 + + // Add a mock file with an old sync version + addMockFile( + fileId, + groupId, + keyId, + JSON.stringify({ keyId }), + oldSyncVersion, + ); + + const syncRequest = createMinimalSyncRequest(fileId, groupId, keyId); + + const res = await sendSyncRequest(syncRequest); + + expect(res.statusCode).toEqual(400); + expect(res.text).toEqual('file-old-version'); + }); + + it('returns 400 if the file needs to be uploaded (no group_id)', async () => { + const fileId = crypto.randomBytes(16).toString('hex'); + const groupId = null; // No group ID + const keyId = 'key-id'; + const syncVersion = 2; + + addMockFile(fileId, groupId, keyId, JSON.stringify({ keyId }), syncVersion); + + const syncRequest = createMinimalSyncRequest(fileId, groupId, keyId); + + const res = await sendSyncRequest(syncRequest); + + expect(res.statusCode).toEqual(400); + expect(res.text).toEqual('file-needs-upload'); + }); + + it('returns 400 if the file has a new encryption key', async () => { + const fileId = crypto.randomBytes(16).toString('hex'); + const groupId = 'group-id'; + const keyId = 'old-key-id'; + const newKeyId = 'new-key-id'; + const syncVersion = 2; + + // Add a mock file with the old key + addMockFile(fileId, groupId, keyId, JSON.stringify({ keyId }), syncVersion); + + // Create a sync request with the new key + const syncRequest = createMinimalSyncRequest(fileId, groupId, newKeyId); + const res = await sendSyncRequest(syncRequest); + + expect(res.statusCode).toEqual(400); + expect(res.text).toEqual('file-has-new-key'); + }); +}); + +function addMockFile(fileId, groupId, keyId, encryptMeta, syncVersion) { + getAccountDb().mutate( + 'INSERT INTO files (id, group_id, encrypt_keyid, encrypt_meta, sync_version) VALUES (?, ?, ?,?, ?)', + [fileId, groupId, keyId, encryptMeta, syncVersion], + ); +} + +function createMinimalSyncRequest(fileId, groupId, keyId) { + const syncRequest = new SyncProtoBuf.SyncRequest(); + syncRequest.setFileid(fileId); + syncRequest.setGroupid(groupId); + syncRequest.setKeyid(keyId); + syncRequest.setSince('2024-01-01T00:00:00.000Z'); + syncRequest.setMessagesList([]); + return syncRequest; +} + +async function sendSyncRequest(syncRequest) { + const serializedRequest = syncRequest.serializeBinary(); + // Convert Uint8Array to Buffer + const bufferRequest = Buffer.from(serializedRequest); + + const res = await request(app) + .post('/sync') + .set('x-actual-token', 'valid-token') + .set('Content-Type', 'application/actual-sync') + .send(bufferRequest); + return res; +} diff --git a/upcoming-release-notes/423.md b/upcoming-release-notes/423.md new file mode 100644 index 0000000000..530407993b --- /dev/null +++ b/upcoming-release-notes/423.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [tcrasset] +--- + +Add integration tests for the /sync endpoint \ No newline at end of file