From 3c4c33ad95a52a5436f7970c1cf1013e22f5f701 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 15 Apr 2026 16:16:08 +0800 Subject: [PATCH] fix(web): allow anonymous access to public skill detail --- web/e2e/public-skill-detail-anonymous.spec.ts | 46 +++++++++++++++++++ web/src/app/router.tsx | 1 - web/src/pages/skill-detail.test.tsx | 43 +++++++++++++++-- 3 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 web/e2e/public-skill-detail-anonymous.spec.ts diff --git a/web/e2e/public-skill-detail-anonymous.spec.ts b/web/e2e/public-skill-detail-anonymous.spec.ts new file mode 100644 index 00000000..56ba8885 --- /dev/null +++ b/web/e2e/public-skill-detail-anonymous.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { getSearchCard, prepareSearchSeed, type PreparedSearchSeed } from './helpers/search-seed' + +const SEARCH_URL = (q: string) => `/search?q=${encodeURIComponent(q)}&sort=relevance&page=0&starredOnly=false` + +function latestSeed(seed: PreparedSearchSeed) { + return { + skill: seed.skills[seed.skills.length - 1], + skillName: seed.skillNames[seed.skillNames.length - 1], + } +} + +let seeded: PreparedSearchSeed | undefined + +test.describe('Public Skill Detail Anonymous Access (Real API)', () => { + test.beforeAll(async ({ browser }, testInfo) => { + seeded = await prepareSearchSeed(browser, testInfo, { count: 1 }) + }) + + test.afterAll(async () => { + await seeded?.dispose() + seeded = undefined + }) + + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + test('allows anonymous users to open a public skill detail and view install content', async ({ page }) => { + const current = latestSeed(seeded!) + + await page.goto(SEARCH_URL(seeded!.keyword)) + const card = getSearchCard(page, current.skillName) + await expect(card).toBeVisible({ timeout: 15_000 }) + + await card.click() + + await expect(page).toHaveURL(new RegExp(`/space/${current.skill.namespace}/${current.skill.slug}$`)) + await expect(page).not.toHaveURL(/\/login\?returnTo=/) + await expect(page.getByRole('heading', { name: current.skillName, exact: true })).toBeVisible() + await expect(page.getByText('Install', { exact: true })).toBeVisible() + await expect(page.getByText(new RegExp(`npx clawhub install ${current.skill.slug}`))).toBeVisible() + await expect(page.getByRole('button', { name: 'Copy' }).first()).toBeVisible() + }) +}) diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 7fb6f208..4095904d 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -224,7 +224,6 @@ const namespaceRoute = createRoute({ const skillDetailRoute = createRoute({ getParentRoute: () => rootRoute, path: '/space/$namespace/$slug', - beforeLoad: requireAuth, validateSearch: (search: Record): { returnTo?: string } => ({ returnTo: typeof search.returnTo === 'string' && search.returnTo.startsWith('/') ? search.returnTo : undefined, }), diff --git a/web/src/pages/skill-detail.test.tsx b/web/src/pages/skill-detail.test.tsx index 067a171f..87bdcb48 100644 --- a/web/src/pages/skill-detail.test.tsx +++ b/web/src/pages/skill-detail.test.tsx @@ -2,10 +2,17 @@ import { renderToStaticMarkup } from 'react-dom/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const navigateMock = vi.fn() -const hasRoleMock = vi.fn((role: string) => role === 'USER') +const hasRoleMock = vi.fn<(role: string) => boolean>((role: string) => role === 'USER') const useSkillDetailMock = vi.fn() const useSkillLabelsMock = vi.fn() const useSkillVersionsMock = vi.fn() +let authState: { + user: { userId: string; platformRoles: string[] } | null + hasRole: (role: string) => boolean +} = { + user: { userId: 'owner-1', platformRoles: ['USER'] }, + hasRole: hasRoleMock, +} vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigateMock, @@ -32,10 +39,7 @@ vi.mock('@tanstack/react-query', () => ({ })) vi.mock('@/features/auth/use-auth', () => ({ - useAuth: () => ({ - user: { userId: 'owner-1', platformRoles: ['USER'] }, - hasRole: hasRoleMock, - }), + useAuth: () => authState, })) vi.mock('@/features/report/use-skill-reports', () => ({ @@ -165,6 +169,10 @@ describe('SkillDetailPage', () => { beforeEach(() => { navigateMock.mockReset() hasRoleMock.mockImplementation((role: string) => role === 'USER') + authState = { + user: { userId: 'owner-1', platformRoles: ['USER'] }, + hasRole: hasRoleMock, + } useSkillDetailMock.mockReturnValue({ data: createSkill(), isLoading: false, @@ -208,6 +216,31 @@ describe('SkillDetailPage', () => { expect(html).not.toContain('skillDetail.deleteSkill') }) + it('renders public skill details for an anonymous viewer', () => { + authState = { + user: null, + hasRole: vi.fn(() => false), + } + + useSkillDetailMock.mockReturnValue({ + data: createSkill({ + canManageLifecycle: false, + canInteract: true, + visibility: 'PUBLIC', + }), + isLoading: false, + isFetching: false, + error: null, + }) + + const html = renderToStaticMarkup() + + expect(html).toContain('Demo Skill') + expect(html).toContain('install') + expect(html).not.toContain('skillDetail.loginRequired') + expect(html).not.toContain('skillDetail.deleteSkill') + }) + it('shows the label management panel for a user who can manage the skill lifecycle', () => { useSkillDetailMock.mockReturnValue({ data: createSkill({