Add a retargetable, human-readable vanity link namespace /l/<alias> that sits alongside the untouched /share/... routes. - New share_aliases table (workspace-scoped, UNIQUE(workspace_id, alias), page_id nullable ON DELETE SET NULL so the address outlives its target). - ShareAliasRepo + ShareAliasService (create / no-op / 409 reassign guard / availability / request-time readable-target resolution through the single existing share boundary). - Public ShareAliasRedirectController (GET /l/:alias) issues a 302 (never 301, the target is mutable) to the canonical /share/:key/p/:slug page; unknown / dangling / no-longer-readable aliases serve the SPA index with no leak. 'l/:alias' excluded from the global /api prefix. - Authenticated ShareAliasController (set/remove/availability/for-page). - Shared ASCII-only normalize/validate util (server + client copies). - Client: Custom address block in the share modal (live normalize + debounced availability + copy + reassign confirmation dialog). - Unit tests: util, repo SQL-shape, service semantics, migration/entity sanity (server jest) + client alias util (vitest). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
253 lines
8.6 KiB
TypeScript
253 lines
8.6 KiB
TypeScript
import { BadRequestException, ConflictException } from '@nestjs/common';
|
|
import { ShareAliasService } from './share-alias.service';
|
|
|
|
/**
|
|
* Behaviour tests for the alias write/resolve semantics: create vs no-op vs the
|
|
* 409 reassign guard, uniqueness-race handling, availability probe, and the
|
|
* request-time readable-target resolution (which re-runs the share boundary).
|
|
*/
|
|
describe('ShareAliasService', () => {
|
|
function makeService() {
|
|
const shareAliasRepo = {
|
|
findByAliasAndWorkspace: jest.fn(),
|
|
findByPageId: jest.fn(),
|
|
findById: jest.fn(),
|
|
insert: jest.fn(),
|
|
updatePageId: jest.fn(),
|
|
delete: jest.fn(),
|
|
};
|
|
const pageRepo = { findById: jest.fn() };
|
|
const shareService = {
|
|
resolveReadableSharePage: jest.fn(),
|
|
isSharingAllowed: jest.fn(),
|
|
};
|
|
const service = new ShareAliasService(
|
|
shareAliasRepo as any,
|
|
pageRepo as any,
|
|
shareService as any,
|
|
);
|
|
return { service, shareAliasRepo, pageRepo, shareService };
|
|
}
|
|
|
|
describe('setAlias', () => {
|
|
it('rejects an invalid alias before touching the db', async () => {
|
|
const { service, shareAliasRepo } = makeService();
|
|
await expect(
|
|
service.setAlias({
|
|
workspaceId: 'ws-1',
|
|
pageId: 'p-1',
|
|
creatorId: 'u-1',
|
|
alias: 'A', // too short + uppercase
|
|
}),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(shareAliasRepo.findByAliasAndWorkspace).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('normalizes then inserts a brand-new alias', async () => {
|
|
const { service, shareAliasRepo } = makeService();
|
|
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
|
|
shareAliasRepo.insert.mockResolvedValue({ id: 'a-1', alias: 'my-page' });
|
|
|
|
const res = await service.setAlias({
|
|
workspaceId: 'ws-1',
|
|
pageId: 'p-1',
|
|
creatorId: 'u-1',
|
|
alias: ' My Page ',
|
|
});
|
|
|
|
expect(shareAliasRepo.findByAliasAndWorkspace).toHaveBeenCalledWith(
|
|
'my-page',
|
|
'ws-1',
|
|
);
|
|
expect(shareAliasRepo.insert).toHaveBeenCalledWith({
|
|
workspaceId: 'ws-1',
|
|
alias: 'my-page',
|
|
pageId: 'p-1',
|
|
creatorId: 'u-1',
|
|
});
|
|
expect(res).toMatchObject({ id: 'a-1' });
|
|
});
|
|
|
|
it('is a no-op when the alias already points at the same page', async () => {
|
|
const { service, shareAliasRepo } = makeService();
|
|
const existing = { id: 'a-1', alias: 'foo', pageId: 'p-1' };
|
|
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(existing);
|
|
|
|
const res = await service.setAlias({
|
|
workspaceId: 'ws-1',
|
|
pageId: 'p-1',
|
|
creatorId: 'u-1',
|
|
alias: 'foo',
|
|
});
|
|
|
|
expect(res).toBe(existing);
|
|
expect(shareAliasRepo.insert).not.toHaveBeenCalled();
|
|
expect(shareAliasRepo.updatePageId).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('throws 409 with current target when name is taken and not confirmed', async () => {
|
|
const { service, shareAliasRepo, pageRepo } = makeService();
|
|
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
|
|
id: 'a-1',
|
|
alias: 'foo',
|
|
pageId: 'p-other',
|
|
});
|
|
pageRepo.findById.mockResolvedValue({ id: 'p-other', title: 'Other' });
|
|
|
|
try {
|
|
await service.setAlias({
|
|
workspaceId: 'ws-1',
|
|
pageId: 'p-1',
|
|
creatorId: 'u-1',
|
|
alias: 'foo',
|
|
});
|
|
fail('expected ConflictException');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(ConflictException);
|
|
expect((err as ConflictException).getResponse()).toMatchObject({
|
|
code: 'ALIAS_REASSIGN_REQUIRED',
|
|
currentPageId: 'p-other',
|
|
currentPageTitle: 'Other',
|
|
});
|
|
}
|
|
expect(shareAliasRepo.updatePageId).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('retargets (UPDATE page_id) when confirmReassign is set', async () => {
|
|
const { service, shareAliasRepo } = makeService();
|
|
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
|
|
id: 'a-1',
|
|
alias: 'foo',
|
|
pageId: 'p-other',
|
|
});
|
|
shareAliasRepo.updatePageId.mockResolvedValue({ id: 'a-1', pageId: 'p-1' });
|
|
|
|
const res = await service.setAlias({
|
|
workspaceId: 'ws-1',
|
|
pageId: 'p-1',
|
|
creatorId: 'u-1',
|
|
alias: 'foo',
|
|
confirmReassign: true,
|
|
});
|
|
|
|
expect(shareAliasRepo.updatePageId).toHaveBeenCalledWith(
|
|
'a-1',
|
|
'p-1',
|
|
'ws-1',
|
|
);
|
|
expect(res).toMatchObject({ pageId: 'p-1' });
|
|
});
|
|
|
|
it('maps a unique-violation race to 409', async () => {
|
|
const { service, shareAliasRepo } = makeService();
|
|
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
|
|
shareAliasRepo.insert.mockRejectedValue({ code: '23505' });
|
|
|
|
await expect(
|
|
service.setAlias({
|
|
workspaceId: 'ws-1',
|
|
pageId: 'p-1',
|
|
creatorId: 'u-1',
|
|
alias: 'foo',
|
|
}),
|
|
).rejects.toBeInstanceOf(ConflictException);
|
|
});
|
|
});
|
|
|
|
describe('checkAvailability', () => {
|
|
it('reports invalid for a bad slug without a db hit', async () => {
|
|
const { service, shareAliasRepo } = makeService();
|
|
const res = await service.checkAvailability('Bad Slug!', 'ws-1');
|
|
expect(res).toMatchObject({ valid: false, available: false });
|
|
expect(shareAliasRepo.findByAliasAndWorkspace).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reports available when no row exists', async () => {
|
|
const { service, shareAliasRepo } = makeService();
|
|
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
|
|
const res = await service.checkAvailability('free-name', 'ws-1');
|
|
expect(res).toMatchObject({
|
|
alias: 'free-name',
|
|
valid: true,
|
|
available: true,
|
|
currentPageId: null,
|
|
});
|
|
});
|
|
|
|
it('reports taken with the current target page', async () => {
|
|
const { service, shareAliasRepo } = makeService();
|
|
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
|
|
id: 'a-1',
|
|
pageId: 'p-9',
|
|
});
|
|
const res = await service.checkAvailability('taken', 'ws-1');
|
|
expect(res).toMatchObject({ available: false, currentPageId: 'p-9' });
|
|
});
|
|
});
|
|
|
|
describe('resolveReadableTarget', () => {
|
|
it('returns null for an invalid alias', async () => {
|
|
const { service } = makeService();
|
|
expect(await service.resolveReadableTarget('!!', 'ws-1')).toBeNull();
|
|
});
|
|
|
|
it('returns null for an unknown or dangling alias', async () => {
|
|
const { service, shareAliasRepo } = makeService();
|
|
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValueOnce(undefined);
|
|
expect(await service.resolveReadableTarget('foo', 'ws-1')).toBeNull();
|
|
|
|
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValueOnce({
|
|
id: 'a-1',
|
|
pageId: null,
|
|
});
|
|
expect(await service.resolveReadableTarget('foo', 'ws-1')).toBeNull();
|
|
});
|
|
|
|
it('returns null when the page is no longer publicly readable', async () => {
|
|
const { service, shareAliasRepo, shareService } = makeService();
|
|
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
|
|
id: 'a-1',
|
|
pageId: 'p-1',
|
|
});
|
|
shareService.resolveReadableSharePage.mockResolvedValue(null);
|
|
expect(await service.resolveReadableTarget('foo', 'ws-1')).toBeNull();
|
|
});
|
|
|
|
it('returns null when sharing is disabled for the space', async () => {
|
|
const { service, shareAliasRepo, shareService } = makeService();
|
|
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
|
|
id: 'a-1',
|
|
pageId: 'p-1',
|
|
});
|
|
shareService.resolveReadableSharePage.mockResolvedValue({
|
|
share: { key: 'k', spaceId: 's-1' },
|
|
page: { slugId: 'sid', title: 'T' },
|
|
});
|
|
shareService.isSharingAllowed.mockResolvedValue(false);
|
|
expect(await service.resolveReadableTarget('foo', 'ws-1')).toBeNull();
|
|
});
|
|
|
|
it('returns the resolved share+page on success', async () => {
|
|
const { service, shareAliasRepo, shareService } = makeService();
|
|
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
|
|
id: 'a-1',
|
|
pageId: 'p-1',
|
|
});
|
|
const resolved = {
|
|
share: { key: 'k', spaceId: 's-1' },
|
|
page: { slugId: 'sid', title: 'T' },
|
|
};
|
|
shareService.resolveReadableSharePage.mockResolvedValue(resolved);
|
|
shareService.isSharingAllowed.mockResolvedValue(true);
|
|
|
|
const res = await service.resolveReadableTarget('FOO', 'ws-1');
|
|
expect(res).toBe(resolved);
|
|
// alias was normalized to lowercase before lookup
|
|
expect(shareAliasRepo.findByAliasAndWorkspace).toHaveBeenCalledWith(
|
|
'foo',
|
|
'ws-1',
|
|
);
|
|
});
|
|
});
|
|
});
|