Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions apps/aurora/app/middleware.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,38 @@ describe('middleware', () => {
});
});

it('passes a ?version query parameter through to getContent', async () => {
const getContentMock = vi.fn().mockResolvedValue({ data: {} });
const getSiteMock = vi.fn().mockResolvedValue({ data: {} });
config.settings.apiPath = 'http://example.com';
registerPloneClientFactory({
getContent: getContentMock,
getSite: getSiteMock,
});
const request = new Request('http://example.com/test-content?version=2');
const context = new RouterContextProvider();
const nextMock = vi.fn();

await initializePloneClientContext(request, context);

await fetchPloneContent(
{
request,
params: { '*': 'test-content' },
context,
unstable_pattern: '/test-content',
unstable_url: new URL(request.url),
},
nextMock,
);

expect(getContentMock).toHaveBeenCalledWith({
path: '/test-content',
version: '2',
expand: ['navroot', 'breadcrumbs', 'navigation', 'actions'],
});
});

it('throws when content is not found', async () => {
const getContentMock = vi
.fn()
Expand Down
6 changes: 5 additions & 1 deletion apps/aurora/app/middleware.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,9 @@ export const fetchPloneContent: Route.MiddlewareFunction = async (
let cli = context.get(ploneClientContext);

const path = `/${params['*'] || ''}`;
// A `?version=N` query (e.g. History's "View this revision") fetches that
// revision via the @history endpoint instead of the current content.
const version = new URL(request.url).searchParams.get('version') ?? undefined;

let userId = '';
if (token) {
Expand Down Expand Up @@ -172,7 +175,7 @@ export const fetchPloneContent: Route.MiddlewareFunction = async (

try {
const [content, site, user] = await Promise.all([
cli.getContent({ path, expand }),
cli.getContent({ path, version, expand }),
cli.getSite(),
userId ? cli.getUser({ id: userId }).catch(() => null) : null,
]);
Expand Down Expand Up @@ -200,6 +203,7 @@ export const fetchPloneContent: Route.MiddlewareFunction = async (
const [content, site] = await Promise.all([
cli.getContent({
path,
version,
expand: expand.filter((item) => item !== 'types'),
}),
cli.getSite(),
Expand Down
1 change: 1 addition & 0 deletions apps/aurora/news/30.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Resolve the `?version` query parameter when fetching content, so "View this revision" in the History view shows the requested revision.
1 change: 1 addition & 0 deletions packages/client/news/30.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`getContent` no longer drops the `expand` parameter when a specific version is requested.
59 changes: 48 additions & 11 deletions packages/client/src/restapi/content/get.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ const cli = ploneClient.initialize({
apiPath: 'http://localhost:55001/plone',
});

// Versions are permission-protected, so the version tests need a logged-in
// client (same pattern as update.test.ts).
const authCli = ploneClient.initialize({
apiPath: 'http://localhost:55001/plone',
});
await authCli.login({ data: { login: 'admin', password: 'secret' } });

beforeEach(async () => {
await setup();
});
Expand Down Expand Up @@ -54,18 +61,48 @@ describe('getContent', () => {
);
});

test.skip('Version', async () => {
const path = '/';
const version = 'abcd';
const result = await cli.getContent({ path, version });
expect(result.data.title).toBe('Welcome to Plone');
test('Version', async () => {
await authCli.createContent({
path: '/',
data: { '@type': 'Document', title: 'Versioned Page' },
});
await authCli.updateContent({
path: '/versioned-page',
data: { title: 'Versioned Page updated' },
});

const result = await authCli.getContent({
path: '/versioned-page',
version: '0',
});

expect(result.data.title).toBe('Versioned Page');
});

test.skip('FullObjects & version', async () => {
const path = '/';
const fullObjects = true;
const version = 'abcd';
const result = await cli.getContent({ path, fullObjects, version });
expect(result.data.title).toBe('Welcome to Plone');
test('Version & expand', async () => {
await authCli.createContent({
path: '/',
data: { '@type': 'Document', title: 'Versioned Page' },
});
await authCli.updateContent({
path: '/versioned-page',
data: { title: 'Versioned Page updated' },
});

// expand must reach the @history endpoint too (it used to be dropped
// whenever a version was requested)
const result = await authCli.getContent({
path: '/versioned-page',
version: '0',
expand: ['breadcrumbs', 'navigation'],
});

expect(result.data.title).toBe('Versioned Page');
expect(result.data['@components'].breadcrumbs.root).toBe(
'http://localhost:55001/plone',
);
expect(result.data['@components'].navigation.items.length).toBeGreaterThan(
0,
);
});
});
12 changes: 6 additions & 6 deletions packages/client/src/restapi/content/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,18 @@ export async function getContent(
}),
},
};
if (validatedArgs.expand) {
options.params = {
...options.params,
expand,
};
}
if (validatedArgs.version) {
return apiRequest(
'get',
`${path}/@history/${validatedArgs.version}`,
options,
);
}
if (validatedArgs.expand) {
options.params = {
...options.params,
expand,
};
}
return apiRequest('get', path, options);
}
188 changes: 188 additions & 0 deletions packages/cmsui/acceptance/tests/history.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { expect, test } from '../../../tooling/playwright/test';
import { login } from '../../../tooling/playwright/login';
import { createContent } from '../../../tooling/playwright/content';

const apiURL =
process.env.API_PATH ||
`http://${process.env.BACKEND_HOST || '127.0.0.1'}:55001/${process.env.SITE_ID || 'plone'}`;

const authHeader = `Basic ${Buffer.from('admin:secret', 'utf8').toString('base64')}`;

/**
* Opens the history page and waits for hydration: the toolbar back button is
* rendered client-side only (Pluggable), so once it is visible the react-aria
* widgets are interactive.
*/
async function openHistory(page: Parameters<typeof login>[0]) {
await page.goto('/@@history/my-page');
await expect(page.getByRole('link', { name: 'Back' })).toBeVisible();
}

/** Edits the document via the API so a new version is recorded. */
async function editTitle(page: Parameters<typeof login>[0], title: string) {
const response = await page.request.patch(`${apiURL}/my-page`, {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: authHeader,
},
data: { title },
});
expect(response.ok()).toBeTruthy();
}

test.describe('History route', () => {
test.beforeEach(async ({ page }) => {
await createContent(page, {
contentType: 'Document',
contentId: 'my-page',
contentTitle: 'My Page',
});
await login(page);
});

test('lists the revision history of a document', async ({ page }) => {
await page.goto('/@@history/my-page');

await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'Changes to "My Page"',
);
// the creation already yields at least one entry
await expect(page.locator('tbody tr').first()).toBeVisible();
});

test('redirects anonymous visitors to the login', async ({ page }) => {
// a published page: on private content the middleware already fails the
// anonymous content fetch (error boundary) before the loader's auth
// guard can redirect
await createContent(page, {
contentType: 'Document',
contentId: 'public-page',
contentTitle: 'Public Page',
transition: 'publish',
});
await page.context().clearCookies();

await page.goto('/@@history/public-page');

await expect(page).toHaveURL(/\/login/);
});

test('navigates back to the content via the toolbar back button', async ({
page,
}) => {
await openHistory(page);

await page.getByRole('link', { name: 'Back' }).click();

await expect(page).toHaveURL(/\/my-page$/);
await expect(
page.getByRole('heading', { name: 'My Page', exact: true }),
).toBeVisible();
});

test('asks for confirmation before reverting', async ({ page }) => {
// two edits, so an older, revertable version exists
await editTitle(page, 'My Page (v2)');
await editTitle(page, 'My Page (v3)');

await openHistory(page);

// the oldest versioning row is revertable; its menu is the last one
await page.getByRole('button', { name: 'Actions' }).last().click();
await page
.getByRole('menuitem', { name: 'Revert to this version' })
.click();

// the menu popover is itself a (closing) dialog, so match by name
const dialog = page.getByRole('dialog', {
name: 'Revert to this version?',
});
await expect(dialog).toBeVisible();

// cancelling closes the dialog without changing the content
await dialog.getByRole('button', { name: 'Cancel' }).click();
await expect(dialog).toBeHidden();
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'Changes to "My Page (v3)"',
);
});

test('reverts to a previous version after confirmation', async ({ page }) => {
await editTitle(page, 'My Page (v2)');
await editTitle(page, 'My Page (v3)');

await openHistory(page);

await page.getByRole('button', { name: 'Actions' }).last().click();
await page
.getByRole('menuitem', { name: 'Revert to this version' })
.click();
const dialog = page.getByRole('dialog', {
name: 'Revert to this version?',
});
await dialog.getByRole('button', { name: 'Revert' }).click();

// the dialog closes on success and the loader revalidates: the title
// shows the restored (oldest) state again
await expect(dialog).toBeHidden();
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'Changes to "My Page"',
);
});

test('shows an older revision via "View this revision"', async ({ page }) => {
await editTitle(page, 'My Page (v2)');
await editTitle(page, 'My Page (v3)');

await openHistory(page);

// the last menu belongs to the oldest version (v0, title "My Page")
await page.getByRole('button', { name: 'Actions' }).last().click();
await page.getByRole('menuitem', { name: 'View this revision' }).click();

await expect(page).toHaveURL(/\?version=0$/);
await expect(
page.getByRole('heading', { name: 'My Page', exact: true }),
).toBeVisible();
});

test('does not leak old revisions to anonymous visitors', async ({
page,
}) => {
// a published page whose original title differs from the current one
await createContent(page, {
contentType: 'Document',
contentId: 'public-page',
contentTitle: 'Old secret title',
transition: 'publish',
});
const patch = await page.request.patch(`${apiURL}/public-page`, {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: authHeader,
},
data: { title: 'Public title' },
});
expect(patch.ok()).toBeTruthy();

// drop the auth cookie: the backend protects the @history endpoint, so
// the version request must fail instead of serving the old revision
await page.context().clearCookies();
const response = await page.goto('/public-page?version=0');

expect(response?.ok()).toBeFalsy();
await expect(page.getByText('Old secret title')).toBeHidden();
});

test('hides the History toolbar button on the site root', async ({
page,
}) => {
await page.goto('/my-page');
await expect(page.getByRole('link', { name: 'History' })).toBeVisible();

await page.goto('/');
await expect(page.getByRole('link', { name: 'History' })).toBeHidden();
});
});
Loading
Loading