mirror of
https://github.com/docmost/docmost.git
synced 2026-09-11 07:56:54 +08:00
1104 lines
34 KiB
TypeScript
1104 lines
34 KiB
TypeScript
import { NotFoundException, ForbiddenException } from '@nestjs/common';
|
|
import { PublicSpaceService } from './public-space.service';
|
|
import { Feature } from '../../common/features';
|
|
|
|
const WORKSPACE_ID = '018f0000-0000-7000-8000-000000000001';
|
|
const SPACE_ID = '018f0000-0000-7000-8000-000000000002';
|
|
|
|
function makeWorkspace(settings: any = {}) {
|
|
return { id: WORKSPACE_ID, settings } as any;
|
|
}
|
|
|
|
const optInSettings = { publicSpaces: { enabled: true } };
|
|
|
|
function makeService(overrides: any = {}) {
|
|
const publicSpaceRepo = {
|
|
findBySpaceId: jest.fn().mockResolvedValue({
|
|
id: 'ps1',
|
|
spaceId: SPACE_ID,
|
|
enabled: true,
|
|
searchIndexing: false,
|
|
}),
|
|
upsert: jest.fn().mockImplementation(async (opts) => opts),
|
|
...overrides.publicSpaceRepo,
|
|
};
|
|
const spaceRepo = {
|
|
findBySlug: jest.fn().mockResolvedValue({
|
|
id: SPACE_ID,
|
|
slug: 'handbook',
|
|
name: 'Handbook',
|
|
workspaceId: WORKSPACE_ID,
|
|
deletedAt: null,
|
|
}),
|
|
...overrides.spaceRepo,
|
|
};
|
|
const pageRepo = {
|
|
findById: jest.fn().mockResolvedValue({
|
|
id: 'page1',
|
|
slugId: 'abc123XYZ0',
|
|
spaceId: SPACE_ID,
|
|
workspaceId: WORKSPACE_ID,
|
|
deletedAt: null,
|
|
content: null,
|
|
}),
|
|
getSpacePagesExcludingRestricted: jest.fn().mockResolvedValue([]),
|
|
getFirstUnrestrictedRootPage: jest
|
|
.fn()
|
|
.mockResolvedValue({ id: 'page1', slugId: 'abc123XYZ0' }),
|
|
...overrides.pageRepo,
|
|
};
|
|
const pagePermissionRepo = {
|
|
hasRestrictedAncestor: jest.fn().mockResolvedValue(false),
|
|
...overrides.pagePermissionRepo,
|
|
};
|
|
const shareService = {
|
|
updatePublicAttachments: jest.fn().mockImplementation(async (p) => p.content),
|
|
sanitizeTransclusionItemsForPublic: jest
|
|
.fn()
|
|
.mockImplementation(async (items) => items),
|
|
...overrides.shareService,
|
|
};
|
|
const transclusionService = {
|
|
lookupWithAccessSet: jest.fn().mockResolvedValue({ items: [] }),
|
|
...overrides.transclusionService,
|
|
};
|
|
|
|
const licenseCheckService = {
|
|
resolveFeatures: jest.fn().mockReturnValue([Feature.PUBLIC_SPACE_APPEARANCE]),
|
|
...overrides.licenseCheckService,
|
|
};
|
|
|
|
const environmentService = {
|
|
isBetaPublicSpaces: jest.fn().mockReturnValue(true),
|
|
...overrides.environmentService,
|
|
};
|
|
|
|
const service = new PublicSpaceService(
|
|
publicSpaceRepo as any,
|
|
spaceRepo as any,
|
|
pageRepo as any,
|
|
pagePermissionRepo as any,
|
|
shareService as any,
|
|
transclusionService as any,
|
|
licenseCheckService as any,
|
|
environmentService as any,
|
|
);
|
|
return {
|
|
service,
|
|
publicSpaceRepo,
|
|
spaceRepo,
|
|
pageRepo,
|
|
pagePermissionRepo,
|
|
shareService,
|
|
transclusionService,
|
|
licenseCheckService,
|
|
environmentService,
|
|
};
|
|
}
|
|
|
|
describe('PublicSpaceService', () => {
|
|
describe('getPublicSpace', () => {
|
|
it('404s when the workspace opt-in is off (default)', async () => {
|
|
const { service } = makeService();
|
|
await expect(
|
|
service.getPublicSpace('handbook', makeWorkspace({})),
|
|
).rejects.toThrow(NotFoundException);
|
|
});
|
|
|
|
it('404s when BETA_PUBLIC_SPACES is off even with the workspace opt-in on', async () => {
|
|
const { service, spaceRepo } = makeService({
|
|
environmentService: {
|
|
isBetaPublicSpaces: jest.fn().mockReturnValue(false),
|
|
},
|
|
});
|
|
const ws = makeWorkspace({ publicSpaces: { enabled: true } });
|
|
await expect(service.getPublicSpace('handbook', ws)).rejects.toThrow(
|
|
NotFoundException,
|
|
);
|
|
expect(spaceRepo.findBySlug).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('resolves when public page sharing is disabled', async () => {
|
|
const { service } = makeService();
|
|
const ws = makeWorkspace({
|
|
publicSpaces: { enabled: true },
|
|
sharing: { disabled: true },
|
|
});
|
|
const result = await service.getPublicSpace('handbook', ws);
|
|
|
|
expect(result.space.id).toBe(SPACE_ID);
|
|
expect(result.publicSpace.enabled).toBe(true);
|
|
});
|
|
|
|
it('404s when the space row is disabled', async () => {
|
|
const { service } = makeService({
|
|
publicSpaceRepo: {
|
|
findBySpaceId: jest.fn().mockResolvedValue({ enabled: false }),
|
|
},
|
|
});
|
|
await expect(
|
|
service.getPublicSpace('handbook', makeWorkspace(optInSettings)),
|
|
).rejects.toThrow(NotFoundException);
|
|
});
|
|
|
|
it('404s when there is no public_spaces row', async () => {
|
|
const { service } = makeService({
|
|
publicSpaceRepo: {
|
|
findBySpaceId: jest.fn().mockResolvedValue(undefined),
|
|
},
|
|
});
|
|
await expect(
|
|
service.getPublicSpace('handbook', makeWorkspace(optInSettings)),
|
|
).rejects.toThrow(NotFoundException);
|
|
});
|
|
|
|
it('404s when the space is deleted', async () => {
|
|
const { service } = makeService({
|
|
spaceRepo: {
|
|
findBySlug: jest
|
|
.fn()
|
|
.mockResolvedValue({ id: SPACE_ID, deletedAt: new Date() }),
|
|
},
|
|
});
|
|
await expect(
|
|
service.getPublicSpace('handbook', makeWorkspace(optInSettings)),
|
|
).rejects.toThrow(NotFoundException);
|
|
});
|
|
|
|
it('resolves when opted in, enabled, and space exists', async () => {
|
|
const { service } = makeService();
|
|
const result = await service.getPublicSpace(
|
|
'handbook',
|
|
makeWorkspace(optInSettings),
|
|
);
|
|
expect(result.space.id).toBe(SPACE_ID);
|
|
expect(result.publicSpace.enabled).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('getPublicPage', () => {
|
|
it('404s for a page belonging to another space', async () => {
|
|
const { service } = makeService({
|
|
pageRepo: {
|
|
findById: jest.fn().mockResolvedValue({
|
|
id: 'page2',
|
|
spaceId: 'other-space',
|
|
deletedAt: null,
|
|
}),
|
|
},
|
|
});
|
|
await expect(
|
|
service.getPublicPage('handbook', 'abc123XYZ0', makeWorkspace(optInSettings)),
|
|
).rejects.toThrow(NotFoundException);
|
|
});
|
|
|
|
it('404s for a restricted page', async () => {
|
|
const { service } = makeService({
|
|
pagePermissionRepo: {
|
|
hasRestrictedAncestor: jest.fn().mockResolvedValue(true),
|
|
},
|
|
});
|
|
await expect(
|
|
service.getPublicPage('handbook', 'abc123XYZ0', makeWorkspace(optInSettings)),
|
|
).rejects.toThrow(NotFoundException);
|
|
});
|
|
|
|
it('resolves the first unrestricted root page as the home', async () => {
|
|
const { service, pageRepo } = makeService();
|
|
const result = await service.getPublicPage(
|
|
'handbook',
|
|
undefined,
|
|
makeWorkspace(optInSettings),
|
|
);
|
|
expect(pageRepo.getFirstUnrestrictedRootPage).toHaveBeenCalledWith(SPACE_ID);
|
|
expect(result.page.id).toBe('page1');
|
|
});
|
|
|
|
it('sanitizes the served page content for public delivery', async () => {
|
|
const sanitizedDoc = { type: 'doc', sanitized: true };
|
|
const { service, shareService } = makeService({
|
|
pageRepo: {
|
|
findById: jest.fn().mockResolvedValue({
|
|
id: 'page1',
|
|
slugId: 'abc123XYZ0',
|
|
spaceId: SPACE_ID,
|
|
workspaceId: WORKSPACE_ID,
|
|
deletedAt: null,
|
|
content: { type: 'doc', content: [] },
|
|
}),
|
|
},
|
|
shareService: {
|
|
updatePublicAttachments: jest.fn().mockResolvedValue(sanitizedDoc),
|
|
},
|
|
});
|
|
|
|
const result = await service.getPublicPage(
|
|
'handbook',
|
|
'abc123XYZ0',
|
|
makeWorkspace(optInSettings),
|
|
);
|
|
|
|
expect(shareService.updatePublicAttachments.mock.calls[0][0]).toBe(
|
|
result.page,
|
|
);
|
|
expect(result.page.content).toBe(sanitizedDoc);
|
|
});
|
|
|
|
it('returns page null for an empty published space', async () => {
|
|
const { service } = makeService({
|
|
pageRepo: {
|
|
getFirstUnrestrictedRootPage: jest.fn().mockResolvedValue(undefined),
|
|
},
|
|
});
|
|
const result = await service.getPublicPage(
|
|
'handbook',
|
|
undefined,
|
|
makeWorkspace(optInSettings),
|
|
);
|
|
expect(result.page).toBeNull();
|
|
});
|
|
|
|
it('skips content preparation when includeContent is false', async () => {
|
|
const { service, pageRepo, shareService } = makeService();
|
|
|
|
const result = await service.getPublicPage(
|
|
'handbook',
|
|
'abc123XYZ0',
|
|
makeWorkspace(optInSettings),
|
|
{ includeContent: false },
|
|
);
|
|
|
|
expect(pageRepo.findById).toHaveBeenCalledWith('abc123XYZ0');
|
|
expect(shareService.updatePublicAttachments).not.toHaveBeenCalled();
|
|
expect(result.page.id).toBe('page1');
|
|
});
|
|
});
|
|
|
|
describe('lookupTransclusionForPublicSpace', () => {
|
|
const refs = [{ sourcePageId: 'ref1', transclusionId: 't1' }];
|
|
|
|
it('excludes a candidate from another space', async () => {
|
|
const { service, transclusionService } = makeService({
|
|
pageRepo: {
|
|
findById: jest.fn().mockResolvedValue({
|
|
id: 'page2',
|
|
spaceId: 'other-space',
|
|
deletedAt: null,
|
|
}),
|
|
},
|
|
});
|
|
|
|
await service.lookupTransclusionForPublicSpace(
|
|
'handbook',
|
|
refs,
|
|
makeWorkspace(optInSettings),
|
|
);
|
|
|
|
const accessSet = transclusionService.lookupWithAccessSet.mock.calls[0][1];
|
|
expect(Array.from(accessSet)).toEqual([]);
|
|
});
|
|
|
|
it('excludes a restricted candidate', async () => {
|
|
const { service, transclusionService } = makeService({
|
|
pagePermissionRepo: {
|
|
hasRestrictedAncestor: jest.fn().mockResolvedValue(true),
|
|
},
|
|
});
|
|
|
|
await service.lookupTransclusionForPublicSpace(
|
|
'handbook',
|
|
refs,
|
|
makeWorkspace(optInSettings),
|
|
);
|
|
|
|
const accessSet = transclusionService.lookupWithAccessSet.mock.calls[0][1];
|
|
expect(Array.from(accessSet)).toEqual([]);
|
|
});
|
|
|
|
it('includes a valid same-space candidate by its page id', async () => {
|
|
const { service, transclusionService } = makeService();
|
|
|
|
await service.lookupTransclusionForPublicSpace(
|
|
'handbook',
|
|
refs,
|
|
makeWorkspace(optInSettings),
|
|
);
|
|
|
|
const accessSet = transclusionService.lookupWithAccessSet.mock.calls[0][1];
|
|
expect(Array.from(accessSet)).toEqual(['page1']);
|
|
});
|
|
|
|
it('delegates item sanitization to the share service', async () => {
|
|
const rawItems = [
|
|
{ sourcePageId: 'page1', transclusionId: 't1', status: 'not_found' },
|
|
];
|
|
const sanitizedItems = [
|
|
{ sourcePageId: 'page1', transclusionId: 't1', status: 'no_access' },
|
|
];
|
|
const { service, shareService } = makeService({
|
|
transclusionService: {
|
|
lookupWithAccessSet: jest.fn().mockResolvedValue({ items: rawItems }),
|
|
},
|
|
shareService: {
|
|
sanitizeTransclusionItemsForPublic: jest
|
|
.fn()
|
|
.mockResolvedValue(sanitizedItems),
|
|
},
|
|
});
|
|
|
|
const result = await service.lookupTransclusionForPublicSpace(
|
|
'handbook',
|
|
refs,
|
|
makeWorkspace(optInSettings),
|
|
);
|
|
|
|
expect(
|
|
shareService.sanitizeTransclusionItemsForPublic,
|
|
).toHaveBeenCalledWith(rawItems, WORKSPACE_ID);
|
|
expect(result.items).toBe(sanitizedItems);
|
|
});
|
|
});
|
|
|
|
describe('BETA_PUBLIC_SPACES off', () => {
|
|
const flagOff = {
|
|
environmentService: {
|
|
isBetaPublicSpaces: jest.fn().mockReturnValue(false),
|
|
},
|
|
};
|
|
const refs = [{ sourcePageId: 'ref1', transclusionId: 't1' }];
|
|
|
|
it.each([
|
|
['getPublicSpaceInfo', (s, ws) => s.getPublicSpaceInfo('handbook', ws)],
|
|
['getPublicSpaceTree', (s, ws) => s.getPublicSpaceTree('handbook', ws)],
|
|
[
|
|
'getPublicPage',
|
|
(s, ws) => s.getPublicPage('handbook', 'abc123XYZ0', ws),
|
|
],
|
|
[
|
|
'lookupTransclusionForPublicSpace',
|
|
(s, ws) => s.lookupTransclusionForPublicSpace('handbook', refs, ws),
|
|
],
|
|
])(
|
|
'%s 404s before any lookup even with the workspace opted in',
|
|
async (_name, call) => {
|
|
const { service, spaceRepo, publicSpaceRepo, pageRepo } =
|
|
makeService(flagOff);
|
|
await expect(
|
|
call(service, makeWorkspace(optInSettings)),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
expect(spaceRepo.findBySlug).not.toHaveBeenCalled();
|
|
expect(publicSpaceRepo.findBySpaceId).not.toHaveBeenCalled();
|
|
expect(pageRepo.findById).not.toHaveBeenCalled();
|
|
expect(pageRepo.getSpacePagesExcludingRestricted).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('directory 404s before any lookup even with the double opt-in on', async () => {
|
|
const findEnabledWithSpaceByWorkspaceId = jest.fn().mockResolvedValue([]);
|
|
const { service } = makeService({
|
|
...flagOff,
|
|
publicSpaceRepo: { findEnabledWithSpaceByWorkspaceId },
|
|
});
|
|
await expect(
|
|
service.getPublicSpaceDirectory(
|
|
makeWorkspace({ publicSpaces: { enabled: true, directory: true } }),
|
|
),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
expect(findEnabledWithSpaceByWorkspaceId).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([true, false])(
|
|
'publish rejects every write (enabled=%s) without touching the row',
|
|
async (enabled) => {
|
|
const { service, publicSpaceRepo } = makeService(flagOff);
|
|
await expect(
|
|
service.publish({
|
|
space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any,
|
|
workspace: makeWorkspace(optInSettings),
|
|
authUserId: 'u1',
|
|
enabled,
|
|
}),
|
|
).rejects.toBeInstanceOf(ForbiddenException);
|
|
expect(publicSpaceRepo.findBySpaceId).not.toHaveBeenCalled();
|
|
expect(publicSpaceRepo.upsert).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
});
|
|
|
|
describe('publish appearance', () => {
|
|
it('merges appearance into existing settings preserving unknown keys', async () => {
|
|
const { service, publicSpaceRepo } = makeService({
|
|
publicSpaceRepo: {
|
|
findBySpaceId: jest.fn().mockResolvedValue({
|
|
id: 'ps1',
|
|
spaceId: SPACE_ID,
|
|
enabled: true,
|
|
searchIndexing: false,
|
|
settings: {
|
|
future: 1,
|
|
appearance: { primaryColorLight: '#111111' },
|
|
},
|
|
}),
|
|
},
|
|
});
|
|
|
|
await service.publish({
|
|
space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any,
|
|
workspace: makeWorkspace(optInSettings),
|
|
authUserId: 'u1',
|
|
enabled: true,
|
|
appearance: { primaryColorDark: '#222222' },
|
|
});
|
|
|
|
expect(publicSpaceRepo.upsert).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
settings: {
|
|
future: 1,
|
|
appearance: {
|
|
primaryColorLight: '#111111',
|
|
primaryColorDark: '#222222',
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('deletes an appearance key when its value is null', async () => {
|
|
const { service, publicSpaceRepo } = makeService({
|
|
publicSpaceRepo: {
|
|
findBySpaceId: jest.fn().mockResolvedValue({
|
|
id: 'ps1',
|
|
spaceId: SPACE_ID,
|
|
enabled: true,
|
|
searchIndexing: false,
|
|
settings: {
|
|
appearance: {
|
|
primaryColorLight: '#111111',
|
|
primaryColorDark: '#222222',
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
});
|
|
|
|
await service.publish({
|
|
space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any,
|
|
workspace: makeWorkspace(optInSettings),
|
|
authUserId: 'u1',
|
|
enabled: true,
|
|
appearance: { primaryColorLight: null },
|
|
});
|
|
|
|
expect(publicSpaceRepo.upsert.mock.calls[0][0].settings).toEqual({
|
|
appearance: { primaryColorDark: '#222222' },
|
|
});
|
|
});
|
|
|
|
it('passes settings undefined when appearance is omitted', async () => {
|
|
const { service, publicSpaceRepo } = makeService();
|
|
|
|
await service.publish({
|
|
space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any,
|
|
workspace: makeWorkspace(optInSettings),
|
|
authUserId: 'u1',
|
|
enabled: true,
|
|
});
|
|
|
|
expect(publicSpaceRepo.upsert.mock.calls[0][0].settings).toBeUndefined();
|
|
});
|
|
|
|
it('rejects appearance when the workspace lacks the license feature', async () => {
|
|
const { service, publicSpaceRepo } = makeService({
|
|
licenseCheckService: { resolveFeatures: jest.fn().mockReturnValue([]) },
|
|
});
|
|
|
|
await expect(
|
|
service.publish({
|
|
space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any,
|
|
workspace: makeWorkspace(optInSettings),
|
|
authUserId: 'u1',
|
|
enabled: true,
|
|
appearance: { primaryColorLight: '#111111' },
|
|
}),
|
|
).rejects.toBeInstanceOf(ForbiddenException);
|
|
|
|
expect(publicSpaceRepo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('public appearance exposure', () => {
|
|
const storedSettings = {
|
|
future: 1,
|
|
appearance: {
|
|
primaryColorLight: '#111111',
|
|
primaryColorDark: '#222222',
|
|
internal: 'secret',
|
|
},
|
|
};
|
|
const publicAppearance = {
|
|
primaryColorLight: '#111111',
|
|
primaryColorDark: '#222222',
|
|
};
|
|
const publicSpaceRepoWithSettings = {
|
|
findBySpaceId: jest.fn().mockResolvedValue({
|
|
id: 'ps1',
|
|
spaceId: SPACE_ID,
|
|
enabled: true,
|
|
searchIndexing: false,
|
|
settings: storedSettings,
|
|
}),
|
|
};
|
|
|
|
it('whitelists appearance on getPublicSpaceInfo', async () => {
|
|
const { service } = makeService({
|
|
publicSpaceRepo: publicSpaceRepoWithSettings,
|
|
});
|
|
const result = await service.getPublicSpaceInfo(
|
|
'handbook',
|
|
makeWorkspace(optInSettings),
|
|
);
|
|
expect(result.appearance).toEqual(publicAppearance);
|
|
expect(result).not.toHaveProperty('settings');
|
|
});
|
|
|
|
it('whitelists appearance on getPublicSpaceTree', async () => {
|
|
const { service } = makeService({
|
|
publicSpaceRepo: publicSpaceRepoWithSettings,
|
|
});
|
|
const result = await service.getPublicSpaceTree(
|
|
'handbook',
|
|
makeWorkspace(optInSettings),
|
|
);
|
|
expect(result.appearance).toEqual(publicAppearance);
|
|
expect(result).not.toHaveProperty('settings');
|
|
});
|
|
|
|
it('whitelists appearance on the served page response', async () => {
|
|
const { service } = makeService({
|
|
publicSpaceRepo: publicSpaceRepoWithSettings,
|
|
});
|
|
const result = await service.getPublicPage(
|
|
'handbook',
|
|
'abc123XYZ0',
|
|
makeWorkspace(optInSettings),
|
|
);
|
|
expect(result.appearance).toEqual(publicAppearance);
|
|
expect(result).not.toHaveProperty('settings');
|
|
});
|
|
|
|
it('whitelists appearance on the empty-space page response', async () => {
|
|
const { service } = makeService({
|
|
publicSpaceRepo: publicSpaceRepoWithSettings,
|
|
pageRepo: {
|
|
getFirstUnrestrictedRootPage: jest.fn().mockResolvedValue(undefined),
|
|
},
|
|
});
|
|
const result = await service.getPublicPage(
|
|
'handbook',
|
|
undefined,
|
|
makeWorkspace(optInSettings),
|
|
);
|
|
expect(result.page).toBeNull();
|
|
expect(result.appearance).toEqual(publicAppearance);
|
|
expect(result).not.toHaveProperty('settings');
|
|
});
|
|
|
|
it('strips appearance when the workspace lacks the license feature', async () => {
|
|
const { service } = makeService({
|
|
publicSpaceRepo: publicSpaceRepoWithSettings,
|
|
licenseCheckService: { resolveFeatures: jest.fn().mockReturnValue([]) },
|
|
});
|
|
const result = await service.getPublicSpaceInfo(
|
|
'handbook',
|
|
makeWorkspace(optInSettings),
|
|
);
|
|
expect(result.appearance).toBeUndefined();
|
|
});
|
|
|
|
it('omits appearance when none is stored', async () => {
|
|
const { service } = makeService();
|
|
const result = await service.getPublicSpaceInfo(
|
|
'handbook',
|
|
makeWorkspace(optInSettings),
|
|
);
|
|
expect(result.appearance ?? {}).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe('byline', () => {
|
|
function makeRepoWithByline(byline: any) {
|
|
return {
|
|
findBySpaceId: jest.fn().mockResolvedValue({
|
|
id: 'ps1',
|
|
spaceId: SPACE_ID,
|
|
enabled: true,
|
|
searchIndexing: false,
|
|
settings: byline === null ? null : { byline },
|
|
}),
|
|
};
|
|
}
|
|
|
|
it('defaults to a hidden author and a visible updated date', async () => {
|
|
const { service, pageRepo } = makeService({
|
|
publicSpaceRepo: makeRepoWithByline(null),
|
|
});
|
|
|
|
const result = await service.getPublicPage(
|
|
'handbook',
|
|
'abc123XYZ0',
|
|
makeWorkspace(optInSettings),
|
|
);
|
|
|
|
expect(result.byline).toEqual({ author: false, updatedAt: true });
|
|
expect(pageRepo.findById).toHaveBeenCalledWith('abc123XYZ0', {
|
|
includeContent: true,
|
|
includeCreator: false,
|
|
});
|
|
});
|
|
|
|
it('requests the creator only when the author byline is enabled', async () => {
|
|
const { service, pageRepo } = makeService({
|
|
publicSpaceRepo: makeRepoWithByline({ author: true }),
|
|
});
|
|
|
|
const result = await service.getPublicPage(
|
|
'handbook',
|
|
'abc123XYZ0',
|
|
makeWorkspace(optInSettings),
|
|
);
|
|
|
|
expect(result.byline.author).toBe(true);
|
|
expect(pageRepo.findById).toHaveBeenCalledWith('abc123XYZ0', {
|
|
includeContent: true,
|
|
includeCreator: true,
|
|
});
|
|
});
|
|
|
|
it('strips a creator from the page when the author byline is off', async () => {
|
|
const { service } = makeService({
|
|
publicSpaceRepo: makeRepoWithByline({ author: false }),
|
|
pageRepo: {
|
|
findById: jest.fn().mockResolvedValue({
|
|
id: 'page1',
|
|
slugId: 'abc123XYZ0',
|
|
spaceId: SPACE_ID,
|
|
workspaceId: WORKSPACE_ID,
|
|
deletedAt: null,
|
|
content: null,
|
|
creatorId: 'u1',
|
|
creator: { id: 'u1', name: 'Jane', avatarUrl: null },
|
|
}),
|
|
},
|
|
});
|
|
|
|
const result = await service.getPublicPage(
|
|
'handbook',
|
|
'abc123XYZ0',
|
|
makeWorkspace(optInSettings),
|
|
);
|
|
|
|
expect(result.page.creatorId).toBe('u1');
|
|
expect((result.page as any).creator).toBeUndefined();
|
|
});
|
|
|
|
it('returns the byline for an empty published space', async () => {
|
|
const { service } = makeService({
|
|
publicSpaceRepo: makeRepoWithByline({ author: true, updatedAt: false }),
|
|
pageRepo: {
|
|
getFirstUnrestrictedRootPage: jest.fn().mockResolvedValue(undefined),
|
|
},
|
|
});
|
|
|
|
const result = await service.getPublicPage(
|
|
'handbook',
|
|
undefined,
|
|
makeWorkspace(optInSettings),
|
|
);
|
|
|
|
expect(result.page).toBeNull();
|
|
expect(result.byline).toEqual({ author: true, updatedAt: false });
|
|
});
|
|
|
|
it('merges a single byline field into the stored byline', async () => {
|
|
const { service, publicSpaceRepo } = makeService({
|
|
publicSpaceRepo: makeRepoWithByline({ author: true, updatedAt: true }),
|
|
});
|
|
|
|
await service.publish({
|
|
space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any,
|
|
workspace: makeWorkspace(optInSettings),
|
|
authUserId: 'u1',
|
|
enabled: true,
|
|
bylineUpdatedAt: false,
|
|
});
|
|
|
|
expect(publicSpaceRepo.upsert.mock.calls[0][0].settings).toEqual({
|
|
byline: { author: true, updatedAt: false },
|
|
});
|
|
});
|
|
|
|
it('leaves stored settings untouched when no byline field is sent', async () => {
|
|
const { service, publicSpaceRepo } = makeService({
|
|
publicSpaceRepo: makeRepoWithByline({ author: true }),
|
|
});
|
|
|
|
await service.publish({
|
|
space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any,
|
|
workspace: makeWorkspace(optInSettings),
|
|
authUserId: 'u1',
|
|
enabled: true,
|
|
});
|
|
|
|
expect(publicSpaceRepo.upsert.mock.calls[0][0].settings).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('publish', () => {
|
|
it('rejects enabling when the workspace opt-in is off', async () => {
|
|
const { service } = makeService();
|
|
await expect(
|
|
service.publish({
|
|
space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any,
|
|
workspace: makeWorkspace({}),
|
|
authUserId: 'u1',
|
|
enabled: true,
|
|
}),
|
|
).rejects.toThrow(ForbiddenException);
|
|
});
|
|
|
|
it('allows enabling when public page sharing is disabled', async () => {
|
|
const { service, publicSpaceRepo } = makeService();
|
|
await service.publish({
|
|
space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any,
|
|
workspace: makeWorkspace({
|
|
publicSpaces: { enabled: true },
|
|
sharing: { disabled: true },
|
|
}),
|
|
authUserId: 'u1',
|
|
enabled: true,
|
|
});
|
|
|
|
expect(publicSpaceRepo.upsert).toHaveBeenCalledWith(
|
|
expect.objectContaining({ enabled: true }),
|
|
);
|
|
});
|
|
|
|
it('allows disabling even when the opt-in is off', async () => {
|
|
const { service, publicSpaceRepo } = makeService();
|
|
await service.publish({
|
|
space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any,
|
|
workspace: makeWorkspace({}),
|
|
authUserId: 'u1',
|
|
enabled: false,
|
|
});
|
|
expect(publicSpaceRepo.upsert).toHaveBeenCalledWith(
|
|
expect.objectContaining({ enabled: false }),
|
|
);
|
|
});
|
|
|
|
it('defaults every option on except the author byline on first publish', async () => {
|
|
const { service, publicSpaceRepo } = makeService({
|
|
publicSpaceRepo: {
|
|
findBySpaceId: jest.fn().mockResolvedValue(undefined),
|
|
},
|
|
});
|
|
await service.publish({
|
|
space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any,
|
|
workspace: makeWorkspace({
|
|
publicSpaces: { enabled: true, directory: true },
|
|
}),
|
|
authUserId: 'u1',
|
|
enabled: true,
|
|
});
|
|
expect(publicSpaceRepo.upsert).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
enabled: true,
|
|
searchIndexing: true,
|
|
settings: { directory: true },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('skips the directory default when the workspace directory is off', async () => {
|
|
const { service, publicSpaceRepo } = makeService({
|
|
publicSpaceRepo: {
|
|
findBySpaceId: jest.fn().mockResolvedValue(undefined),
|
|
},
|
|
});
|
|
await service.publish({
|
|
space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any,
|
|
workspace: makeWorkspace(optInSettings),
|
|
authUserId: 'u1',
|
|
enabled: true,
|
|
});
|
|
expect(publicSpaceRepo.upsert).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
searchIndexing: true,
|
|
settings: undefined,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('keeps explicitly provided flags on first publish', async () => {
|
|
const { service, publicSpaceRepo } = makeService({
|
|
publicSpaceRepo: {
|
|
findBySpaceId: jest.fn().mockResolvedValue(undefined),
|
|
},
|
|
});
|
|
await service.publish({
|
|
space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any,
|
|
workspace: makeWorkspace({
|
|
publicSpaces: { enabled: true, directory: true },
|
|
}),
|
|
authUserId: 'u1',
|
|
enabled: true,
|
|
searchIndexing: false,
|
|
});
|
|
expect(publicSpaceRepo.upsert).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
searchIndexing: false,
|
|
settings: { directory: true },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('republish preserves prior customization instead of re-applying defaults', async () => {
|
|
const { service, publicSpaceRepo } = makeService({
|
|
publicSpaceRepo: {
|
|
findBySpaceId: jest.fn().mockResolvedValue({
|
|
id: 'ps1',
|
|
spaceId: SPACE_ID,
|
|
enabled: false,
|
|
searchIndexing: false,
|
|
settings: { directory: false },
|
|
}),
|
|
},
|
|
});
|
|
await service.publish({
|
|
space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any,
|
|
workspace: makeWorkspace(optInSettings),
|
|
authUserId: 'u1',
|
|
enabled: true,
|
|
});
|
|
expect(publicSpaceRepo.upsert).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
searchIndexing: undefined,
|
|
settings: undefined,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('directory', () => {
|
|
const directorySettings = {
|
|
publicSpaces: { enabled: true, directory: true },
|
|
};
|
|
|
|
function makeDirectoryRows() {
|
|
return [
|
|
{
|
|
settings: { directory: true },
|
|
searchIndexing: false,
|
|
name: 'Handbook',
|
|
slug: 'handbook',
|
|
description: 'All about us',
|
|
logo: null,
|
|
},
|
|
{
|
|
settings: {},
|
|
searchIndexing: true,
|
|
name: 'Hidden',
|
|
slug: 'hidden',
|
|
description: null,
|
|
logo: null,
|
|
},
|
|
];
|
|
}
|
|
|
|
it('404s when the workspace opt-in is off', async () => {
|
|
const { service } = makeService();
|
|
await expect(
|
|
service.getPublicSpaceDirectory(
|
|
makeWorkspace({ publicSpaces: { enabled: false, directory: true } }),
|
|
),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
});
|
|
|
|
it('404s when the directory flag is off', async () => {
|
|
const { service } = makeService();
|
|
await expect(
|
|
service.getPublicSpaceDirectory(makeWorkspace(optInSettings)),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
});
|
|
|
|
it('lists only spaces opted into the directory with whitelisted fields', async () => {
|
|
const { service } = makeService({
|
|
publicSpaceRepo: {
|
|
findEnabledWithSpaceByWorkspaceId: jest
|
|
.fn()
|
|
.mockResolvedValue(makeDirectoryRows()),
|
|
},
|
|
});
|
|
const result = await service.getPublicSpaceDirectory(
|
|
makeWorkspace(directorySettings),
|
|
);
|
|
expect(result.spaces).toEqual([
|
|
{
|
|
name: 'Handbook',
|
|
slug: 'handbook',
|
|
description: 'All about us',
|
|
logo: null,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('publish stores the directory flag while preserving other settings', async () => {
|
|
const { service, publicSpaceRepo } = makeService({
|
|
publicSpaceRepo: {
|
|
findBySpaceId: jest.fn().mockResolvedValue({
|
|
id: 'ps1',
|
|
spaceId: SPACE_ID,
|
|
enabled: true,
|
|
settings: { appearance: { primaryColorLight: '#111111' } },
|
|
}),
|
|
},
|
|
});
|
|
await service.publish({
|
|
space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any,
|
|
workspace: makeWorkspace(optInSettings),
|
|
authUserId: 'u1',
|
|
enabled: true,
|
|
directory: true,
|
|
});
|
|
expect(publicSpaceRepo.upsert).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
settings: {
|
|
appearance: { primaryColorLight: '#111111' },
|
|
directory: true,
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('publish leaves settings untouched when directory is omitted', async () => {
|
|
const { service, publicSpaceRepo } = makeService();
|
|
await service.publish({
|
|
space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any,
|
|
workspace: makeWorkspace(optInSettings),
|
|
authUserId: 'u1',
|
|
enabled: true,
|
|
});
|
|
expect(publicSpaceRepo.upsert).toHaveBeenCalledWith(
|
|
expect.objectContaining({ settings: undefined }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('cross-space link resolution', () => {
|
|
const OTHER_SPACE_ID = '018f0000-0000-7000-8000-000000000004';
|
|
|
|
const crossSpacePage = {
|
|
id: 'page2',
|
|
slugId: 'crossSlug001',
|
|
spaceId: OTHER_SPACE_ID,
|
|
workspaceId: WORKSPACE_ID,
|
|
deletedAt: null,
|
|
content: null,
|
|
};
|
|
|
|
const otherSpace = {
|
|
id: OTHER_SPACE_ID,
|
|
slug: 'engineering',
|
|
name: 'Engineering',
|
|
workspaceId: WORKSPACE_ID,
|
|
deletedAt: null,
|
|
};
|
|
|
|
it('resolves a contentless probe into another published space', async () => {
|
|
const { service } = makeService({
|
|
pageRepo: { findById: jest.fn().mockResolvedValue(crossSpacePage) },
|
|
spaceRepo: { findById: jest.fn().mockResolvedValue(otherSpace) },
|
|
});
|
|
const result = await service.getPublicPage(
|
|
'handbook',
|
|
'crossSlug001',
|
|
makeWorkspace(optInSettings),
|
|
{ includeContent: false },
|
|
);
|
|
expect(result.space.slug).toBe('engineering');
|
|
expect(result.page.id).toBe('page2');
|
|
});
|
|
|
|
it('404s a cross-space target when content is requested', async () => {
|
|
const { service } = makeService({
|
|
pageRepo: { findById: jest.fn().mockResolvedValue(crossSpacePage) },
|
|
spaceRepo: { findById: jest.fn().mockResolvedValue(otherSpace) },
|
|
});
|
|
await expect(
|
|
service.getPublicPage(
|
|
'handbook',
|
|
'crossSlug001',
|
|
makeWorkspace(optInSettings),
|
|
),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
});
|
|
|
|
it('404s when the target space is not published', async () => {
|
|
const { service } = makeService({
|
|
pageRepo: { findById: jest.fn().mockResolvedValue(crossSpacePage) },
|
|
spaceRepo: { findById: jest.fn().mockResolvedValue(otherSpace) },
|
|
publicSpaceRepo: {
|
|
findBySpaceId: jest
|
|
.fn()
|
|
.mockImplementation(async (spaceId: string) =>
|
|
spaceId === SPACE_ID
|
|
? { id: 'ps1', spaceId: SPACE_ID, enabled: true }
|
|
: { id: 'ps2', spaceId, enabled: false },
|
|
),
|
|
},
|
|
});
|
|
await expect(
|
|
service.getPublicPage(
|
|
'handbook',
|
|
'crossSlug001',
|
|
makeWorkspace(optInSettings),
|
|
{ includeContent: false },
|
|
),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
});
|
|
|
|
it('404s when the target space is outside the workspace', async () => {
|
|
const { service } = makeService({
|
|
pageRepo: { findById: jest.fn().mockResolvedValue(crossSpacePage) },
|
|
spaceRepo: { findById: jest.fn().mockResolvedValue(undefined) },
|
|
});
|
|
await expect(
|
|
service.getPublicPage(
|
|
'handbook',
|
|
'crossSlug001',
|
|
makeWorkspace(optInSettings),
|
|
{ includeContent: false },
|
|
),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
});
|
|
|
|
it('404s when the cross-space target has a restricted ancestor', async () => {
|
|
const { service } = makeService({
|
|
pageRepo: { findById: jest.fn().mockResolvedValue(crossSpacePage) },
|
|
spaceRepo: { findById: jest.fn().mockResolvedValue(otherSpace) },
|
|
pagePermissionRepo: {
|
|
hasRestrictedAncestor: jest.fn().mockResolvedValue(true),
|
|
},
|
|
});
|
|
await expect(
|
|
service.getPublicPage(
|
|
'handbook',
|
|
'crossSlug001',
|
|
makeWorkspace(optInSettings),
|
|
{ includeContent: false },
|
|
),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
});
|
|
});
|
|
});
|