UNPKG

@sync-in/server

Version:

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

640 lines (639 loc) 28.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 _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