Просмотр исходного кода

feat(ai-tools): implement parent page grant resolution for suggest-path

Add resolveParentGrant function that looks up a page's grant value
by directory path, returning GRANT_OWNER as safe default when the
page is not found. Update generateMemoSuggestion to use actual
grant resolution when user pages are disabled instead of the
Phase 1 hardcoded value.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
VANELLOPE\tomoyuki-t 1 месяц назад
Родитель
Сommit
c89444cda8

+ 1 - 1
.kiro/specs/suggest-path/tasks.md

@@ -29,7 +29,7 @@
 
 ## Phase 2
 
-- [ ] 2. (P) Implement parent page grant resolution
+- [x] 2. (P) Implement parent page grant resolution
   - Implement a function that accepts a directory path and returns the corresponding page's grant value as the upper bound for child page permissions
   - When the parent page exists, return its grant value; when not found, return owner-only grant as a safe default
   - Update memo suggestion generation for the user-pages-disabled case to use actual parent grant resolution instead of the Phase 1 hardcoded value

+ 52 - 23
apps/app/src/server/routes/apiv3/ai-tools/generate-memo-suggestion.spec.ts

@@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => {
     configManagerMock: {
       getConfig: vi.fn(),
     },
+    resolveParentGrantMock: vi.fn(),
   };
 });
 
@@ -25,7 +26,13 @@ vi.mock('~/server/service/config-manager', () => {
   return { configManager: mocks.configManagerMock };
 });
 
+vi.mock('./resolve-parent-grant', () => ({
+  resolveParentGrant: mocks.resolveParentGrantMock,
+}));
+
+const GRANT_PUBLIC = 1;
 const GRANT_OWNER = 4;
+const GRANT_USER_GROUP = 5;
 
 describe('generateMemoSuggestion', () => {
   beforeEach(() => {
@@ -40,33 +47,38 @@ describe('generateMemoSuggestion', () => {
       });
     });
 
-    it('should return a suggestion with type "memo"', () => {
-      const result = generateMemoSuggestion({ username: 'alice' });
+    it('should return a suggestion with type "memo"', async () => {
+      const result = await generateMemoSuggestion({ username: 'alice' });
       expect(result.type).toBe('memo');
     });
 
-    it('should generate path under user home directory', () => {
-      const result = generateMemoSuggestion({ username: 'alice' });
+    it('should generate path under user home directory', async () => {
+      const result = await generateMemoSuggestion({ username: 'alice' });
       expect(result.path).toBe('/user/alice/memo/');
     });
 
-    it('should set grant to GRANT_OWNER (4)', () => {
-      const result = generateMemoSuggestion({ username: 'alice' });
+    it('should set grant to GRANT_OWNER (4)', async () => {
+      const result = await generateMemoSuggestion({ username: 'alice' });
       expect(result.grant).toBe(GRANT_OWNER);
     });
 
-    it('should include a fixed description', () => {
-      const result = generateMemoSuggestion({ username: 'alice' });
+    it('should not call resolveParentGrant', async () => {
+      await generateMemoSuggestion({ username: 'alice' });
+      expect(mocks.resolveParentGrantMock).not.toHaveBeenCalled();
+    });
+
+    it('should include a fixed description', async () => {
+      const result = await generateMemoSuggestion({ username: 'alice' });
       expect(result.description).toBe('Save to your personal memo area');
     });
 
-    it('should include a label', () => {
-      const result = generateMemoSuggestion({ username: 'alice' });
+    it('should include a label', async () => {
+      const result = await generateMemoSuggestion({ username: 'alice' });
       expect(result.label).toBe('Save as memo');
     });
 
-    it('should generate path with trailing slash', () => {
-      const result = generateMemoSuggestion({ username: 'alice' });
+    it('should generate path with trailing slash', async () => {
+      const result = await generateMemoSuggestion({ username: 'alice' });
       expect(result.path).toMatch(/\/$/);
     });
   });
@@ -79,28 +91,45 @@ describe('generateMemoSuggestion', () => {
       });
     });
 
-    it('should generate path under alternative namespace', () => {
-      const result = generateMemoSuggestion({ username: 'bob' });
+    it('should generate path under alternative namespace', async () => {
+      mocks.resolveParentGrantMock.mockResolvedValue(GRANT_OWNER);
+      const result = await generateMemoSuggestion({ username: 'bob' });
       expect(result.path).toBe('/memo/bob/');
     });
 
-    it('should set grant to GRANT_OWNER (4) as hardcoded default in Phase 1', () => {
-      const result = generateMemoSuggestion({ username: 'bob' });
-      expect(result.grant).toBe(GRANT_OWNER);
+    it('should resolve grant from parent page via resolveParentGrant', async () => {
+      mocks.resolveParentGrantMock.mockResolvedValue(GRANT_PUBLIC);
+      const result = await generateMemoSuggestion({ username: 'bob' });
+      expect(result.grant).toBe(GRANT_PUBLIC);
+    });
+
+    it('should call resolveParentGrant with the generated path', async () => {
+      mocks.resolveParentGrantMock.mockResolvedValue(GRANT_OWNER);
+      await generateMemoSuggestion({ username: 'bob' });
+      expect(mocks.resolveParentGrantMock).toHaveBeenCalledWith('/memo/bob/');
+    });
+
+    it('should use GRANT_USER_GROUP when parent has user group grant', async () => {
+      mocks.resolveParentGrantMock.mockResolvedValue(GRANT_USER_GROUP);
+      const result = await generateMemoSuggestion({ username: 'bob' });
+      expect(result.grant).toBe(GRANT_USER_GROUP);
     });
 
-    it('should return a suggestion with type "memo"', () => {
-      const result = generateMemoSuggestion({ username: 'bob' });
+    it('should return a suggestion with type "memo"', async () => {
+      mocks.resolveParentGrantMock.mockResolvedValue(GRANT_OWNER);
+      const result = await generateMemoSuggestion({ username: 'bob' });
       expect(result.type).toBe('memo');
     });
 
-    it('should generate path with trailing slash', () => {
-      const result = generateMemoSuggestion({ username: 'bob' });
+    it('should generate path with trailing slash', async () => {
+      mocks.resolveParentGrantMock.mockResolvedValue(GRANT_OWNER);
+      const result = await generateMemoSuggestion({ username: 'bob' });
       expect(result.path).toMatch(/\/$/);
     });
 
-    it('should include same fixed description as enabled case', () => {
-      const result = generateMemoSuggestion({ username: 'bob' });
+    it('should include same fixed description as enabled case', async () => {
+      mocks.resolveParentGrantMock.mockResolvedValue(GRANT_OWNER);
+      const result = await generateMemoSuggestion({ username: 'bob' });
       expect(result.description).toBe('Save to your personal memo area');
     });
   });

+ 15 - 6
apps/app/src/server/routes/apiv3/ai-tools/generate-memo-suggestion.ts

@@ -3,24 +3,33 @@ import { userHomepagePath } from '@growi/core/dist/utils/page-path-utils';
 
 import { configManager } from '~/server/service/config-manager';
 
+import { resolveParentGrant } from './resolve-parent-grant';
 import type { PathSuggestion } from './suggest-path-types';
 import { SuggestionType } from './suggest-path-types';
 
 const MEMO_LABEL = 'Save as memo';
 const MEMO_DESCRIPTION = 'Save to your personal memo area';
 
-export const generateMemoSuggestion = (user: {
+export const generateMemoSuggestion = async (user: {
   username: string;
-}): PathSuggestion => {
+}): Promise<PathSuggestion> => {
   const disableUserPages = configManager.getConfig('security:disableUserPages');
 
-  const path = disableUserPages
-    ? `/memo/${user.username}/`
-    : `${userHomepagePath(user)}/memo/`;
+  if (disableUserPages) {
+    const path = `/memo/${user.username}/`;
+    const grant = await resolveParentGrant(path);
+    return {
+      type: SuggestionType.MEMO,
+      path,
+      label: MEMO_LABEL,
+      description: MEMO_DESCRIPTION,
+      grant,
+    };
+  }
 
   return {
     type: SuggestionType.MEMO,
-    path,
+    path: `${userHomepagePath(user)}/memo/`,
     label: MEMO_LABEL,
     description: MEMO_DESCRIPTION,
     grant: PageGrant.GRANT_OWNER,

+ 90 - 0
apps/app/src/server/routes/apiv3/ai-tools/resolve-parent-grant.spec.ts

@@ -0,0 +1,90 @@
+import { resolveParentGrant } from './resolve-parent-grant';
+
+const mocks = vi.hoisted(() => {
+  const leanMock = vi.fn();
+  const findOneMock = vi.fn().mockReturnValue({ lean: leanMock });
+  return { findOneMock, leanMock };
+});
+
+vi.mock('@growi/core', () => ({
+  PageGrant: {
+    GRANT_PUBLIC: 1,
+    GRANT_RESTRICTED: 2,
+    GRANT_OWNER: 4,
+    GRANT_USER_GROUP: 5,
+  },
+}));
+
+vi.mock('mongoose', () => ({
+  default: {
+    model: () => ({
+      findOne: mocks.findOneMock,
+    }),
+  },
+}));
+
+const GRANT_PUBLIC = 1;
+const GRANT_OWNER = 4;
+const GRANT_USER_GROUP = 5;
+
+describe('resolveParentGrant', () => {
+  beforeEach(() => {
+    vi.resetAllMocks();
+    mocks.findOneMock.mockReturnValue({ lean: mocks.leanMock });
+  });
+
+  describe('when parent page exists', () => {
+    it('should return GRANT_PUBLIC when page has public grant', async () => {
+      mocks.leanMock.mockResolvedValue({ grant: GRANT_PUBLIC });
+
+      const result = await resolveParentGrant('/tech-notes/React/');
+      expect(result).toBe(GRANT_PUBLIC);
+    });
+
+    it('should return GRANT_OWNER when page has owner grant', async () => {
+      mocks.leanMock.mockResolvedValue({ grant: GRANT_OWNER });
+
+      const result = await resolveParentGrant('/user/alice/memo/');
+      expect(result).toBe(GRANT_OWNER);
+    });
+
+    it('should return GRANT_USER_GROUP when page has user group grant', async () => {
+      mocks.leanMock.mockResolvedValue({ grant: GRANT_USER_GROUP });
+
+      const result = await resolveParentGrant('/team/engineering/');
+      expect(result).toBe(GRANT_USER_GROUP);
+    });
+  });
+
+  describe('when parent page does not exist', () => {
+    it('should return GRANT_OWNER (4) as safe default', async () => {
+      mocks.leanMock.mockResolvedValue(null);
+
+      const result = await resolveParentGrant('/memo/bob/');
+      expect(result).toBe(GRANT_OWNER);
+    });
+  });
+
+  describe('path normalization', () => {
+    it('should strip trailing slash for database lookup', async () => {
+      mocks.leanMock.mockResolvedValue({ grant: GRANT_PUBLIC });
+
+      await resolveParentGrant('/tech-notes/');
+      expect(mocks.findOneMock).toHaveBeenCalledWith({ path: '/tech-notes' });
+    });
+
+    it('should handle path without trailing slash', async () => {
+      mocks.leanMock.mockResolvedValue({ grant: GRANT_PUBLIC });
+
+      await resolveParentGrant('/tech-notes');
+      expect(mocks.findOneMock).toHaveBeenCalledWith({ path: '/tech-notes' });
+    });
+
+    it('should use root path when trailing slash is stripped from root', async () => {
+      mocks.leanMock.mockResolvedValue({ grant: GRANT_PUBLIC });
+
+      await resolveParentGrant('/');
+      expect(mocks.findOneMock).toHaveBeenCalledWith({ path: '/' });
+    });
+  });
+});

+ 15 - 0
apps/app/src/server/routes/apiv3/ai-tools/resolve-parent-grant.ts

@@ -0,0 +1,15 @@
+import { PageGrant } from '@growi/core';
+import mongoose from 'mongoose';
+
+export const resolveParentGrant = async (dirPath: string): Promise<number> => {
+  const pagePath = dirPath.replace(/\/$/, '') || '/';
+
+  const Page = mongoose.model('Page');
+  const page = await Page.findOne({ path: pagePath }).lean();
+
+  if (page == null) {
+    return PageGrant.GRANT_OWNER;
+  }
+
+  return (page as { grant: number }).grant;
+};

+ 2 - 2
apps/app/src/server/routes/apiv3/ai-tools/suggest-path.ts

@@ -48,7 +48,7 @@ export const suggestPathHandlersFactory = (crowi: Crowi): RequestHandler[] => {
     certifyAiService,
     ...validator,
     apiV3FormValidator,
-    (req: SuggestPathReq, res: ApiV3Response) => {
+    async (req: SuggestPathReq, res: ApiV3Response) => {
       const { user } = req;
       assert(
         user != null,
@@ -56,7 +56,7 @@ export const suggestPathHandlersFactory = (crowi: Crowi): RequestHandler[] => {
       );
 
       try {
-        const memoSuggestion = generateMemoSuggestion(user);
+        const memoSuggestion = await generateMemoSuggestion(user);
         return res.apiv3({ suggestions: [memoSuggestion] });
       } catch (err) {
         logger.error(err);