import { describe, test, expect } from 'vitest' import { createMemoryStores } from '../src/store/memory.js' import { createHostRegistry } from '../src/registry/hosts.js' import { createMeteringCollector, MeteringError } from '../src/metering/collect.js' import { fingerprint } from '../src/ca/fingerprint.js' import { generateEd25519 } from '../src/util/crypto.js' import type { NodeIdentity } from '../src/node-auth/identity.js' const node: NodeIdentity = { nodeId: 'spiffe://relay/node-a' } const ACCOUNT = '11111111-1111-4111-8111-111111111111' async function withHost() { const stores = createMemoryStores() const hosts = createHostRegistry({ hosts: stores.hosts }) const { publicKeyRaw } = generateEd25519() const host = await hosts.bindHost({ accountId: ACCOUNT, subdomain: 'alice', agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) }) const collector = createMeteringCollector({ metering: stores.metering, hosts }) return { stores, hosts, host, collector } } describe('T12 metering (INV1/INV3-analog/INV8)', () => { test('ingestSample derives accountId + nodeId server-side; append-only', async () => { const { stores, host, collector } = await withHost() await collector.ingestSample(node, { hostId: host.hostId, concurrentViewers: 3, sampledAt: new Date().toISOString() }) const rows = await stores.metering.query(ACCOUNT, '1970-01-01T00:00:00.000Z', '2999-01-01T00:00:00.000Z') expect(rows.length).toBe(1) expect(rows[0]?.accountId).toBe(ACCOUNT) expect(rows[0]?.nodeId).toBe(node.nodeId) }) test('forged attribution: unknown hostId rejected (cross-tenant metering impossible, INV1)', async () => { const { collector } = await withHost() await expect( collector.ingestSample(node, { hostId: '99999999-9999-4999-8999-999999999999', concurrentViewers: 1, sampledAt: new Date().toISOString() }), ).rejects.toBeInstanceOf(MeteringError) }) test('pairedHostCount excludes revoked hosts', async () => { const { hosts, host, collector } = await withHost() expect(await collector.pairedHostCount(ACCOUNT)).toBe(1) await hosts.setHostStatus(host.hostId, 'revoked') expect(await collector.pairedHostCount(ACCOUNT)).toBe(0) }) test('rollupUsage computes viewer peak + viewer-hours over a window', async () => { const { host, collector } = await withHost() const t0 = '2026-06-30T00:00:00.000Z' const t1 = '2026-06-30T01:00:00.000Z' const to = '2026-06-30T02:00:00.000Z' await collector.ingestSample(node, { hostId: host.hostId, concurrentViewers: 2, sampledAt: t0 }) await collector.ingestSample(node, { hostId: host.hostId, concurrentViewers: 4, sampledAt: t1 }) const usage = await collector.rollupUsage(ACCOUNT, t0, to) expect(usage.viewerPeak).toBe(4) expect(usage.viewerHours).toBeCloseTo(2 * 1 + 4 * 1, 5) // 2 viewers×1h + 4 viewers×1h expect(usage.pairedHostPeak).toBe(1) }) test('malformed sample → Zod error', async () => { const { collector } = await withHost() await expect(collector.ingestSample(node, { hostId: 'not-a-uuid', concurrentViewers: -1, sampledAt: 'x' })).rejects.toBeInstanceOf(MeteringError) }) })