@sync-in/server
Version:
The secure, open-source platform for file storage, sharing, collaboration, and sync
602 lines (601 loc) • 25.1 kB
JavaScript
/*
* 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