Merge pull request #311 from iflytek/fix/public-skill-detail-anon

fix(web): allow anonymous access to public skill detail
This commit is contained in:
dongmucat 2026-04-15 16:58:45 +08:00 committed by GitHub
commit 89bc58d29e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 84 additions and 6 deletions

View file

@ -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()
})
})

View file

@ -224,7 +224,6 @@ const namespaceRoute = createRoute({
const skillDetailRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/space/$namespace/$slug',
beforeLoad: requireAuth,
validateSearch: (search: Record<string, unknown>): { returnTo?: string } => ({
returnTo: typeof search.returnTo === 'string' && search.returnTo.startsWith('/') ? search.returnTo : undefined,
}),

View file

@ -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(<SkillDetailPage />)
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({