feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

View 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)
})
})

View 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')
})
})

View 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}`)))
})

View 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')
})
})

View File

@ -0,0 +1,5 @@
export default {
fetch(): Response {
return new Response('fixture worker')
},
}

View 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('生产数据')
})
})

View 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
}