diff --git a/apps/web/package.json b/apps/web/package.json index 4f5ccab..4b5a2af 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,6 +19,7 @@ "@ant-design/icons-vue": "catalog:", "@gocell/core": "workspace:*", "@gocell/access": "workspace:*", + "@gocell/audit": "workspace:*", "@gocell/request": "workspace:*", "vue-i18n": "catalog:" }, diff --git a/apps/web/src/router/index.spec.ts b/apps/web/src/router/index.spec.ts index 43b42e4..76b4d36 100644 --- a/apps/web/src/router/index.spec.ts +++ b/apps/web/src/router/index.spec.ts @@ -40,4 +40,14 @@ describe('router layout fork', () => { expect(m.meta.requiredAction).toBe('read') expect(m.meta.requiredResource).toBe('identity') }) + + it('renders /audit nested under the shell, behind the auth + PDP gates (Batch 4)', () => { + const m = router.resolve('/audit') + expect(m.name).toBe('audit') + expect(m.matched.length).toBeGreaterThanOrEqual(2) + expect(m.meta.requiresAuth).toBe(true) + // PDP fail-closed gate (read on audit) — guards.ts denies until the PDP backend allows. + expect(m.meta.requiredAction).toBe('read') + expect(m.meta.requiredResource).toBe('audit') + }) }) diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index fd83945..0311980 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -62,6 +62,15 @@ const routes: RouteRecordRaw[] = [ props: { titleKey: 'nav.reviews' }, meta: { requiresAuth: true }, }, + { + // Operate · Audit log (Batch 4). PDP-gated read on `audit` (fail-closed + // until the /access/decide backend ships, BR-004); own async chunk via + // the @gocell/audit/views/audit subpath export. + path: 'audit', + name: 'audit', + component: () => import('@gocell/audit/views/audit'), + meta: { requiresAuth: true, requiredAction: 'read', requiredResource: 'audit' }, + }, ], }, ] diff --git a/packages/audit/README.md b/packages/audit/README.md index f4b328b..1616f07 100644 --- a/packages/audit/README.md +++ b/packages/audit/README.md @@ -1,11 +1,54 @@ # @gocell/audit -> 镜像后端 cells/auditcore:auditquery 审计页 +> 对应后端 cell:`cells/auditcore/` (`auditquery` slice — cursor-paginated audit log list) -- **对应后端 cell**:cells/auditcore -- **对外 exports**:`.` → `src/index.ts`(唯一收口;未列出路径外部不可访问) -- **依赖 contract**:(待 Batch 实施时补全) +## 对外 exports -## 边界 +- `.` → 主入口:`useAuditStore` store + `AuditEntry` 类型 +- `./stores` → `useAuditStore`(Pinia setup-store) +- `./views/audit` → `AuditView.vue`(路由级页面组件) -依赖规则见仓库根 `CLAUDE.md` §依赖规则。跨包仅经 `@gocell/contracts` 类型 + `@gocell/request` client,禁深路径 import。 +## 依赖的 contract + +- `HttpAuditListV1Response` (v1) — `packages/contracts/src/http/audit/list/v1/response.ts` + - 行类型:`AuditEntry = HttpAuditListV1Response['data'][number]` + +## 已知后端缺口(BR-006) + +契约当前缺少以下字段(设计稿有,后端未暴露): + +| 字段 | 降级处理 | +|------|---------| +| `hash` / `prevHash` | chain integrity card 显示 "verification unavailable" | +| `result` (ok/denied/failed) | 不在 UI 展示 | +| `reason` | 不在 UI 展示 | +| `actor.ip` / `actor.mfa` | 不在 UI 展示 | +| `actorType` | classifyActor 启发式前缀推断(见 `src/lib/auditClassify.ts`) | + +后端补字段 + `pnpm codegen` 后,hash chain 校验将自动激活(verifyChain 已完整实现)。 + +## 提供给其他包的能力 + +- `useAuditStore` — audit log 查询 store(列表 / 分页 / 过滤 / 分组 / chain status) +- `AuditEntry` — 单条审计条目类型(codegen-derived) + +## 内部模块(不在 exports,不可跨包直接引用) + +- `src/api/audit.ts` — HTTP 层(listAudit) +- `src/lib/hashChain.ts` — 纯函数 verifyChain +- `src/lib/auditClassify.ts` — classifyActor + groupByDay +- `src/components/ActorPill.vue` — 行内角色装饰组件 + +## 测试 + +```bash +pnpm -F @gocell/audit test --run +pnpm -F @gocell/audit test:coverage --run +pnpm -F @gocell/audit typecheck +``` + +## 边界约束 + +- 禁 import `@gocell/access` / `@gocell/config` / `@gocell/observability` / `@gocell/devboard` +- 跨包只走 `@gocell/contracts` 类型 + `@gocell/request` client + `@gocell/core` UI 壳 +- 禁深路径 `@gocell/x/src/...` diff --git a/packages/audit/package.json b/packages/audit/package.json index 79083a8..43ffc77 100644 --- a/packages/audit/package.json +++ b/packages/audit/package.json @@ -4,12 +4,34 @@ "private": true, "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./stores": "./src/stores/index.ts", + "./views/audit": "./src/views/AuditView.vue" }, "files": [ "src" ], "scripts": { - "typecheck": "vue-tsc --noEmit" + "typecheck": "vue-tsc --noEmit", + "test": "vitest run", + "test:coverage": "vitest run --coverage" + }, + "dependencies": { + "@gocell/core": "workspace:*", + "@gocell/request": "workspace:*", + "@gocell/contracts": "workspace:*", + "pinia": "catalog:", + "vue": "catalog:", + "vue-i18n": "catalog:", + "vue-router": "catalog:" + }, + "devDependencies": { + "vitest": "catalog:", + "@pinia/testing": "catalog:", + "@vitejs/plugin-vue": "catalog:", + "@vue/test-utils": "catalog:", + "axios-mock-adapter": "catalog:", + "jsdom": "catalog:", + "@vitest/coverage-v8": "catalog:" } } diff --git a/packages/audit/src/api/audit.spec.ts b/packages/audit/src/api/audit.spec.ts new file mode 100644 index 0000000..10a72c2 --- /dev/null +++ b/packages/audit/src/api/audit.spec.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import MockAdapter from 'axios-mock-adapter' +import { http } from '@gocell/request' +import { listAudit, AUDIT_URL } from './audit' + +const page = { + data: [ + { + id: 'evt-001', + eventId: 'eid-001', + eventType: 'slice.merge', + actorId: 'dario@gocell.dev', + subjectId: 'auditcore/persist@a7f3', + correlationId: 'corr-abc', + occurredAt: '2026-06-01T12:48:55Z', + timestamp: '2026-06-01T12:48:56Z', + payload: { pr: 2241, sha: 'a7f37c1' }, + }, + ], + nextCursor: 'cur-2', + hasMore: true, +} + +describe('audit api · listAudit', () => { + let mock: MockAdapter + + beforeEach(() => { + mock = new MockAdapter(http) + }) + + afterEach(() => { + mock.restore() + }) + + it('GETs the audit collection URL and returns the list envelope', async () => { + mock.onGet(AUDIT_URL).reply(200, page) + const res = await listAudit() + expect(res).toEqual(page) + }) + + it('forwards cursor + limit as query params', async () => { + let seenParams: Record | undefined + mock.onGet(AUDIT_URL).reply((config) => { + seenParams = config.params as Record + return [200, page] + }) + await listAudit({ cursor: 'cur-1', limit: 25 }) + expect(seenParams).toEqual({ cursor: 'cur-1', limit: 25 }) + }) + + it('sends no query params when called without args', async () => { + let seenParams: Record | undefined + mock.onGet(AUDIT_URL).reply((config) => { + seenParams = config.params as Record + return [200, page] + }) + await listAudit() + expect(seenParams).toEqual({}) + }) + + it('rejects on network failure (interceptor maps the error upstream)', async () => { + mock.onGet(AUDIT_URL).networkError() + await expect(listAudit()).rejects.toThrow() + }) +}) diff --git a/packages/audit/src/api/audit.ts b/packages/audit/src/api/audit.ts new file mode 100644 index 0000000..c38d730 --- /dev/null +++ b/packages/audit/src/api/audit.ts @@ -0,0 +1,34 @@ +/** + * audit.ts — audit-query API (auditcore `auditquery` slice). + * + * MVP scope: cursor-paginated audit log list (HttpAuditListV1Response). + * The request schema is not yet codegen-derived (no backend request schema); + * ListAuditParams is defined locally and annotated for future cutover. + */ +import { http } from '@gocell/request' +import type { HttpAuditListV1Response } from '@gocell/contracts' + +/** Collection endpoint for audit entries. */ +export const AUDIT_URL = '/api/v1/audit/' + +/** + * A single audit log entry. Derives directly from the codegen-produced + * response contract so the row type stays schema-bound. + */ +export type AuditEntry = HttpAuditListV1Response['data'][number] + +/** + * Local request params type. + * Note: the backend has no audit list request schema yet; once codegen + * derives HttpAuditListV1Request, delete this interface and import it instead. + */ +export interface ListAuditParams { + cursor?: string + limit?: number +} + +/** GET /audit/ — cursor-paginated audit entry list. */ +export async function listAudit(params: ListAuditParams = {}): Promise { + const res = await http.get(AUDIT_URL, { params }) + return res.data +} diff --git a/packages/audit/src/components/ActorPill.spec.ts b/packages/audit/src/components/ActorPill.spec.ts new file mode 100644 index 0000000..a43703f --- /dev/null +++ b/packages/audit/src/components/ActorPill.spec.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import ActorPill from './ActorPill.vue' + +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k: string) => k }) })) + +describe('ActorPill', () => { + it('renders the actorId as the visible text', () => { + const wrapper = mount(ActorPill, { props: { actorId: 'dario@gocell.dev' } }) + expect(wrapper.find('.pill__label').text()).toBe('dario@gocell.dev') + }) + + it('decorative dot is aria-hidden', () => { + const wrapper = mount(ActorPill, { props: { actorId: 'dario@gocell.dev' } }) + const dot = wrapper.find('.pill__dot') + expect(dot.attributes('aria-hidden')).toBe('true') + }) + + it('human actor gets human variant', () => { + const wrapper = mount(ActorPill, { props: { actorId: 'dario@gocell.dev' } }) + expect(wrapper.find('.pill').attributes('data-variant')).toBe('human') + }) + + it('sandbox actor (sb- prefix) gets sandbox variant', () => { + const wrapper = mount(ActorPill, { props: { actorId: 'sb-790' } }) + expect(wrapper.find('.pill').attributes('data-variant')).toBe('sandbox') + }) + + it('service actor (sa: prefix) gets service variant', () => { + const wrapper = mount(ActorPill, { props: { actorId: 'sa:platform-bot' } }) + expect(wrapper.find('.pill').attributes('data-variant')).toBe('service') + }) + + it('cell actor (core suffix) gets cell variant', () => { + const wrapper = mount(ActorPill, { props: { actorId: 'observecore' } }) + expect(wrapper.find('.pill').attributes('data-variant')).toBe('cell') + }) + + it('unknown actor gets unknown variant', () => { + const wrapper = mount(ActorPill, { props: { actorId: 'some-random-id' } }) + expect(wrapper.find('.pill').attributes('data-variant')).toBe('unknown') + }) +}) diff --git a/packages/audit/src/components/ActorPill.vue b/packages/audit/src/components/ActorPill.vue new file mode 100644 index 0000000..62c1b77 --- /dev/null +++ b/packages/audit/src/components/ActorPill.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/packages/audit/src/index.ts b/packages/audit/src/index.ts index 336ce12..3c07182 100644 --- a/packages/audit/src/index.ts +++ b/packages/audit/src/index.ts @@ -1 +1,2 @@ -export {} +export { useAuditStore } from './stores' +export type { AuditEntry } from './api/audit' diff --git a/packages/audit/src/lib/auditClassify.spec.ts b/packages/audit/src/lib/auditClassify.spec.ts new file mode 100644 index 0000000..d563129 --- /dev/null +++ b/packages/audit/src/lib/auditClassify.spec.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'vitest' +import { classifyActor, groupByDay, formatDayLabel } from './auditClassify' + +describe('classifyActor', () => { + it('classifies sa: prefix as service', () => { + expect(classifyActor('sa:platform-bot')).toBe('service') + }) + + it('classifies svc prefix as service', () => { + expect(classifyActor('svc-deployer')).toBe('service') + }) + + it('classifies sb- prefix as sandbox', () => { + expect(classifyActor('sb-790')).toBe('sandbox') + }) + + it('classifies actorId containing @ as human', () => { + expect(classifyActor('dario@gocell.dev')).toBe('human') + }) + + it('classifies cell-like names (known cell names ending with core) as cell', () => { + expect(classifyActor('observecore')).toBe('cell') + }) + + it('classifies auditcore as cell', () => { + expect(classifyActor('auditcore')).toBe('cell') + }) + + it('returns unknown for unrecognised actors', () => { + expect(classifyActor('some-random-id')).toBe('unknown') + }) + + it('returns unknown for empty string', () => { + expect(classifyActor('')).toBe('unknown') + }) +}) + +describe('groupByDay', () => { + const makeEntry = (ts: string, id: string) => ({ + id, + eventId: id, + eventType: 'test.event', + actorId: 'actor', + timestamp: ts, + occurredAt: ts, + }) + + it('groups entries by local date label', () => { + // Use dates far apart (noon UTC) so they always land on the same local date + // regardless of the test runner's timezone offset (±14h max). + const entries = [ + makeEntry('2026-06-01T12:00:00Z', 'a'), + makeEntry('2026-06-01T13:00:00Z', 'b'), + makeEntry('2026-06-10T12:00:00Z', 'c'), + ] + const groups = groupByDay(entries) + expect(groups).toHaveLength(2) + expect(groups[0]?.entries).toHaveLength(2) + expect(groups[1]?.entries).toHaveLength(1) + }) + + it('returns empty array for empty input', () => { + expect(groupByDay([])).toEqual([]) + }) + + it('falls back to timestamp when occurredAt is absent', () => { + const entries = [ + { + id: 'a', + eventId: 'a', + eventType: 'x', + actorId: 'actor', + timestamp: '2026-06-01T10:00:00Z', + }, + ] + const groups = groupByDay(entries) + expect(groups).toHaveLength(1) + expect(groups[0]?.entries).toHaveLength(1) + }) +}) + +describe('formatDayLabel', () => { + it('returns kind=date and a non-empty dateLabel for an old date key', () => { + // Use a date far in the past so it never matches today or yesterday. + const result = formatDayLabel('2024-01-15') + expect(result.kind).toBe('date') + expect(typeof result.dateLabel).toBe('string') + expect(result.dateLabel.length).toBeGreaterThan(0) + }) + + it('returns kind=today for todays date key', () => { + const today = new Date() + const key = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}` + const result = formatDayLabel(key) + expect(result.kind).toBe('today') + }) + + it('returns kind=yesterday for yesterdays date key', () => { + const d = new Date() + d.setDate(d.getDate() - 1) + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` + const result = formatDayLabel(key) + expect(result.kind).toBe('yesterday') + }) + + it('returns kind=date and the original key as dateLabel for an invalid date', () => { + const result = formatDayLabel('not-a-date') + expect(result.kind).toBe('date') + expect(result.dateLabel).toBe('not-a-date') + }) +}) diff --git a/packages/audit/src/lib/auditClassify.ts b/packages/audit/src/lib/auditClassify.ts new file mode 100644 index 0000000..673cb11 --- /dev/null +++ b/packages/audit/src/lib/auditClassify.ts @@ -0,0 +1,120 @@ +/** + * auditClassify.ts — heuristic actor classification and day-grouping helpers. + * + * IMPORTANT: classifyActor is a best-effort prefix heuristic. + * It is NOT authoritative; it will misclassify actors that don't follow the + * known naming conventions. The backend is expected to expose an `actorType` + * field in a future iteration (BR-006); when it does, replace this heuristic + * with a direct field read. + * + * Current rules (newest-wins, checked in order): + * 1. contains '@' → human + * 2. starts with 'sa:' → service (service accounts) + * 3. starts with 'svc' → service + * 4. starts with 'sb-' → sandbox (AI sandboxes) + * 5. ends with 'core' → cell (gocell internal cells, e.g. observecore) + * 6. otherwise → unknown + */ + +import type { AuditEntry } from '../api/audit' + +export type ActorKind = 'human' | 'service' | 'cell' | 'sandbox' | 'unknown' + +/** Day-label kind: used by the view to render i18n keys for today/yesterday. */ +export type DayLabelKind = 'today' | 'yesterday' | 'date' + +/** Classify an opaque actorId into a best-effort actor kind. */ +export function classifyActor(actorId: string): ActorKind { + if (!actorId) return 'unknown' + if (actorId.includes('@')) return 'human' + if (actorId.startsWith('sa:')) return 'service' + if (actorId.startsWith('svc')) return 'service' + if (actorId.startsWith('sb-')) return 'sandbox' + if (actorId.endsWith('core')) return 'cell' + return 'unknown' +} + +// ─── day grouping ───────────────────────────────────────────────────────────── + +export interface DayGroup { + /** YYYY-MM-DD local date key (used as Map key and for label lookup). */ + dayKey: string + /** + * Discriminant for i18n rendering: + * - 'today'/'yesterday' → use t('audit.log.day.today') / t('audit.log.day.yesterday') + * - 'date' → use the already-formatted `dateLabel` string from Intl + */ + labelKind: DayLabelKind + /** Intl-formatted date string; only meaningful when labelKind === 'date'. */ + dateLabel: string + entries: AuditEntry[] +} + +// Module-level Intl instance — reused across all calls (Q: avoid per-call allocation). +const dayLabelFormat = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }) + +/** Precise entry type: timestamp is required, occurredAt is optional. */ +type EntryWithTs = { occurredAt?: string; timestamp: string } + +/** + * Return a local-date key (YYYY-MM-DD) for an entry, preferring `occurredAt` + * over `timestamp` (both ISO-8601). + */ +function entryDateKey(entry: EntryWithTs): string { + const iso = entry.occurredAt ?? entry.timestamp + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return iso + // Use local date parts (matches what the user sees in the browser). + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${day}` +} + +/** + * Classify a YYYY-MM-DD dayKey into a DayLabelKind for i18n rendering. + * Returns 'today', 'yesterday', or 'date' (with a pre-formatted dateLabel). + * Exported so specs can assert the kind without depending on locale strings. + */ +export function formatDayLabel(dayKey: string): { kind: DayLabelKind; dateLabel: string } { + const d = new Date(dayKey) + if (Number.isNaN(d.getTime())) return { kind: 'date', dateLabel: dayKey } + + const today = new Date() + const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}` + const yesterday = new Date(today) + yesterday.setDate(yesterday.getDate() - 1) + const yesterdayKey = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, '0')}-${String(yesterday.getDate()).padStart(2, '0')}` + + if (dayKey === todayKey) return { kind: 'today', dateLabel: '' } + if (dayKey === yesterdayKey) return { kind: 'yesterday', dateLabel: '' } + return { kind: 'date', dateLabel: dayLabelFormat.format(d) } +} + +/** + * Group a list of AuditEntry values by local date. + * Preserves the input order within each group (typically newest-first). + */ +export function groupByDay(entries: AuditEntry[]): DayGroup[] { + const map = new Map() + + for (const entry of entries) { + const key = entryDateKey(entry) + const existing = map.get(key) + if (existing) { + existing.push(entry) + } else { + map.set(key, [entry]) + } + } + + return Array.from(map.entries()).map(([dayKey, dayEntries]) => { + const { kind, dateLabel } = formatDayLabel(dayKey) + return { + dayKey, + labelKind: kind, + dateLabel, + entries: dayEntries, + } + }) +} diff --git a/packages/audit/src/lib/hashChain.spec.ts b/packages/audit/src/lib/hashChain.spec.ts new file mode 100644 index 0000000..4defa1b --- /dev/null +++ b/packages/audit/src/lib/hashChain.spec.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest' +import { verifyChain } from './hashChain' + +describe('verifyChain', () => { + it('returns unavailable when entries is empty', () => { + expect(verifyChain([])).toEqual({ status: 'unavailable' }) + }) + + it('returns unavailable when entries have no hash field', () => { + // exactOptionalPropertyTypes: don't pass explicit undefined; just omit the field + const entries = [{}, {}] + expect(verifyChain(entries)).toEqual({ status: 'unavailable' }) + }) + + it('returns unavailable when only one entry and hash field absent', () => { + expect(verifyChain([{}])).toEqual({ status: 'unavailable' }) + }) + + it('returns ok when single entry has a hash (no chain to verify)', () => { + expect(verifyChain([{ hash: '0xabc' }])).toEqual({ status: 'ok' }) + }) + + it('returns ok when chain is intact (entries[i].prevHash === entries[i+1].hash)', () => { + // entries are newest-first: entries[0].prev should equal entries[1].hash + const entries = [ + { hash: '0x9999', prevHash: '0x8888' }, + { hash: '0x8888', prevHash: '0x7777' }, + { hash: '0x7777' }, + ] + expect(verifyChain(entries)).toEqual({ status: 'ok' }) + }) + + it('returns broken with brokenAt when chain link is broken', () => { + const entries = [ + { hash: '0x9999', prevHash: '0x0000' }, // broken: prevHash != entries[1].hash + { hash: '0x8888', prevHash: '0x7777' }, + { hash: '0x7777' }, + ] + const result = verifyChain(entries) + expect(result.status).toBe('broken') + expect(result.brokenAt).toBe(0) + }) + + it('reports brokenAt as the index of the first broken link', () => { + const entries = [ + { hash: '0xd', prevHash: '0xc' }, // ok + { hash: '0xc', prevHash: '0xa' }, // broken: prevHash 0xa != entries[2].hash 0xb + { hash: '0xb' }, + ] + const result = verifyChain(entries) + expect(result.status).toBe('broken') + expect(result.brokenAt).toBe(1) + }) + + it('returns unavailable when some entries lack hash (mixed)', () => { + const entries = [ + { hash: '0x9' }, + {}, // no hash + ] + expect(verifyChain(entries)).toEqual({ status: 'unavailable' }) + }) +}) diff --git a/packages/audit/src/lib/hashChain.ts b/packages/audit/src/lib/hashChain.ts new file mode 100644 index 0000000..0a93ead --- /dev/null +++ b/packages/audit/src/lib/hashChain.ts @@ -0,0 +1,65 @@ +/** + * hashChain.ts — pure-function hash-chain verification. + * + * Implements `verifyChain` against the fields that would be present once the + * backend exposes hash/prevHash (tracked in BR-006). The algorithm is complete + * and fully tested against all three paths (ok/broken/unavailable); when the + * backend ships the fields, passing real data will make the ok/broken paths live + * without any frontend change. + * + * Convention: entries are newest-first (same order as the API response). + * The chain link direction: entries[i].prevHash should equal entries[i+1].hash. + */ + +/** + * With exactOptionalPropertyTypes: true, optional properties cannot be + * assigned `undefined` explicitly. Use `hash?: string` — fields that are + * truly absent are simply not present in the object literal. + */ +export interface ChainEntry { + hash?: string + prevHash?: string +} + +export interface ChainResult { + status: 'ok' | 'broken' | 'unavailable' + brokenAt?: number +} + +/** + * Verify the hash chain across a slice of audit entries. + * + * - Returns `unavailable` when no entry has a hash field (backend gap BR-006). + * - Returns `ok` when all consecutive links are intact. + * - Returns `broken` with `brokenAt` (index of the first broken link) when a + * link mismatch is detected. + */ +export function verifyChain(entries: ChainEntry[]): ChainResult { + if (entries.length === 0) return { status: 'unavailable' } + + // Check whether any entry carries a hash. If none do, the backend has not + // yet exposed the field (BR-006). + const hasAnyHash = entries.some((e) => e.hash !== undefined && e.hash !== null) + if (!hasAnyHash) return { status: 'unavailable' } + + // If there is exactly one entry with a hash, there is nothing to chain-check. + if (entries.length === 1) return { status: 'ok' } + + // Walk the chain: entries[i].prevHash must equal entries[i+1].hash. + for (let i = 0; i < entries.length - 1; i++) { + const current = entries[i] + const next = entries[i + 1] + + // If we reach a segment that has no hash info, treat the whole chain as + // unavailable (mixed state — backend partially backfilled). + if (current?.hash === undefined || next?.hash === undefined) { + return { status: 'unavailable' } + } + + if (current.prevHash !== next.hash) { + return { status: 'broken', brokenAt: i } + } + } + + return { status: 'ok' } +} diff --git a/packages/audit/src/stores/index.ts b/packages/audit/src/stores/index.ts new file mode 100644 index 0000000..8ab9f26 --- /dev/null +++ b/packages/audit/src/stores/index.ts @@ -0,0 +1 @@ +export { useAuditStore } from './useAuditStore' diff --git a/packages/audit/src/stores/useAuditStore.spec.ts b/packages/audit/src/stores/useAuditStore.spec.ts new file mode 100644 index 0000000..049854a --- /dev/null +++ b/packages/audit/src/stores/useAuditStore.spec.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import MockAdapter from 'axios-mock-adapter' +import { http } from '@gocell/request' +import { useAuditStore } from './useAuditStore' +import { AUDIT_URL } from '../api/audit' +import type { AuditEntry } from '../api/audit' + +const mkEntry = (over: Partial = {}): AuditEntry => ({ + id: 'evt-001', + eventId: 'eid-001', + eventType: 'slice.merge', + actorId: 'dario@gocell.dev', + timestamp: '2026-06-01T12:48:55Z', + ...over, +}) + +const page1 = { + data: [mkEntry({ id: 'evt-001' })], + nextCursor: 'cur-2', + hasMore: true, +} +const page2 = { + data: [mkEntry({ id: 'evt-002', eventType: 'flag.flip' })], + nextCursor: '', + hasMore: false, +} + +describe('useAuditStore', () => { + let mock: MockAdapter + + beforeEach(() => { + setActivePinia(createPinia()) + mock = new MockAdapter(http) + }) + + afterEach(() => { + mock.restore() + vi.restoreAllMocks() + }) + + it('starts with empty state and is not loading', () => { + const store = useAuditStore() + expect(store.entries).toEqual([]) + expect(store.loading).toBe(false) + expect(store.errorKey).toBeNull() + expect(store.hasMore).toBe(false) + expect(store.filter).toBe('') + expect(store.actorKind).toBe('all') + expect(store.actionNs).toBe('all') + }) + + describe('fetchList', () => { + it('toggles loading and populates entries on success', async () => { + mock.onGet(AUDIT_URL).reply(200, page1) + const store = useAuditStore() + + const p = store.fetchList() + expect(store.loading).toBe(true) + await p + + expect(store.loading).toBe(false) + expect(store.entries).toHaveLength(1) + expect(store.entries[0]?.id).toBe('evt-001') + expect(store.nextCursor).toBe('cur-2') + expect(store.hasMore).toBe(true) + expect(store.errorKey).toBeNull() + }) + + it('swallows errors into errorKey (does not throw)', async () => { + mock.onGet(AUDIT_URL).networkError() + const store = useAuditStore() + await expect(store.fetchList()).resolves.toBeUndefined() + expect(store.errorKey).not.toBeNull() + expect(store.loading).toBe(false) + }) + + it('replaces entries on a re-fetch (not appends)', async () => { + mock.onGet(AUDIT_URL).replyOnce(200, page1).onGet(AUDIT_URL).replyOnce(200, page2) + const store = useAuditStore() + await store.fetchList() + await store.fetchList() + expect(store.entries).toHaveLength(1) + expect(store.entries[0]?.eventType).toBe('flag.flip') + }) + }) + + describe('loadMore', () => { + it('appends entries and updates cursor', async () => { + mock.onGet(AUDIT_URL).replyOnce(200, page1).onGet(AUDIT_URL).replyOnce(200, page2) + const store = useAuditStore() + await store.fetchList() + await store.loadMore() + expect(store.entries).toHaveLength(2) + expect(store.hasMore).toBe(false) + }) + + it('is a no-op when hasMore is false', async () => { + mock.onGet(AUDIT_URL).reply(200, { data: [], nextCursor: '', hasMore: false }) + const store = useAuditStore() + await store.fetchList() + const getSpy = vi.spyOn(http, 'get') + await store.loadMore() + expect(getSpy).not.toHaveBeenCalled() + }) + + it('swallows errors into errorKey', async () => { + mock.onGet(AUDIT_URL).replyOnce(200, page1).onGet(AUDIT_URL).networkError() + const store = useAuditStore() + await store.fetchList() + await expect(store.loadMore()).resolves.toBeUndefined() + expect(store.errorKey).not.toBeNull() + }) + }) + + describe('filteredEntries (client-side filtering)', () => { + beforeEach(async () => { + mock.onGet(AUDIT_URL).reply(200, { + data: [ + mkEntry({ id: 'e1', eventType: 'slice.merge', actorId: 'dario@gocell.dev' }), + mkEntry({ id: 'e2', eventType: 'flag.flip', actorId: 'sb-790' }), + mkEntry({ id: 'e3', eventType: 'config.publish', actorId: 'sa:platform-bot' }), + ], + nextCursor: '', + hasMore: false, + }) + }) + + it('returns all entries when no filter is set', async () => { + const store = useAuditStore() + await store.fetchList() + expect(store.filteredEntries).toHaveLength(3) + }) + + it('filters by free text (eventType match)', async () => { + const store = useAuditStore() + await store.fetchList() + store.filter = 'slice' + expect(store.filteredEntries).toHaveLength(1) + expect(store.filteredEntries[0]?.id).toBe('e1') + }) + + it('filters by free text (actorId match)', async () => { + const store = useAuditStore() + await store.fetchList() + store.filter = 'dario' + expect(store.filteredEntries).toHaveLength(1) + expect(store.filteredEntries[0]?.id).toBe('e1') + }) + + it('filters by actorKind', async () => { + const store = useAuditStore() + await store.fetchList() + store.actorKind = 'sandbox' + expect(store.filteredEntries).toHaveLength(1) + expect(store.filteredEntries[0]?.id).toBe('e2') + }) + + it('filters by actionNs namespace prefix', async () => { + const store = useAuditStore() + await store.fetchList() + store.actionNs = 'flag' + expect(store.filteredEntries).toHaveLength(1) + expect(store.filteredEntries[0]?.id).toBe('e2') + }) + + it('applies all three filters together', async () => { + const store = useAuditStore() + await store.fetchList() + store.filter = 'platform' + store.actorKind = 'service' + store.actionNs = 'config' + expect(store.filteredEntries).toHaveLength(1) + expect(store.filteredEntries[0]?.id).toBe('e3') + }) + }) + + describe('entriesByDay', () => { + it('groups entries by day', async () => { + mock.onGet(AUDIT_URL).reply(200, { + data: [ + mkEntry({ + id: 'e1', + occurredAt: '2026-06-02T10:00:00Z', + timestamp: '2026-06-02T10:00:00Z', + }), + mkEntry({ + id: 'e2', + occurredAt: '2026-06-01T10:00:00Z', + timestamp: '2026-06-01T10:00:00Z', + }), + ], + nextCursor: '', + hasMore: false, + }) + const store = useAuditStore() + await store.fetchList() + const groups = store.entriesByDay + expect(groups.length).toBeGreaterThanOrEqual(1) + }) + }) + + describe('chainStatus', () => { + it('returns unavailable when entries have no hash fields (contract gap)', async () => { + mock.onGet(AUDIT_URL).reply(200, page1) + const store = useAuditStore() + await store.fetchList() + expect(store.chainStatus.status).toBe('unavailable') + }) + }) +}) diff --git a/packages/audit/src/stores/useAuditStore.ts b/packages/audit/src/stores/useAuditStore.ts new file mode 100644 index 0000000..27aeac8 --- /dev/null +++ b/packages/audit/src/stores/useAuditStore.ts @@ -0,0 +1,175 @@ +import { defineStore } from 'pinia' +import { shallowRef, ref, computed } from 'vue' +import { toI18nKey } from '@gocell/request' +import { listAudit } from '../api/audit' +import type { AuditEntry } from '../api/audit' +import { classifyActor, groupByDay } from '../lib/auditClassify' +import type { DayGroup, ActorKind } from '../lib/auditClassify' +import { verifyChain } from '../lib/hashChain' +import type { ChainResult } from '../lib/hashChain' + +/** Filter union types — exported for view consumption. */ +export type ActorKindFilter = 'all' | ActorKind +export type ActionNsFilter = + | 'all' + | 'slice' + | 'flag' + | 'config' + | 'sandbox' + | 'role' + | 'secret' + | 'cell' + | 'tenant' + | 'user' + | 'anomaly' + +/** Server page size for the cursor-paginated audit list. */ +const PAGE_SIZE = 50 + +/** Narrows an unknown value to an indexable record (no `as` cast at call sites). */ +function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null +} + +/** + * audit.query — audit log query state. + * + * Owns server-list state (entries, cursor, loading, error) + three client-side + * quick-filter dimensions (free text / actor kind / action namespace), plus + * computed getters for filtered rows, day grouping, and hash-chain status. + * + * Read actions swallow errors into `errorKey` (rendered inline). + * Chain verification is computed client-side from loaded entries; + * the status will be `unavailable` until the backend exposes hash fields (BR-006). + */ +export const useAuditStore = defineStore('audit.query', () => { + // ─── state ────────────────────────────────────────────────────────────── + // shallowRef: entries are replaced as a whole on each fetch/loadMore; element + // properties are never mutated in-place, so deep reactivity is unnecessary. + const entries = shallowRef([]) + const loading = ref(false) + const errorKey = ref(null) + const nextCursor = ref('') + const hasMore = ref(false) + + /** Free-text filter: matches against eventType + actorId + subjectId. */ + const filter = ref('') + /** Actor kind filter. */ + const actorKind = ref('all') + /** Action namespace filter. */ + const actionNs = ref('all') + + // ─── getters ──────────────────────────────────────────────────────────── + + const filteredEntries = computed(() => { + const q = filter.value.trim().toLowerCase() + const kind = actorKind.value + const ns = actionNs.value + + return entries.value.filter((e) => { + // Free-text match across eventType + actorId + subjectId + if (q) { + const hay = `${e.eventType} ${e.actorId} ${e.subjectId ?? ''}`.toLowerCase() + if (!hay.includes(q)) return false + } + + // Actor kind filter (heuristic classification) + if (kind !== 'all' && classifyActor(e.actorId) !== kind) return false + + // Action namespace filter: match by eventType prefix before the first dot + if (ns !== 'all') { + const entryNs = e.eventType.split('.')[0] ?? '' + if (entryNs !== ns) return false + } + + return true + }) + }) + + /** Entries grouped by local date. Derived from filteredEntries. */ + const entriesByDay = computed(() => groupByDay(filteredEntries.value)) + + /** + * Hash-chain integrity status for the loaded window. + * Returns 'unavailable' until the backend exposes hash/prevHash (BR-006). + * The verifyChain algorithm is fully implemented — passing real data will + * activate the ok/broken paths without any frontend change. + */ + const chainStatus = computed(() => { + // Contract gap: hash/prevHash are not in HttpAuditListV1Response (BR-006). + // Early exit: if no entry has a hash field, skip the full map and return + // unavailable immediately. + const hasAnyHashField = entries.value.some((e) => { + const o: unknown = e + return isRecord(o) && typeof o['hash'] === 'string' + }) + if (!hasAnyHashField) return { status: 'unavailable' } + + // Build ChainEntry objects safely via the isRecord type guard — no `as` cast + // that would skip contract constraints (G). + const chainEntries = entries.value.map((e) => { + const o: unknown = e + const entry: { hash?: string; prevHash?: string } = {} + if (isRecord(o)) { + if (typeof o['hash'] === 'string') entry.hash = o['hash'] + if (typeof o['prevHash'] === 'string') entry.prevHash = o['prevHash'] + } + return entry + }) + return verifyChain(chainEntries) + }) + + // ─── read actions ─────────────────────────────────────────────────────── + + /** Fetch the first page, replacing any current entries. */ + async function fetchList(): Promise { + loading.value = true + errorKey.value = null + try { + const page = await listAudit({ limit: PAGE_SIZE }) + entries.value = page.data + nextCursor.value = page.nextCursor + hasMore.value = page.hasMore + } catch (err: unknown) { + errorKey.value = toI18nKey(err) + } finally { + loading.value = false + } + } + + /** Append the next page; no-op when there is none or a request is in flight. */ + async function loadMore(): Promise { + if (!hasMore.value || loading.value) return + loading.value = true + errorKey.value = null + try { + const page = await listAudit({ cursor: nextCursor.value, limit: PAGE_SIZE }) + entries.value = [...entries.value, ...page.data] + nextCursor.value = page.nextCursor + hasMore.value = page.hasMore + } catch (err: unknown) { + errorKey.value = toI18nKey(err) + } finally { + loading.value = false + } + } + + return { + // state + entries, + loading, + errorKey, + nextCursor, + hasMore, + filter, + actorKind, + actionNs, + // getters + filteredEntries, + entriesByDay, + chainStatus, + // read actions + fetchList, + loadMore, + } +}) diff --git a/packages/audit/src/views/AuditView.spec.ts b/packages/audit/src/views/AuditView.spec.ts new file mode 100644 index 0000000..3aae128 --- /dev/null +++ b/packages/audit/src/views/AuditView.spec.ts @@ -0,0 +1,286 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { computed } from 'vue' +import { createTestingPinia } from '@pinia/testing' +import { PDP_INJECTION_KEY, type PdpClient } from '@gocell/core' +import { useAuditStore } from '../stores/useAuditStore' +import type { AuditEntry } from '../api/audit' +import AuditView from './AuditView.vue' + +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k: string) => k }) })) + +const pdp = (allowed: boolean): PdpClient => ({ can: () => computed(() => allowed) }) + +const mkEntry = (over: Partial = {}): AuditEntry => ({ + id: 'evt-001', + eventId: 'eid-001', + eventType: 'slice.merge', + actorId: 'dario@gocell.dev', + timestamp: '2026-06-01T12:48:55Z', + occurredAt: '2026-06-01T12:48:55Z', + ...over, +}) + +type StoreState = { + entries?: AuditEntry[] + loading?: boolean + errorKey?: string | null + hasMore?: boolean + filter?: string + actorKind?: string + actionNs?: string +} + +function mountView(state: StoreState = {}, pdpAllowed = true) { + const wrapper = mount(AuditView, { + global: { + plugins: [ + createTestingPinia({ + createSpy: vi.fn, + initialState: { + 'audit.query': { + entries: [], + loading: false, + errorKey: null, + hasMore: false, + filter: '', + actorKind: 'all', + actionNs: 'all', + ...state, + }, + }, + }), + ], + provide: { + [PDP_INJECTION_KEY]: pdp(pdpAllowed), + }, + }, + }) + const store = useAuditStore() + return { wrapper, store } +} + +describe('AuditView · page structure', () => { + beforeEach(() => vi.clearAllMocks()) + + it('renders the page heading with i18n key', () => { + const { wrapper } = mountView() + expect(wrapper.find('h1').text()).toContain('audit.log.title') + }) + + it('renders the filter input with associated label', () => { + const { wrapper } = mountView() + expect(wrapper.find('label[for="audit-filter"]').exists()).toBe(true) + expect(wrapper.find('#audit-filter').exists()).toBe(true) + }) + + it('renders actor kind select with associated label', () => { + const { wrapper } = mountView() + expect(wrapper.find('label[for="audit-actor-kind"]').exists()).toBe(true) + expect(wrapper.find('#audit-actor-kind').exists()).toBe(true) + }) + + it('renders action namespace select with associated label', () => { + const { wrapper } = mountView() + expect(wrapper.find('label[for="audit-action-ns"]').exists()).toBe(true) + expect(wrapper.find('#audit-action-ns').exists()).toBe(true) + }) + + it('renders chain integrity card in unavailable state', () => { + const { wrapper } = mountView() + const chainCard = wrapper.find('[data-testid="chain-integrity"]') + expect(chainCard.exists()).toBe(true) + expect(chainCard.attributes('role')).toBe('status') + expect(chainCard.text()).toContain('audit.log.chain.unavailable') + }) + + it('renders chain card in ok state when entries have intact hash chain', () => { + // Entries with matching hash/prevHash — chain is intact. + const e1 = mkEntry({ id: 'e1', ...({ hash: 'h2', prevHash: 'h1' } as object) }) + const e2 = mkEntry({ id: 'e2', ...({ hash: 'h1' } as object) }) + const { wrapper } = mountView({ entries: [e1, e2] }) + const chainCard = wrapper.find('[data-testid="chain-integrity"]') + expect(chainCard.text()).toContain('audit.log.chain.ok') + }) + + it('renders chain card in broken state when entries have mismatched hashes', () => { + // Entries with mismatched prevHash/hash — chain is broken. + const e1 = mkEntry({ id: 'e1', ...({ hash: 'h2', prevHash: 'WRONG' } as object) }) + const e2 = mkEntry({ id: 'e2', ...({ hash: 'h1' } as object) }) + const { wrapper } = mountView({ entries: [e1, e2] }) + const chainCard = wrapper.find('[data-testid="chain-integrity"]') + expect(chainCard.text()).toContain('audit.log.chain.broken') + }) + + it('calls store.fetchList on mount', async () => { + const { store } = mountView() + await flushPromises() + expect(store.fetchList).toHaveBeenCalledOnce() + }) +}) + +describe('AuditView · states', () => { + beforeEach(() => vi.clearAllMocks()) + + it('shows loading indicator (role=status) while loading', () => { + const { wrapper } = mountView({ loading: true, entries: [] }) + const status = wrapper.find('[role="status"]') + expect(status.exists()).toBe(true) + // Loading text should appear somewhere + expect(wrapper.text()).toContain('audit.log.loading') + }) + + it('shows empty state when entries is empty and not loading', () => { + const { wrapper } = mountView({ loading: false, entries: [] }) + expect(wrapper.text()).toContain('audit.log.empty') + }) + + it('shows error in role=alert when errorKey is set', () => { + const { wrapper } = mountView({ errorKey: 'errors.network' }) + const alert = wrapper.find('[role="alert"]') + expect(alert.exists()).toBe(true) + expect(alert.text()).toContain('errors.network') + }) + + it('renders day group sections when entries exist', () => { + const { wrapper } = mountView({ + entries: [ + mkEntry({ + id: 'e1', + occurredAt: '2026-06-01T10:00:00Z', + timestamp: '2026-06-01T10:00:00Z', + }), + ], + }) + // day heading should appear in a section + expect(wrapper.findAll('section').length).toBeGreaterThanOrEqual(1) + }) + + it('renders ActorPill for each entry', () => { + const { wrapper } = mountView({ + entries: [mkEntry({ id: 'e1' })], + }) + expect(wrapper.findComponent({ name: 'ActorPill' }).exists()).toBe(true) + }) +}) + +describe('AuditView · detail panel', () => { + beforeEach(() => vi.clearAllMocks()) + + it('shows detail panel with entry fields when a row is selected', async () => { + const { wrapper } = mountView({ + entries: [mkEntry({ id: 'e1', eventType: 'slice.merge', actorId: 'dario@gocell.dev' })], + }) + // Click the first row + const row = wrapper.find('[data-testid="audit-row"]') + if (row.exists()) { + await row.trigger('click') + } + // Detail panel should exist (either pre-selected or after click) + const detail = wrapper.find('[data-testid="audit-detail"]') + expect(detail.exists()).toBe(true) + }) + + it('detail panel shows chain unavailable text', () => { + const { wrapper } = mountView({ + entries: [mkEntry()], + }) + expect(wrapper.text()).toContain('audit.log.chain.unavailable') + }) +}) + +describe('AuditView · quick filter chips', () => { + beforeEach(() => vi.clearAllMocks()) + + it('quick filter chips are keyboard reachable (tabindex != -1)', () => { + const { wrapper } = mountView() + const chips = wrapper.findAll('[data-testid="qf-chip"]') + chips.forEach((chip) => { + expect(chip.attributes('tabindex')).not.toBe('-1') + }) + }) + + it('clicking a quick filter chip updates store filter state', async () => { + const { wrapper, store } = mountView() + const chips = wrapper.findAll('[data-testid="qf-chip"]') + expect(chips.length).toBeGreaterThan(0) + // Click the "flag flips" chip (3rd chip) + await chips[2]?.trigger('click') + await flushPromises() + expect(store.actionNs).toBe('flag') + }) +}) + +describe('AuditView · time range segment', () => { + beforeEach(() => vi.clearAllMocks()) + + it('renders 4 time range buttons', () => { + const { wrapper } = mountView() + const rangeGroup = wrapper.find('[aria-label="audit.log.range.label"]') + expect(rangeGroup.exists()).toBe(true) + const btns = rangeGroup.findAll('button') + expect(btns).toHaveLength(4) + }) + + it('24h is the default active range', () => { + const { wrapper } = mountView() + const active = wrapper.find('.audit__seg-btn--active') + expect(active.text()).toBe('24h') + }) + + it('clicking a range button updates active range', async () => { + const { wrapper } = mountView() + const btns = wrapper.findAll('.audit__seg-btn') + await btns[0]?.trigger('click') // 1h + const active = wrapper.find('.audit__seg-btn--active') + expect(active.text()).toBe('1h') + }) +}) + +describe('AuditView · load more', () => { + beforeEach(() => vi.clearAllMocks()) + + it('shows load-more button when hasMore is true', () => { + const { wrapper } = mountView({ + entries: [mkEntry()], + hasMore: true, + }) + const btn = wrapper.find('[data-action="load-more"]') + expect(btn.exists()).toBe(true) + }) + + it('clicking load-more calls store.loadMore', async () => { + const { wrapper, store } = mountView({ + entries: [mkEntry()], + hasMore: true, + }) + await wrapper.find('[data-action="load-more"]').trigger('click') + await flushPromises() + expect(store.loadMore).toHaveBeenCalled() + }) +}) + +describe('AuditView · filter controls', () => { + beforeEach(() => vi.clearAllMocks()) + + it('typing in filter input updates store filter', async () => { + const { wrapper, store } = mountView() + const input = wrapper.find('#audit-filter') + await input.setValue('slice') + expect(store.filter).toBe('slice') + }) + + it('selecting actor kind updates store actorKind', async () => { + const { wrapper, store } = mountView() + const select = wrapper.find('#audit-actor-kind') + await select.setValue('human') + expect(store.actorKind).toBe('human') + }) + + it('selecting action namespace updates store actionNs', async () => { + const { wrapper, store } = mountView() + const select = wrapper.find('#audit-action-ns') + await select.setValue('flag') + expect(store.actionNs).toBe('flag') + }) +}) diff --git a/packages/audit/src/views/AuditView.vue b/packages/audit/src/views/AuditView.vue new file mode 100644 index 0000000..047dccc --- /dev/null +++ b/packages/audit/src/views/AuditView.vue @@ -0,0 +1,902 @@ + + + + + diff --git a/packages/audit/tsconfig.json b/packages/audit/tsconfig.json index 564a599..1da8cb9 100644 --- a/packages/audit/tsconfig.json +++ b/packages/audit/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../../tsconfig.base.json", - "include": ["src"] + "include": ["src", "vitest.config.ts"] } diff --git a/packages/audit/vitest.config.ts b/packages/audit/vitest.config.ts new file mode 100644 index 0000000..312ee3e --- /dev/null +++ b/packages/audit/vitest.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + test: { + environment: 'jsdom', + include: ['src/**/*.spec.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts', 'src/**/*.vue'], + exclude: [ + 'src/**/*.spec.ts', + 'src/**/__tests__/**', + // Barrel re-export files — no logic to test, coverage from consumers + 'src/index.ts', + 'src/stores/index.ts', + ], + thresholds: { + lines: 80, + functions: 80, + branches: 80, + statements: 80, + }, + }, + }, +}) diff --git a/packages/core/src/i18n/messages/en-US.ts b/packages/core/src/i18n/messages/en-US.ts index 195076a..5882e23 100644 --- a/packages/core/src/i18n/messages/en-US.ts +++ b/packages/core/src/i18n/messages/en-US.ts @@ -421,6 +421,80 @@ const enUS = { body: 'This area is under construction and will arrive in a later wave.', }, }, + audit: { + log: { + title: 'Audit log', + subtitle: + 'Tamper-evident record of every privileged action — by humans, service accounts, cells, and AI sandboxes. Hash-chained per tenant and verified continuously.', + loading: 'Loading audit entries…', + empty: 'No audit entries match this filter', + loadMore: 'Load more', + list: { label: 'Audit log list' }, + day: { today: 'Today', yesterday: 'Yesterday' }, + range: { label: 'Time range' }, + actions: { + export: 'Export CSV', + verify: 'Verify chain', + }, + chain: { + label: 'Chain integrity', + ok: 'Hash chain intact', + broken: 'Hash chain broken', + unavailable: 'Verification unavailable', + // BR-006 reference is kept in code comments only, not surfaced to users + unavailableNote: 'Hash chain verification is not yet available in this version', + }, + filter: { + label: 'Search', + placeholder: 'Filter eventType, actorId, subjectId…', + count: '{shown} shown', + actorKind: { + label: 'Actor type', + all: 'All types', + human: 'Human', + service: 'Service account', + cell: 'Cell (auto)', + sandbox: 'AI sandbox', + unknown: 'Unknown', + }, + actionNs: { + label: 'Action namespace', + all: 'All actions', + slice: 'slice.*', + flag: 'flag.*', + config: 'config.*', + sandbox: 'sandbox.*', + role: 'role.*', + secret: 'secret.*', + cell: 'cell.*', + tenant: 'tenant.*', + user: 'user.*', + anomaly: 'anomaly.*', + }, + }, + quickFilter: { + label: 'Quick filters', + prefix: 'Quick filters:', + sandboxActions: 'Sandbox actions', + secretReads: 'Secret reads', + flagFlips: 'Flag flips', + roleChangesByHumans: 'Role changes by humans', + }, + detail: { + label: 'Entry detail', + entryId: 'Entry ID', + eventType: 'Event type', + actor: 'Actor', + target: 'Target', + correlationId: 'Correlation ID', + timestamp: 'Timestamp', + payload: 'Payload (signed)', + noPayload: 'No payload', + chainContext: 'Hash chain context', + noTarget: '—', + }, + }, + }, errors: { unknown: 'An unknown error occurred. Please try again.', network: 'Network connection failed. Please check your connection.', diff --git a/packages/core/src/i18n/messages/zh-CN.ts b/packages/core/src/i18n/messages/zh-CN.ts index 004f5a2..9799a60 100644 --- a/packages/core/src/i18n/messages/zh-CN.ts +++ b/packages/core/src/i18n/messages/zh-CN.ts @@ -413,6 +413,80 @@ const zhCN = { body: '该功能正在建设中,将在后续版本提供。', }, }, + audit: { + log: { + title: '审计日志', + subtitle: + '防篡改的特权操作记录 —— 人工、服务账号、Cell 及 AI 沙箱均纳入;按租户哈希链持续校验。', + loading: '正在加载审计条目…', + empty: '暂无匹配的审计条目', + loadMore: '加载更多', + list: { label: '审计日志列表' }, + day: { today: '今天', yesterday: '昨天' }, + range: { label: '时间范围' }, + actions: { + export: '导出 CSV', + verify: '验证链', + }, + chain: { + label: '哈希链完整性', + ok: '哈希链完整', + broken: '哈希链已断裂', + unavailable: '链校验不可用', + // BR-006 仅保留在代码注释中,不向用户暴露内部编号 + unavailableNote: '此版本暂不支持哈希链校验', + }, + filter: { + label: '搜索', + placeholder: '过滤 eventType、actorId、subjectId…', + count: '显示 {shown} 条', + actorKind: { + label: '操作者类型', + all: '全部类型', + human: '人工', + service: '服务账号', + cell: 'Cell(自动)', + sandbox: 'AI 沙箱', + unknown: '未知', + }, + actionNs: { + label: '操作命名空间', + all: '全部操作', + slice: 'slice.*', + flag: 'flag.*', + config: 'config.*', + sandbox: 'sandbox.*', + role: 'role.*', + secret: 'secret.*', + cell: 'cell.*', + tenant: 'tenant.*', + user: 'user.*', + anomaly: 'anomaly.*', + }, + }, + quickFilter: { + label: '快捷过滤', + prefix: '快捷过滤:', + sandboxActions: '沙箱操作', + secretReads: '密钥读取', + flagFlips: '开关变更', + roleChangesByHumans: '人工角色变更', + }, + detail: { + label: '条目详情', + entryId: '条目 ID', + eventType: '事件类型', + actor: '操作者', + target: '目标', + correlationId: '关联 ID', + timestamp: '时间', + payload: 'Payload(已签名)', + noPayload: '暂无 payload', + chainContext: '哈希链上下文', + noTarget: '—', + }, + }, + }, errors: { unknown: '发生未知错误,请稍后重试', network: '网络连接失败,请检查网络后重试', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33d6f0b..7646243 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,6 +179,9 @@ importers: '@gocell/access': specifier: workspace:* version: link:../../packages/access + '@gocell/audit': + specifier: workspace:* + version: link:../../packages/audit '@gocell/core': specifier: workspace:* version: link:../../packages/core @@ -275,7 +278,51 @@ importers: specifier: 'catalog:' version: 3.0.4(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1) - packages/audit: {} + packages/audit: + dependencies: + '@gocell/contracts': + specifier: workspace:* + version: link:../contracts + '@gocell/core': + specifier: workspace:* + version: link:../core + '@gocell/request': + specifier: workspace:* + version: link:../request + pinia: + specifier: 'catalog:' + version: 3.0.4(typescript@5.7.3)(vue@3.5.35(typescript@5.7.3)) + vue: + specifier: 'catalog:' + version: 3.5.35(typescript@5.7.3) + vue-i18n: + specifier: 'catalog:' + version: 11.4.4(vue@3.5.35(typescript@5.7.3)) + vue-router: + specifier: 'catalog:' + version: 4.6.4(vue@3.5.35(typescript@5.7.3)) + devDependencies: + '@pinia/testing': + specifier: 'catalog:' + version: 1.0.3(pinia@3.0.4(typescript@5.7.3)(vue@3.5.35(typescript@5.7.3))) + '@vitejs/plugin-vue': + specifier: 'catalog:' + version: 5.2.1(vite@6.4.2(@types/node@22.19.19)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1))(vue@3.5.35(typescript@5.7.3)) + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 3.0.4(vitest@3.0.4(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1)) + '@vue/test-utils': + specifier: 'catalog:' + version: 2.4.10(@vue/compiler-dom@3.5.35)(@vue/server-renderer@3.5.35(vue@3.5.35(typescript@5.7.3)))(vue@3.5.35(typescript@5.7.3)) + axios-mock-adapter: + specifier: 'catalog:' + version: 2.1.0(axios@1.16.1) + jsdom: + specifier: 'catalog:' + version: 25.0.1 + vitest: + specifier: 'catalog:' + version: 3.0.4(@types/node@22.19.19)(jsdom@25.0.1)(sass@1.100.0)(tsx@4.22.3)(yaml@2.6.1) packages/config: {}