feat:移除了弹窗,服务器添加sls
This commit is contained in:
65
apps/telemetry-dashboard/test/server/auth.test.ts
Normal file
65
apps/telemetry-dashboard/test/server/auth.test.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { generateKeyPair, SignJWT } from 'jose'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { authenticateAccessToken, mockAuthEnabled, verifyAccessJwt } from '../../server/utils/auth'
|
||||
|
||||
const settings = { teamDomain: 'fixture-team', audience: 'fixture-audience' }
|
||||
const issuer = 'https://fixture-team.cloudflareaccess.com'
|
||||
|
||||
async function token(
|
||||
privateKey: CryptoKey,
|
||||
overrides: { audience?: string; expiresAt?: number } = {},
|
||||
): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1_000)
|
||||
return new SignJWT({ email: 'fixture@example.invalid', name: 'Fixture Operator' })
|
||||
.setProtectedHeader({ alg: 'RS256' })
|
||||
.setIssuer(issuer)
|
||||
.setAudience(overrides.audience ?? settings.audience)
|
||||
.setIssuedAt(now)
|
||||
.setExpirationTime(overrides.expiresAt ?? now + 600)
|
||||
.sign(privateKey)
|
||||
}
|
||||
|
||||
describe('Cloudflare Access authentication', () => {
|
||||
const previousNodeEnv = process.env.NODE_ENV
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = previousNodeEnv
|
||||
delete process.env.TELEMETRY_ADMIN_MOCK_AUTH
|
||||
})
|
||||
|
||||
it('rejects a missing Access assertion', async () => {
|
||||
await expect(authenticateAccessToken(null, settings)).rejects.toMatchObject({
|
||||
statusCode: 401,
|
||||
code: 'unauthenticated',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects the wrong audience and expired tokens, then accepts a valid token', async () => {
|
||||
const { privateKey, publicKey } = (await generateKeyPair('RS256')) as CryptoKeyPair
|
||||
await expect(
|
||||
verifyAccessJwt(await token(privateKey, { audience: 'wrong-audience' }), settings, publicKey),
|
||||
).rejects.toMatchObject({ statusCode: 401 })
|
||||
await expect(
|
||||
verifyAccessJwt(
|
||||
await token(privateKey, { expiresAt: Math.floor(Date.now() / 1_000) - 10 }),
|
||||
settings,
|
||||
publicKey,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 401 })
|
||||
|
||||
const session = await verifyAccessJwt(await token(privateKey), settings, publicKey)
|
||||
expect(session).toMatchObject({
|
||||
identity: { name: 'Fixture Operator', email: 'fixture@example.invalid' },
|
||||
organization: 'Axolotl-Launcher',
|
||||
mock: false,
|
||||
dataSource: 'production',
|
||||
})
|
||||
})
|
||||
|
||||
it('cannot enable MockAuthProvider in production', () => {
|
||||
process.env.NODE_ENV = 'production'
|
||||
process.env.TELEMETRY_ADMIN_MOCK_AUTH = 'true'
|
||||
expect(mockAuthEnabled({ mockAuth: true } as ReturnType<typeof useRuntimeConfig>)).toBe(false)
|
||||
})
|
||||
})
|
||||
233
apps/telemetry-dashboard/test/server/d1-admin-api.test.ts
Normal file
233
apps/telemetry-dashboard/test/server/d1-admin-api.test.ts
Normal file
@ -0,0 +1,233 @@
|
||||
import { env } from 'cloudflare:test'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { D1TelemetryAdminApi } from '../../server/utils/d1-admin-api'
|
||||
import { parseErrorQuery } from '../../server/utils/validation'
|
||||
|
||||
const NOW = new Date('2026-08-14T10:00:00.000Z')
|
||||
const ANALYTICS_URL = 'https://api.cloudflare.com/client/v4/graphql'
|
||||
const FIXTURE_ACCOUNT_TAG = 'a7659e62e4d157aba4a45e4829b24e91'
|
||||
|
||||
async function gzip(input: string): Promise<ArrayBuffer> {
|
||||
return new Response(
|
||||
new Blob([input]).stream().pipeThrough(new CompressionStream('gzip')),
|
||||
).arrayBuffer()
|
||||
}
|
||||
|
||||
async function seed(): Promise<void> {
|
||||
await env.DB.batch([
|
||||
env.DB.prepare(
|
||||
`INSERT INTO installations
|
||||
(installation_hash, first_seen_at, last_seen_at, first_seen_day, app_version, platform, arch)
|
||||
VALUES ('fixture-hash-a', 1, 2, '2026-08-13', '9.4.0-fixture', 'windows-fixture', 'x86_64-fixture')`,
|
||||
),
|
||||
env.DB.prepare(
|
||||
`INSERT INTO installations
|
||||
(installation_hash, first_seen_at, last_seen_at, first_seen_day, app_version, platform, arch)
|
||||
VALUES ('fixture-hash-b', 1, 2, '2026-08-14', '9.3.2-fixture', 'linux-fixture', 'aarch64-fixture')`,
|
||||
),
|
||||
env.DB.prepare(
|
||||
`INSERT INTO daily_active (day, installation_hash, app_version, platform, arch)
|
||||
VALUES ('2026-08-14', 'fixture-hash-a', '9.4.0-fixture', 'windows-fixture', 'x86_64-fixture')`,
|
||||
),
|
||||
env.DB.prepare(
|
||||
`INSERT INTO daily_active (day, installation_hash, app_version, platform, arch)
|
||||
VALUES ('2026-08-14', 'fixture-hash-b', '9.3.2-fixture', 'linux-fixture', 'aarch64-fixture')`,
|
||||
),
|
||||
env.DB.prepare(
|
||||
`INSERT INTO error_reports
|
||||
(event_id, installation_hash, day, occurred_at, fingerprint, app_version, platform, arch,
|
||||
error_type, message, occurrence_count, object_key, created_at)
|
||||
VALUES ('fixture-event-a', 'fixture-hash-a', '2026-08-14', '2026-08-14T08:00:00Z',
|
||||
'fixture-render-01', '9.4.0-fixture', 'windows-fixture', 'x86_64-fixture',
|
||||
'RenderFixtureError', 'Fixture render failure', 4,
|
||||
'errors/2026-08-14/fixture-render-01/fixture-event-a.json.gz', 1)`,
|
||||
),
|
||||
env.DB.prepare(
|
||||
`INSERT INTO error_context_reservations
|
||||
(event_id, day, fingerprint, app_version, object_key, created_at)
|
||||
VALUES ('fixture-event-a', '2026-08-14', 'fixture-render-01', '9.4.0-fixture',
|
||||
'errors/2026-08-14/fixture-render-01/fixture-event-a.json.gz', 1)`,
|
||||
),
|
||||
env.DB.prepare(
|
||||
`INSERT INTO error_daily
|
||||
(day, fingerprint, app_version, occurrence_count, installation_count,
|
||||
latest_error_type, latest_message, has_sample)
|
||||
VALUES ('2026-08-14', 'fixture-render-01', '9.4.0-fixture', 4, 0,
|
||||
'RenderFixtureError', 'Fixture render failure', 1)`,
|
||||
),
|
||||
env.DB.prepare(
|
||||
`INSERT INTO error_daily_installations
|
||||
(day, fingerprint, app_version, installation_hash)
|
||||
VALUES ('2026-08-14', 'fixture-render-01', '9.4.0-fixture', 'fixture-hash-a')`,
|
||||
),
|
||||
env.DB.prepare(
|
||||
`INSERT INTO daily_totals (day, new_installations, active_installations, error_occurrences, distinct_error_groups)
|
||||
VALUES ('2026-08-13', 1, 0, 0, 0)`,
|
||||
),
|
||||
env.DB.prepare(`INSERT INTO platforms (platform) VALUES ('windows-fixture')`),
|
||||
])
|
||||
await env.ERROR_CONTEXTS.put(
|
||||
'errors/2026-08-14/fixture-render-01/fixture-event-a.json.gz',
|
||||
await gzip(
|
||||
JSON.stringify({
|
||||
occurred_at: '2026-08-14T08:00:00Z',
|
||||
fingerprint: 'fixture-render-01',
|
||||
app: {
|
||||
version: '9.4.0-fixture',
|
||||
platform: 'windows-fixture',
|
||||
arch: 'x86_64-fixture',
|
||||
},
|
||||
error_type: 'RenderFixtureError',
|
||||
message: 'Fixture render failure',
|
||||
stack: 'Fixture stack',
|
||||
route: '/fixture',
|
||||
command: 'fixture-command',
|
||||
context: 'synthetic fixture context',
|
||||
installation_hash: 'must-not-leak',
|
||||
object_key: 'must-not-leak',
|
||||
}),
|
||||
),
|
||||
{ httpMetadata: { contentEncoding: 'gzip', contentType: 'application/json' } },
|
||||
)
|
||||
}
|
||||
|
||||
function api(analytics?: 'configured' | 'partial' | 'unconfigured'): D1TelemetryAdminApi {
|
||||
const fetcher: typeof fetch = async (input, init) => {
|
||||
const url = String(input)
|
||||
if (url === 'https://fixture.invalid/health') {
|
||||
return new Response(JSON.stringify({ status: 'ok' }))
|
||||
}
|
||||
if (url === ANALYTICS_URL) {
|
||||
const query = JSON.parse(String(init?.body ?? '{}')).query as string
|
||||
if (analytics === 'partial' && query.includes('workersInvocationsAdaptive')) {
|
||||
return new Response(
|
||||
JSON.stringify({ data: null, errors: [{ message: 'fixture analytics failure' }] }),
|
||||
)
|
||||
}
|
||||
if (query.includes('workersInvocationsAdaptive')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
viewer: {
|
||||
accounts: [{ workersInvocationsAdaptive: [{ sum: { requests: 12_345 } }] }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (query.includes('d1AnalyticsAdaptiveGroups')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
viewer: {
|
||||
accounts: [
|
||||
{
|
||||
d1AnalyticsAdaptiveGroups: [{ sum: { rowsRead: 45_012, rowsWritten: 3_201 } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (query.includes('r2OperationsAdaptiveGroups')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
viewer: {
|
||||
accounts: [{ r2OperationsAdaptiveGroups: [{ sum: { requests: 8_412 } }] }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({ data: null, errors: [{ message: 'unknown fixture query' }] }),
|
||||
)
|
||||
}
|
||||
return new Response('not found', { status: 404 })
|
||||
}
|
||||
return new D1TelemetryAdminApi(env.DB, env.ERROR_CONTEXTS, {
|
||||
storeErrorContext: true,
|
||||
healthUrl: 'https://fixture.invalid/health',
|
||||
fetcher,
|
||||
now: () => NOW,
|
||||
cacheTtlMs: 0,
|
||||
analytics:
|
||||
analytics === 'unconfigured'
|
||||
? undefined
|
||||
: { accountTag: FIXTURE_ACCOUNT_TAG, apiToken: 'fixture-analytics-token' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('D1TelemetryAdminApi', () => {
|
||||
beforeEach(seed)
|
||||
|
||||
it('maps D1 aggregates to stable overview and DAU/WAU/MAU DTOs', async () => {
|
||||
const overview = await api().overview('30d')
|
||||
expect(overview.metrics.totalInstallations.value).toBe(2)
|
||||
expect(overview.metrics.dau.value).toBe(2)
|
||||
expect(overview.metrics.wau.value).toBe(2)
|
||||
expect(overview.metrics.mau.value).toBe(2)
|
||||
expect(overview.metrics.errorOccurrences.value).toBe(4)
|
||||
expect(JSON.stringify(overview)).not.toContain('installation_hash')
|
||||
})
|
||||
|
||||
it('supports bound search, filters, sorting, and server pagination', async () => {
|
||||
const result = await api().errors(
|
||||
parseErrorQuery({
|
||||
range: '30d',
|
||||
search: 'render',
|
||||
platform: 'windows-fixture',
|
||||
page: '1',
|
||||
pageSize: '25',
|
||||
sort: 'occurrences',
|
||||
direction: 'desc',
|
||||
}),
|
||||
)
|
||||
expect(result.total).toBe(1)
|
||||
expect(result.items[0]).toMatchObject({
|
||||
fingerprint: 'fixture-render-01',
|
||||
occurrenceCount: 4,
|
||||
hasSample: true,
|
||||
})
|
||||
expect(JSON.stringify(result)).not.toMatch(/installation_hash|object_key/)
|
||||
})
|
||||
|
||||
it('reads only a D1-registered sample and never returns internal keys', async () => {
|
||||
await env.ERROR_CONTEXTS.put(
|
||||
'errors/unregistered.json.gz',
|
||||
await gzip('{"message":"unregistered"}'),
|
||||
)
|
||||
const registered = await api().errorSample('fixture-render-01')
|
||||
expect(registered).toMatchObject({
|
||||
fingerprint: 'fixture-render-01',
|
||||
message: 'Fixture render failure',
|
||||
})
|
||||
expect(JSON.stringify(registered)).not.toMatch(/installation_hash|object_key|must-not-leak/)
|
||||
expect(await api().errorSample('fixture-unregistered')).toBeNull()
|
||||
})
|
||||
|
||||
it('reports the unconfigured state when analytics credentials are absent', async () => {
|
||||
const system = await api('unconfigured').system()
|
||||
expect(system.accountUsage).toMatchObject({ status: 'unavailable', label: '未配置' })
|
||||
expect(system.accountUsage.detail).toContain('尚未配置 Cloudflare Analytics API')
|
||||
})
|
||||
|
||||
it('aggregates Workers, D1, and R2 usage into one account usage check', async () => {
|
||||
const system = await api('configured').system()
|
||||
expect(system.accountUsage.status).toBe('available')
|
||||
expect(system.accountUsage.detail).toContain('Workers 12,345/100,000 请求')
|
||||
expect(system.accountUsage.detail).toContain('写 3,201/100,000 行')
|
||||
expect(system.accountUsage.detail).toContain('R2 8,412/1,000,000 操作')
|
||||
})
|
||||
|
||||
it('degrades when one analytics dataset fails but others respond', async () => {
|
||||
const system = await api('partial').system()
|
||||
expect(system.accountUsage).toMatchObject({ status: 'degraded', label: '部分可用' })
|
||||
expect(system.accountUsage.detail).toContain('fixture analytics failure')
|
||||
expect(system.accountUsage.detail).toContain('D1')
|
||||
expect(system.accountUsage.detail).toContain('R2')
|
||||
})
|
||||
})
|
||||
31
apps/telemetry-dashboard/test/server/setup.ts
Normal file
31
apps/telemetry-dashboard/test/server/setup.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { applyD1Migrations, type D1Migration, env } from 'cloudflare:test'
|
||||
import { beforeAll, beforeEach } from 'vitest'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
interface ProvidedEnv {
|
||||
DB: D1Database
|
||||
ERROR_CONTEXTS: R2Bucket
|
||||
TEST_MIGRATIONS: D1Migration[]
|
||||
}
|
||||
}
|
||||
|
||||
const tables = [
|
||||
'error_context_reservations',
|
||||
'error_context_samples',
|
||||
'error_context_budget',
|
||||
'error_reports',
|
||||
'error_daily',
|
||||
'error_groups',
|
||||
'daily_active',
|
||||
'daily_totals',
|
||||
'accepted_batches',
|
||||
'installations',
|
||||
]
|
||||
|
||||
beforeAll(async () => {
|
||||
await applyD1Migrations(env.DB, env.TEST_MIGRATIONS)
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await env.DB.batch(tables.map((table) => env.DB.prepare(`DELETE FROM ${table}`)))
|
||||
})
|
||||
24
apps/telemetry-dashboard/test/server/validation.test.ts
Normal file
24
apps/telemetry-dashboard/test/server/validation.test.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseErrorQuery, parseRange } from '../../server/utils/validation'
|
||||
|
||||
describe('admin query validation', () => {
|
||||
it('allows only known UTC ranges', () => {
|
||||
expect(parseRange('7d')).toBe('7d')
|
||||
expect(parseRange(undefined)).toBe('30d')
|
||||
expect(() => parseRange('31d')).toThrowError('Range must be 7d, 30d, 90d, or 365d')
|
||||
})
|
||||
|
||||
it('bounds page size, search length, and sort fields', () => {
|
||||
expect(parseErrorQuery({ page: '2', pageSize: '100', sort: 'occurrences' })).toMatchObject({
|
||||
page: 2,
|
||||
pageSize: 100,
|
||||
sort: 'occurrences',
|
||||
})
|
||||
expect(() => parseErrorQuery({ pageSize: '101' })).toThrowError('Invalid query parameters')
|
||||
expect(() => parseErrorQuery({ search: 'x'.repeat(121) })).toThrowError(
|
||||
'Invalid query parameters',
|
||||
)
|
||||
expect(() => parseErrorQuery({ sort: 'object_key' })).toThrowError('Invalid query parameters')
|
||||
})
|
||||
})
|
||||
5
apps/telemetry-dashboard/test/server/worker.ts
Normal file
5
apps/telemetry-dashboard/test/server/worker.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export default {
|
||||
fetch(): Response {
|
||||
return new Response('fixture worker')
|
||||
},
|
||||
}
|
||||
44
apps/telemetry-dashboard/test/ui/states.test.ts
Normal file
44
apps/telemetry-dashboard/test/ui/states.test.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import AppState from '../../components/AppState.vue'
|
||||
import TrendChart from '../../components/charts/TrendChart.vue'
|
||||
import Skeleton from '../../components/ui/Skeleton.vue'
|
||||
|
||||
describe('dashboard states', () => {
|
||||
it.each([
|
||||
['error', '遥测数据暂不可用'],
|
||||
['unauthenticated', '需要登录'],
|
||||
['forbidden', '无权访问'],
|
||||
] as const)('renders the %s state', (kind, title) => {
|
||||
const wrapper = mount(AppState, { props: { kind } })
|
||||
expect(wrapper.attributes('data-state')).toBe(kind)
|
||||
expect(wrapper.text()).toContain(title)
|
||||
})
|
||||
|
||||
it('renders stable loading and empty chart states', () => {
|
||||
expect(mount(Skeleton, { props: { class: 'h-14' } }).classes()).toContain('h-14')
|
||||
const chart = mount(TrendChart, {
|
||||
props: {
|
||||
data: [],
|
||||
series: [{ key: 'errorOccurrences', label: '错误次数', color: '#d85d4a' }],
|
||||
},
|
||||
})
|
||||
expect(chart.get('[data-state="empty"]').text()).toContain('暂无数据')
|
||||
})
|
||||
})
|
||||
|
||||
describe('responsive theme contract', () => {
|
||||
it('keeps dark tokens and narrow layouts from creating page overflow', () => {
|
||||
const root = process.cwd()
|
||||
const css = readFileSync(`${root}/assets/styles/tailwind.css`, 'utf8')
|
||||
const layout = readFileSync(`${root}/layouts/default.vue`, 'utf8')
|
||||
expect(css).toContain('.dark')
|
||||
expect(css).toContain('overflow-x-hidden')
|
||||
expect(layout).toContain('min-w-0 md:pl-60')
|
||||
expect(layout).toContain('w-60 -translate-x-full')
|
||||
expect(layout).toContain('生产数据')
|
||||
})
|
||||
})
|
||||
6
apps/telemetry-dashboard/test/vue-shim.d.ts
vendored
Normal file
6
apps/telemetry-dashboard/test/vue-shim.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
|
||||
export default component
|
||||
}
|
||||
Reference in New Issue
Block a user