test(share): cover alias controllers; address PR #214 review
Add the two blocking test-coverage specs requested in the PR #214 review and clear the cheap non-blocking items. Must-fix: - share-alias-redirect.controller.spec.ts: routing/leak guard for the public GET /l/:alias resolver (modeled on share-seo.controller.routing.spec). Pins 302-to-canonical on a hit; SPA index without a 302 for unknown/dangling/ unreadable aliases and a null workspace (no name-existence leak); defensive percent-decoding treated as unknown; self-hosted findFirst vs subdomain findByHostname workspace resolution; 404 when no built client index exists. - share-alias.controller.spec.ts: authz gates with mocked PageRepo/ShareService/ ShareAliasService/PageAccessService. Covers cross-workspace/nonexistent page -> NotFoundException, validateCanEdit, resolveReadableSharePage null -> BadRequestException, isSharingAllowed false -> ForbiddenException, set happy path delegation, remove() of a dangling alias (pageId null) skipping validateCanEdit but still deleting, and for-page validateCanView. Cheap review items: - Remove dead Logger import/field from ShareAliasRedirectController. - Remove dead PagePermissionRepo import/dependency from ShareAliasController. - Register the new share-alias UI strings in en-US and ru-RU catalogs. - Add an [Unreleased]/Added CHANGELOG entry for /l/:alias (#205). - Drop the tautological boilerplate assertions from the migration spec (exports up/down; runtime checks of typed entity literals). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -12,6 +12,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- **Custom pretty-links for shared pages (`/l/:alias`).** A page editor can give
|
||||
any publicly shared page a short, memorable, workspace-scoped vanity address
|
||||
backed by a new `share_aliases` table. Hitting `/l/<alias>` issues a `302`
|
||||
(never `301`, since the target is retargetable) to the canonical
|
||||
`/share/<key>/p/<slug>` page; an unknown, dangling, or no-longer-readable alias
|
||||
serves the plain SPA index so that the existence of a name never leaks. An
|
||||
alias can be moved to another page (with a confirm-reassign guard) and the
|
||||
foreign key is `ON DELETE SET NULL`, so deleting the target leaves a dangling
|
||||
alias any workspace member can reclaim. (#205)
|
||||
|
||||
- **Persistent AI-chat history as the source of truth + server-side export.**
|
||||
An assistant turn is now persisted to the database step by step: the row is
|
||||
inserted upfront as `streaming` and updated as each agent step finishes, then
|
||||
|
||||
@@ -1315,5 +1315,15 @@
|
||||
"Protocol": "Protocol",
|
||||
"How chat requests are sent and how reasoning is surfaced": "How chat requests are sent and how reasoning is surfaced",
|
||||
"OpenAI-compatible (surfaces reasoning)": "OpenAI-compatible (surfaces reasoning)",
|
||||
"OpenAI (official)": "OpenAI (official)"
|
||||
"OpenAI (official)": "OpenAI (official)",
|
||||
"Custom address": "Custom address",
|
||||
"A short, memorable link you can point at any shared page.": "A short, memorable link you can point at any shared page.",
|
||||
"Use 2-60 lowercase letters, digits and hyphens": "Use 2-60 lowercase letters, digits and hyphens",
|
||||
"This address is already in use": "This address is already in use",
|
||||
"Move custom address?": "Move custom address?",
|
||||
"Move here": "Move here",
|
||||
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?",
|
||||
"The address \"{{alias}}\" is already in use. Move it to this page?": "The address \"{{alias}}\" is already in use. Move it to this page?",
|
||||
"Failed to set custom address": "Failed to set custom address",
|
||||
"Failed to remove custom address": "Failed to remove custom address"
|
||||
}
|
||||
|
||||
@@ -1169,5 +1169,15 @@
|
||||
"Protocol": "Протокол",
|
||||
"How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning",
|
||||
"OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)",
|
||||
"OpenAI (official)": "OpenAI (официальный)"
|
||||
"OpenAI (official)": "OpenAI (официальный)",
|
||||
"Custom address": "Пользовательский адрес",
|
||||
"A short, memorable link you can point at any shared page.": "Короткая запоминающаяся ссылка, которую можно направить на любую опубликованную страницу.",
|
||||
"Use 2-60 lowercase letters, digits and hyphens": "Используйте 2–60 строчных букв, цифр и дефисов",
|
||||
"This address is already in use": "Этот адрес уже занят",
|
||||
"Move custom address?": "Переместить пользовательский адрес?",
|
||||
"Move here": "Переместить сюда",
|
||||
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "Адрес «{{alias}}» сейчас указывает на «{{title}}». Переместить его на эту страницу?",
|
||||
"The address \"{{alias}}\" is already in use. Move it to this page?": "Адрес «{{alias}}» уже используется. Переместить его на эту страницу?",
|
||||
"Failed to set custom address": "Не удалось задать пользовательский адрес",
|
||||
"Failed to remove custom address": "Не удалось удалить пользовательский адрес"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
// `@sindresorhus/slugify` is ESM-only and not in jest's transformIgnorePatterns,
|
||||
// so the real module fails to parse under ts-jest. Stub it with a minimal,
|
||||
// deterministic slugifier — this spec asserts the controller's slug *assembly*
|
||||
// (`<title-slug>-<slugId>`, 70-char clamp, `untitled` fallback), not the upstream
|
||||
// slug algorithm. The factory keeps the real ESM module from ever being loaded.
|
||||
jest.mock('@sindresorhus/slugify', () => ({
|
||||
__esModule: true,
|
||||
default: (input: string) =>
|
||||
String(input)
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, ''),
|
||||
}));
|
||||
|
||||
import { ShareAliasRedirectController } from './share-alias-redirect.controller';
|
||||
|
||||
/**
|
||||
* Routing/leak guard for the PUBLIC `GET /l/:alias` resolver.
|
||||
*
|
||||
* This is the most security-sensitive surface of the alias feature: an
|
||||
* unauthenticated route that MUST serve the plain SPA index (exactly like any
|
||||
* unknown path) for an unknown / dangling / no-longer-readable alias so that the
|
||||
* existence of a name never leaks. Only a resolvable, still-readable alias may
|
||||
* 302 to the canonical `/share/<key>/p/<title-slug>-<slugId>` page (302 — never
|
||||
* 301 — because the target is retargetable). These tests pin that routing and
|
||||
* the defensive percent-decoding, mirroring `share-seo.controller.routing.spec`.
|
||||
*/
|
||||
|
||||
const STREAM_SENTINEL = { __isStream: true } as unknown as fs.ReadStream;
|
||||
|
||||
// Stub fs at CALL time (jest.spyOn), NOT module load (jest.mock): the controller
|
||||
// transitively pulls bcrypt, whose native module is located by node-gyp-build
|
||||
// reading the filesystem at import time — a module-level fs mock breaks that.
|
||||
beforeEach(() => {
|
||||
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
jest.spyOn(fs, 'createReadStream').mockReturnValue(STREAM_SENTINEL);
|
||||
});
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
function makeRes() {
|
||||
const res: any = {
|
||||
sent: undefined as unknown,
|
||||
statusCode: undefined as number | undefined,
|
||||
redirectUrl: undefined as string | undefined,
|
||||
type: jest.fn(() => res),
|
||||
status: jest.fn((code: number) => {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
}),
|
||||
send: jest.fn((v: unknown) => {
|
||||
res.sent = v;
|
||||
return res;
|
||||
}),
|
||||
redirect: jest.fn((url: string, code: number) => {
|
||||
res.redirectUrl = url;
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
}),
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
function makeController(opts: {
|
||||
resolved?: { share: any; page: any } | null;
|
||||
selfHosted?: boolean;
|
||||
}) {
|
||||
const shareAliasService = {
|
||||
resolveReadableTarget: jest.fn(async () => opts.resolved ?? null),
|
||||
};
|
||||
const workspaceRepo = {
|
||||
findFirst: jest.fn(async () => ({ id: 'ws-self' })),
|
||||
findByHostname: jest.fn(async (sub: string) =>
|
||||
sub === 'acme' ? { id: 'ws-acme' } : null,
|
||||
),
|
||||
};
|
||||
const environmentService = {
|
||||
isSelfHosted: jest.fn(() => opts.selfHosted ?? true),
|
||||
};
|
||||
const controller = new ShareAliasRedirectController(
|
||||
shareAliasService as any,
|
||||
workspaceRepo as any,
|
||||
environmentService as any,
|
||||
);
|
||||
return { controller, shareAliasService, workspaceRepo, environmentService };
|
||||
}
|
||||
|
||||
const selfReq: any = { raw: { headers: { host: 'self' } } };
|
||||
|
||||
describe('ShareAliasRedirectController.resolve', () => {
|
||||
it('302-redirects a resolvable alias to the canonical share page', async () => {
|
||||
const { controller, shareAliasService } = makeController({
|
||||
resolved: {
|
||||
share: { key: 'SHAREKEY' },
|
||||
page: { slugId: 'abc123', title: 'Quarterly Report' },
|
||||
},
|
||||
});
|
||||
const res = makeRes();
|
||||
|
||||
await controller.resolve('promo', selfReq, res);
|
||||
|
||||
expect(shareAliasService.resolveReadableTarget).toHaveBeenCalledWith(
|
||||
'promo',
|
||||
'ws-self',
|
||||
);
|
||||
expect(res.redirect).toHaveBeenCalledWith(
|
||||
'/share/SHAREKEY/p/quarterly-report-abc123',
|
||||
302,
|
||||
);
|
||||
// No index stream was served on a hit.
|
||||
expect(res.sent).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to "untitled" in the slug when the target has no title', async () => {
|
||||
const { controller } = makeController({
|
||||
resolved: { share: { key: 'K' }, page: { slugId: 'sid', title: '' } },
|
||||
});
|
||||
const res = makeRes();
|
||||
|
||||
await controller.resolve('promo', selfReq, res);
|
||||
|
||||
expect(res.redirect).toHaveBeenCalledWith('/share/K/p/untitled-sid', 302);
|
||||
});
|
||||
|
||||
it('streams the SPA index WITHOUT a 302 for an unknown/dangling/unreadable alias (no leak)', async () => {
|
||||
const { controller, shareAliasService } = makeController({ resolved: null });
|
||||
const res = makeRes();
|
||||
|
||||
await controller.resolve('does-not-exist', selfReq, res);
|
||||
|
||||
expect(shareAliasService.resolveReadableTarget).toHaveBeenCalled();
|
||||
// The plain index stream was served and no redirect leaked alias existence.
|
||||
expect(res.redirect).not.toHaveBeenCalled();
|
||||
expect(res.sent).toBe(STREAM_SENTINEL);
|
||||
expect(res.type).toHaveBeenCalledWith('text/html');
|
||||
});
|
||||
|
||||
it('streams the SPA index without even resolving when the workspace is null', async () => {
|
||||
// Subdomain host that maps to no workspace => workspace === null.
|
||||
const { controller, shareAliasService, workspaceRepo } = makeController({
|
||||
selfHosted: false,
|
||||
});
|
||||
const res = makeRes();
|
||||
const req: any = { raw: { headers: { host: 'unknown.example.com' } } };
|
||||
|
||||
await controller.resolve('promo', req, res);
|
||||
|
||||
expect(workspaceRepo.findByHostname).toHaveBeenCalledWith('unknown');
|
||||
// Never even attempts to resolve (alias existence cannot leak per-host).
|
||||
expect(shareAliasService.resolveReadableTarget).not.toHaveBeenCalled();
|
||||
expect(res.redirect).not.toHaveBeenCalled();
|
||||
expect(res.sent).toBe(STREAM_SENTINEL);
|
||||
});
|
||||
|
||||
it('defensively decodes broken percent-encoding and treats it as unknown', async () => {
|
||||
const { controller, shareAliasService } = makeController({ resolved: null });
|
||||
const res = makeRes();
|
||||
|
||||
// '%E0%A4%A' is invalid -> decodeURIComponent throws -> raw value is used,
|
||||
// and the alias resolves to nothing (no crash, served as index).
|
||||
await controller.resolve('%E0%A4%A', selfReq, res);
|
||||
|
||||
expect(shareAliasService.resolveReadableTarget).toHaveBeenCalledWith(
|
||||
'%E0%A4%A',
|
||||
'ws-self',
|
||||
);
|
||||
expect(res.redirect).not.toHaveBeenCalled();
|
||||
expect(res.sent).toBe(STREAM_SENTINEL);
|
||||
});
|
||||
|
||||
it('decodes a valid percent-encoded alias before resolving', async () => {
|
||||
const { controller, shareAliasService } = makeController({ resolved: null });
|
||||
const res = makeRes();
|
||||
|
||||
await controller.resolve('my%2Dlink', selfReq, res);
|
||||
|
||||
expect(shareAliasService.resolveReadableTarget).toHaveBeenCalledWith(
|
||||
'my-link',
|
||||
'ws-self',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves the workspace via findFirst on the self-hosted path', async () => {
|
||||
const { controller, workspaceRepo, shareAliasService } = makeController({
|
||||
selfHosted: true,
|
||||
resolved: null,
|
||||
});
|
||||
const res = makeRes();
|
||||
|
||||
await controller.resolve('promo', selfReq, res);
|
||||
|
||||
expect(workspaceRepo.findFirst).toHaveBeenCalled();
|
||||
expect(workspaceRepo.findByHostname).not.toHaveBeenCalled();
|
||||
expect(shareAliasService.resolveReadableTarget).toHaveBeenCalledWith(
|
||||
'promo',
|
||||
'ws-self',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves the workspace via findByHostname (subdomain) on the cloud path', async () => {
|
||||
const { controller, workspaceRepo, shareAliasService } = makeController({
|
||||
selfHosted: false,
|
||||
resolved: null,
|
||||
});
|
||||
const res = makeRes();
|
||||
const req: any = { raw: { headers: { host: 'acme.example.com' } } };
|
||||
|
||||
await controller.resolve('promo', req, res);
|
||||
|
||||
expect(workspaceRepo.findByHostname).toHaveBeenCalledWith('acme');
|
||||
expect(workspaceRepo.findFirst).not.toHaveBeenCalled();
|
||||
expect(shareAliasService.resolveReadableTarget).toHaveBeenCalledWith(
|
||||
'promo',
|
||||
'ws-acme',
|
||||
);
|
||||
});
|
||||
|
||||
it('serves a 404 when no built client index exists', async () => {
|
||||
jest.spyOn(fs, 'existsSync').mockReturnValue(false);
|
||||
const { controller } = makeController({ resolved: null });
|
||||
const res = makeRes();
|
||||
|
||||
await controller.resolve('promo', selfReq, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get, Logger, Param, Req, Res } from '@nestjs/common';
|
||||
import { Controller, Get, Param, Req, Res } from '@nestjs/common';
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { join } from 'path';
|
||||
import * as fs from 'node:fs';
|
||||
@@ -24,8 +24,6 @@ import { ShareAliasService } from './share-alias.service';
|
||||
*/
|
||||
@Controller('l')
|
||||
export class ShareAliasRedirectController {
|
||||
private readonly logger = new Logger(ShareAliasRedirectController.name);
|
||||
|
||||
constructor(
|
||||
private readonly shareAliasService: ShareAliasService,
|
||||
private readonly workspaceRepo: WorkspaceRepo,
|
||||
|
||||
260
apps/server/src/core/share/share-alias.controller.spec.ts
Normal file
260
apps/server/src/core/share/share-alias.controller.spec.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ShareAliasController } from './share-alias.controller';
|
||||
|
||||
/**
|
||||
* Authz-gate tests for the authenticated alias management controller. The access
|
||||
* decisions for creating/retargeting/removing an alias live in THIS controller
|
||||
* (the service spec delegates authorization to the caller), so each gate is
|
||||
* pinned here against mocked PageRepo / ShareService / ShareAliasService /
|
||||
* PageAccessService. A regression that drops any gate must fail here.
|
||||
*/
|
||||
describe('ShareAliasController authz gates', () => {
|
||||
function makeController() {
|
||||
const shareAliasService = {
|
||||
setAlias: jest.fn(async () => ({ id: 'alias-1' })),
|
||||
removeAlias: jest.fn(async () => undefined),
|
||||
getAliasById: jest.fn(),
|
||||
getAliasForPage: jest.fn(),
|
||||
checkAvailability: jest.fn(),
|
||||
};
|
||||
const shareService = {
|
||||
resolveReadableSharePage: jest.fn(),
|
||||
isSharingAllowed: jest.fn(),
|
||||
};
|
||||
const pageRepo = { findById: jest.fn() };
|
||||
const pageAccessService = {
|
||||
validateCanEdit: jest.fn(async () => undefined),
|
||||
validateCanView: jest.fn(async () => undefined),
|
||||
};
|
||||
const controller = new ShareAliasController(
|
||||
shareAliasService as any,
|
||||
shareService as any,
|
||||
pageRepo as any,
|
||||
pageAccessService as any,
|
||||
);
|
||||
return {
|
||||
controller,
|
||||
shareAliasService,
|
||||
shareService,
|
||||
pageRepo,
|
||||
pageAccessService,
|
||||
};
|
||||
}
|
||||
|
||||
const user: any = { id: 'u-1' };
|
||||
const workspace: any = { id: 'ws-1' };
|
||||
|
||||
describe('set', () => {
|
||||
it('throws NotFoundException for a nonexistent page', async () => {
|
||||
const { controller, pageRepo, pageAccessService } = makeController();
|
||||
pageRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
controller.set({ pageId: 'p-x', alias: 'promo' } as any, user, workspace),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(pageAccessService.validateCanEdit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundException for a page in another workspace', async () => {
|
||||
const { controller, pageRepo } = makeController();
|
||||
pageRepo.findById.mockResolvedValue({
|
||||
id: 'p-1',
|
||||
workspaceId: 'ws-OTHER',
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.set({ pageId: 'p-1', alias: 'promo' } as any, user, workspace),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('enforces validateCanEdit before setting the alias', async () => {
|
||||
const { controller, pageRepo, pageAccessService, shareService } =
|
||||
makeController();
|
||||
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
|
||||
pageAccessService.validateCanEdit.mockRejectedValue(
|
||||
new ForbiddenException('no edit'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
controller.set({ pageId: 'p-1', alias: 'promo' } as any, user, workspace),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
// Gate short-circuits before any share resolution.
|
||||
expect(shareService.resolveReadableSharePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws BadRequestException when the page is not publicly shared', async () => {
|
||||
const { controller, pageRepo, shareService } = makeController();
|
||||
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
|
||||
shareService.resolveReadableSharePage.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
controller.set({ pageId: 'p-1', alias: 'promo' } as any, user, workspace),
|
||||
).rejects.toThrow('Page is not publicly shared');
|
||||
await expect(
|
||||
controller.set({ pageId: 'p-1', alias: 'promo' } as any, user, workspace),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('throws ForbiddenException when public sharing is disabled', async () => {
|
||||
const { controller, pageRepo, shareService } = makeController();
|
||||
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
|
||||
shareService.resolveReadableSharePage.mockResolvedValue({
|
||||
share: { spaceId: 'sp-1' },
|
||||
});
|
||||
shareService.isSharingAllowed.mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
controller.set({ pageId: 'p-1', alias: 'promo' } as any, user, workspace),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('delegates to setAlias on the happy path with all gates passed', async () => {
|
||||
const { controller, pageRepo, shareService, shareAliasService } =
|
||||
makeController();
|
||||
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
|
||||
shareService.resolveReadableSharePage.mockResolvedValue({
|
||||
share: { spaceId: 'sp-1' },
|
||||
});
|
||||
shareService.isSharingAllowed.mockResolvedValue(true);
|
||||
|
||||
const result = await controller.set(
|
||||
{ pageId: 'p-1', alias: 'promo', confirmReassign: true } as any,
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
|
||||
expect(shareAliasService.setAlias).toHaveBeenCalledWith({
|
||||
workspaceId: 'ws-1',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
alias: 'promo',
|
||||
confirmReassign: true,
|
||||
});
|
||||
expect(result).toEqual({ id: 'alias-1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('throws NotFoundException for an unknown alias', async () => {
|
||||
const { controller, shareAliasService } = makeController();
|
||||
shareAliasService.getAliasById.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
controller.remove({ aliasId: 'a-x' } as any, user, workspace),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(shareAliasService.removeAlias).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires validateCanEdit on the current target before removing', async () => {
|
||||
const { controller, shareAliasService, pageRepo, pageAccessService } =
|
||||
makeController();
|
||||
shareAliasService.getAliasById.mockResolvedValue({
|
||||
id: 'a-1',
|
||||
pageId: 'p-1',
|
||||
});
|
||||
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
|
||||
pageAccessService.validateCanEdit.mockRejectedValue(
|
||||
new ForbiddenException('no edit'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
controller.remove({ aliasId: 'a-1' } as any, user, workspace),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(shareAliasService.removeAlias).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes a dangling alias (pageId null) WITHOUT an edit check', async () => {
|
||||
const { controller, shareAliasService, pageRepo, pageAccessService } =
|
||||
makeController();
|
||||
shareAliasService.getAliasById.mockResolvedValue({
|
||||
id: 'a-1',
|
||||
pageId: null,
|
||||
});
|
||||
|
||||
await controller.remove({ aliasId: 'a-1' } as any, user, workspace);
|
||||
|
||||
expect(pageRepo.findById).not.toHaveBeenCalled();
|
||||
expect(pageAccessService.validateCanEdit).not.toHaveBeenCalled();
|
||||
expect(shareAliasService.removeAlias).toHaveBeenCalledWith('a-1', 'ws-1');
|
||||
});
|
||||
|
||||
it('removes when the editor can edit the current target', async () => {
|
||||
const { controller, shareAliasService, pageRepo, pageAccessService } =
|
||||
makeController();
|
||||
shareAliasService.getAliasById.mockResolvedValue({
|
||||
id: 'a-1',
|
||||
pageId: 'p-1',
|
||||
});
|
||||
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
|
||||
|
||||
await controller.remove({ aliasId: 'a-1' } as any, user, workspace);
|
||||
|
||||
expect(pageAccessService.validateCanEdit).toHaveBeenCalled();
|
||||
expect(shareAliasService.removeAlias).toHaveBeenCalledWith('a-1', 'ws-1');
|
||||
});
|
||||
|
||||
it('removes even if the recorded target page no longer exists', async () => {
|
||||
const { controller, shareAliasService, pageRepo, pageAccessService } =
|
||||
makeController();
|
||||
shareAliasService.getAliasById.mockResolvedValue({
|
||||
id: 'a-1',
|
||||
pageId: 'p-gone',
|
||||
});
|
||||
pageRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await controller.remove({ aliasId: 'a-1' } as any, user, workspace);
|
||||
|
||||
expect(pageAccessService.validateCanEdit).not.toHaveBeenCalled();
|
||||
expect(shareAliasService.removeAlias).toHaveBeenCalledWith('a-1', 'ws-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('forPage', () => {
|
||||
it('throws NotFoundException for a cross-workspace/nonexistent page', async () => {
|
||||
const { controller, pageRepo, pageAccessService } = makeController();
|
||||
pageRepo.findById.mockResolvedValue({
|
||||
id: 'p-1',
|
||||
workspaceId: 'ws-OTHER',
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.forPage({ pageId: 'p-1' } as any, user, workspace),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(pageAccessService.validateCanView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires validateCanView and returns the alias (or null)', async () => {
|
||||
const { controller, pageRepo, pageAccessService, shareAliasService } =
|
||||
makeController();
|
||||
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
|
||||
shareAliasService.getAliasForPage.mockResolvedValue({ id: 'a-1' });
|
||||
|
||||
const result = await controller.forPage(
|
||||
{ pageId: 'p-1' } as any,
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
|
||||
expect(pageAccessService.validateCanView).toHaveBeenCalled();
|
||||
expect(result).toEqual({ id: 'a-1' });
|
||||
});
|
||||
|
||||
it('returns null when the page has no alias', async () => {
|
||||
const { controller, pageRepo, shareAliasService } = makeController();
|
||||
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
|
||||
shareAliasService.getAliasForPage.mockResolvedValue(undefined);
|
||||
|
||||
const result = await controller.forPage(
|
||||
{ pageId: 'p-1' } as any,
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,6 @@ import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { PageAccessService } from '../page/page-access/page-access.service';
|
||||
import { ShareService } from './share.service';
|
||||
import { ShareAliasService } from './share-alias.service';
|
||||
@@ -37,7 +36,6 @@ export class ShareAliasController {
|
||||
private readonly shareAliasService: ShareAliasService,
|
||||
private readonly shareService: ShareService,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
) {}
|
||||
|
||||
|
||||
@@ -13,11 +13,6 @@ import type {
|
||||
* the generated entity types line up with the column set.
|
||||
*/
|
||||
describe('share-aliases migration', () => {
|
||||
it('exports up and down functions', () => {
|
||||
expect(typeof migration.up).toBe('function');
|
||||
expect(typeof migration.down).toBe('function');
|
||||
});
|
||||
|
||||
it('up creates the table, the unique index and the page_id index', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
@@ -76,7 +71,9 @@ describe('share-aliases migration', () => {
|
||||
});
|
||||
|
||||
it('entity types expose the alias columns', () => {
|
||||
// Compile-time + runtime sanity: a well-formed row/insert/update value.
|
||||
// Compile-time only: these typed declarations fail `tsc` if the entity types
|
||||
// drift (missing/renamed columns, wrong nullability). The runtime assertions
|
||||
// would be tautological, so the value is purely in the type-check.
|
||||
const row: ShareAlias = {
|
||||
id: 'a-1',
|
||||
workspaceId: 'ws-1',
|
||||
@@ -92,8 +89,6 @@ describe('share-aliases migration', () => {
|
||||
};
|
||||
const update: UpdatableShareAlias = { pageId: null };
|
||||
|
||||
expect(row.alias).toBe('foo');
|
||||
expect(insert.workspaceId).toBe('ws-1');
|
||||
expect(update.pageId).toBeNull();
|
||||
expect([row, insert, update]).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user