@actual-app/sync-server 26.2.0 → 26.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ import { getAccountDb } from '../src/account-db.js';
2
+ export const up = async function () {
3
+ const accountDb = getAccountDb();
4
+ const admin = accountDb.first('SELECT id FROM users WHERE role = ? ORDER BY id LIMIT 1', ['ADMIN']);
5
+ if (admin) {
6
+ accountDb.mutate('UPDATE files SET owner = ? WHERE owner IS NULL', [
7
+ admin.id,
8
+ ]);
9
+ }
10
+ };
11
+ export const down = async function () {
12
+ // Cannot reliably restore NULL owner for backfilled rows; no-op.
13
+ };
@@ -1,12 +1,13 @@
1
1
  import express from 'express';
2
2
  import { handleError } from '../app-gocardless/util/handle-error.js';
3
3
  import { SecretName, secretsService } from '../services/secrets-service.js';
4
- import { requestLoggerMiddleware } from '../util/middlewares.js';
4
+ import { requestLoggerMiddleware, validateSessionMiddleware, } from '../util/middlewares.js';
5
5
  import { pluggyaiService } from './pluggyai-service.js';
6
6
  const app = express();
7
7
  export { app as handlers };
8
- app.use(express.json());
9
8
  app.use(requestLoggerMiddleware);
9
+ app.use(express.json());
10
+ app.use(validateSessionMiddleware);
10
11
  app.post('/status', handleError(async (req, res) => {
11
12
  const clientId = secretsService.get(SecretName.pluggyai_clientId);
12
13
  const configured = clientId != null;
@@ -2,11 +2,12 @@ import https from 'https';
2
2
  import express from 'express';
3
3
  import { handleError } from '../app-gocardless/util/handle-error.js';
4
4
  import { SecretName, secretsService } from '../services/secrets-service.js';
5
- import { requestLoggerMiddleware } from '../util/middlewares.js';
5
+ import { requestLoggerMiddleware, validateSessionMiddleware, } from '../util/middlewares.js';
6
6
  const app = express();
7
7
  export { app as handlers };
8
- app.use(express.json());
9
8
  app.use(requestLoggerMiddleware);
9
+ app.use(express.json());
10
+ app.use(validateSessionMiddleware);
10
11
  app.post('/status', handleError(async (req, res) => {
11
12
  const token = secretsService.get(SecretName.simplefin_token);
12
13
  const configured = token != null && token !== 'Forbidden';
@@ -10,6 +10,7 @@ import { FileNotFound } from './app-sync/errors.js';
10
10
  import { File, FilesService, FileUpdate, } from './app-sync/services/files-service.js';
11
11
  import { validateSyncedFile, validateUploadedFile, } from './app-sync/validation.js';
12
12
  import { config } from './load-config.js';
13
+ import * as UserService from './services/user-service.js';
13
14
  import * as simpleSync from './sync-simple.js';
14
15
  import { errorMiddleware, requestLoggerMiddleware, validateSessionMiddleware, } from './util/middlewares.js';
15
16
  import { getPathForGroupFile, getPathForUserFile } from './util/paths.js';
@@ -46,6 +47,17 @@ const verifyFileExists = (fileId, filesService, res, errorObject) => {
46
47
  throw e;
47
48
  }
48
49
  };
50
+ function requireFileAccess(file, userId) {
51
+ const isOwner = file.owner === userId;
52
+ const isServerAdmin = isAdmin(userId);
53
+ if (isOwner || isServerAdmin) {
54
+ return null;
55
+ }
56
+ if (UserService.countUserAccess(file.id, userId) > 0) {
57
+ return null;
58
+ }
59
+ return 'file-access-not-allowed';
60
+ }
49
61
  app.post('/sync', async (req, res) => {
50
62
  let requestPb;
51
63
  try {
@@ -75,6 +87,12 @@ app.post('/sync', async (req, res) => {
75
87
  if (!currentFile) {
76
88
  return;
77
89
  }
90
+ const fileAccessError = requireFileAccess(currentFile, res.locals.user_id);
91
+ if (fileAccessError) {
92
+ res.status(403);
93
+ res.send(fileAccessError);
94
+ return;
95
+ }
78
96
  const errorMessage = validateSyncedFile(groupId, keyId, currentFile);
79
97
  if (errorMessage) {
80
98
  res.status(400);
@@ -99,6 +117,12 @@ app.post('/user-get-key', (req, res) => {
99
117
  if (!file) {
100
118
  return;
101
119
  }
120
+ const fileAccessError = requireFileAccess(file, res.locals.user_id);
121
+ if (fileAccessError) {
122
+ res.status(403);
123
+ res.send(fileAccessError);
124
+ return;
125
+ }
102
126
  res.send({
103
127
  status: 'ok',
104
128
  data: {
@@ -111,7 +135,14 @@ app.post('/user-get-key', (req, res) => {
111
135
  app.post('/user-create-key', (req, res) => {
112
136
  const { fileId, keyId, keySalt, testContent } = req.body || {};
113
137
  const filesService = new FilesService(getAccountDb());
114
- if (!verifyFileExists(fileId, filesService, res, 'file not found')) {
138
+ const file = verifyFileExists(fileId, filesService, res, 'file-not-found');
139
+ if (!file) {
140
+ return;
141
+ }
142
+ const fileAccessError = requireFileAccess(file, res.locals.user_id);
143
+ if (fileAccessError) {
144
+ res.status(403);
145
+ res.send(fileAccessError);
115
146
  return;
116
147
  }
117
148
  filesService.update(fileId, new FileUpdate({
@@ -128,6 +159,12 @@ app.post('/reset-user-file', async (req, res) => {
128
159
  if (!file) {
129
160
  return;
130
161
  }
162
+ const fileAccessError = requireFileAccess(file, res.locals.user_id);
163
+ if (fileAccessError) {
164
+ res.status(403);
165
+ res.send(fileAccessError);
166
+ return;
167
+ }
131
168
  const groupId = file.groupId;
132
169
  filesService.update(fileId, new FileUpdate({ groupId: null }));
133
170
  if (groupId) {
@@ -172,6 +209,14 @@ app.post('/upload-user-file', async (req, res) => {
172
209
  throw e;
173
210
  }
174
211
  }
212
+ const fileAccessError = currentFile
213
+ ? requireFileAccess(currentFile, res.locals.user_id)
214
+ : null;
215
+ if (fileAccessError) {
216
+ res.status(403);
217
+ res.send(fileAccessError);
218
+ return;
219
+ }
175
220
  const errorMessage = validateUploadedFile(groupId, keyId, currentFile);
176
221
  if (errorMessage) {
177
222
  res.status(400).send(errorMessage);
@@ -224,7 +269,14 @@ app.get('/download-user-file', async (req, res) => {
224
269
  return;
225
270
  }
226
271
  const filesService = new FilesService(getAccountDb());
227
- if (!verifyFileExists(fileId, filesService, res, 'User or file not found')) {
272
+ const file = verifyFileExists(fileId, filesService, res, 'User or file not found');
273
+ if (!file) {
274
+ return;
275
+ }
276
+ const fileAccessError = requireFileAccess(file, res.locals.user_id);
277
+ if (fileAccessError) {
278
+ res.status(403);
279
+ res.send(fileAccessError);
228
280
  return;
229
281
  }
230
282
  const path = getPathForUserFile(fileId);
@@ -239,7 +291,14 @@ app.get('/download-user-file', async (req, res) => {
239
291
  app.post('/update-user-filename', (req, res) => {
240
292
  const { fileId, name } = req.body || {};
241
293
  const filesService = new FilesService(getAccountDb());
242
- if (!verifyFileExists(fileId, filesService, res, 'file not found')) {
294
+ const file = verifyFileExists(fileId, filesService, res, 'file-not-found');
295
+ if (!file) {
296
+ return;
297
+ }
298
+ const fileAccessError = requireFileAccess(file, res.locals.user_id);
299
+ if (fileAccessError) {
300
+ res.status(403);
301
+ res.send(fileAccessError);
243
302
  return;
244
303
  }
245
304
  filesService.update(fileId, new FileUpdate({ name }));
@@ -282,6 +341,12 @@ app.get('/get-user-file-info', (req, res) => {
282
341
  if (!file) {
283
342
  return;
284
343
  }
344
+ const fileAccessError = requireFileAccess(file, res.locals.user_id);
345
+ if (fileAccessError) {
346
+ res.status(403);
347
+ res.send(fileAccessError);
348
+ return;
349
+ }
285
350
  res.send({
286
351
  status: 'ok',
287
352
  data: {
@@ -312,16 +377,10 @@ app.post('/delete-user-file', (req, res) => {
312
377
  if (!file) {
313
378
  return;
314
379
  }
315
- // Check if user has permission to delete the file
316
- const { user_id: userId } = res.locals;
317
- const isOwner = file.owner === userId;
318
- const isServerAdmin = isAdmin(userId);
319
- if (!isOwner && !isServerAdmin) {
320
- res.status(403).send({
321
- status: 'error',
322
- reason: 'forbidden',
323
- details: 'file-delete-not-allowed',
324
- });
380
+ const fileAccessError = requireFileAccess(file, res.locals.user_id);
381
+ if (fileAccessError) {
382
+ res.status(403);
383
+ res.send(fileAccessError);
325
384
  return;
326
385
  }
327
386
  filesService.update(fileId, new FileUpdate({ deleted: true }));
@@ -7,6 +7,7 @@ import { getAccountDb } from './account-db.js';
7
7
  import { handlers as app } from './app-sync.js';
8
8
  import { getPathForUserFile } from './util/paths.js';
9
9
  const ADMIN_ROLE = 'ADMIN';
10
+ const OTHER_USER_ID = 'otherUser';
10
11
  const createUser = (userId, userName, role, owner = 0, enabled = 1) => {
11
12
  getAccountDb().mutate('INSERT INTO users (id, user_name, display_name, enabled, owner, role) VALUES (?, ?, ?, ?, ?, ?)', [userId, userName, `${userName} display`, enabled, owner, role]);
12
13
  };
@@ -51,6 +52,36 @@ describe('/user-get-key', () => {
51
52
  expect(res.statusCode).toEqual(400);
52
53
  expect(res.text).toBe('file-not-found');
53
54
  });
55
+ it('returns 403 when non-owner gets encryption key', async () => {
56
+ const fileId = crypto.randomBytes(16).toString('hex');
57
+ getAccountDb().mutate('INSERT INTO files (id, encrypt_salt, encrypt_keyid, encrypt_test, owner) VALUES (?, ?, ?, ?, ?)', [fileId, 'salt', 'key-id', 'test', OTHER_USER_ID]);
58
+ const res = await request(app)
59
+ .post('/user-get-key')
60
+ .set('x-actual-token', 'valid-token-user')
61
+ .send({ fileId });
62
+ expect(res.statusCode).toEqual(403);
63
+ expect(res.text).toEqual('file-access-not-allowed');
64
+ });
65
+ it("allows an admin to get encryption key for another user's file", async () => {
66
+ const fileId = crypto.randomBytes(16).toString('hex');
67
+ const encrypt_salt = 'salt';
68
+ const encrypt_keyid = 'key-id';
69
+ const encrypt_test = 'test';
70
+ getAccountDb().mutate('INSERT INTO files (id, encrypt_salt, encrypt_keyid, encrypt_test, owner) VALUES (?, ?, ?, ?, ?)', [fileId, encrypt_salt, encrypt_keyid, encrypt_test, OTHER_USER_ID]);
71
+ const res = await request(app)
72
+ .post('/user-get-key')
73
+ .set('x-actual-token', 'valid-token-admin')
74
+ .send({ fileId });
75
+ expect(res.statusCode).toEqual(200);
76
+ expect(res.body).toEqual({
77
+ status: 'ok',
78
+ data: {
79
+ id: encrypt_keyid,
80
+ salt: encrypt_salt,
81
+ test: encrypt_test,
82
+ },
83
+ });
84
+ });
54
85
  });
55
86
  describe('/user-create-key', () => {
56
87
  it('returns 401 if the user is not authenticated', async () => {
@@ -68,7 +99,53 @@ describe('/user-create-key', () => {
68
99
  .set('x-actual-token', 'valid-token')
69
100
  .send({ fileId: 'non-existent-file-id' });
70
101
  expect(res.statusCode).toEqual(400);
71
- expect(res.text).toBe('file not found');
102
+ expect(res.text).toBe('file-not-found');
103
+ });
104
+ it('returns 403 when non-owner creates encryption key', async () => {
105
+ const fileId = crypto.randomBytes(16).toString('hex');
106
+ getAccountDb().mutate('INSERT INTO files (id, encrypt_salt, encrypt_keyid, encrypt_test, owner) VALUES (?, ?, ?, ?, ?)', [fileId, 'old-salt', 'old-key', 'old-test', OTHER_USER_ID]);
107
+ const res = await request(app)
108
+ .post('/user-create-key')
109
+ .set('x-actual-token', 'valid-token-user')
110
+ .send({
111
+ fileId,
112
+ keyId: 'new-key',
113
+ keySalt: 'new-salt',
114
+ testContent: 'new-test',
115
+ });
116
+ expect(res.statusCode).toEqual(403);
117
+ expect(res.text).toEqual('file-access-not-allowed');
118
+ });
119
+ it("allows an admin to create encryption key for another user's file", async () => {
120
+ const fileId = crypto.randomBytes(16).toString('hex');
121
+ const old_encrypt_salt = 'old-salt';
122
+ const old_encrypt_keyid = 'old-key';
123
+ const old_encrypt_test = 'old-test';
124
+ const encrypt_salt = 'new-salt';
125
+ const encrypt_keyid = 'new-key-id';
126
+ const encrypt_test = 'new-encrypt-test';
127
+ getAccountDb().mutate('INSERT INTO files (id, encrypt_salt, encrypt_keyid, encrypt_test, owner) VALUES (?, ?, ?, ?, ?)', [
128
+ fileId,
129
+ old_encrypt_salt,
130
+ old_encrypt_keyid,
131
+ old_encrypt_test,
132
+ OTHER_USER_ID,
133
+ ]);
134
+ const res = await request(app)
135
+ .post('/user-create-key')
136
+ .set('x-actual-token', 'valid-token-admin')
137
+ .send({
138
+ fileId,
139
+ keyId: encrypt_keyid,
140
+ keySalt: encrypt_salt,
141
+ testContent: encrypt_test,
142
+ });
143
+ expect(res.statusCode).toEqual(200);
144
+ expect(res.body).toEqual({ status: 'ok' });
145
+ const rows = getAccountDb().all('SELECT encrypt_salt, encrypt_keyid, encrypt_test FROM files WHERE id = ?', [fileId]);
146
+ expect(rows[0].encrypt_salt).toEqual(encrypt_salt);
147
+ expect(rows[0].encrypt_keyid).toEqual(encrypt_keyid);
148
+ expect(rows[0].encrypt_test).toEqual(encrypt_test);
72
149
  });
73
150
  it('creates a new encryption key for the file', async () => {
74
151
  const fileId = crypto.randomBytes(16).toString('hex');
@@ -132,6 +209,31 @@ describe('/reset-user-file', () => {
132
209
  expect(res.statusCode).toEqual(400);
133
210
  expect(res.text).toBe('User or file not found');
134
211
  });
212
+ it('returns 403 when non-owner resets another user file', async () => {
213
+ const fileId = crypto.randomBytes(16).toString('hex');
214
+ getAccountDb().mutate('INSERT OR IGNORE INTO files (id, deleted, owner) VALUES (?, FALSE, ?)', [fileId, OTHER_USER_ID]);
215
+ const res = await request(app)
216
+ .post('/reset-user-file')
217
+ .set('x-actual-token', 'valid-token-user')
218
+ .send({ fileId });
219
+ expect(res.statusCode).toEqual(403);
220
+ expect(res.text).toEqual('file-access-not-allowed');
221
+ });
222
+ it("allows an admin to reset another user's file", async () => {
223
+ const fileId = crypto.randomBytes(16).toString('hex');
224
+ const groupId = 'admin-reset-group-id';
225
+ getAccountDb().mutate('INSERT INTO files (id, group_id, deleted, owner) VALUES (?, ?, FALSE, ?)', [fileId, groupId, OTHER_USER_ID]);
226
+ const res = await request(app)
227
+ .post('/reset-user-file')
228
+ .set('x-actual-token', 'valid-token-admin')
229
+ .send({ fileId });
230
+ expect(res.statusCode).toEqual(200);
231
+ expect(res.body).toEqual({ status: 'ok' });
232
+ const rows = getAccountDb().all('SELECT group_id FROM files WHERE id = ?', [
233
+ fileId,
234
+ ]);
235
+ expect(rows[0].group_id).toBeNull();
236
+ });
135
237
  });
136
238
  describe('/upload-user-file', () => {
137
239
  it('returns 401 if the user is not authenticated', async () => {
@@ -170,6 +272,12 @@ describe('/upload-user-file', () => {
170
272
  const fileContentBuffer = Buffer.from(fileContent);
171
273
  const syncVersion = 2;
172
274
  const encryptMeta = JSON.stringify({ keyId: 'key-id' });
275
+ onTestFinished(() => {
276
+ try {
277
+ fs.unlinkSync(getPathForUserFile(fileId));
278
+ }
279
+ catch { }
280
+ });
173
281
  // Verify that the file does not exist before upload
174
282
  const rowsBefore = getAccountDb().all('SELECT * FROM files WHERE id = ?', [
175
283
  fileId,
@@ -201,8 +309,6 @@ describe('/upload-user-file', () => {
201
309
  const filePath = getPathForUserFile(fileId);
202
310
  const writtenContent = await fs.promises.readFile(filePath, 'utf8');
203
311
  expect(writtenContent).toEqual(fileContent);
204
- // Clean up the file
205
- await fs.promises.unlink(filePath);
206
312
  });
207
313
  it('uploads and updates an existing file successfully', async () => {
208
314
  const fileId = crypto.randomBytes(16).toString('hex');
@@ -219,6 +325,12 @@ describe('/upload-user-file', () => {
219
325
  keyId: oldKeyId,
220
326
  sentinelValue: 1,
221
327
  }); //keep the same key, but change other things
328
+ onTestFinished(() => {
329
+ try {
330
+ fs.unlinkSync(getPathForUserFile(fileId));
331
+ }
332
+ catch { }
333
+ });
222
334
  // Create the old file version
223
335
  getAccountDb().mutate('INSERT INTO files (id, group_id, sync_version, name, encrypt_meta, encrypt_keyid) VALUES (?, ?, ?, ?, ?, ?)', [
224
336
  fileId,
@@ -258,8 +370,6 @@ describe('/upload-user-file', () => {
258
370
  const filePath = getPathForUserFile(fileId);
259
371
  const writtenContent = await fs.promises.readFile(filePath, 'utf8');
260
372
  expect(writtenContent).toEqual(newFileContent);
261
- // Clean up the file
262
- await fs.promises.unlink(filePath);
263
373
  });
264
374
  it('returns 400 if the file is part of an old group', async () => {
265
375
  const fileId = crypto.randomBytes(16).toString('hex');
@@ -299,6 +409,81 @@ describe('/upload-user-file', () => {
299
409
  expect(res.statusCode).toEqual(400);
300
410
  expect(res.text).toEqual('file-has-new-key');
301
411
  });
412
+ it('returns 403 when non-owner overwrites another user file', async () => {
413
+ const fileId = crypto.randomBytes(16).toString('hex');
414
+ const groupId = 'group-id';
415
+ const keyId = 'key-id';
416
+ const syncVersion = 2;
417
+ fs.writeFileSync(getPathForUserFile(fileId), 'existing content');
418
+ getAccountDb().mutate('INSERT INTO files (id, group_id, sync_version, name, encrypt_meta, encrypt_keyid, deleted, owner) VALUES (?, ?, ?, ?, ?, ?, 0, ?)', [
419
+ fileId,
420
+ groupId,
421
+ syncVersion,
422
+ 'existing.txt',
423
+ JSON.stringify({ keyId }),
424
+ keyId,
425
+ OTHER_USER_ID,
426
+ ]);
427
+ onTestFinished(() => {
428
+ try {
429
+ fs.unlinkSync(getPathForUserFile(fileId));
430
+ }
431
+ catch { }
432
+ });
433
+ const res = await request(app)
434
+ .post('/upload-user-file')
435
+ .set('Content-Type', 'application/encrypted-file')
436
+ .set('x-actual-token', 'valid-token-user')
437
+ .set('x-actual-file-id', fileId)
438
+ .set('x-actual-name', 'hacked.txt')
439
+ .set('x-actual-group-id', groupId)
440
+ .set('x-actual-format', syncVersion.toString())
441
+ .set('x-actual-encrypt-meta', JSON.stringify({ keyId }))
442
+ .send(Buffer.from('overwrite content'));
443
+ expect(res.statusCode).toEqual(403);
444
+ expect(res.text).toEqual('file-access-not-allowed');
445
+ });
446
+ it("allows an admin to overwrite another user's file", async () => {
447
+ const fileId = crypto.randomBytes(16).toString('hex');
448
+ const groupId = 'admin-upload-group-id';
449
+ const keyId = 'key-id';
450
+ const syncVersion = 2;
451
+ const existingContent = 'existing content';
452
+ const newContent = 'admin overwrite content';
453
+ fs.writeFileSync(getPathForUserFile(fileId), existingContent);
454
+ getAccountDb().mutate('INSERT INTO files (id, group_id, sync_version, name, encrypt_meta, encrypt_keyid, deleted, owner) VALUES (?, ?, ?, ?, ?, ?, 0, ?)', [
455
+ fileId,
456
+ groupId,
457
+ syncVersion,
458
+ 'existing.txt',
459
+ JSON.stringify({ keyId }),
460
+ keyId,
461
+ OTHER_USER_ID,
462
+ ]);
463
+ onTestFinished(() => {
464
+ try {
465
+ fs.unlinkSync(getPathForUserFile(fileId));
466
+ }
467
+ catch { }
468
+ });
469
+ const res = await request(app)
470
+ .post('/upload-user-file')
471
+ .set('Content-Type', 'application/encrypted-file')
472
+ .set('x-actual-token', 'valid-token-admin')
473
+ .set('x-actual-file-id', fileId)
474
+ .set('x-actual-name', 'admin-renamed.txt')
475
+ .set('x-actual-group-id', groupId)
476
+ .set('x-actual-format', syncVersion.toString())
477
+ .set('x-actual-encrypt-meta', JSON.stringify({ keyId }))
478
+ .send(Buffer.from(newContent));
479
+ expect(res.statusCode).toEqual(200);
480
+ expect(res.body).toEqual({ status: 'ok', groupId });
481
+ expect(fs.readFileSync(getPathForUserFile(fileId), 'utf8')).toEqual(newContent);
482
+ const rows = getAccountDb().all('SELECT name FROM files WHERE id = ?', [
483
+ fileId,
484
+ ]);
485
+ expect(rows[0].name).toEqual('admin-renamed.txt');
486
+ });
302
487
  });
303
488
  describe('/download-user-file', () => {
304
489
  describe('default version', () => {
@@ -354,6 +539,71 @@ describe('/download-user-file', () => {
354
539
  expect(res.body).toBeInstanceOf(Buffer);
355
540
  expect(res.body.toString('utf8')).toEqual(fileContent);
356
541
  });
542
+ describe('access control', () => {
543
+ it('returns 403 when non-owner downloads another user file', async () => {
544
+ const fileId = crypto.randomBytes(16).toString('hex');
545
+ const fileContent = 'sensitive content';
546
+ fs.writeFileSync(getPathForUserFile(fileId), fileContent);
547
+ getAccountDb().mutate('INSERT INTO files (id, deleted, owner) VALUES (?, FALSE, ?)', [fileId, OTHER_USER_ID]);
548
+ onTestFinished(() => {
549
+ try {
550
+ fs.unlinkSync(getPathForUserFile(fileId));
551
+ }
552
+ catch { }
553
+ });
554
+ const res = await request(app)
555
+ .get('/download-user-file')
556
+ .set('x-actual-token', 'valid-token-user')
557
+ .set('x-actual-file-id', fileId);
558
+ expect(res.statusCode).toEqual(403);
559
+ expect(res.text).toEqual('file-access-not-allowed');
560
+ });
561
+ it("allows an admin to download another user's file", async () => {
562
+ const fileId = crypto.randomBytes(16).toString('hex');
563
+ const fileContent = 'admin-downloaded content';
564
+ fs.writeFileSync(getPathForUserFile(fileId), fileContent);
565
+ getAccountDb().mutate('INSERT INTO files (id, deleted, owner) VALUES (?, FALSE, ?)', [fileId, OTHER_USER_ID]);
566
+ onTestFinished(() => {
567
+ try {
568
+ fs.unlinkSync(getPathForUserFile(fileId));
569
+ }
570
+ catch { }
571
+ });
572
+ const res = await request(app)
573
+ .get('/download-user-file')
574
+ .set('x-actual-token', 'valid-token-admin')
575
+ .set('x-actual-file-id', fileId);
576
+ expect(res.statusCode).toEqual(200);
577
+ expect(res.body).toBeInstanceOf(Buffer);
578
+ expect(res.body.toString('utf8')).toEqual(fileContent);
579
+ });
580
+ it('allows non-owner with user_access to download via requireFileAccess (UserService.countUserAccess > 0)', async () => {
581
+ // File owned by another user; access granted only via user_access row, not owner/admin.
582
+ // This exercises the requireFileAccess branch that uses UserService.countUserAccess.
583
+ const fileId = crypto.randomBytes(16).toString('hex');
584
+ const fileContent = 'shared-user content';
585
+ fs.writeFileSync(getPathForUserFile(fileId), fileContent);
586
+ getAccountDb().mutate('INSERT INTO files (id, deleted, owner) VALUES (?, FALSE, ?)', [fileId, OTHER_USER_ID]);
587
+ getAccountDb().mutate('INSERT INTO user_access (file_id, user_id) VALUES (?, ?)', [fileId, 'genericUser']);
588
+ onTestFinished(() => {
589
+ try {
590
+ fs.unlinkSync(getPathForUserFile(fileId));
591
+ }
592
+ catch { }
593
+ });
594
+ const res = await request(app)
595
+ .get('/download-user-file')
596
+ .set('x-actual-token', 'valid-token-user')
597
+ .set('x-actual-file-id', fileId);
598
+ expect(res.statusCode).toEqual(200);
599
+ expect(res.headers).toEqual(expect.objectContaining({
600
+ 'content-disposition': `attachment;filename=${fileId}`,
601
+ 'content-type': 'application/octet-stream',
602
+ }));
603
+ expect(res.body).toBeInstanceOf(Buffer);
604
+ expect(res.body.toString('utf8')).toEqual(fileContent);
605
+ });
606
+ });
357
607
  });
358
608
  });
359
609
  describe('/update-user-filename', () => {
@@ -372,7 +622,7 @@ describe('/update-user-filename', () => {
372
622
  .set('x-actual-token', 'valid-token')
373
623
  .send({ fileId: 'non-existent-file-id', name: 'new-filename' });
374
624
  expect(res.statusCode).toEqual(400);
375
- expect(res.text).toBe('file not found');
625
+ expect(res.text).toBe('file-not-found');
376
626
  });
377
627
  it('successfully updates the filename', async () => {
378
628
  const fileId = crypto.randomBytes(16).toString('hex');
@@ -392,6 +642,32 @@ describe('/update-user-filename', () => {
392
642
  ]);
393
643
  expect(rows[0].name).toEqual(newName);
394
644
  });
645
+ it('returns 403 when non-owner renames another user file', async () => {
646
+ const fileId = crypto.randomBytes(16).toString('hex');
647
+ getAccountDb().mutate('INSERT INTO files (id, name, deleted, owner) VALUES (?, ?, FALSE, ?)', [fileId, 'original-name', OTHER_USER_ID]);
648
+ const res = await request(app)
649
+ .post('/update-user-filename')
650
+ .set('x-actual-token', 'valid-token-user')
651
+ .send({ fileId, name: 'stolen' });
652
+ expect(res.statusCode).toEqual(403);
653
+ expect(res.text).toEqual('file-access-not-allowed');
654
+ });
655
+ it("allows an admin to rename another user's file", async () => {
656
+ const fileId = crypto.randomBytes(16).toString('hex');
657
+ const originalName = 'original-name';
658
+ const newName = 'admin-renamed-file';
659
+ getAccountDb().mutate('INSERT INTO files (id, name, deleted, owner) VALUES (?, ?, FALSE, ?)', [fileId, originalName, OTHER_USER_ID]);
660
+ const res = await request(app)
661
+ .post('/update-user-filename')
662
+ .set('x-actual-token', 'valid-token-admin')
663
+ .send({ fileId, name: newName });
664
+ expect(res.statusCode).toEqual(200);
665
+ expect(res.body).toEqual({ status: 'ok' });
666
+ const rows = getAccountDb().all('SELECT name FROM files WHERE id = ?', [
667
+ fileId,
668
+ ]);
669
+ expect(rows[0].name).toEqual(newName);
670
+ });
395
671
  });
396
672
  describe('/list-user-files', () => {
397
673
  it('returns 401 if the user is not authenticated', async () => {
@@ -493,6 +769,39 @@ describe('/get-user-file-info', () => {
493
769
  details: 'token-not-found',
494
770
  });
495
771
  });
772
+ it('returns 403 when non-owner gets another user file info', async () => {
773
+ const fileId = crypto.randomBytes(16).toString('hex');
774
+ getAccountDb().mutate('INSERT INTO files (id, group_id, name, deleted, owner) VALUES (?, ?, ?, FALSE, ?)', [fileId, 'group-id', 'budget', OTHER_USER_ID]);
775
+ const res = await request(app)
776
+ .get('/get-user-file-info')
777
+ .set('x-actual-token', 'valid-token-user')
778
+ .set('x-actual-file-id', fileId);
779
+ expect(res.statusCode).toEqual(403);
780
+ expect(res.text).toEqual('file-access-not-allowed');
781
+ });
782
+ it("allows an admin to get another user's file info", async () => {
783
+ const fileId = crypto.randomBytes(16).toString('hex');
784
+ const groupId = 'admin-file-info-group';
785
+ const name = 'admin-info-file';
786
+ const encrypt_meta = JSON.stringify({ key: 'value' });
787
+ getAccountDb().mutate('INSERT INTO files (id, group_id, name, encrypt_meta, deleted, owner) VALUES (?, ?, ?, ?, 0, ?)', [fileId, groupId, name, encrypt_meta, OTHER_USER_ID]);
788
+ const res = await request(app)
789
+ .get('/get-user-file-info')
790
+ .set('x-actual-token', 'valid-token-admin')
791
+ .set('x-actual-file-id', fileId);
792
+ expect(res.statusCode).toEqual(200);
793
+ expect(res.body).toEqual({
794
+ status: 'ok',
795
+ data: {
796
+ deleted: 0,
797
+ fileId,
798
+ groupId,
799
+ name,
800
+ encryptMeta: { key: 'value' },
801
+ usersWithAccess: [],
802
+ },
803
+ });
804
+ });
496
805
  });
497
806
  describe('/delete-user-file', () => {
498
807
  it('returns 401 if the user is not authenticated', async () => {
@@ -552,11 +861,7 @@ describe('/delete-user-file', () => {
552
861
  .set('x-actual-token', 'valid-token-user')
553
862
  .send({ fileId });
554
863
  expect(res.statusCode).toEqual(403);
555
- expect(res.body).toEqual({
556
- status: 'error',
557
- reason: 'forbidden',
558
- details: 'file-delete-not-allowed',
559
- });
864
+ expect(res.text).toEqual('file-access-not-allowed');
560
865
  // Verify that the file is NOT deleted
561
866
  const rows = accountDb.all('SELECT deleted FROM files WHERE id = ?', [
562
867
  fileId,
@@ -691,9 +996,34 @@ describe('/sync', () => {
691
996
  expect(res.statusCode).toEqual(400);
692
997
  expect(res.text).toEqual('file-has-new-key');
693
998
  });
999
+ it('returns 403 when non-owner syncs another user file', async () => {
1000
+ const fileId = crypto.randomBytes(16).toString('hex');
1001
+ const groupId = 'group-id';
1002
+ const keyId = 'key-id';
1003
+ const syncVersion = 2;
1004
+ const encryptMeta = JSON.stringify({ keyId });
1005
+ addMockFile(fileId, groupId, keyId, encryptMeta, syncVersion, OTHER_USER_ID);
1006
+ const syncRequest = createMinimalSyncRequest(fileId, groupId, keyId);
1007
+ const res = await sendSyncRequest(syncRequest, 'valid-token-user');
1008
+ expect(res.statusCode).toEqual(403);
1009
+ expect(res.text).toEqual('file-access-not-allowed');
1010
+ });
1011
+ it("allows an admin to sync another user's file", async () => {
1012
+ const fileId = crypto.randomBytes(16).toString('hex');
1013
+ const groupId = 'group-id';
1014
+ const keyId = 'key-id';
1015
+ const syncVersion = 2;
1016
+ const encryptMeta = JSON.stringify({ keyId });
1017
+ addMockFile(fileId, groupId, keyId, encryptMeta, syncVersion, OTHER_USER_ID);
1018
+ const syncRequest = createMinimalSyncRequest(fileId, groupId, keyId);
1019
+ const res = await sendSyncRequest(syncRequest, 'valid-token-admin');
1020
+ expect(res.statusCode).toEqual(200);
1021
+ expect(res.headers['content-type']).toEqual('application/actual-sync');
1022
+ expect(res.headers['x-actual-sync-method']).toEqual('simple');
1023
+ });
694
1024
  });
695
- function addMockFile(fileId, groupId, keyId, encryptMeta, syncVersion) {
696
- getAccountDb().mutate('INSERT INTO files (id, group_id, encrypt_keyid, encrypt_meta, sync_version, owner) VALUES (?, ?, ?,?, ?, ?)', [fileId, groupId, keyId, encryptMeta, syncVersion, 'genericAdmin']);
1025
+ function addMockFile(fileId, groupId, keyId, encryptMeta, syncVersion, owner = 'genericAdmin') {
1026
+ getAccountDb().mutate('INSERT INTO files (id, group_id, encrypt_keyid, encrypt_meta, sync_version, owner) VALUES (?, ?, ?,?, ?, ?)', [fileId, groupId, keyId, encryptMeta, syncVersion, owner]);
697
1027
  }
698
1028
  function createMinimalSyncRequest(fileId, groupId, keyId) {
699
1029
  const syncRequest = new SyncProtoBuf.SyncRequest();
@@ -704,13 +1034,13 @@ function createMinimalSyncRequest(fileId, groupId, keyId) {
704
1034
  syncRequest.setMessagesList([]);
705
1035
  return syncRequest;
706
1036
  }
707
- async function sendSyncRequest(syncRequest) {
1037
+ async function sendSyncRequest(syncRequest, token = 'valid-token') {
708
1038
  const serializedRequest = syncRequest.serializeBinary();
709
1039
  // Convert Uint8Array to Buffer
710
1040
  const bufferRequest = Buffer.from(serializedRequest);
711
1041
  const res = await request(app)
712
1042
  .post('/sync')
713
- .set('x-actual-token', 'valid-token')
1043
+ .set('x-actual-token', token)
714
1044
  .set('Content-Type', 'application/actual-sync')
715
1045
  .send(bufferRequest);
716
1046
  return res;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@actual-app/sync-server",
3
- "version": "26.2.0",
3
+ "version": "26.2.1",
4
4
  "description": "actual syncing server",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@actual-app/crdt": "2.1.0",
32
- "@actual-app/web": "26.2.0",
32
+ "@actual-app/web": "26.2.1",
33
33
  "bcrypt": "^6.0.0",
34
34
  "better-sqlite3": "^12.5.0",
35
35
  "convict": "^6.2.4",