fbac8e6976
A share can now be published in 'approved' mode: public readers by URL see
the last manually-saved version (page_history.kind='manual') instead of the
live draft. The content swap lives in the single canonical resolver
resolveReadableSharePage, so EVERY public read path is frozen consistently —
the shared-page view, the SEO controller, AND the share assistant's page-read
tool (owner decision: a visitor and the assistant see the same bytes). Swaps
only content+title; page.id/workspaceId stay LIVE so attachment tokens keep
minting for the right owner. MVP residue kept live: the assistant's page
LIST/TREE tools and transclusions.
- migration 20260712T130000: shares.published_mode varchar(20) NOT NULL
DEFAULT 'live' (additive, standalone; existing shares stay live)
- db.d.ts: publishedMode on Shares (Share/Insertable/Updatable derive)
- page-history.repo: findLatestByPageIdAndKind(pageId, kind, {includeContent})
- share.repo: thread publishedMode through baseFields/insert/update
- share.service.getShareForPage: 4-point CTE threading (anchor + union +
returned object + repo allowlist) — type-guarded, so dropping any point
fails the build/tests
- createShare/updateShare: accept publishedMode; reject approved+includeSubPages
(BadRequestException); auto-mint the first manual baseline ON ENABLE
- share.dto: publishedMode @IsOptional @IsIn(['live','approved'])
- client: "Publish only the saved version" toggle (XOR with sub-pages),
version caption + "unsaved changes newer than the published version" hint;
share types/api carry publishedMode
- tests: resolve-swap (content+title swapped, id+workspaceId preserved),
assistant getSharePage sees frozen content, getShareForPage threading
(direct + descendant), baseline auto-create, XOR reject, migration up/down
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
277 lines
9.5 KiB
TypeScript
277 lines
9.5 KiB
TypeScript
import { Kysely } from 'kysely';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { BadRequestException } from '@nestjs/common';
|
|
import { ShareService } from 'src/core/share/share.service';
|
|
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
|
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
|
|
import * as publishedModeMigration from 'src/database/migrations/20260712T130000-shares-published-mode';
|
|
import {
|
|
getTestDb,
|
|
destroyTestDb,
|
|
createWorkspace,
|
|
createSpace,
|
|
createPage,
|
|
} from './db';
|
|
|
|
/**
|
|
* #370 Stage B — "approved" (publish-only-the-saved-version) share mode, against
|
|
* real Postgres (so the migration column + the hand-written recursive-CTE
|
|
* threading in getShareForPage are exercised for real, not mocked).
|
|
*
|
|
* Guards:
|
|
* - getShareForPage carries `publishedMode` through ALL of the CTE points
|
|
* (anchor select, recursive-union select, returned object) — a direct share
|
|
* AND a descendant (union) share both read it back. If ANY of the 4 threading
|
|
* points is dropped, one of these reddens.
|
|
* - createShare / updateShare auto-mint the first manual baseline ON ENABLE.
|
|
* - approved + includeSubPages is rejected (BadRequestException) on both paths.
|
|
* - resolveReadableSharePage under approved swaps content+title to the manual
|
|
* version while PRESERVING page.id + workspaceId.
|
|
*/
|
|
describe('share published_mode (approved) [integration]', () => {
|
|
let db: Kysely<any>;
|
|
let service: ShareService;
|
|
let shareRepo: ShareRepo;
|
|
let pageHistoryRepo: PageHistoryRepo;
|
|
let wsId: string;
|
|
let spaceId: string;
|
|
|
|
// shares.creator_id / page_history.last_updated_by_id are uuid columns; use
|
|
// null (nullable) rather than a synthetic non-uuid id.
|
|
const USER = null as any;
|
|
|
|
// resolveReadableSharePage runs the restricted-ancestor gate; keep it open.
|
|
const pagePermissionRepo = {
|
|
hasRestrictedAncestor: async () => false,
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
db = getTestDb();
|
|
shareRepo = new ShareRepo(db as any, {} as any);
|
|
const pageRepo = new PageRepo(db as any, {} as any, {} as any);
|
|
pageHistoryRepo = new PageHistoryRepo(db as any);
|
|
service = new ShareService(
|
|
shareRepo as any,
|
|
pageRepo as any,
|
|
pagePermissionRepo as any,
|
|
db as any,
|
|
{} as any, // tokenService — no attachments in these docs
|
|
{} as any, // transclusionService
|
|
{ findById: async () => ({ settings: {} }) } as any, // workspaceRepo
|
|
pageHistoryRepo as any,
|
|
);
|
|
wsId = (await createWorkspace(db)).id;
|
|
spaceId = (await createSpace(db, wsId)).id;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await destroyTestDb();
|
|
});
|
|
|
|
const docWith = (text: string) => ({
|
|
type: 'doc',
|
|
content: [
|
|
{ type: 'paragraph', content: [{ type: 'text', text }] },
|
|
],
|
|
});
|
|
|
|
const newPage = async (
|
|
text = 'live body',
|
|
parentPageId?: string,
|
|
): Promise<string> => {
|
|
const { id } = await createPage(db, { workspaceId: wsId, spaceId });
|
|
await db
|
|
.updateTable('pages')
|
|
.set({ content: docWith(text) as any, parentPageId: parentPageId ?? null })
|
|
.where('id', '=', id)
|
|
.execute();
|
|
return id;
|
|
};
|
|
|
|
const insertShare = async (args: {
|
|
pageId: string;
|
|
publishedMode?: string;
|
|
includeSubPages?: boolean;
|
|
}) => {
|
|
const id = randomUUID();
|
|
await db
|
|
.insertInto('shares')
|
|
.values({
|
|
id,
|
|
key: `k-${id.slice(0, 12)}`,
|
|
pageId: args.pageId,
|
|
includeSubPages: args.includeSubPages ?? false,
|
|
publishedMode: args.publishedMode ?? 'live',
|
|
creatorId: USER,
|
|
spaceId,
|
|
workspaceId: wsId,
|
|
})
|
|
.execute();
|
|
return id;
|
|
};
|
|
|
|
const manualRows = (pageId: string) =>
|
|
db
|
|
.selectFrom('pageHistory')
|
|
.select(['id', 'kind', 'title'])
|
|
.where('pageId', '=', pageId)
|
|
.where('kind', '=', 'manual')
|
|
.execute();
|
|
|
|
const publishedModeColumn = async () =>
|
|
db
|
|
.selectFrom('information_schema.columns' as any)
|
|
.select(['columnName', 'dataType', 'isNullable', 'columnDefault'] as any)
|
|
.where('tableName' as any, '=', 'shares')
|
|
.where('columnName' as any, '=', 'published_mode')
|
|
.executeTakeFirst();
|
|
|
|
// --- migration up/down round-trip ----------------------------------------
|
|
|
|
it('migration down() drops published_mode; up() re-adds it NOT NULL default live', async () => {
|
|
// global-setup already migrated up, so the column is present.
|
|
const before: any = await publishedModeColumn();
|
|
expect(before).toBeDefined();
|
|
expect(before.isNullable).toBe('NO');
|
|
expect(String(before.columnDefault)).toContain("'live'");
|
|
|
|
await publishedModeMigration.down(db as any);
|
|
expect(await publishedModeColumn()).toBeUndefined();
|
|
|
|
// Restore so the rest of the suite (and afterAll) sees the real schema.
|
|
await publishedModeMigration.up(db as any);
|
|
const after: any = await publishedModeColumn();
|
|
expect(after).toBeDefined();
|
|
expect(after.isNullable).toBe('NO');
|
|
});
|
|
|
|
// --- getShareForPage 4-point threading -----------------------------------
|
|
|
|
it('getShareForPage carries publishedMode for a DIRECT share (anchor select + returned object)', async () => {
|
|
const pageId = await newPage();
|
|
await insertShare({ pageId, publishedMode: 'approved' });
|
|
|
|
const share = await service.getShareForPage(pageId, wsId);
|
|
expect(share).toBeDefined();
|
|
expect(share!.publishedMode).toBe('approved');
|
|
});
|
|
|
|
it('getShareForPage carries publishedMode for a DESCENDANT share (recursive-union select)', async () => {
|
|
// Parent shared with includeSubPages + approved (inserted directly, bypassing
|
|
// the service XOR gate, purely to exercise the CTE union threading on a
|
|
// child that inherits the ancestor share).
|
|
const parentId = await newPage('parent body');
|
|
await insertShare({
|
|
pageId: parentId,
|
|
publishedMode: 'approved',
|
|
includeSubPages: true,
|
|
});
|
|
const childId = await newPage('child body', parentId);
|
|
|
|
const share = await service.getShareForPage(childId, wsId);
|
|
expect(share).toBeDefined();
|
|
// Resolved via the recursive union up to the ancestor share.
|
|
expect((share!.level as number) > 0).toBe(true);
|
|
expect(share!.publishedMode).toBe('approved');
|
|
});
|
|
|
|
// --- baseline auto-create on enable --------------------------------------
|
|
|
|
it('createShare(approved) mints the first manual baseline from current content', async () => {
|
|
const pageId = await newPage('baseline body');
|
|
const page = { id: pageId, spaceId } as any;
|
|
|
|
expect(await manualRows(pageId)).toHaveLength(0);
|
|
|
|
await service.createShare({
|
|
authUserId: USER,
|
|
workspaceId: wsId,
|
|
page,
|
|
createShareDto: { pageId, includeSubPages: false, publishedMode: 'approved' } as any,
|
|
});
|
|
|
|
const rows = await manualRows(pageId);
|
|
expect(rows).toHaveLength(1);
|
|
});
|
|
|
|
it('updateShare enabling approved on a live share mints the baseline; a second call is idempotent', async () => {
|
|
const pageId = await newPage('later-saved body');
|
|
const shareId = await insertShare({ pageId, publishedMode: 'live' });
|
|
|
|
expect(await manualRows(pageId)).toHaveLength(0);
|
|
|
|
await service.updateShare(shareId, {
|
|
shareId,
|
|
publishedMode: 'approved',
|
|
} as any);
|
|
expect(await manualRows(pageId)).toHaveLength(1);
|
|
|
|
// Idempotent: staying approved does not duplicate the baseline.
|
|
await service.updateShare(shareId, {
|
|
shareId,
|
|
searchIndexing: true,
|
|
} as any);
|
|
expect(await manualRows(pageId)).toHaveLength(1);
|
|
});
|
|
|
|
// --- XOR gate ------------------------------------------------------------
|
|
|
|
it('createShare rejects approved + includeSubPages', async () => {
|
|
const pageId = await newPage();
|
|
await expect(
|
|
service.createShare({
|
|
authUserId: USER,
|
|
workspaceId: wsId,
|
|
page: { id: pageId, spaceId } as any,
|
|
createShareDto: {
|
|
pageId,
|
|
includeSubPages: true,
|
|
publishedMode: 'approved',
|
|
} as any,
|
|
}),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it('updateShare rejects setting approved while includeSubPages is already on', async () => {
|
|
const pageId = await newPage();
|
|
const shareId = await insertShare({ pageId, includeSubPages: true });
|
|
await expect(
|
|
service.updateShare(shareId, { shareId, publishedMode: 'approved' } as any),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
// --- resolveReadableSharePage content freeze -----------------------------
|
|
|
|
it('resolveReadableSharePage under approved swaps content+title but PRESERVES id + workspaceId', async () => {
|
|
const pageId = await newPage('LIVE draft body');
|
|
const shareId = await insertShare({ pageId, publishedMode: 'approved' });
|
|
|
|
// Seed a manual version distinct from the live draft.
|
|
await pageHistoryRepo.insertPageHistory({
|
|
pageId,
|
|
slugId: `hist-${pageId.slice(0, 8)}`,
|
|
title: 'SAVED title',
|
|
content: docWith('SAVED version body') as any,
|
|
kind: 'manual',
|
|
spaceId,
|
|
workspaceId: wsId,
|
|
} as any);
|
|
|
|
const resolved = await service.resolveReadableSharePage(
|
|
shareId,
|
|
pageId,
|
|
wsId,
|
|
);
|
|
expect(resolved).not.toBeNull();
|
|
// Content + title are the SAVED version.
|
|
expect(resolved!.page.title).toBe('SAVED title');
|
|
expect(JSON.stringify(resolved!.page.content)).toContain(
|
|
'SAVED version body',
|
|
);
|
|
// id + workspaceId stay LIVE (attachment-token ownership must not shift).
|
|
expect(resolved!.page.id).toBe(pageId);
|
|
expect(resolved!.page.workspaceId).toBe(wsId);
|
|
});
|
|
});
|