UNPKG

@sync-in/server

Version:

The secure, open-source platform for file storage, sharing, collaboration, and sync

602 lines (601 loc) 25.1 kB
/* * Copyright (C) 2012-2025 Johan Legrand <johan.legrand@sync-in.com> * This file is part of Sync-in | The open source file sync and share solution * See the LICENSE file for licensing details */ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const _axios = require("@nestjs/axios"); const _common = require("@nestjs/common"); const _testing = require("@nestjs/testing"); const _nodecrypto = /*#__PURE__*/ _interop_require_default(require("node:crypto")); const _promises = /*#__PURE__*/ _interop_require_default(require("node:fs/promises")); const _authmethod = require("../../../authentication/models/auth-method"); const _authmanagerservice = require("../../../authentication/services/auth-manager.service"); const _authmethodtwofaservice = require("../../../authentication/services/auth-methods/auth-method-two-fa.service"); const _functions = /*#__PURE__*/ _interop_require_wildcard(require("../../../common/functions")); const _shared = /*#__PURE__*/ _interop_require_wildcard(require("../../../common/shared")); const _configenvironment = require("../../../configuration/config.environment"); const _cacheservice = require("../../../infrastructure/cache/services/cache.service"); const _files = require("../../files/utils/files"); const _usermodel = require("../../users/models/user.model"); const _usersmanagerservice = require("../../users/services/users-manager.service"); const _auth = require("../constants/auth"); const _store = require("../constants/store"); const _sync = require("../constants/sync"); const _syncclientsmanagerservice = require("./sync-clients-manager.service"); const _syncqueriesservice = require("./sync-queries.service"); function _interop_require_default(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } function _interop_require_wildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = { __proto__: null }; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for(var key in obj){ if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } // Pilotage permission via UserModel let mockHavePermission = true; jest.mock('../../users/models/user.model', ()=>({ UserModel: jest.fn().mockImplementation((props)=>({ ...props, havePermission: ()=>mockHavePermission })) })); // Mock ciblé de convertHumanTimeToSeconds jest.mock('../../../common/functions', ()=>{ const actual = jest.requireActual('../../../common/functions'); return { ...actual, convertHumanTimeToSeconds: jest.fn() }; }); // Mock currentTimeStamp jest.mock('../../../common/shared', ()=>({ currentTimeStamp: jest.fn() })); // Mock FS et helper d'existence jest.mock('node:fs/promises', ()=>({ readFile: jest.fn() })); jest.mock('../../files/utils/files', ()=>({ isPathExists: jest.fn() })); describe(_syncclientsmanagerservice.SyncClientsManager.name, ()=>{ let service; // Mocks let http; let authManager; let authMethod; let usersManager; let syncQueries; let cacheMock; // Helpers const setRepo = (repo)=>{ ; _configenvironment.configuration.applications.appStore.repository = repo; }; const makeClient = (overrides = {})=>({ id: 'cid', ownerId: 1, tokenExpiration: 2000, enabled: true, info: { type: 'desktop' }, ...overrides }); const makeUser = (overrides = {})=>new _usermodel.UserModel({ id: 1, isActive: true, login: 'u', email: 'u@x', firstName: 'U', lastName: 'X', role: 1, permissions: 'desktop', ...overrides }); beforeAll(async ()=>{ http = { axiosRef: jest.fn() }; authManager = { setCookies: jest.fn(), getTokens: jest.fn() }; authMethod = { validateUser: jest.fn() }; usersManager = { fromUserId: jest.fn(), updateAccesses: jest.fn() }; syncQueries = { getOrCreateClient: jest.fn(), deleteClient: jest.fn(), getClient: jest.fn(), updateClientInfo: jest.fn(), renewClientTokenAndExpiration: jest.fn(), getClients: jest.fn() }; cacheMock = { genSlugKey: jest.fn().mockReturnValue('syncclientsmanager:checkappstore'), get: jest.fn().mockResolvedValue(undefined), set: jest.fn().mockResolvedValue(undefined), del: jest.fn().mockResolvedValue(undefined) }; const module = await _testing.Test.createTestingModule({ providers: [ _syncclientsmanagerservice.SyncClientsManager, { provide: _cacheservice.Cache, useValue: cacheMock }, { provide: _axios.HttpService, useValue: http }, { provide: _syncqueriesservice.SyncQueries, useValue: syncQueries }, { provide: _usersmanagerservice.UsersManager, useValue: usersManager }, { provide: _authmanagerservice.AuthManager, useValue: authManager }, { provide: _authmethod.AuthMethod, useValue: authMethod }, { provide: _authmethodtwofaservice.AuthMethod2FA, useValue: {} } ] }).compile(); module.useLogger([ 'fatal' ]); service = module.get(_syncclientsmanagerservice.SyncClientsManager); service.cache = cacheMock; }); beforeEach(()=>{ jest.restoreAllMocks(); jest.clearAllMocks(); mockHavePermission = true; _shared.currentTimeStamp.mockReturnValue(1_000); _functions.convertHumanTimeToSeconds.mockImplementation((v)=>{ if (v === '90d') return 90 * 24 * 3600; if (v === '180d') return 180 * 24 * 3600; if (typeof v === 'number') return v; return 0; }); _files.isPathExists.mockReset(); _promises.default.readFile.mockReset(); syncQueries.updateClientInfo.mockResolvedValue(undefined); usersManager.updateAccesses.mockResolvedValue(undefined); service.cache = cacheMock; cacheMock.get.mockResolvedValue(undefined); cacheMock.get.mockClear(); cacheMock.set.mockClear(); cacheMock.del.mockClear(); cacheMock.genSlugKey.mockClear(); setRepo(_store.APP_STORE_REPOSITORY.PUBLIC); }); it('should be defined', ()=>expect(service).toBeDefined()); describe('register', ()=>{ const baseDto = { login: 'john', password: 'secret', clientId: 'client-1', info: { type: 'desktop', version: '1.0.0' } }; test.each([ [ 'Unauthorized when credentials are invalid', null, _common.HttpStatus.UNAUTHORIZED ], [ 'Forbidden when user lacks DESKTOP_APP permission', { id: 10, login: 'john', havePermission: ()=>false }, _common.HttpStatus.FORBIDDEN ] ])('should throw %s', async (_label, user, status)=>{ authMethod.validateUser.mockResolvedValue(user); await expect(service.register(baseDto, '1.2.3.4')).rejects.toMatchObject({ status }); }); it('should return client token when registration succeeds', async ()=>{ authMethod.validateUser.mockResolvedValue({ id: 10, login: 'john', havePermission: ()=>true }); syncQueries.getOrCreateClient.mockResolvedValue('token-abc'); const r = await service.register(baseDto, '1.2.3.4'); expect(r).toEqual({ clientToken: 'token-abc' }); expect(syncQueries.getOrCreateClient).toHaveBeenCalledWith(10, 'client-1', baseDto.info, '1.2.3.4'); }); it('should throw Internal Server Error when persistence fails', async ()=>{ authMethod.validateUser.mockResolvedValue({ id: 10, login: 'john', havePermission: ()=>true }); syncQueries.getOrCreateClient.mockRejectedValue(new Error('db error')); await expect(service.register(baseDto, '1.2.3.4')).rejects.toMatchObject({ status: _common.HttpStatus.INTERNAL_SERVER_ERROR }); }); }); describe('unregister', ()=>{ it('should delete client without error', async ()=>{ syncQueries.deleteClient.mockResolvedValue(undefined); await expect(service.unregister({ id: 1, clientId: 'c1' })).resolves.toBeUndefined(); expect(syncQueries.deleteClient).toHaveBeenCalledWith(1, 'c1'); }); it('should throw Internal Server Error when deletion fails', async ()=>{ syncQueries.deleteClient.mockRejectedValue(new Error('db error')); await expect(service.unregister({ id: 1, clientId: 'c1' })).rejects.toMatchObject({ status: _common.HttpStatus.INTERNAL_SERVER_ERROR }); }); }); describe('authenticate', ()=>{ const ip = '9.9.9.9'; const dto = { clientId: 'cid', token: 'ctok' }; it('should forbid when client is unknown', async ()=>{ syncQueries.getClient.mockResolvedValue(undefined); await expect(service.authenticate(_auth.CLIENT_AUTH_TYPE.TOKEN, dto, ip, {})).rejects.toMatchObject({ status: _common.HttpStatus.FORBIDDEN, response: 'Client is unknown' }); }); it('should forbid when client is disabled', async ()=>{ syncQueries.getClient.mockResolvedValue(makeClient({ enabled: false, tokenExpiration: 5000 })); await expect(service.authenticate(_auth.CLIENT_AUTH_TYPE.TOKEN, dto, ip, {})).rejects.toMatchObject({ status: _common.HttpStatus.FORBIDDEN, response: 'Client is disabled' }); }); it('should forbid when client token is expired', async ()=>{ ; _shared.currentTimeStamp.mockReturnValue(1000); syncQueries.getClient.mockResolvedValue(makeClient({ tokenExpiration: 1000 })); await expect(service.authenticate(_auth.CLIENT_AUTH_TYPE.TOKEN, dto, ip, {})).rejects.toMatchObject({ status: _common.HttpStatus.FORBIDDEN, response: _auth.CLIENT_TOKEN_EXPIRED_ERROR }); }); it('should forbid when owner user does not exist', async ()=>{ syncQueries.getClient.mockResolvedValue(makeClient()); syncQueries.updateClientInfo.mockRejectedValueOnce(new Error('update-fails')); // silence expected usersManager.fromUserId.mockResolvedValue(null); await expect(service.authenticate(_auth.CLIENT_AUTH_TYPE.TOKEN, dto, ip, {})).rejects.toMatchObject({ status: _common.HttpStatus.FORBIDDEN, response: 'User does not exist' }); }); it('should forbid when owner account is inactive', async ()=>{ syncQueries.getClient.mockResolvedValue(makeClient()); usersManager.fromUserId.mockResolvedValue(makeUser({ isActive: false })); await expect(service.authenticate(_auth.CLIENT_AUTH_TYPE.TOKEN, dto, ip, {})).rejects.toMatchObject({ status: _common.HttpStatus.FORBIDDEN, response: 'Account suspended or not authorized' }); }); it('should forbid when owner lacks DESKTOP_APP permission', async ()=>{ mockHavePermission = false; syncQueries.getClient.mockResolvedValue(makeClient()); usersManager.fromUserId.mockResolvedValue(makeUser({ permissions: '', role: 999 })); await expect(service.authenticate(_auth.CLIENT_AUTH_TYPE.TOKEN, dto, ip, {})).rejects.toMatchObject({ status: _common.HttpStatus.FORBIDDEN, response: 'Missing permission' }); }); it('should perform COOKIE authentication and renew client token when needed', async ()=>{ syncQueries.getClient.mockResolvedValue(makeClient({ ownerId: 7 })); usersManager.fromUserId.mockResolvedValue(makeUser({ id: 7, login: 'john', email: 'john@doe', firstName: 'John', lastName: 'Doe' })); usersManager.updateAccesses.mockRejectedValueOnce(new Error('update-access-fail')); // silence expected authManager.setCookies.mockResolvedValue({ access_token: 'a', refresh_token: 'b' }); jest.spyOn(service, 'renewTokenAndExpiration').mockResolvedValue('new-client-token'); const reply = {}; const r = await service.authenticate(_auth.CLIENT_AUTH_TYPE.COOKIE, dto, ip, reply); expect(authManager.setCookies).toHaveBeenCalledTimes(1); expect(service.renewTokenAndExpiration).toHaveBeenCalledTimes(1); expect(r.client_token_update).toBe('new-client-token'); }); it('should perform TOKEN authentication and not renew when not needed', async ()=>{ syncQueries.getClient.mockResolvedValue(makeClient({ ownerId: 8 })); usersManager.fromUserId.mockResolvedValue(makeUser({ id: 8, login: 'alice', email: 'alice@doe', firstName: 'Alice' })); authManager.getTokens.mockResolvedValue({ access_token: 'x', refresh_token: 'y' }); jest.spyOn(service, 'renewTokenAndExpiration').mockResolvedValue(undefined); const r = await service.authenticate(_auth.CLIENT_AUTH_TYPE.TOKEN, dto, ip, {}); expect(authManager.getTokens).toHaveBeenCalledTimes(1); expect(r.client_token_update).toBeUndefined(); }); it('should throw when auth type is unknown (else branch)', async ()=>{ syncQueries.getClient.mockResolvedValue(makeClient({ ownerId: 9 })); usersManager.fromUserId.mockResolvedValue(makeUser({ id: 9, login: 'bob', email: 'bob@doe', firstName: 'Bob' })); jest.spyOn(service, 'renewTokenAndExpiration').mockResolvedValue(undefined); await expect(service.authenticate('unknown', { clientId: 'cid', token: 'ctok' }, ip, {})).rejects.toBeInstanceOf(TypeError); }); }); describe('getClients', ()=>{ it('should proxy to SyncQueries.getClients', async ()=>{ const fake = [ { id: 'c1', paths: [] } ]; syncQueries.getClients.mockResolvedValue(fake); const r = await service.getClients({ id: 1, clientId: 'c1' }); expect(r).toBe(fake); expect(syncQueries.getClients).toHaveBeenCalledWith({ id: 1, clientId: 'c1' }); }); }); describe('renewTokenAndExpiration', ()=>{ const owner = { id: 1, login: 'bob' }; it('should return undefined when token expiration is far enough', async ()=>{ ; _shared.currentTimeStamp.mockReturnValue(1_000); _functions.convertHumanTimeToSeconds.mockImplementation((v)=>v === '90d' ? 90 * 24 * 3600 : 0); const client = { id: 'cid', tokenExpiration: 1_000 + 90 * 24 * 3600 + 1 }; expect(await service.renewTokenAndExpiration(client, owner)).toBeUndefined(); }); it('should renew token and return new value when close to expiration', async ()=>{ ; _shared.currentTimeStamp.mockReturnValue(1_000); _functions.convertHumanTimeToSeconds.mockImplementation((v)=>v === '60d' ? 60 * 24 * 3600 : v === '120d' ? 120 * 24 * 3600 : 0); const client = { id: 'cid', tokenExpiration: 1_000 + 60 * 24 * 3600 - 1 }; syncQueries.renewClientTokenAndExpiration.mockResolvedValue(undefined); const r = await service.renewTokenAndExpiration(client, owner); expect(typeof r).toBe('string'); expect(r).toBeTruthy(); expect(syncQueries.renewClientTokenAndExpiration).toHaveBeenCalledWith('cid', r, expect.any(Number)); }); it('should throw Bad Request when renewal persistence fails', async ()=>{ ; _shared.currentTimeStamp.mockReturnValue(1_000); const client = { id: 'cid', tokenExpiration: 1_000 }; jest.spyOn(_nodecrypto.default, 'randomUUID').mockReturnValue('uuid-err'); syncQueries.renewClientTokenAndExpiration.mockRejectedValue(new Error('db fail')); await expect(service.renewTokenAndExpiration(client, owner)).rejects.toMatchObject({ status: _common.HttpStatus.BAD_REQUEST }); }); }); describe('deleteClient', ()=>{ it('should delete client successfully', async ()=>{ syncQueries.deleteClient.mockResolvedValue(undefined); await expect(service.deleteClient({ id: 5 }, 'cid')).resolves.toBeUndefined(); expect(syncQueries.deleteClient).toHaveBeenCalledWith(5, 'cid'); }); it('should throw Internal Server Error when deletion fails', async ()=>{ syncQueries.deleteClient.mockRejectedValue(new Error('db error')); await expect(service.deleteClient({ id: 5 }, 'cid')).rejects.toMatchObject({ status: _common.HttpStatus.INTERNAL_SERVER_ERROR }); }); }); describe('checkAppStore', ()=>{ it('should return PUBLIC manifest when HTTP fetch succeeds', async ()=>{ setRepo(_store.APP_STORE_REPOSITORY.PUBLIC); http.axiosRef.mockResolvedValue({ data: { platform: { win: [] } } }); const manifest = await service.checkAppStore(); expect(manifest).toBeTruthy(); expect(manifest.repository).toBe(_store.APP_STORE_REPOSITORY.PUBLIC); expect(http.axiosRef).toHaveBeenCalled(); }); it('should return null when PUBLIC manifest fetch fails', async ()=>{ setRepo(_store.APP_STORE_REPOSITORY.PUBLIC); http.axiosRef.mockRejectedValue(new Error('network')); expect(await service.checkAppStore()).toBeNull(); }); it('should return null when LOCAL manifest file does not exist', async ()=>{ setRepo(_store.APP_STORE_REPOSITORY.LOCAL); _files.isPathExists.mockResolvedValue(false); expect(await service.checkAppStore()).toBeNull(); }); it('should return LOCAL manifest with rewritten URLs when file is valid', async ()=>{ setRepo(_store.APP_STORE_REPOSITORY.LOCAL); _files.isPathExists.mockResolvedValue(true); const raw = { platform: { win: [ { package: 'desktop-win.exe' }, { package: 'cli-win.zip' } ], linux: [ { package: 'desktop-linux.AppImage' } ] } }; _promises.default.readFile.mockResolvedValue(JSON.stringify(raw)); const manifest = await service.checkAppStore(); expect(manifest.repository).toBe(_store.APP_STORE_REPOSITORY.LOCAL); expect(manifest.platform.win[0].url.startsWith(_store.APP_STORE_DIRNAME)).toBe(true); expect(manifest.platform.win[0].url.endsWith('desktop-win.exe')).toBe(true); expect(manifest.platform.win[1].url.startsWith(_store.APP_STORE_DIRNAME)).toBe(true); expect(manifest.platform.win[1].url.endsWith('cli-win.zip')).toBe(true); expect(manifest.platform.linux[0].url.startsWith(_store.APP_STORE_DIRNAME)).toBe(true); expect(manifest.platform.linux[0].url.endsWith('desktop-linux.AppImage')).toBe(true); }); it('should return null when LOCAL manifest cannot be parsed', async ()=>{ setRepo(_store.APP_STORE_REPOSITORY.LOCAL); _files.isPathExists.mockResolvedValue(true); _promises.default.readFile.mockRejectedValue(new Error('fs error')); expect(await service.checkAppStore()).toBeNull(); }); it('should rewrite desktop packages under desktop/os when package starts with "desktop"', async ()=>{ setRepo(_store.APP_STORE_REPOSITORY.LOCAL); _files.isPathExists.mockResolvedValue(true); const raw = { platform: { win: [ { package: `${_sync.SYNC_CLIENT_TYPE.DESKTOP}-win.exe` } ], mac: [ { package: `${_sync.SYNC_CLIENT_TYPE.DESKTOP}-mac.dmg` } ], linux: [ { package: `${_sync.SYNC_CLIENT_TYPE.DESKTOP}-linux.AppImage` } ] } }; _promises.default.readFile.mockResolvedValue(JSON.stringify(raw)); const manifest = await service.checkAppStore(); expect(manifest).toBeTruthy(); expect(manifest.repository).toBe(_store.APP_STORE_REPOSITORY.LOCAL); expect(manifest.platform.win[0].url).toBe(`${_store.APP_STORE_DIRNAME}/${_sync.SYNC_CLIENT_TYPE.DESKTOP}/win/${_sync.SYNC_CLIENT_TYPE.DESKTOP}-win.exe`); expect(manifest.platform.mac[0].url).toBe(`${_store.APP_STORE_DIRNAME}/${_sync.SYNC_CLIENT_TYPE.DESKTOP}/mac/${_sync.SYNC_CLIENT_TYPE.DESKTOP}-mac.dmg`); expect(manifest.platform.linux[0].url).toBe(`${_store.APP_STORE_DIRNAME}/${_sync.SYNC_CLIENT_TYPE.DESKTOP}/linux/${_sync.SYNC_CLIENT_TYPE.DESKTOP}-linux.AppImage`); }); }); }); //# sourceMappingURL=sync-clients-manager.service.spec.js.map