Kaynağa Gözat

test(page): characterize grant preservation on update without grant (#11272)

Locks the server premise the pre-load race fix depends on: PageService.updatePage
preserves the page's existing grant when options.grant is omitted, and only
changes it when a grant is explicitly provided. A GRANT_OWNER page stays
GRANT_OWNER on a grantless update.

Stubs the fire-and-forget updatePageSubOperation and page events so their async
DB work doesn't outlive the in-memory mongo (same approach as page.integ.ts).

Refs #11272

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Yuki Takei 3 gün önce
ebeveyn
işleme
b25790dbf3

+ 125 - 0
apps/app/src/server/service/page/grant-preserve-on-update.integ.ts

@@ -0,0 +1,125 @@
+import { type IUser, PageGrant } from '@growi/core';
+import type { HydratedDocument, Model } from 'mongoose';
+import mongoose from 'mongoose';
+import { vi } from 'vitest';
+
+import { getInstance } from '^/test/setup/crowi';
+
+import type Crowi from '~/server/crowi';
+import type { PageDocument, PageModel } from '~/server/models/page';
+
+/**
+ * Characterization test for the server premise that the pre-load race fix relies
+ * on (issue #11272): when a page is updated WITHOUT a grant, the update endpoint
+ * must preserve the page's existing grant rather than defaulting it.
+ *
+ * The editor omits the grant from the update request while selectedGrant is
+ * unresolved (toPageUpdateGrantParams), so this preservation is what keeps a
+ * restricted page from being silently published.
+ */
+describe('PageService.updatePage grant preservation', () => {
+  let crowi: Crowi;
+  let Page: PageModel;
+  let User: Model<IUser>;
+  let user: HydratedDocument<IUser>;
+
+  const create = async (
+    path: string,
+    body: string,
+    options = {},
+  ): Promise<HydratedDocument<PageDocument>> => {
+    const mockedCreateSubOperation = vi
+      .spyOn(crowi.pageService, 'createSubOperation')
+      .mockReturnValue(Promise.resolve());
+
+    const createdPage = await crowi.pageService.create(
+      path,
+      body,
+      user,
+      options,
+    );
+
+    const argsForCreateSubOperation = mockedCreateSubOperation.mock.calls[0];
+    mockedCreateSubOperation.mockRestore();
+    await crowi.pageService.createSubOperation(
+      ...(argsForCreateSubOperation as Parameters<
+        typeof crowi.pageService.createSubOperation
+      >),
+    );
+
+    return createdPage;
+  };
+
+  beforeAll(async () => {
+    crowi = await getInstance();
+    await crowi.configManager.updateConfig('app:isV5Compatible', true);
+
+    User = mongoose.model<IUser>('User');
+    Page = mongoose.model<PageDocument, PageModel>('Page');
+
+    // Suppress page events so their async listeners (e.g. obsolete-page onUpdate)
+    // don't run DB work after the in-memory mongo is torn down. Same pattern as
+    // page.integ.ts. The grant is set synchronously in create/updatePage, so this
+    // does not affect what we assert.
+    vi.spyOn(crowi.pageService.pageEvent, 'emit').mockReturnValue(true);
+
+    // updatePage fires updatePageSubOperation without awaiting it (descendant
+    // bookkeeping). Stub it so that fire-and-forget DB work doesn't outlive the
+    // test and hit the closed connection pool. The grant is already applied to the
+    // saved page before this runs, so stubbing it doesn't affect the assertions.
+    vi.spyOn(crowi.pageService, 'updatePageSubOperation').mockResolvedValue();
+
+    // Ensure a root page exists so created pages can be attached to the tree.
+    const existingRoot = await Page.findOne({ path: '/' });
+    if (existingRoot == null) {
+      await Page.create({ path: '/', grant: Page.GRANT_PUBLIC });
+    }
+
+    const username = 'grantPreserveUser';
+    user =
+      (await User.findOne({ username })) ??
+      (await User.create({
+        name: username,
+        username,
+        email: 'grant-preserve@example.com',
+      }));
+  });
+
+  it('keeps GRANT_OWNER when the update omits a grant', async () => {
+    const page = await create('/grant-preserve-owner', 'initial body', {
+      grant: PageGrant.GRANT_OWNER,
+    });
+    expect(page.grant).toBe(PageGrant.GRANT_OWNER);
+
+    const updated = await crowi.pageService.updatePage(
+      page,
+      'updated body',
+      'initial body',
+      user,
+      {}, // no grant
+    );
+
+    expect(updated.grant).toBe(PageGrant.GRANT_OWNER);
+  });
+
+  it('changes the grant when the update explicitly provides one', async () => {
+    const page = await create(
+      '/grant-preserve-owner-to-public',
+      'initial body',
+      {
+        grant: PageGrant.GRANT_OWNER,
+      },
+    );
+    expect(page.grant).toBe(PageGrant.GRANT_OWNER);
+
+    const updated = await crowi.pageService.updatePage(
+      page,
+      'updated body',
+      'initial body',
+      user,
+      { grant: PageGrant.GRANT_PUBLIC },
+    );
+
+    expect(updated.grant).toBe(PageGrant.GRANT_PUBLIC);
+  });
+});