diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c2aa9c9..b15ac01a 100644 --- a/CHANGELOG.md +++ b/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/` issues a `302` + (never `301`, since the target is retargetable) to the canonical + `/share//p/` 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 diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index bd8c4ed3..df8d66b6 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -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" } diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index f8c59436..7c4f7e38 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -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": "Не удалось удалить пользовательский адрес" } diff --git a/apps/server/src/core/share/share-alias-redirect.controller.spec.ts b/apps/server/src/core/share/share-alias-redirect.controller.spec.ts new file mode 100644 index 00000000..5fdd4af2 --- /dev/null +++ b/apps/server/src/core/share/share-alias-redirect.controller.spec.ts @@ -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* +// (`-`, 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//p/-` 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(); + }); +}); diff --git a/apps/server/src/core/share/share-alias-redirect.controller.ts b/apps/server/src/core/share/share-alias-redirect.controller.ts index 3685273b..81d0af0e 100644 --- a/apps/server/src/core/share/share-alias-redirect.controller.ts +++ b/apps/server/src/core/share/share-alias-redirect.controller.ts @@ -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, diff --git a/apps/server/src/core/share/share-alias.controller.spec.ts b/apps/server/src/core/share/share-alias.controller.spec.ts new file mode 100644 index 00000000..0ff1d2fc --- /dev/null +++ b/apps/server/src/core/share/share-alias.controller.spec.ts @@ -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(); + }); + }); +}); diff --git a/apps/server/src/core/share/share-alias.controller.ts b/apps/server/src/core/share/share-alias.controller.ts index 18fb288e..5c50f8b7 100644 --- a/apps/server/src/core/share/share-alias.controller.ts +++ b/apps/server/src/core/share/share-alias.controller.ts @@ -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, ) {} diff --git a/apps/server/src/database/migrations/share-aliases.migration.spec.ts b/apps/server/src/database/migrations/share-aliases.migration.spec.ts index 43722087..d891c799 100644 --- a/apps/server/src/database/migrations/share-aliases.migration.spec.ts +++ b/apps/server/src/database/migrations/share-aliases.migration.spec.ts @@ -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); }); });