mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
Merge pull request #699 from iflytek/feat/compliance-search-detail-projection
feat(search): surface compliance mappings in discovery
This commit is contained in:
commit
c56e21e4bb
19 changed files with 344 additions and 84 deletions
|
|
@ -63,7 +63,8 @@ x-astron-compliance: # 可选,平台私有合规元数据
|
|||
```
|
||||
|
||||
> 合规元数据先按 SkillHub/Astron 私有扩展实现,字段名采用 `x-astron-compliance`。
|
||||
> 当前第一阶段支持发布校验和版本级 `complianceSnapshot` 固化;详情展示、审核 diff、搜索 facet
|
||||
> 当前第一阶段支持发布校验和版本级 `complianceSnapshot` 固化;这些信息表示“技能作者声明的合规映射”,
|
||||
> SkillHub 校验证据引用的格式和可访问性,但不等同于第三方认证或平台背书。详情展示、审核 diff、搜索投影
|
||||
> 和 Runtime trace 集成按后续阶段推进。设计边界、分阶段实现和 Runtime 职责划分见
|
||||
> [24-compliance-metadata-design.md](24-compliance-metadata-design.md)。
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ Issue #556 提出的方向是让 SkillHub 支持“可标准映射、可审计
|
|||
|
||||
> SkillHub 负责“这个技能版本声明了什么合规能力”;Agent Runtime 负责“这次执行实际用了哪个技能版本”。两者通过 `skillVersionId + complianceSnapshotDigest` 关联。
|
||||
|
||||
这里的 compliance 是作者随技能包提交的声明型元数据。SkillHub 第一阶段只验证字段结构、取值格式、
|
||||
包内证据文件是否存在、外部证据 URL 是否是合法 HTTP(S) URL,并生成不可变快照摘要;它不验证外部标准内容是否真实适用,
|
||||
也不代表第三方审计、认证通过或平台背书。
|
||||
|
||||
## 2. 职责边界
|
||||
|
||||
### 2.1 SkillHub 职责
|
||||
|
|
|
|||
|
|
@ -20,5 +20,6 @@ public record SkillSummaryResponse(
|
|||
SkillLifecycleVersionResponse headlineVersion,
|
||||
SkillLifecycleVersionResponse publishedVersion,
|
||||
SkillLifecycleVersionResponse ownerPreviewVersion,
|
||||
String resolutionMode
|
||||
String resolutionMode,
|
||||
ComplianceSnapshotResponse complianceSnapshot
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -79,7 +79,8 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository {
|
|||
toLifecycleVersion(headlineVersion),
|
||||
toLifecycleVersion(publishedVersion),
|
||||
toLifecycleVersion(ownerPreviewVersion),
|
||||
projection.resolutionMode().name()
|
||||
projection.resolutionMode().name(),
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import java.util.Map;
|
|||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
|
|
@ -36,6 +37,7 @@ public class SkillSearchAppService {
|
|||
private final NamespaceRepository namespaceRepository;
|
||||
private final NamespaceService namespaceService;
|
||||
private final SkillLifecycleProjectionService skillLifecycleProjectionService;
|
||||
private final ComplianceSnapshotProjectionService complianceSnapshotProjectionService;
|
||||
private final RbacService rbacService;
|
||||
|
||||
public SkillSearchAppService(
|
||||
|
|
@ -45,11 +47,32 @@ public class SkillSearchAppService {
|
|||
NamespaceService namespaceService,
|
||||
SkillLifecycleProjectionService skillLifecycleProjectionService,
|
||||
RbacService rbacService) {
|
||||
this(
|
||||
searchQueryService,
|
||||
skillRepository,
|
||||
namespaceRepository,
|
||||
namespaceService,
|
||||
skillLifecycleProjectionService,
|
||||
new ComplianceSnapshotProjectionService(new com.fasterxml.jackson.databind.ObjectMapper()),
|
||||
rbacService
|
||||
);
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public SkillSearchAppService(
|
||||
SearchQueryService searchQueryService,
|
||||
SkillRepository skillRepository,
|
||||
NamespaceRepository namespaceRepository,
|
||||
NamespaceService namespaceService,
|
||||
SkillLifecycleProjectionService skillLifecycleProjectionService,
|
||||
ComplianceSnapshotProjectionService complianceSnapshotProjectionService,
|
||||
RbacService rbacService) {
|
||||
this.searchQueryService = searchQueryService;
|
||||
this.skillRepository = skillRepository;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.namespaceService = namespaceService;
|
||||
this.skillLifecycleProjectionService = skillLifecycleProjectionService;
|
||||
this.complianceSnapshotProjectionService = complianceSnapshotProjectionService;
|
||||
this.rbacService = rbacService;
|
||||
}
|
||||
|
||||
|
|
@ -198,7 +221,11 @@ public class SkillSearchAppService {
|
|||
return skillIds.stream()
|
||||
.map(skillsById::get)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.map(skill -> toSummaryResponse(skill, namespaceSlugsById, projectionsBySkillId.get(skill.getId())))
|
||||
.map(skill -> toSummaryResponse(
|
||||
skill,
|
||||
namespaceSlugsById,
|
||||
projectionsBySkillId.get(skill.getId())
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
|
|
@ -207,6 +234,7 @@ public class SkillSearchAppService {
|
|||
Map<Long, String> namespaceSlugsById,
|
||||
SkillLifecycleProjectionService.Projection projection) {
|
||||
String namespaceSlug = namespaceSlugsById.get(skill.getNamespaceId());
|
||||
SkillLifecycleProjectionService.VersionProjection headlineVersion = projection.headlineVersion();
|
||||
|
||||
return new SkillSummaryResponse(
|
||||
skill.getId(),
|
||||
|
|
@ -225,7 +253,10 @@ public class SkillSearchAppService {
|
|||
toLifecycleVersion(projection.headlineVersion()),
|
||||
toLifecycleVersion(projection.publishedVersion()),
|
||||
toLifecycleVersion(projection.ownerPreviewVersion()),
|
||||
projection.resolutionMode().name()
|
||||
projection.resolutionMode().name(),
|
||||
headlineVersion != null
|
||||
? complianceSnapshotProjectionService.fromParsedMetadataJson(headlineVersion.parsedMetadataJson())
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -114,7 +114,8 @@ class ClawHubCompatControllerTest {
|
|||
new SkillLifecycleVersionResponse(11L, "1.2.0", "PUBLISHED"),
|
||||
new SkillLifecycleVersionResponse(11L, "1.2.0", "PUBLISHED"),
|
||||
null,
|
||||
"PUBLISHED")),
|
||||
"PUBLISHED",
|
||||
null)),
|
||||
1,
|
||||
0,
|
||||
20
|
||||
|
|
|
|||
|
|
@ -55,7 +55,8 @@ class ClawHubRegistryFacadeTest {
|
|||
new SkillLifecycleVersionResponse(11L, "1.0.0", "PUBLISHED"),
|
||||
new SkillLifecycleVersionResponse(11L, "1.0.0", "PUBLISHED"),
|
||||
null,
|
||||
"PUBLISHED"
|
||||
"PUBLISHED",
|
||||
null
|
||||
)),
|
||||
1,
|
||||
0,
|
||||
|
|
|
|||
|
|
@ -75,7 +75,8 @@ class MeControllerTest {
|
|||
new SkillLifecycleVersionResponse(11L, "1.0.0", "PUBLISHED"),
|
||||
new SkillLifecycleVersionResponse(11L, "1.0.0", "PUBLISHED"),
|
||||
null,
|
||||
"PUBLISHED"
|
||||
"PUBLISHED",
|
||||
null
|
||||
)),
|
||||
9,
|
||||
1,
|
||||
|
|
|
|||
|
|
@ -187,6 +187,49 @@ class SkillSearchAppServiceTest {
|
|||
.findBySkillIdInAndStatus(List.of(10L, 11L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_shouldProjectComplianceSnapshotFromHeadlineVersion() {
|
||||
Skill skill = new Skill(1L, "compliance-skill", "owner-1", SkillVisibility.PUBLIC);
|
||||
setField(skill, "id", 10L);
|
||||
skill.setLatestVersionId(101L);
|
||||
|
||||
SkillVersion version = publishedVersion(10L, 101L, "1.0.0");
|
||||
version.setParsedMetadataJson("""
|
||||
{
|
||||
"complianceSnapshot": {
|
||||
"schemaVersion": "1.0",
|
||||
"items": [
|
||||
{
|
||||
"standard": "mitre-attack",
|
||||
"version": "v19.1",
|
||||
"controlId": "T1059",
|
||||
"title": "Command and Scripting Interpreter",
|
||||
"evidence": []
|
||||
}
|
||||
],
|
||||
"digest": "sha256:demo"
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
Namespace namespace = new Namespace("global", "Global", "owner-1");
|
||||
setField(namespace, "id", 1L);
|
||||
namespace.setStatus(NamespaceStatus.ACTIVE);
|
||||
|
||||
when(searchQueryService.search(any()))
|
||||
.thenReturn(new SearchResult(List.of(10L), 1, 0, 20));
|
||||
when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(skill));
|
||||
when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace));
|
||||
when(skillVersionRepository.findByIdIn(List.of(101L))).thenReturn(List.of(version));
|
||||
|
||||
SkillSearchAppService.SearchResponse response = service.search("T1059", null, "relevance", 0, 20, null, null);
|
||||
|
||||
assertEquals(1, response.items().size());
|
||||
assertEquals("mitre-attack", response.items().getFirst().complianceSnapshot().items().getFirst().standard());
|
||||
assertEquals("T1059", response.items().getFirst().complianceSnapshot().items().getFirst().controlId());
|
||||
assertEquals("sha256:demo", response.items().getFirst().complianceSnapshot().digest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_shouldNotFallbackToOlderPublishedVersionWhenLatestIsMissing() {
|
||||
Skill skill = new Skill(1L, "missing-latest", "owner-1", SkillVisibility.PUBLIC);
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ class CliSkillAppServiceTest {
|
|||
"global", Instant.now(), false,
|
||||
new SkillLifecycleVersionResponse(1L, "1.2.0", "PUBLISHED"),
|
||||
new SkillLifecycleVersionResponse(1L, "1.2.0", "PUBLISHED"),
|
||||
null, "PUBLISHED"
|
||||
null, "PUBLISHED", null
|
||||
)),
|
||||
1L, 0, 20
|
||||
);
|
||||
|
|
@ -103,7 +103,7 @@ class CliSkillAppServiceTest {
|
|||
"global", Instant.now(), false,
|
||||
new SkillLifecycleVersionResponse(2L, "1.0.0", "PUBLISHED"),
|
||||
new SkillLifecycleVersionResponse(2L, "1.0.0", "PUBLISHED"),
|
||||
null, "PUBLISHED"
|
||||
null, "PUBLISHED", null
|
||||
)
|
||||
),
|
||||
1L, 0, 20
|
||||
|
|
|
|||
|
|
@ -30,8 +30,13 @@ public class SkillLifecycleProjectionService {
|
|||
public record VersionProjection(
|
||||
Long id,
|
||||
String version,
|
||||
String status
|
||||
) {}
|
||||
String status,
|
||||
String parsedMetadataJson
|
||||
) {
|
||||
public VersionProjection(Long id, String version, String status) {
|
||||
this(id, version, status, null);
|
||||
}
|
||||
}
|
||||
|
||||
public record Projection(
|
||||
VersionProjection headlineVersion,
|
||||
|
|
@ -153,6 +158,11 @@ public class SkillLifecycleProjectionService {
|
|||
if (version == null) {
|
||||
return null;
|
||||
}
|
||||
return new VersionProjection(version.getId(), version.getVersion(), version.getStatus().name());
|
||||
return new VersionProjection(
|
||||
version.getId(),
|
||||
version.getVersion(),
|
||||
version.getStatus().name(),
|
||||
version.getParsedMetadataJson()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
37
web/src/api/generated/schema.d.ts
vendored
37
web/src/api/generated/schema.d.ts
vendored
|
|
@ -4058,6 +4058,24 @@ export interface components {
|
|||
timestamp?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
ComplianceEvidenceResponse: {
|
||||
type?: string;
|
||||
path?: string;
|
||||
url?: string;
|
||||
sha256?: string;
|
||||
};
|
||||
ComplianceMappingResponse: {
|
||||
standard?: string;
|
||||
version?: string;
|
||||
controlId?: string;
|
||||
title?: string;
|
||||
evidence?: components["schemas"]["ComplianceEvidenceResponse"][];
|
||||
};
|
||||
ComplianceSnapshotResponse: {
|
||||
schemaVersion?: string;
|
||||
items?: components["schemas"]["ComplianceMappingResponse"][];
|
||||
digest?: string;
|
||||
};
|
||||
SearchResponse: {
|
||||
items?: components["schemas"]["SkillSummaryResponse"][];
|
||||
/** Format: int64 */
|
||||
|
|
@ -4096,6 +4114,7 @@ export interface components {
|
|||
publishedVersion?: components["schemas"]["SkillLifecycleVersionResponse"];
|
||||
ownerPreviewVersion?: components["schemas"]["SkillLifecycleVersionResponse"];
|
||||
resolutionMode?: string;
|
||||
complianceSnapshot?: components["schemas"]["ComplianceSnapshotResponse"];
|
||||
};
|
||||
ApiResponseBoolean: {
|
||||
/** Format: int32 */
|
||||
|
|
@ -4147,24 +4166,6 @@ export interface components {
|
|||
timestamp?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
ComplianceEvidenceResponse: {
|
||||
type?: string;
|
||||
path?: string;
|
||||
url?: string;
|
||||
sha256?: string;
|
||||
};
|
||||
ComplianceMappingResponse: {
|
||||
standard?: string;
|
||||
version?: string;
|
||||
controlId?: string;
|
||||
title?: string;
|
||||
evidence?: components["schemas"]["ComplianceEvidenceResponse"][];
|
||||
};
|
||||
ComplianceSnapshotResponse: {
|
||||
schemaVersion?: string;
|
||||
items?: components["schemas"]["ComplianceMappingResponse"][];
|
||||
digest?: string;
|
||||
};
|
||||
SkillVersionDetailResponse: {
|
||||
/** Format: int64 */
|
||||
id?: number;
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ export interface SkillSummary {
|
|||
publishedVersion?: SkillLifecycleVersion
|
||||
ownerPreviewVersion?: SkillLifecycleVersion
|
||||
resolutionMode?: string
|
||||
complianceSnapshot?: ComplianceSnapshot
|
||||
}
|
||||
|
||||
export type LabelItem = Omit<components['schemas']['SkillLabelDto'], 'slug' | 'type' | 'displayName'> & {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
/** @vitest-environment jsdom */
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ComplianceSnapshotPanel } from './compliance-snapshot-panel'
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
|
|
@ -8,14 +11,32 @@ vi.mock('react-i18next', async () => {
|
|||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, values?: Record<string, number>) =>
|
||||
key === 'compliance.mappingCount' ? `${values?.count} mappings` : key,
|
||||
key === 'compliance.mappingCount'
|
||||
? `${values?.count} mappings`
|
||||
: key === 'common.expand'
|
||||
? '展开详情'
|
||||
: key === 'common.collapse'
|
||||
? '收起详情'
|
||||
: key === 'compliance.title'
|
||||
? '合规声明'
|
||||
: key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('ComplianceSnapshotPanel', () => {
|
||||
it('renders compliance mappings and evidence', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders nothing when there are no compliance mappings', () => {
|
||||
const html = renderToStaticMarkup(<ComplianceSnapshotPanel snapshot={{ schemaVersion: '1.0', items: [], digest: 'sha256:empty' }} />)
|
||||
|
||||
expect(html).toBe('')
|
||||
})
|
||||
|
||||
it('renders a compact summary by default and expands on demand', () => {
|
||||
render(
|
||||
<ComplianceSnapshotPanel
|
||||
snapshot={{
|
||||
schemaVersion: '1.0',
|
||||
|
|
@ -28,21 +49,37 @@ describe('ComplianceSnapshotPanel', () => {
|
|||
title: 'Command and Scripting Interpreter',
|
||||
evidence: [{ type: 'packaged-file', path: 'references/standards.md', sha256: 'abc' }],
|
||||
},
|
||||
{
|
||||
standard: 'nist-csf',
|
||||
version: '2.0',
|
||||
controlId: 'PR.DS-01',
|
||||
title: 'Data-at-rest protection',
|
||||
evidence: [],
|
||||
},
|
||||
{
|
||||
standard: 'soc2',
|
||||
version: '2023',
|
||||
controlId: 'CC6.1',
|
||||
title: 'Logical Access Security',
|
||||
evidence: [],
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(html).toContain('compliance.title')
|
||||
expect(html).toContain('1 mappings')
|
||||
expect(html).toContain('mitre-attack')
|
||||
expect(html).toContain('T1059')
|
||||
expect(html).toContain('references/standards.md')
|
||||
})
|
||||
const toggle = screen.getByRole('button', { name: '展开详情' })
|
||||
expect(toggle).toBeTruthy()
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.getByText('mitre-attack · T1059')).toBeTruthy()
|
||||
expect(screen.getByText('+1')).toBeTruthy()
|
||||
expect(screen.queryByText('references/standards.md')).toBeNull()
|
||||
|
||||
it('renders nothing when there are no compliance mappings', () => {
|
||||
const html = renderToStaticMarkup(<ComplianceSnapshotPanel snapshot={{ schemaVersion: '1.0', items: [], digest: 'sha256:empty' }} />)
|
||||
fireEvent.click(toggle)
|
||||
|
||||
expect(html).toBe('')
|
||||
const expandedToggle = screen.getByRole('button', { name: '收起详情' })
|
||||
expect(expandedToggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(screen.getByText('references/standards.md')).toBeTruthy()
|
||||
expect(screen.getByText('soc2')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { ExternalLink, ShieldCheck } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, ChevronUp, ExternalLink, ShieldCheck } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ComplianceSnapshot } from '@/api/types'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
|
|
@ -6,6 +7,7 @@ import { cn } from '@/shared/lib/utils'
|
|||
interface ComplianceSnapshotPanelProps {
|
||||
snapshot?: ComplianceSnapshot | null
|
||||
className?: string
|
||||
defaultExpanded?: boolean
|
||||
}
|
||||
|
||||
function shortDigest(digest?: string) {
|
||||
|
|
@ -18,61 +20,98 @@ function shortDigest(digest?: string) {
|
|||
return `${digest.slice(0, 17)}…`
|
||||
}
|
||||
|
||||
export function ComplianceSnapshotPanel({ snapshot, className }: ComplianceSnapshotPanelProps) {
|
||||
export function ComplianceSnapshotPanel({ snapshot, className, defaultExpanded = false }: ComplianceSnapshotPanelProps) {
|
||||
const { t } = useTranslation()
|
||||
const items = snapshot?.items?.filter((item) => item.standard || item.controlId) ?? []
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded)
|
||||
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-2xl border border-emerald-500/20 bg-emerald-500/5 p-4', className)}>
|
||||
<div className={cn('rounded-2xl border border-emerald-500/20 bg-emerald-500/5 p-4', className)} data-compliance-snapshot-panel>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<ShieldCheck className="h-4 w-4 text-emerald-500" />
|
||||
{t('compliance.title')}
|
||||
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||
{t('compliance.mappingCount', { count: items.length })}
|
||||
</span>
|
||||
{snapshot?.schemaVersion ? (
|
||||
<span className="rounded-full bg-secondary px-2 py-0.5 font-mono text-xs text-secondary-foreground">
|
||||
{snapshot.schemaVersion}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="font-mono text-xs text-muted-foreground" title={snapshot?.digest}>
|
||||
{shortDigest(snapshot?.digest)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-mono text-xs text-muted-foreground" title={snapshot?.digest}>
|
||||
{shortDigest(snapshot?.digest)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-background/80 px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:bg-background"
|
||||
aria-expanded={isExpanded}
|
||||
data-compliance-snapshot-toggle
|
||||
onClick={() => setIsExpanded((value) => !value)}
|
||||
>
|
||||
{isExpanded ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
||||
{isExpanded ? t('common.collapse') : t('common.expand')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2">
|
||||
{items.map((item, index) => (
|
||||
<div key={`${item.standard ?? 'standard'}-${item.version ?? 'version'}-${item.controlId ?? index}`} className="rounded-xl border border-border/60 bg-background/70 p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full bg-secondary px-2 py-0.5 font-mono text-xs text-secondary-foreground">
|
||||
{item.standard ?? t('compliance.unknownStandard')}
|
||||
</span>
|
||||
{item.version ? (
|
||||
<span className="font-mono text-xs text-muted-foreground">{item.version}</span>
|
||||
) : null}
|
||||
<span className="font-mono text-sm font-semibold text-foreground">{item.controlId ?? '—'}</span>
|
||||
</div>
|
||||
{item.title ? (
|
||||
<div className="mt-1 text-sm text-muted-foreground">{item.title}</div>
|
||||
) : null}
|
||||
{item.evidence && item.evidence.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{item.evidence.map((evidence, evidenceIndex) => (
|
||||
<span
|
||||
key={`${evidence.type ?? 'evidence'}-${evidence.path ?? evidence.url ?? evidenceIndex}`}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-xs text-muted-foreground"
|
||||
title={evidence.sha256}
|
||||
>
|
||||
{evidence.url ? <ExternalLink className="h-3 w-3" /> : null}
|
||||
{evidence.path ?? evidence.url ?? evidence.type ?? t('compliance.evidence')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{items.slice(0, isExpanded ? items.length : 2).map((item, index) => (
|
||||
<span
|
||||
key={`${item.standard ?? 'standard'}-${item.version ?? 'version'}-${item.controlId ?? index}`}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2.5 py-1 text-xs font-medium text-emerald-800 dark:text-emerald-200"
|
||||
>
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
{[item.standard, item.controlId].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
))}
|
||||
{!isExpanded && items.length > 2 ? (
|
||||
<span className="inline-flex items-center rounded-full border border-dashed border-border/70 px-2.5 py-1 text-xs text-muted-foreground">
|
||||
+{items.length - 2}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isExpanded ? (
|
||||
<div className="mt-3 grid gap-2" data-compliance-snapshot-detail>
|
||||
{items.map((item, index) => (
|
||||
<div key={`${item.standard ?? 'standard'}-${item.version ?? 'version'}-${item.controlId ?? index}`} className="rounded-xl border border-border/60 bg-background/70 p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full bg-secondary px-2 py-0.5 font-mono text-xs text-secondary-foreground">
|
||||
{item.standard ?? t('compliance.unknownStandard')}
|
||||
</span>
|
||||
{item.version ? (
|
||||
<span className="font-mono text-xs text-muted-foreground">{item.version}</span>
|
||||
) : null}
|
||||
<span className="font-mono text-sm font-semibold text-foreground">{item.controlId ?? '—'}</span>
|
||||
</div>
|
||||
{item.title ? (
|
||||
<div className="mt-1 text-sm text-muted-foreground">{item.title}</div>
|
||||
) : null}
|
||||
{item.evidence && item.evidence.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{item.evidence.map((evidence, evidenceIndex) => (
|
||||
<span
|
||||
key={`${evidence.type ?? 'evidence'}-${evidence.path ?? evidence.url ?? evidenceIndex}`}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-xs text-muted-foreground"
|
||||
title={evidence.sha256}
|
||||
>
|
||||
{evidence.url ? <ExternalLink className="h-3 w-3" /> : null}
|
||||
{evidence.path ?? evidence.url ?? evidence.type ?? t('compliance.evidence')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,30 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { createElement, type ReactNode } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import * as mod from './skill-card'
|
||||
import { SkillCard } from './skill-card'
|
||||
|
||||
vi.mock('@/features/auth/use-auth', () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/social/use-star', () => ({
|
||||
useStarredIdSet: () => ({
|
||||
starredIds: new Set<number>(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children, className }: { children?: ReactNode; className?: string }) => (
|
||||
createElement('div', { className }, children)
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/namespace-badge', () => ({
|
||||
NamespaceBadge: ({ name }: { name: string }) => createElement('span', null, name),
|
||||
}))
|
||||
|
||||
/**
|
||||
* skill-card.tsx exports a single React component (SkillCard).
|
||||
|
|
@ -14,4 +39,38 @@ describe('skill-card module exports', () => {
|
|||
expect(mod.SkillCard).toBeDefined()
|
||||
expect(typeof mod.SkillCard).toBe('function')
|
||||
})
|
||||
|
||||
it('renders compliance badges from the skill summary snapshot', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(SkillCard, {
|
||||
skill: {
|
||||
id: 1,
|
||||
slug: 'audit-runner',
|
||||
displayName: 'Audit Runner',
|
||||
summary: 'Runs controls',
|
||||
downloadCount: 10,
|
||||
starCount: 2,
|
||||
ratingCount: 0,
|
||||
namespace: 'global',
|
||||
updatedAt: '2026-08-07T00:00:00Z',
|
||||
canSubmitPromotion: false,
|
||||
headlineVersion: { id: 11, version: '1.0.0', status: 'PUBLISHED' },
|
||||
complianceSnapshot: {
|
||||
schemaVersion: '1.0',
|
||||
digest: 'sha256:demo',
|
||||
items: [
|
||||
{ standard: 'mitre-attack', controlId: 'T1059', title: 'Command and Scripting Interpreter' },
|
||||
{ standard: 'nist-csf', controlId: 'PR.AA-01' },
|
||||
{ standard: 'soc2', controlId: 'CC6.1' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
expect(html).toContain('mitre-attack')
|
||||
expect(html).toContain('T1059')
|
||||
expect(html).toContain('nist-csf')
|
||||
expect(html).toContain('+1')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Card } from '@/shared/ui/card'
|
|||
import { NamespaceBadge } from '@/shared/components/namespace-badge'
|
||||
import { getHeadlineVersion } from '@/shared/lib/skill-lifecycle'
|
||||
import { formatCompactCount } from '@/shared/lib/number-format'
|
||||
import { Bookmark } from 'lucide-react'
|
||||
import { Bookmark, ShieldCheck } from 'lucide-react'
|
||||
|
||||
interface SkillCardProps {
|
||||
skill: SkillSummary
|
||||
|
|
@ -23,6 +23,7 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
|
|||
const showStarredHighlight = highlightStarred && isAuthenticated && starredIds.has(skill.id)
|
||||
const headlineVersion = getHeadlineVersion(skill)
|
||||
const isInteractive = typeof onClick === 'function'
|
||||
const complianceItems = skill.complianceSnapshot?.items?.filter((item) => item.standard || item.controlId) ?? []
|
||||
|
||||
return (
|
||||
<Card
|
||||
|
|
@ -60,6 +61,26 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
|
|||
</p>
|
||||
)}
|
||||
|
||||
{complianceItems.length > 0 ? (
|
||||
<div className="mb-4 flex flex-wrap gap-1.5">
|
||||
{complianceItems.slice(0, 2).map((item, index) => (
|
||||
<span
|
||||
key={`${item.standard ?? 'standard'}-${item.controlId ?? index}`}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-300"
|
||||
title={item.title}
|
||||
>
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
{[item.standard, item.controlId].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
))}
|
||||
{complianceItems.length > 2 ? (
|
||||
<span className="rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
|
||||
+{complianceItems.length - 2}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-auto flex items-center gap-4 text-xs text-muted-foreground">
|
||||
{headlineVersion && (
|
||||
<span className="px-2.5 py-1 rounded-full bg-secondary/60 font-mono">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
{
|
||||
"compliance": {
|
||||
"title": "Compliance mappings",
|
||||
"title": "Compliance claims",
|
||||
"mappingCount": "{{count}} items",
|
||||
"unknownStandard": "Unknown standard",
|
||||
"evidence": "Evidence"
|
||||
},
|
||||
"common": {
|
||||
"expand": "Expand details",
|
||||
"collapse": "Collapse details"
|
||||
},
|
||||
"nav": {
|
||||
"landing": "Home",
|
||||
"home": "Skill Center",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
{
|
||||
"compliance": {
|
||||
"title": "合规映射",
|
||||
"title": "合规声明",
|
||||
"mappingCount": "{{count}} 项",
|
||||
"unknownStandard": "未知标准",
|
||||
"evidence": "证据"
|
||||
},
|
||||
"common": {
|
||||
"expand": "展开详情",
|
||||
"collapse": "收起详情"
|
||||
},
|
||||
"nav": {
|
||||
"landing": "首页",
|
||||
"home": "技能中心",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue