@sync-in/server
Version:
The secure, open-source platform for file storage, sharing, collaboration, and sync
640 lines (639 loc) • 28.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 _common = require("@nestjs/common");
const _testing = require("@nestjs/testing");
const _functions = /*#__PURE__*/ _interop_require_wildcard(require("../../../common/functions"));
const _shared = require("../../../common/shared");
const _contextmanagerservice = require("../../../infrastructure/context/services/context-manager.service");
const _constants = require("../../../infrastructure/database/constants");
const _links = require("../../links/constants/links");
const _linksqueriesservice = require("../../links/services/links-queries.service");
const _notificationsmanagerservice = require("../../notifications/services/notifications-manager.service");
const _spacesqueriesservice = require("../../spaces/services/spaces-queries.service");
const _permissions = /*#__PURE__*/ _interop_require_wildcard(require("../../spaces/utils/permissions"));
const _user = require("../../users/constants/user");
const _usersqueriesservice = require("../../users/services/users-queries.service");
const _shares = require("../constants/shares");
const _sharesmanagerservice = require("./shares-manager.service");
const _sharesqueriesservice = require("./shares-queries.service");
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;
}
// Mock classes and utility modules used by SharesManager
jest.mock('../../spaces/models/space-env.model', ()=>({
SpaceEnv: jest.fn().mockImplementation(()=>({
setPermissions: jest.fn(),
envPermissions: 'ENV_PERMS'
}))
}));
jest.mock('../../spaces/utils/permissions', ()=>({
havePermission: jest.fn(),
haveSpacePermission: jest.fn(),
removePermissions: jest.fn(()=>'trimmed')
}));
jest.mock('../../../common/functions', ()=>{
const actual = jest.requireActual('../../../common/functions');
return {
...actual,
generateShortUUID: jest.fn(),
hashPassword: jest.fn()
};
});
jest.mock('../../../common/shared', ()=>{
const actual = jest.requireActual('../../../common/shared');
return {
...actual,
intersectPermissions: jest.fn()
};
});
describe(_sharesmanagerservice.SharesManager.name, ()=>{
let service;
// Mocks
const contextManagerMock = {
headerOriginUrl: jest.fn()
};
const notificationsManagerMock = {
create: jest.fn().mockResolvedValue(undefined),
sendEmailNotification: jest.fn().mockResolvedValue(undefined)
};
const spacesQueriesMock = {
permissions: jest.fn()
};
const usersQueriesMock = {
createUserOrGuest: jest.fn(),
deleteGuestLink: jest.fn(),
usersWhitelist: jest.fn().mockResolvedValue([]),
groupsWhitelist: jest.fn().mockResolvedValue([]),
allUserIdsFromGroupsAndSubGroups: jest.fn().mockResolvedValue([])
};
const linksQueriesMock = {
isUniqueUUID: jest.fn(),
isReservedUUID: jest.fn(),
allLinksFromSpaceOrShare: jest.fn(),
createLinkToSpaceOrShare: jest.fn(),
updateLinkFromSpaceOrShare: jest.fn(),
linkFromShare: jest.fn(),
linkFromSpace: jest.fn()
};
const sharesQueriesMock = {
permissions: jest.fn(),
listShareLinks: jest.fn(),
getShareWithMembers: jest.fn(),
createShare: jest.fn(),
updateShare: jest.fn(),
selectShares: jest.fn(),
deleteShare: jest.fn(),
updateMember: jest.fn(),
updateMembers: jest.fn(),
shareExistsForOwner: jest.fn(),
childExistsForShareOwner: jest.fn(),
clearCachePermissions: jest.fn().mockResolvedValue(true)
};
const user = {
id: 1,
isAdmin: false
};
beforeAll(async ()=>{
const module = await _testing.Test.createTestingModule({
providers: [
{
provide: _constants.DB_TOKEN_PROVIDER,
useValue: {}
},
{
provide: _contextmanagerservice.ContextManager,
useValue: contextManagerMock
},
{
provide: _notificationsmanagerservice.NotificationsManager,
useValue: notificationsManagerMock
},
{
provide: _spacesqueriesservice.SpacesQueries,
useValue: spacesQueriesMock
},
{
provide: _usersqueriesservice.UsersQueries,
useValue: usersQueriesMock
},
{
provide: _linksqueriesservice.LinksQueries,
useValue: linksQueriesMock
},
{
provide: _sharesqueriesservice.SharesQueries,
useValue: sharesQueriesMock
},
_sharesmanagerservice.SharesManager
]
}).compile();
module.useLogger([
'fatal'
]);
service = module.get(_sharesmanagerservice.SharesManager);
});
beforeEach(()=>{
jest.clearAllMocks();
});
it('should be defined', ()=>{
expect(service).toBeDefined();
});
describe('setAllowedPermissions', ()=>{
it('sets all operations when the user is the file owner (personal space case)', async ()=>{
const share = {
file: {
ownerId: user.id,
permissions: ''
}
};
await service.setAllowedPermissions(user, share);
expect(share.file.permissions).toBe(_shares.SHARE_ALL_OPERATIONS);
});
it('uses space permissions when file has a space alias', async ()=>{
spacesQueriesMock.permissions.mockResolvedValueOnce({
any: 'thing'
});
const share = {
file: {
ownerId: 999,
space: {
alias: 'space-1',
root: {
alias: 'root'
}
},
permissions: undefined
}
};
await service.setAllowedPermissions(user, share);
expect(spacesQueriesMock.permissions).toHaveBeenCalledWith(user.id, 'space-1', 'root');
expect(share.file.ownerId).toBeNull();
expect(share.file.permissions).toBe('ENV_PERMS');
});
it('uses parent share permissions when parent alias is present', async ()=>{
sharesQueriesMock.permissions.mockResolvedValueOnce({
permissions: 'PARENT_PERMS'
});
const share = {
ownerId: 77,
parent: {
alias: 'parent-share'
},
file: {
permissions: undefined
}
};
await service.setAllowedPermissions(user, share);
expect(sharesQueriesMock.permissions).toHaveBeenCalledWith(user.id, 'parent-share', +user.isAdmin);
expect(share.file.permissions).toBe('PARENT_PERMS');
});
it('throws Bad Request when missing required information', async ()=>{
const share = {
file: {},
parent: {}
};
await expect(service.setAllowedPermissions(user, share)).rejects.toEqual(new _common.HttpException('Missing information', _common.HttpStatus.BAD_REQUEST));
});
});
describe('getShareWithMembers', ()=>{
it('returns the share and calls setAllowedPermissions', async ()=>{
const share = {
id: 10,
file: {}
};
sharesQueriesMock.getShareWithMembers.mockResolvedValueOnce(share);
const spy = jest.spyOn(service, 'setAllowedPermissions').mockResolvedValueOnce(void 0);
const result = await service.getShareWithMembers(user, 10, true);
expect(result).toBe(share);
expect(spy).toHaveBeenCalledWith(user, share, true);
});
it('throws Forbidden when share is not found or not authorized', async ()=>{
sharesQueriesMock.getShareWithMembers.mockResolvedValueOnce(null);
await expect(service.getShareWithMembers(user, 99, false)).rejects.toEqual(new _common.HttpException('Not authorized', _common.HttpStatus.FORBIDDEN));
});
});
describe('generateLinkUUID', ()=>{
it('loops until a unique UUID is found', async ()=>{
;
_functions.generateShortUUID.mockReturnValueOnce('aaa').mockReturnValueOnce('bbb');
linksQueriesMock.isUniqueUUID.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
const { uuid } = await service.generateLinkUUID(user.id);
expect(linksQueriesMock.isUniqueUUID).toHaveBeenCalledTimes(2);
expect(linksQueriesMock.isUniqueUUID).toHaveBeenNthCalledWith(1, user.id, 'aaa');
expect(linksQueriesMock.isUniqueUUID).toHaveBeenNthCalledWith(2, user.id, 'bbb');
expect(uuid).toBe('bbb');
});
});
describe('getShareLink', ()=>{
it('returns the share link and trims unsupported permissions', async ()=>{
const shareLink = {
id: 5,
file: {
permissions: 'ORIG'
}
};
sharesQueriesMock.listShareLinks.mockResolvedValueOnce(shareLink);
const spy = jest.spyOn(service, 'setAllowedPermissions').mockResolvedValueOnce(void 0);
const result = await service.getShareLink(user, 5);
expect(spy).toHaveBeenCalledWith(user, shareLink);
expect(result).toBe(shareLink);
expect(result.file.permissions).toBe('trimmed');
expect(_permissions.removePermissions.mock.calls[0][0]).toBe('ORIG');
});
it('throws Forbidden when link is not found', async ()=>{
sharesQueriesMock.listShareLinks.mockResolvedValueOnce(null);
await expect(service.getShareLink(user, 123)).rejects.toEqual(new _common.HttpException('Not authorized', _common.HttpStatus.FORBIDDEN));
});
});
describe('updateLinkFromSpaceOrShare (from API)', ()=>{
it('intersects permissions and returns updated link object', async ()=>{
const baseLink = {
id: 42,
name: 'old',
email: 'x@x',
requireAuth: false,
limitAccess: null,
expiresAt: null,
permissions: 'OLD',
shareName: 'OldShare',
shareDescription: 'OldDesc'
};
jest.spyOn(service, 'getLinkFromSpaceOrShare').mockResolvedValueOnce(baseLink);
jest.spyOn(service, 'getShareLink').mockResolvedValueOnce({
file: {
permissions: 'SHARE_PERMS'
}
});
_shared.intersectPermissions.mockReturnValue('INTERSECTED');
linksQueriesMock.updateLinkFromSpaceOrShare.mockResolvedValueOnce(undefined);
const dto = {
permissions: 'NEW_PERMS',
language: 'fr',
isActive: false
};
const result = await service.updateLinkFromSpaceOrShare(user, 7, 55, 1, dto, true);
expect(linksQueriesMock.updateLinkFromSpaceOrShare).toHaveBeenCalled();
expect(result.permissions).toBe('INTERSECTED');
expect(result.language).toBe('fr');
expect(result.isActive).toBe(false);
});
});
describe('createGuestLink', ()=>{
it('creates guest link with hashed password and returns created user info', async ()=>{
;
_functions.hashPassword.mockResolvedValue('HASHED');
_functions.generateShortUUID.mockReturnValue('RANDOMSEQ');
usersQueriesMock.createUserOrGuest.mockResolvedValueOnce(99);
const guest = await service.createGuestLink(_user.GUEST_PERMISSION.SHARES, 'plaintext', 'en', true);
expect(usersQueriesMock.createUserOrGuest).toHaveBeenCalled();
expect(guest.id).toBe(99);
expect(guest.password).toBe('HASHED');
expect(guest.role).toBeDefined();
expect(guest.permissions).toBe(_user.GUEST_PERMISSION.SHARES);
expect(guest.language).toBe('en');
expect(guest.isActive).toBe(true);
});
it('generates a random password and defaults isActive when not provided', async ()=>{
;
_functions.hashPassword.mockResolvedValue('HASHED-RAND');
_functions.generateShortUUID.mockReturnValueOnce('RANDOMSEQ');
usersQueriesMock.createUserOrGuest.mockResolvedValueOnce(123);
const guest = await service.createGuestLink(_user.GUEST_PERMISSION.SPACES);
expect(_functions.hashPassword).toHaveBeenCalled();
expect(guest.id).toBe(123);
expect(guest.isActive).toBe(true);
expect(guest.language).toBeNull();
});
});
describe('getLinkFromSpaceOrShare', ()=>{
it('returns a link guest for SPACE type', async ()=>{
const lg = {
id: 1
};
linksQueriesMock.linkFromSpace.mockResolvedValueOnce(lg);
const res = await service.getLinkFromSpaceOrShare(user, 11, 22, _links.LINK_TYPE.SPACE);
expect(res).toBe(lg);
expect(linksQueriesMock.linkFromSpace).toHaveBeenCalledWith(user.id, 11, 22);
expect(linksQueriesMock.linkFromShare).not.toHaveBeenCalled();
});
it('returns a link guest for SHARE type', async ()=>{
const lg = {
id: 2
};
linksQueriesMock.linkFromShare.mockResolvedValueOnce(lg);
const res = await service.getLinkFromSpaceOrShare(user, 33, 44, _links.LINK_TYPE.SHARE);
expect(res).toBe(lg);
expect(linksQueriesMock.linkFromShare).toHaveBeenCalledWith(user.id, 33, 44, +user.isAdmin);
expect(linksQueriesMock.linkFromSpace).not.toHaveBeenCalled();
});
it('throws when link not found', async ()=>{
linksQueriesMock.linkFromSpace.mockResolvedValueOnce(null);
await expect(service.getLinkFromSpaceOrShare(user, 55, 66, _links.LINK_TYPE.SPACE)).rejects.toEqual(new _common.HttpException('Link not found', _common.HttpStatus.NOT_FOUND));
});
});
describe('updateLinkFromSpaceOrShare (additional branches)', ()=>{
it('returns null when no diff and not from API', async ()=>{
const link = {
id: 1,
name: 'n',
email: 'e',
requireAuth: false,
limitAccess: null,
expiresAt: null
};
jest.spyOn(service, 'getLinkFromSpaceOrShare').mockResolvedValueOnce(link);
const result = await service.updateLinkFromSpaceOrShare(user, 1, 2, _links.LINK_TYPE.SHARE, {}, false);
expect(result).toBeNull();
expect(linksQueriesMock.updateLinkFromSpaceOrShare).not.toHaveBeenCalled();
});
it('hashes password and does not leak it when fromAPI is true', async ()=>{
const link = {
id: 1
};
jest.spyOn(service, 'getLinkFromSpaceOrShare').mockResolvedValueOnce(link);
_functions.hashPassword.mockResolvedValueOnce('HASHED');
linksQueriesMock.updateLinkFromSpaceOrShare.mockImplementation(async (_link, _spaceOrShareId, updateUser)=>{
// Assert at call time before the service deletes the password
expect(updateUser).toMatchObject({
password: 'HASHED'
});
return;
});
const result = await service.updateLinkFromSpaceOrShare(user, 1, 2, _links.LINK_TYPE.SHARE, {
password: 'secret'
}, true);
expect(linksQueriesMock.updateLinkFromSpaceOrShare).toHaveBeenCalled();
// The returned link must not leak password
expect(result).toBe(link);
expect(result.password).toBeUndefined();
});
it('updates multiple link/user fields and ignores equal expiresAt', async ()=>{
const base = {
id: 9,
name: 'a',
email: 'b',
requireAuth: false,
limitAccess: null,
expiresAt: {
date: '2025-01-01'
}
};
jest.spyOn(service, 'getLinkFromSpaceOrShare').mockResolvedValueOnce(base);
linksQueriesMock.updateLinkFromSpaceOrShare.mockResolvedValueOnce(undefined);
const dto = {
name: 'a2',
email: 'b2',
requireAuth: true,
limitAccess: 5,
expiresAt: {
date: '2025-01-01'
} // equal, should be ignored
};
await service.updateLinkFromSpaceOrShare(user, 9, 99, _links.LINK_TYPE.SHARE, dto, false);
const [, , , updateLink] = linksQueriesMock.updateLinkFromSpaceOrShare.mock.calls[0].slice(0, 5);
expect(updateLink).toMatchObject({
name: 'a2',
email: 'b2',
requireAuth: true,
limitAccess: 5
});
expect(updateLink.expiresAt).toBeUndefined();
});
});
describe('setAllowedPermissions (additional branches)', ()=>{
it('sets all operations when share has externalPath and user is admin', async ()=>{
const admin = {
id: 10,
isAdmin: true
};
const share = {
externalPath: '/ext',
file: {}
};
await service.setAllowedPermissions(admin, share);
expect(share.file.permissions).toBe(_shares.SHARE_ALL_OPERATIONS);
});
it('throws NOT_FOUND when space permissions are missing', async ()=>{
spacesQueriesMock.permissions.mockResolvedValueOnce(null);
const share = {
file: {
space: {
alias: 'space-x',
root: {
alias: 'r'
}
}
}
};
await expect(service.setAllowedPermissions(user, share)).rejects.toEqual(new _common.HttpException('Space not found', _common.HttpStatus.NOT_FOUND));
});
it('throws NOT_FOUND when parent share permissions are missing', async ()=>{
sharesQueriesMock.permissions.mockResolvedValueOnce(null);
const share = {
ownerId: 42,
parent: {
alias: 'parent'
},
file: {}
};
await expect(service.setAllowedPermissions(user, share)).rejects.toEqual(new _common.HttpException('Share not found', _common.HttpStatus.NOT_FOUND));
});
it('uses owner permissions when asAdmin is true', async ()=>{
const asAdminUser = {
id: 3,
isAdmin: false
};
sharesQueriesMock.permissions.mockResolvedValueOnce({
permissions: 'ADMIN_PARENT'
});
const share = {
ownerId: 77,
parent: {
alias: 'pa'
},
file: {}
};
await service.setAllowedPermissions(asAdminUser, share, true);
expect(sharesQueriesMock.permissions).toHaveBeenCalledWith(77, 'pa', +asAdminUser.isAdmin);
expect(share.file.permissions).toBe('ADMIN_PARENT');
});
});
describe('getShareLink (additional branch)', ()=>{
it('does not trim permissions if file.permissions is falsy', async ()=>{
const shareLink = {
id: 7,
file: {}
};
sharesQueriesMock.listShareLinks.mockResolvedValueOnce(shareLink);
const spy = jest.spyOn(service, 'setAllowedPermissions').mockResolvedValueOnce(void 0);
const res = await service.getShareLink(user, 7);
expect(spy).toHaveBeenCalled();
expect(res).toBe(shareLink);
expect(_permissions.removePermissions).not.toHaveBeenCalled();
});
});
describe('deleteShare', ()=>{
it('throws Forbidden when user is not admin and not owner', async ()=>{
sharesQueriesMock.shareExistsForOwner.mockResolvedValueOnce(false);
await expect(service.deleteShare({
id: 2,
isAdmin: false
}, 123)).rejects.toEqual(new _common.HttpException('Not authorized', _common.HttpStatus.FORBIDDEN));
});
it('deletes links and removes shares when authorized (asAdmin)', async ()=>{
const deleteLinksSpy = jest.spyOn(service, 'deleteAllLinkMembers').mockResolvedValue(void 0);
const removeSpy = jest.spyOn(service, 'removeShareFromOwners').mockResolvedValue(void 0);
await service.deleteShare(user, 456, true);
expect(deleteLinksSpy).toHaveBeenCalledWith(456, expect.anything());
expect(removeSpy).toHaveBeenCalledWith(456, 'all', false, user.id);
});
});
describe('child share wrappers', ()=>{
it('getChildShare returns share link when isLink = true', async ()=>{
sharesQueriesMock.childExistsForShareOwner.mockResolvedValueOnce(99);
const getShareLinkSpy = jest.spyOn(service, 'getShareLink').mockResolvedValueOnce({
id: 99
});
const res = await service.getChildShare(user, 1, 99, true);
expect(res).toEqual({
id: 99
});
expect(getShareLinkSpy).toHaveBeenCalledWith(user, 99, true);
});
it('getChildShare returns child share when isLink = false', async ()=>{
sharesQueriesMock.childExistsForShareOwner.mockResolvedValueOnce(100);
const getShareSpy = jest.spyOn(service, 'getShareWithMembers').mockResolvedValueOnce({
id: 100
});
const res = await service.getChildShare(user, 1, 100, false);
expect(res).toEqual({
id: 100
});
expect(getShareSpy).toHaveBeenCalledWith(user, 100, true);
});
it('updateChildShare forwards update and deleteChildShare forwards delete', async ()=>{
sharesQueriesMock.childExistsForShareOwner.mockResolvedValue(200);
const updateSpy = jest.spyOn(service, 'updateShare').mockResolvedValueOnce({
id: 200
});
const deleteSpy = jest.spyOn(service, 'deleteShare').mockResolvedValueOnce(void 0);
await service.updateChildShare(user, 1, 200, {});
expect(updateSpy).toHaveBeenCalledWith(user, 200, {}, true);
await service.deleteChildShare(user, 1, 200);
expect(deleteSpy).toHaveBeenCalledWith(user, 200, true);
});
it('throws Forbidden when not allowed to manage child share', async ()=>{
sharesQueriesMock.childExistsForShareOwner.mockResolvedValueOnce(null);
await expect(service.getChildShare(user, 1, 2, false)).rejects.toEqual(new _common.HttpException('Not authorized', _common.HttpStatus.FORBIDDEN));
});
});
describe('createOrUpdateLinksAsMembers', ()=>{
it('creates new links for id < 0 and notifies guest', async ()=>{
const createLinkSpy = jest.spyOn(service, 'createLinkFromSpaceOrShare').mockResolvedValue(void 0);
const notifySpy = jest.spyOn(service, 'notifyGuestLink').mockResolvedValue(void 0);
const links = [
{
id: -1,
linkSettings: {
uuid: 'u',
email: 'e',
permissions: 'p'
},
permissions: 'p'
}
];
const res = await service.createOrUpdateLinksAsMembers(user, {
id: 1,
name: 'S'
}, _links.LINK_TYPE.SHARE, links);
expect(res).toEqual([]);
expect(createLinkSpy).toHaveBeenCalled();
expect(notifySpy).toHaveBeenCalled();
});
it('updates modified links and returns them along with unmodified ones', async ()=>{
const updateLinkSpy = jest.spyOn(service, 'updateLinkFromSpaceOrShare').mockResolvedValue(void 0);
const members = await service.createOrUpdateLinksAsMembers(user, {
id: 1,
name: 'S'
}, _links.LINK_TYPE.SHARE, [
{
id: 2,
linkId: 2,
permissions: 'p',
linkSettings: {
name: 'new'
}
},
{
id: 3,
linkId: 3,
permissions: 'q'
} // unmodified
]);
expect(updateLinkSpy).toHaveBeenCalledWith(user, 2, 1, _links.LINK_TYPE.SHARE, {
name: 'new'
});
expect(members).toHaveLength(2);
expect(members.map((m)=>m.id)).toEqual([
2,
3
]);
});
});
describe('generateLinkUUID (additional)', ()=>{
it('returns immediately when the first UUID is unique', async ()=>{
;
_functions.generateShortUUID.mockReturnValueOnce('only-one');
linksQueriesMock.isUniqueUUID.mockResolvedValueOnce(true);
const { uuid } = await service.generateLinkUUID(user.id);
expect(uuid).toBe('only-one');
expect(linksQueriesMock.isUniqueUUID).toHaveBeenCalledTimes(1);
expect(linksQueriesMock.isUniqueUUID).toHaveBeenCalledWith(user.id, 'only-one');
});
});
});
//# sourceMappingURL=shares-manager.service.spec.js.map