@corgelabs/corge 0.0.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai/index.d.ts +3 -0
- package/dist/ai/index.d.ts.map +1 -0
- package/dist/ai/index.js +3 -0
- package/dist/ai/index.js.map +1 -0
- package/dist/ai/schema.d.ts +83 -0
- package/dist/ai/schema.d.ts.map +1 -0
- package/dist/ai/schema.js +19 -0
- package/dist/ai/schema.js.map +1 -0
- package/dist/ai/service.d.ts +14 -0
- package/dist/ai/service.d.ts.map +1 -0
- package/dist/ai/service.js +266 -0
- package/dist/ai/service.js.map +1 -0
- package/dist/ai/utils.d.ts +10 -0
- package/dist/ai/utils.d.ts.map +1 -0
- package/dist/ai/utils.js +27 -0
- package/dist/ai/utils.js.map +1 -0
- package/dist/attachments/index.d.ts +4 -0
- package/dist/attachments/index.d.ts.map +1 -0
- package/dist/attachments/index.js +4 -0
- package/dist/attachments/index.js.map +1 -0
- package/dist/attachments/routes.d.ts +75 -0
- package/dist/attachments/routes.d.ts.map +1 -0
- package/dist/attachments/routes.js +18 -0
- package/dist/attachments/routes.js.map +1 -0
- package/dist/attachments/schema.d.ts +61 -0
- package/dist/attachments/schema.d.ts.map +1 -0
- package/dist/attachments/schema.js +30 -0
- package/dist/attachments/schema.js.map +1 -0
- package/dist/attachments/service.d.ts +27 -0
- package/dist/attachments/service.d.ts.map +1 -0
- package/dist/attachments/service.js +224 -0
- package/dist/attachments/service.js.map +1 -0
- package/dist/attachments/utils.d.ts +9 -0
- package/dist/attachments/utils.d.ts.map +1 -0
- package/dist/attachments/utils.js +80 -0
- package/dist/attachments/utils.js.map +1 -0
- package/dist/auth/index.d.ts +4 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +4 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/oidc.d.ts +25 -0
- package/dist/auth/oidc.d.ts.map +1 -0
- package/dist/auth/oidc.js +298 -0
- package/dist/auth/oidc.js.map +1 -0
- package/dist/auth/recovery.d.ts +41 -0
- package/dist/auth/recovery.d.ts.map +1 -0
- package/dist/auth/recovery.js +250 -0
- package/dist/auth/recovery.js.map +1 -0
- package/dist/auth/routes.d.ts +226 -0
- package/dist/auth/routes.d.ts.map +1 -0
- package/dist/auth/routes.js +96 -0
- package/dist/auth/routes.js.map +1 -0
- package/dist/auth/schema.d.ts +158 -0
- package/dist/auth/schema.d.ts.map +1 -0
- package/dist/auth/schema.js +82 -0
- package/dist/auth/schema.js.map +1 -0
- package/dist/auth/service.d.ts +96 -0
- package/dist/auth/service.d.ts.map +1 -0
- package/dist/auth/service.js +129 -0
- package/dist/auth/service.js.map +1 -0
- package/dist/auth/session.d.ts +51 -0
- package/dist/auth/session.d.ts.map +1 -0
- package/dist/auth/session.js +344 -0
- package/dist/auth/session.js.map +1 -0
- package/dist/auth/utils.d.ts +17 -0
- package/dist/auth/utils.d.ts.map +1 -0
- package/dist/auth/utils.js +96 -0
- package/dist/auth/utils.js.map +1 -0
- package/dist/bin/corge.js +547 -0
- package/dist/chat/context.d.ts +10 -0
- package/dist/chat/context.d.ts.map +1 -0
- package/dist/chat/context.js +221 -0
- package/dist/chat/context.js.map +1 -0
- package/dist/chat/index.d.ts +4 -0
- package/dist/chat/index.d.ts.map +1 -0
- package/dist/chat/index.js +4 -0
- package/dist/chat/index.js.map +1 -0
- package/dist/chat/routes.d.ts +160 -0
- package/dist/chat/routes.d.ts.map +1 -0
- package/dist/chat/routes.js +210 -0
- package/dist/chat/routes.js.map +1 -0
- package/dist/chat/schema.d.ts +285 -0
- package/dist/chat/schema.d.ts.map +1 -0
- package/dist/chat/schema.js +74 -0
- package/dist/chat/schema.js.map +1 -0
- package/dist/chat/service.d.ts +64 -0
- package/dist/chat/service.d.ts.map +1 -0
- package/dist/chat/service.js +279 -0
- package/dist/chat/service.js.map +1 -0
- package/dist/chat/stream.d.ts +53 -0
- package/dist/chat/stream.d.ts.map +1 -0
- package/dist/chat/stream.js +339 -0
- package/dist/chat/stream.js.map +1 -0
- package/dist/chat/utils.d.ts +61 -0
- package/dist/chat/utils.d.ts.map +1 -0
- package/dist/chat/utils.js +285 -0
- package/dist/chat/utils.js.map +1 -0
- package/dist/cli/builder.d.ts +6 -0
- package/dist/cli/builder.d.ts.map +1 -0
- package/dist/cli/builder.js +91 -0
- package/dist/cli/builder.js.map +1 -0
- package/dist/cli/deployer.d.ts +2 -0
- package/dist/cli/deployer.d.ts.map +1 -0
- package/dist/cli/deployer.js +152 -0
- package/dist/cli/deployer.js.map +1 -0
- package/dist/cli/dev-server.d.ts +2 -0
- package/dist/cli/dev-server.d.ts.map +1 -0
- package/dist/cli/dev-server.js +70 -0
- package/dist/cli/dev-server.js.map +1 -0
- package/dist/cli/env.d.ts +4 -0
- package/dist/cli/env.d.ts.map +1 -0
- package/dist/cli/env.js +68 -0
- package/dist/cli/env.js.map +1 -0
- package/dist/cli/init.d.ts +6 -0
- package/dist/cli/init.d.ts.map +1 -0
- package/dist/cli/init.js +103 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/config.d.ts +262 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +212 -0
- package/dist/config.js.map +1 -0
- package/dist/db/index.d.ts +3 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +3 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/schema.d.ts +410 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +294 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/db/service.d.ts +42 -0
- package/dist/db/service.d.ts.map +1 -0
- package/dist/db/service.js +372 -0
- package/dist/db/service.js.map +1 -0
- package/dist/db/utils.d.ts +25 -0
- package/dist/db/utils.d.ts.map +1 -0
- package/dist/db/utils.js +188 -0
- package/dist/db/utils.js.map +1 -0
- package/dist/emails/index.d.ts +4 -0
- package/dist/emails/index.d.ts.map +1 -0
- package/dist/emails/index.js +4 -0
- package/dist/emails/index.js.map +1 -0
- package/dist/emails/routes.d.ts +191 -0
- package/dist/emails/routes.d.ts.map +1 -0
- package/dist/emails/routes.js +39 -0
- package/dist/emails/routes.js.map +1 -0
- package/dist/emails/schema.d.ts +237 -0
- package/dist/emails/schema.d.ts.map +1 -0
- package/dist/emails/schema.js +98 -0
- package/dist/emails/schema.js.map +1 -0
- package/dist/emails/service.d.ts +22 -0
- package/dist/emails/service.d.ts.map +1 -0
- package/dist/emails/service.js +337 -0
- package/dist/emails/service.js.map +1 -0
- package/dist/emails/utils.d.ts +49 -0
- package/dist/emails/utils.d.ts.map +1 -0
- package/dist/emails/utils.js +341 -0
- package/dist/emails/utils.js.map +1 -0
- package/dist/files/index.d.ts +4 -0
- package/dist/files/index.d.ts.map +1 -0
- package/dist/files/index.js +4 -0
- package/dist/files/index.js.map +1 -0
- package/dist/files/routes.d.ts +72 -0
- package/dist/files/routes.d.ts.map +1 -0
- package/dist/files/routes.js +38 -0
- package/dist/files/routes.js.map +1 -0
- package/dist/files/schema.d.ts +57 -0
- package/dist/files/schema.d.ts.map +1 -0
- package/dist/files/schema.js +39 -0
- package/dist/files/schema.js.map +1 -0
- package/dist/files/service.d.ts +27 -0
- package/dist/files/service.d.ts.map +1 -0
- package/dist/files/service.js +175 -0
- package/dist/files/service.js.map +1 -0
- package/dist/files/utils.d.ts +3 -0
- package/dist/files/utils.d.ts.map +1 -0
- package/dist/files/utils.js +63 -0
- package/dist/files/utils.js.map +1 -0
- package/dist/index.d.ts +875 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9919 -0
- package/dist/index.js.map +7 -0
- package/dist/payments/billing.d.ts +29 -0
- package/dist/payments/billing.d.ts.map +1 -0
- package/dist/payments/billing.js +378 -0
- package/dist/payments/billing.js.map +1 -0
- package/dist/payments/index.d.ts +4 -0
- package/dist/payments/index.d.ts.map +1 -0
- package/dist/payments/index.js +4 -0
- package/dist/payments/index.js.map +1 -0
- package/dist/payments/routes.d.ts +340 -0
- package/dist/payments/routes.d.ts.map +1 -0
- package/dist/payments/routes.js +56 -0
- package/dist/payments/routes.js.map +1 -0
- package/dist/payments/schema.d.ts +412 -0
- package/dist/payments/schema.d.ts.map +1 -0
- package/dist/payments/schema.js +260 -0
- package/dist/payments/schema.js.map +1 -0
- package/dist/payments/service.d.ts +31 -0
- package/dist/payments/service.d.ts.map +1 -0
- package/dist/payments/service.js +322 -0
- package/dist/payments/service.js.map +1 -0
- package/dist/payments/utils.d.ts +61 -0
- package/dist/payments/utils.d.ts.map +1 -0
- package/dist/payments/utils.js +369 -0
- package/dist/payments/utils.js.map +1 -0
- package/dist/router/guard.d.ts +19 -0
- package/dist/router/guard.d.ts.map +1 -0
- package/dist/router/guard.js +243 -0
- package/dist/router/guard.js.map +1 -0
- package/dist/router/index.d.ts +3 -0
- package/dist/router/index.d.ts.map +1 -0
- package/dist/router/index.js +3 -0
- package/dist/router/index.js.map +1 -0
- package/dist/router/schema.d.ts +103 -0
- package/dist/router/schema.d.ts.map +1 -0
- package/dist/router/schema.js +282 -0
- package/dist/router/schema.js.map +1 -0
- package/dist/router/service.d.ts +25 -0
- package/dist/router/service.d.ts.map +1 -0
- package/dist/router/service.js +339 -0
- package/dist/router/service.js.map +1 -0
- package/dist/tasks/index.d.ts +4 -0
- package/dist/tasks/index.d.ts.map +1 -0
- package/dist/tasks/index.js +4 -0
- package/dist/tasks/index.js.map +1 -0
- package/dist/tasks/routes.d.ts +54 -0
- package/dist/tasks/routes.d.ts.map +1 -0
- package/dist/tasks/routes.js +195 -0
- package/dist/tasks/routes.js.map +1 -0
- package/dist/tasks/schema.d.ts +261 -0
- package/dist/tasks/schema.d.ts.map +1 -0
- package/dist/tasks/schema.js +35 -0
- package/dist/tasks/schema.js.map +1 -0
- package/dist/tasks/service.d.ts +32 -0
- package/dist/tasks/service.d.ts.map +1 -0
- package/dist/tasks/service.js +201 -0
- package/dist/tasks/service.js.map +1 -0
- package/dist/tasks/steps.d.ts +18 -0
- package/dist/tasks/steps.d.ts.map +1 -0
- package/dist/tasks/steps.js +365 -0
- package/dist/tasks/steps.js.map +1 -0
- package/dist/tasks/utils.d.ts +75 -0
- package/dist/tasks/utils.d.ts.map +1 -0
- package/dist/tasks/utils.js +311 -0
- package/dist/tasks/utils.js.map +1 -0
- package/dist/tasks/watcher.d.ts +13 -0
- package/dist/tasks/watcher.d.ts.map +1 -0
- package/dist/tasks/watcher.js +255 -0
- package/dist/tasks/watcher.js.map +1 -0
- package/dist/tasks/worker.d.ts +28 -0
- package/dist/tasks/worker.d.ts.map +1 -0
- package/dist/tasks/worker.js +326 -0
- package/dist/tasks/worker.js.map +1 -0
- package/dist/utils/index.d.ts +3 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +3 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/schema.d.ts +79 -0
- package/dist/utils/schema.d.ts.map +1 -0
- package/dist/utils/schema.js +41 -0
- package/dist/utils/schema.js.map +1 -0
- package/dist/utils/service.d.ts +32 -0
- package/dist/utils/service.d.ts.map +1 -0
- package/dist/utils/service.js +384 -0
- package/dist/utils/service.js.map +1 -0
- package/docs/SDK_SPEC.md +1645 -0
- package/infra/firestore.base.indexes.json +138 -0
- package/infra/firestore.base.rules +152 -0
- package/infra/storage.base.rules +8 -0
- package/package.json +59 -13
- package/src/ai/index.ts +2 -0
- package/src/ai/schema.ts +64 -0
- package/src/ai/service.ts +317 -0
- package/src/ai/utils.ts +38 -0
- package/src/attachments/index.ts +3 -0
- package/src/attachments/routes.ts +28 -0
- package/src/attachments/schema.ts +45 -0
- package/src/attachments/service.ts +315 -0
- package/src/attachments/utils.ts +115 -0
- package/src/auth/index.ts +3 -0
- package/src/auth/oidc.ts +419 -0
- package/src/auth/recovery.ts +365 -0
- package/src/auth/routes.ts +142 -0
- package/src/auth/schema.ts +181 -0
- package/src/auth/service.ts +183 -0
- package/src/auth/session.ts +414 -0
- package/src/auth/utils.ts +106 -0
- package/src/chat/context.ts +310 -0
- package/src/chat/index.ts +3 -0
- package/src/chat/routes.ts +264 -0
- package/src/chat/schema.ts +295 -0
- package/src/chat/service.ts +360 -0
- package/src/chat/stream.ts +481 -0
- package/src/chat/utils.ts +370 -0
- package/src/cli/builder.ts +93 -0
- package/src/cli/deployer.ts +184 -0
- package/src/cli/dev-server.ts +77 -0
- package/src/cli/env.ts +89 -0
- package/src/cli/init.ts +125 -0
- package/src/config.ts +447 -0
- package/src/db/index.ts +2 -0
- package/src/db/schema.ts +409 -0
- package/src/db/service.ts +477 -0
- package/src/db/utils.ts +250 -0
- package/src/emails/index.ts +3 -0
- package/src/emails/routes.ts +64 -0
- package/src/emails/schema.ts +239 -0
- package/src/emails/service.ts +488 -0
- package/src/emails/utils.ts +449 -0
- package/src/files/index.ts +3 -0
- package/src/files/routes.ts +44 -0
- package/src/files/schema.ts +76 -0
- package/src/files/service.ts +238 -0
- package/src/files/utils.ts +77 -0
- package/src/index.ts +425 -0
- package/src/payments/billing.ts +488 -0
- package/src/payments/index.ts +3 -0
- package/src/payments/routes.ts +92 -0
- package/src/payments/schema.ts +401 -0
- package/src/payments/service.ts +445 -0
- package/src/payments/utils.ts +472 -0
- package/src/router/guard.ts +319 -0
- package/src/router/index.ts +2 -0
- package/src/router/schema.ts +458 -0
- package/src/router/service.ts +453 -0
- package/src/tasks/index.ts +11 -0
- package/src/tasks/routes.ts +230 -0
- package/src/tasks/schema.ts +310 -0
- package/src/tasks/service.ts +254 -0
- package/src/tasks/steps.ts +472 -0
- package/src/tasks/utils.ts +445 -0
- package/src/tasks/watcher.ts +307 -0
- package/src/tasks/worker.ts +461 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/schema.ts +69 -0
- package/src/utils/service.ts +461 -0
- package/tests/e2e.test.ts +2531 -0
- package/tsconfig.base.json +12 -0
- package/README.md +0 -2
- package/index.d.ts +0 -1
- package/index.js +0 -1
|
@@ -0,0 +1,2531 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { createHash, generateKeyPairSync, sign as cryptoSign } from 'node:crypto'
|
|
3
|
+
import { EventEmitter } from 'node:events'
|
|
4
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { after, before, describe, test } from 'node:test'
|
|
8
|
+
import { serve, type ServerType } from '@hono/node-server'
|
|
9
|
+
import { z } from 'zod'
|
|
10
|
+
import { agents } from '@config/agents'
|
|
11
|
+
import { routes as customRoutes } from '@config/routes'
|
|
12
|
+
import { Core, core, loadGcpSecrets, useCore } from '@core'
|
|
13
|
+
import { devApp } from '@app-entry'
|
|
14
|
+
|
|
15
|
+
type AttachmentTypesMap = core.types.AttachmentTypesMap
|
|
16
|
+
type RouteConfig<I = any, O = any, P = Record<string, string>, Q = Record<string, string>> =
|
|
17
|
+
core.types.RouteConfig<I, O, P, Q>
|
|
18
|
+
type RoutesIntrospectionResponse = core.types.RoutesIntrospectionResponse
|
|
19
|
+
const CORE_CONFIG = core.config
|
|
20
|
+
const defineCollection = core.define.collection
|
|
21
|
+
|
|
22
|
+
EventEmitter.defaultMaxListeners = 100
|
|
23
|
+
|
|
24
|
+
await loadGcpSecrets()
|
|
25
|
+
|
|
26
|
+
const EXPLICIT_BASE_URL = process.env.BASE_URL
|
|
27
|
+
const PORT = 8791
|
|
28
|
+
const BASE_URL = (EXPLICIT_BASE_URL ?? `http://localhost:${PORT}`).replace(/\/+$/, '')
|
|
29
|
+
const SPAWN_LOCAL = !EXPLICIT_BASE_URL
|
|
30
|
+
|
|
31
|
+
const ENV = core.env
|
|
32
|
+
const API_KEY = ENV.SDK_API_KEY
|
|
33
|
+
const WEB_API_KEY = ENV.WEB_API_KEY
|
|
34
|
+
|
|
35
|
+
const AGENT_IDS = Object.keys(agents)
|
|
36
|
+
const DEFAULT_AGENT_ID = AGENT_IDS[0]!
|
|
37
|
+
const DEFAULT_AGENT_NAME = agents[DEFAULT_AGENT_ID as keyof typeof agents]?.name ?? DEFAULT_AGENT_ID
|
|
38
|
+
|
|
39
|
+
const ACTIVE_ATTACHMENT_TYPES: AttachmentTypesMap = (() => {
|
|
40
|
+
const fromAgents: AttachmentTypesMap = {}
|
|
41
|
+
for (const ag of Object.values(agents)) {
|
|
42
|
+
if (ag.attachmentTypes) Object.assign(fromAgents, ag.attachmentTypes)
|
|
43
|
+
}
|
|
44
|
+
return Object.keys(fromAgents).length ? fromAgents : core.internals.registry.getAttachmentTypes()
|
|
45
|
+
})()
|
|
46
|
+
|
|
47
|
+
interface GeneratedFixture {
|
|
48
|
+
type: string
|
|
49
|
+
filename: string
|
|
50
|
+
mime: 'application/pdf' | 'image/jpeg'
|
|
51
|
+
bytes: Uint8Array
|
|
52
|
+
dataUrl: string
|
|
53
|
+
schema?: z.ZodType
|
|
54
|
+
expectedKeys: string[]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface DiscoveredRouteItem {
|
|
58
|
+
id: string
|
|
59
|
+
mode: 'rest' | 'workflow'
|
|
60
|
+
method: string
|
|
61
|
+
path: string
|
|
62
|
+
workflowName?: string
|
|
63
|
+
description: string
|
|
64
|
+
inputSchema?: {
|
|
65
|
+
type?: string
|
|
66
|
+
properties?: Record<string, Record<string, unknown>>
|
|
67
|
+
required?: string[]
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const sanitizeAscii = (str: string): string =>
|
|
72
|
+
str
|
|
73
|
+
.replace(/[\u2013\u2014]/g, '-')
|
|
74
|
+
.replace(/\u2026/g, '...')
|
|
75
|
+
.replace(/[^\x20-\x7E]/g, ' ')
|
|
76
|
+
|
|
77
|
+
const wrapText = (text: string, maxLen = 80): string[] => {
|
|
78
|
+
const words = sanitizeAscii(text).split(/\s+/).filter(Boolean)
|
|
79
|
+
const lines: string[] = []
|
|
80
|
+
let current = ''
|
|
81
|
+
for (const word of words) {
|
|
82
|
+
if (current && current.length + 1 + word.length > maxLen) {
|
|
83
|
+
lines.push(current)
|
|
84
|
+
current = word
|
|
85
|
+
} else {
|
|
86
|
+
current = current ? `${current} ${word}` : word
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (current) lines.push(current)
|
|
90
|
+
return lines
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const sampleValueFromJsonSchema = (key: string, prop: Record<string, unknown>): unknown => {
|
|
94
|
+
const desc = String(prop.description ?? '')
|
|
95
|
+
if (prop.type === 'boolean') return true
|
|
96
|
+
if (prop.type === 'number' || prop.type === 'integer') return 1
|
|
97
|
+
if (prop.type === 'array') {
|
|
98
|
+
const items = (prop.items ?? {}) as { properties?: Record<string, Record<string, unknown>>; type?: string }
|
|
99
|
+
if (items.properties) {
|
|
100
|
+
const item: Record<string, unknown> = {}
|
|
101
|
+
for (const [k, v] of Object.entries(items.properties)) {
|
|
102
|
+
item[k] = sampleValueFromJsonSchema(k, v)
|
|
103
|
+
}
|
|
104
|
+
return [item]
|
|
105
|
+
}
|
|
106
|
+
return [`SAMPLE_${key.toUpperCase()}`]
|
|
107
|
+
}
|
|
108
|
+
if (prop.type === 'object') {
|
|
109
|
+
const subProps = (prop.properties ?? {}) as Record<string, Record<string, unknown>>
|
|
110
|
+
const obj: Record<string, unknown> = {}
|
|
111
|
+
for (const [k, v] of Object.entries(subProps)) {
|
|
112
|
+
obj[k] = sampleValueFromJsonSchema(k, v)
|
|
113
|
+
}
|
|
114
|
+
return obj
|
|
115
|
+
}
|
|
116
|
+
const egMatch = /e\.g\.\s*"([^"]+)"/.exec(desc)
|
|
117
|
+
if (egMatch?.[1]) return egMatch[1]
|
|
118
|
+
const orMatch = /^([A-Za-z0-9_-]+)\s+or\s+([A-Za-z0-9_-]+)$/.exec(desc.trim())
|
|
119
|
+
if (orMatch?.[1]) return orMatch[1]
|
|
120
|
+
return `SAMPLE_${key.toUpperCase()}`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const formatSchemaLines = (
|
|
124
|
+
properties: Record<string, Record<string, unknown>>,
|
|
125
|
+
sampleObj: Record<string, unknown>
|
|
126
|
+
): string[] => {
|
|
127
|
+
const lines: string[] = []
|
|
128
|
+
for (const [key, prop] of Object.entries(properties)) {
|
|
129
|
+
const val = sampleObj[key]
|
|
130
|
+
if (Array.isArray(val)) {
|
|
131
|
+
lines.push(`${key}:`)
|
|
132
|
+
for (const item of val) {
|
|
133
|
+
if (typeof item === 'object' && item !== null) {
|
|
134
|
+
const pairs = Object.entries(item as Record<string, unknown>)
|
|
135
|
+
.map(([k, v]) => `${k}: ${String(v)}`)
|
|
136
|
+
.join(', ')
|
|
137
|
+
lines.push(` - ${pairs}`)
|
|
138
|
+
} else {
|
|
139
|
+
lines.push(` - ${String(item)}`)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} else if (typeof val === 'object' && val !== null) {
|
|
143
|
+
const entries = Object.entries(val as Record<string, unknown>)
|
|
144
|
+
const summary = entries.map(([k, v]) => `${k}: ${String(v)} (granted / authorized)`).join(', ')
|
|
145
|
+
lines.push(`${key}: ${summary}`)
|
|
146
|
+
if (typeof prop.description === 'string') {
|
|
147
|
+
lines.push(...wrapText(` (${prop.description})`, 78))
|
|
148
|
+
}
|
|
149
|
+
} else {
|
|
150
|
+
lines.push(`${key}: ${String(val)}`)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return lines
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const escapePdfLiteral = (s: string): string =>
|
|
157
|
+
sanitizeAscii(s).replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)')
|
|
158
|
+
|
|
159
|
+
const buildSimplePdf = (lines: { text: string; bold?: boolean; size?: number }[]): Uint8Array => {
|
|
160
|
+
let y = 790
|
|
161
|
+
const ops: string[] = ['BT']
|
|
162
|
+
for (const line of lines) {
|
|
163
|
+
const size = line.size ?? 11
|
|
164
|
+
if (y < 40) break
|
|
165
|
+
ops.push(`1 0 0 1 45 ${y} Tm /F${line.bold ? 2 : 1} ${size} Tf (${escapePdfLiteral(line.text)}) Tj`)
|
|
166
|
+
y -= size + 6
|
|
167
|
+
}
|
|
168
|
+
ops.push('ET')
|
|
169
|
+
const streamContent = ops.join('\n')
|
|
170
|
+
|
|
171
|
+
const objects = [
|
|
172
|
+
'1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n',
|
|
173
|
+
'2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n',
|
|
174
|
+
'3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>\nendobj\n',
|
|
175
|
+
'4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>\nendobj\n',
|
|
176
|
+
'5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>\nendobj\n',
|
|
177
|
+
`6 0 obj\n<< /Length ${Buffer.byteLength(streamContent, 'ascii')} >>\nstream\n${streamContent}\nendstream\nendobj\n`
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
let pdf = '%PDF-1.4\n'
|
|
181
|
+
const offsets: number[] = [0]
|
|
182
|
+
for (const obj of objects) {
|
|
183
|
+
offsets.push(Buffer.byteLength(pdf, 'ascii'))
|
|
184
|
+
pdf += obj
|
|
185
|
+
}
|
|
186
|
+
const xrefStart = Buffer.byteLength(pdf, 'ascii')
|
|
187
|
+
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`
|
|
188
|
+
for (let i = 1; i <= objects.length; i++) {
|
|
189
|
+
pdf += `${String(offsets[i]!).padStart(10, '0')} 00000 n \n`
|
|
190
|
+
}
|
|
191
|
+
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefStart}\n%%EOF\n`
|
|
192
|
+
return new Uint8Array(Buffer.from(pdf, 'ascii'))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const buildSimpleJpeg = (quantVal = 16): Uint8Array => {
|
|
196
|
+
const soi = [0xff, 0xd8]
|
|
197
|
+
const app0 = [0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00]
|
|
198
|
+
const dqt = [0xff, 0xdb, 0x00, 0x43, 0x00, ...Array(64).fill(quantVal)]
|
|
199
|
+
const sof0 = [0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x40, 0x00, 0x40, 0x01, 0x01, 0x11, 0x00]
|
|
200
|
+
const dhtDc = [0xff, 0xc4, 0x00, 0x14, 0x00, 0x01, ...Array(15).fill(0), 0x00]
|
|
201
|
+
const dhtAc = [0xff, 0xc4, 0x00, 0x14, 0x10, 0x01, ...Array(15).fill(0), 0x00]
|
|
202
|
+
const sos = [0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00]
|
|
203
|
+
const scanData = Array(16).fill(0x00)
|
|
204
|
+
const eoi = [0xff, 0xd9]
|
|
205
|
+
return new Uint8Array([...soi, ...app0, ...dqt, ...sof0, ...dhtDc, ...dhtAc, ...sos, ...scanData, ...eoi])
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const generatePdfForAttachmentType = async (
|
|
209
|
+
type: string,
|
|
210
|
+
cfg: { description: string; schema: z.ZodType },
|
|
211
|
+
outDir: string
|
|
212
|
+
): Promise<GeneratedFixture> => {
|
|
213
|
+
const jsonSchema = z.toJSONSchema(cfg.schema, { io: 'output' }) as {
|
|
214
|
+
properties?: Record<string, Record<string, unknown>>
|
|
215
|
+
}
|
|
216
|
+
const properties = jsonSchema.properties ?? {}
|
|
217
|
+
const sampleObj: Record<string, unknown> = {}
|
|
218
|
+
for (const [k, prop] of Object.entries(properties)) {
|
|
219
|
+
sampleObj[k] = sampleValueFromJsonSchema(k, prop)
|
|
220
|
+
}
|
|
221
|
+
cfg.schema.parse(sampleObj)
|
|
222
|
+
|
|
223
|
+
const pdfLines: { text: string; bold?: boolean; size?: number }[] = [
|
|
224
|
+
{ text: `OFFICIAL DOCUMENT - ${type.toUpperCase().replaceAll('_', ' ')}`, bold: true, size: 15 },
|
|
225
|
+
{ text: `Document Classification Type: ${type}`, bold: true, size: 12 },
|
|
226
|
+
...wrapText(`Document Description: ${cfg.description}`, 80).map((t) => ({ text: t, bold: false, size: 10 })),
|
|
227
|
+
{ text: 'EXTRACTED DOCUMENT FIELDS:', bold: true, size: 12 },
|
|
228
|
+
...formatSchemaLines(properties, sampleObj).flatMap((fieldLine) =>
|
|
229
|
+
wrapText(fieldLine, 82).map((t) => ({ text: t, bold: false, size: 11 }))
|
|
230
|
+
)
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
const bytes = buildSimplePdf(pdfLines)
|
|
234
|
+
const filename = `${type}.pdf`
|
|
235
|
+
writeFileSync(join(outDir, filename), bytes)
|
|
236
|
+
return {
|
|
237
|
+
type,
|
|
238
|
+
filename,
|
|
239
|
+
mime: 'application/pdf',
|
|
240
|
+
bytes,
|
|
241
|
+
dataUrl: `data:application/pdf;base64,${Buffer.from(bytes).toString('base64')}`,
|
|
242
|
+
schema: cfg.schema,
|
|
243
|
+
expectedKeys: Object.keys(properties).sort()
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const generateOtherJpeg = async (outDir: string): Promise<GeneratedFixture> => {
|
|
248
|
+
const bytes = buildSimpleJpeg(16)
|
|
249
|
+
const filename = 'other.jpeg'
|
|
250
|
+
writeFileSync(join(outDir, filename), bytes)
|
|
251
|
+
return {
|
|
252
|
+
type: 'other',
|
|
253
|
+
filename,
|
|
254
|
+
mime: 'image/jpeg',
|
|
255
|
+
bytes,
|
|
256
|
+
dataUrl: `data:image/jpeg;base64,${Buffer.from(bytes).toString('base64')}`,
|
|
257
|
+
expectedKeys: []
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let tempDir = ''
|
|
262
|
+
let localServer: ServerType | null = null
|
|
263
|
+
const fixtures: GeneratedFixture[] = []
|
|
264
|
+
const uploadedRefs = new Map<string, string>()
|
|
265
|
+
let discoveredDomainRoutes: DiscoveredRouteItem[] = []
|
|
266
|
+
let testAuthUserId = ''
|
|
267
|
+
let testAuthCookie = ''
|
|
268
|
+
let initialEmailCookie = ''
|
|
269
|
+
let asyncTaskId = ''
|
|
270
|
+
let chatId1 = ''
|
|
271
|
+
let userErased = false
|
|
272
|
+
|
|
273
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
|
274
|
+
|
|
275
|
+
const bearerHeaders = (extra: Record<string, string> = {}) => ({
|
|
276
|
+
authorization: `Bearer ${API_KEY}`,
|
|
277
|
+
...extra
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
const userHeaders = (extra: Record<string, string> = {}) => ({
|
|
281
|
+
cookie: testAuthCookie,
|
|
282
|
+
origin: BASE_URL,
|
|
283
|
+
...extra
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
const apiFetch = async (path: string, init: RequestInit = {}, label?: string): Promise<Response> => {
|
|
287
|
+
const method = (init.method ?? 'GET').toUpperCase()
|
|
288
|
+
const t0 = performance.now()
|
|
289
|
+
const res = await fetch(`${BASE_URL}${path}`, {
|
|
290
|
+
...init,
|
|
291
|
+
signal: init.signal ?? AbortSignal.timeout(240_000)
|
|
292
|
+
})
|
|
293
|
+
const rttMs = performance.now() - t0
|
|
294
|
+
const authHeader = res.headers.get('x-auth-time-ms')
|
|
295
|
+
const authSuffix = authHeader !== null ? ` | auth: ${Number(authHeader).toFixed(3)}ms` : ''
|
|
296
|
+
const tag = label ? `${method} ${path} (${label})` : `${method} ${path}`
|
|
297
|
+
console.log(`[http] ${tag.padEnd(54)} -> ${res.status} | ${rttMs.toFixed(1).padStart(7)}ms${authSuffix}`)
|
|
298
|
+
return res
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const apiJson = async <T = unknown>(
|
|
302
|
+
path: string,
|
|
303
|
+
init: RequestInit = {},
|
|
304
|
+
useBearer = false,
|
|
305
|
+
label?: string
|
|
306
|
+
): Promise<{ status: number; data: T; headers: Headers }> => {
|
|
307
|
+
const baseHeaders = useBearer ? bearerHeaders() : userHeaders()
|
|
308
|
+
const res = await apiFetch(
|
|
309
|
+
path,
|
|
310
|
+
{
|
|
311
|
+
...init,
|
|
312
|
+
headers: {
|
|
313
|
+
...baseHeaders,
|
|
314
|
+
...(init.headers as Record<string, string> | undefined)
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
label
|
|
318
|
+
)
|
|
319
|
+
const text = await res.text()
|
|
320
|
+
const data = (text ? JSON.parse(text) : null) as T
|
|
321
|
+
return { status: res.status, data, headers: res.headers }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const mcpCall = async <T = unknown>(method: string, params: Record<string, unknown> = {}, id = 1): Promise<T> => {
|
|
325
|
+
const label = params.name ? `mcp:${method}:${String(params.name)}` : `mcp:${method}`
|
|
326
|
+
const res = await apiFetch(
|
|
327
|
+
'/api/mcp',
|
|
328
|
+
{
|
|
329
|
+
method: 'POST',
|
|
330
|
+
headers: userHeaders({
|
|
331
|
+
'content-type': 'application/json',
|
|
332
|
+
accept: 'application/json, text/event-stream'
|
|
333
|
+
}),
|
|
334
|
+
body: JSON.stringify({ jsonrpc: '2.0', id, method, params })
|
|
335
|
+
},
|
|
336
|
+
label
|
|
337
|
+
)
|
|
338
|
+
assert.equal(res.status, 200)
|
|
339
|
+
const raw = await res.text()
|
|
340
|
+
const contentType = res.headers.get('content-type') ?? ''
|
|
341
|
+
if (contentType.includes('text/event-stream')) {
|
|
342
|
+
const dataLine = raw
|
|
343
|
+
.split('\n')
|
|
344
|
+
.map((l) => l.trim())
|
|
345
|
+
.find((l) => l.startsWith('data: '))
|
|
346
|
+
if (!dataLine) throw new Error(`No SSE data line in MCP response: ${raw}`)
|
|
347
|
+
const parsed = JSON.parse(dataLine.slice(6)) as { result?: T; error?: unknown }
|
|
348
|
+
if (parsed.error) throw new Error(`MCP error: ${JSON.stringify(parsed.error)}`)
|
|
349
|
+
return parsed.result as T
|
|
350
|
+
}
|
|
351
|
+
const parsed = JSON.parse(raw) as { result?: T; error?: unknown }
|
|
352
|
+
if (parsed.error) throw new Error(`MCP error: ${JSON.stringify(parsed.error)}`)
|
|
353
|
+
return parsed.result as T
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const synthesizeDomainInput = (route: DiscoveredRouteItem): Record<string, unknown> => {
|
|
357
|
+
const props = route.inputSchema?.properties ?? {}
|
|
358
|
+
const payload: Record<string, unknown> = {}
|
|
359
|
+
const sampleRefs = [
|
|
360
|
+
uploadedRefs.get('title_deed') ?? [...uploadedRefs.values()][0]!,
|
|
361
|
+
uploadedRefs.get('passport') ?? [...uploadedRefs.values()][1] ?? [...uploadedRefs.values()][0]!
|
|
362
|
+
]
|
|
363
|
+
for (const [k, prop] of Object.entries(props)) {
|
|
364
|
+
if (k === 'refs') {
|
|
365
|
+
payload[k] = sampleRefs
|
|
366
|
+
} else if (k === 'ref') {
|
|
367
|
+
payload[k] = sampleRefs[0]
|
|
368
|
+
} else {
|
|
369
|
+
payload[k] = sampleValueFromJsonSchema(k, prop)
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return payload
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
before(async () => {
|
|
376
|
+
if (!API_KEY || !WEB_API_KEY) {
|
|
377
|
+
throw new Error('SDK_API_KEY and WEB_API_KEY are required from GCP Secret Manager')
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
tempDir = mkdtempSync(join(tmpdir(), 'ai-sdk-e2e-'))
|
|
381
|
+
const pdfEntries = await Promise.all(
|
|
382
|
+
Object.entries(ACTIVE_ATTACHMENT_TYPES).map(([type, cfg]) => generatePdfForAttachmentType(type, cfg, tempDir))
|
|
383
|
+
)
|
|
384
|
+
const otherJpeg = await generateOtherJpeg(tempDir)
|
|
385
|
+
fixtures.push(...pdfEntries, otherJpeg)
|
|
386
|
+
|
|
387
|
+
if (SPAWN_LOCAL) {
|
|
388
|
+
localServer = serve({ fetch: devApp.fetch, port: PORT })
|
|
389
|
+
let ready = false
|
|
390
|
+
for (let i = 0; i < 40; i++) {
|
|
391
|
+
try {
|
|
392
|
+
const res = await fetch(`${BASE_URL}/api/routes`, { headers: bearerHeaders() })
|
|
393
|
+
if (res.ok) {
|
|
394
|
+
ready = true
|
|
395
|
+
break
|
|
396
|
+
}
|
|
397
|
+
} catch {}
|
|
398
|
+
await sleep(250)
|
|
399
|
+
}
|
|
400
|
+
if (!ready) throw new Error('Local server failed to become ready in time')
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const existingUsers = await core.auth.listUsers(true).catch(() => [])
|
|
404
|
+
for (const u of existingUsers) {
|
|
405
|
+
if (u.email?.endsWith('@test.local')) {
|
|
406
|
+
await fetch(`${BASE_URL}/api/erase-user`, {
|
|
407
|
+
method: 'POST',
|
|
408
|
+
headers: bearerHeaders({ 'content-type': 'application/json' }),
|
|
409
|
+
body: JSON.stringify({ userId: u.id, backupBeforeErase: false })
|
|
410
|
+
}).catch(() => {})
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
after(async () => {
|
|
416
|
+
if (!userErased && testAuthUserId) {
|
|
417
|
+
await fetch(`${BASE_URL}/api/erase-user`, {
|
|
418
|
+
method: 'POST',
|
|
419
|
+
headers: bearerHeaders({ 'content-type': 'application/json' }),
|
|
420
|
+
body: JSON.stringify({ userId: testAuthUserId, backupBeforeErase: false })
|
|
421
|
+
}).catch(() => {})
|
|
422
|
+
}
|
|
423
|
+
if (localServer) {
|
|
424
|
+
localServer.close()
|
|
425
|
+
}
|
|
426
|
+
if (tempDir) {
|
|
427
|
+
rmSync(tempDir, { recursive: true, force: true })
|
|
428
|
+
}
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
describe('1. Auth & RBAC (/api/auth/*, /api/routes)', () => {
|
|
432
|
+
const testEmail = `e2e-${Date.now()}-${Math.random().toString(36).slice(2, 6)}@test.local`
|
|
433
|
+
const testPassword = 'TestPassword123!'
|
|
434
|
+
|
|
435
|
+
test('GET /api/health, GET /api/routes & GET /api/auth/session — health check (200) & reject unauthenticated (401)', async () => {
|
|
436
|
+
const healthRes = await apiFetch('/api/health', {}, 'health')
|
|
437
|
+
assert.equal(healthRes.status, 200)
|
|
438
|
+
const healthData = (await healthRes.json()) as { ok: boolean; status: string; service: string }
|
|
439
|
+
assert.equal(healthData.ok, true)
|
|
440
|
+
assert.equal(healthData.status, 'healthy')
|
|
441
|
+
assert.equal(healthData.service, CORE_CONFIG.appName)
|
|
442
|
+
|
|
443
|
+
const unauthRoutesRes = await apiFetch('/api/routes', {}, 'unauth')
|
|
444
|
+
assert.equal(unauthRoutesRes.status, 401)
|
|
445
|
+
|
|
446
|
+
const badBearerRes = await apiFetch(
|
|
447
|
+
'/api/routes',
|
|
448
|
+
{ headers: { authorization: 'Bearer invalid-token' } },
|
|
449
|
+
'invalid-bearer'
|
|
450
|
+
)
|
|
451
|
+
assert.equal(badBearerRes.status, 401)
|
|
452
|
+
|
|
453
|
+
const unauthSessionRes = await apiFetch('/api/auth/session', {}, 'unauth')
|
|
454
|
+
assert.equal(unauthSessionRes.status, 401)
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
test('GET /api/auth/session & GET /api/routes — Admin Bearer cold & warm cache', async () => {
|
|
458
|
+
const adminSessionRes = await apiFetch('/api/auth/session', { headers: bearerHeaders() }, 'admin-cold')
|
|
459
|
+
assert.equal(adminSessionRes.status, 200)
|
|
460
|
+
assert.notEqual(adminSessionRes.headers.get('x-auth-time-ms'), null)
|
|
461
|
+
assert.ok((adminSessionRes.headers.get('server-timing') ?? '').includes('auth;dur='))
|
|
462
|
+
assert.deepEqual(await adminSessionRes.json(), { userId: CORE_CONFIG.auth.adminUserId })
|
|
463
|
+
|
|
464
|
+
const adminWarmRes = await apiFetch('/api/routes', { headers: bearerHeaders() }, 'admin-warm')
|
|
465
|
+
assert.equal(adminWarmRes.status, 200)
|
|
466
|
+
assert.ok(Number(adminWarmRes.headers.get('x-auth-time-ms')) < 5)
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
test('POST /api/auth/sign-up/email — create test user', async () => {
|
|
470
|
+
const signUpRes = await apiFetch('/api/auth/sign-up/email', {
|
|
471
|
+
method: 'POST',
|
|
472
|
+
headers: { 'content-type': 'application/json', origin: BASE_URL },
|
|
473
|
+
body: JSON.stringify({ email: testEmail, password: testPassword, name: 'E2E User' })
|
|
474
|
+
})
|
|
475
|
+
assert.equal(signUpRes.status, 200)
|
|
476
|
+
const signedUpUser = (await signUpRes.json()) as { id: string; email: string; name: string }
|
|
477
|
+
assert.equal(signedUpUser.email, testEmail)
|
|
478
|
+
assert.equal(signedUpUser.name, 'E2E User')
|
|
479
|
+
testAuthUserId = signedUpUser.id
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
test('POST /api/auth/sign-in/email — sign in with email & password', async () => {
|
|
483
|
+
const signInRes = await apiFetch('/api/auth/sign-in/email', {
|
|
484
|
+
method: 'POST',
|
|
485
|
+
headers: { 'content-type': 'application/json', origin: BASE_URL },
|
|
486
|
+
body: JSON.stringify({ email: testEmail, password: testPassword })
|
|
487
|
+
})
|
|
488
|
+
assert.equal(signInRes.status, 200)
|
|
489
|
+
const signedInUser = (await signInRes.json()) as { id: string }
|
|
490
|
+
assert.equal(signedInUser.id, testAuthUserId)
|
|
491
|
+
const signInCookies = signInRes.headers.getSetCookie?.() ?? [signInRes.headers.get('set-cookie') ?? '']
|
|
492
|
+
initialEmailCookie = signInCookies.map((c) => c.split(';')[0]).filter(Boolean).join('; ')
|
|
493
|
+
assert.ok(initialEmailCookie.includes(`${core.internals.auth.sessionCookie}=`))
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
test('POST /api/auth/users/block — block user, verify 401 on session, then unblock', async () => {
|
|
497
|
+
const blockRes = await apiJson<{ userId: string; blocked: boolean; deleted: boolean }>(
|
|
498
|
+
'/api/auth/users/block',
|
|
499
|
+
{
|
|
500
|
+
method: 'POST',
|
|
501
|
+
headers: { 'content-type': 'application/json' },
|
|
502
|
+
body: JSON.stringify({ userId: testAuthUserId, blocked: true })
|
|
503
|
+
},
|
|
504
|
+
true,
|
|
505
|
+
'block'
|
|
506
|
+
)
|
|
507
|
+
assert.equal(blockRes.status, 200)
|
|
508
|
+
assert.deepEqual(blockRes.data, { userId: testAuthUserId, blocked: true, deleted: true })
|
|
509
|
+
|
|
510
|
+
const blockedSessionRes = await apiFetch(
|
|
511
|
+
'/api/auth/session',
|
|
512
|
+
{ headers: { cookie: initialEmailCookie, origin: BASE_URL } },
|
|
513
|
+
'blocked-cookie'
|
|
514
|
+
)
|
|
515
|
+
assert.equal(blockedSessionRes.status, 401)
|
|
516
|
+
|
|
517
|
+
const unblockRes = await apiJson<{ userId: string; blocked: boolean; deleted: boolean }>(
|
|
518
|
+
'/api/auth/users/block',
|
|
519
|
+
{
|
|
520
|
+
method: 'POST',
|
|
521
|
+
headers: { 'content-type': 'application/json' },
|
|
522
|
+
body: JSON.stringify({ userId: testAuthUserId, blocked: false })
|
|
523
|
+
},
|
|
524
|
+
true,
|
|
525
|
+
'unblock'
|
|
526
|
+
)
|
|
527
|
+
assert.equal(unblockRes.status, 200)
|
|
528
|
+
assert.deepEqual(unblockRes.data, { userId: testAuthUserId, blocked: false, deleted: false })
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
test('POST /api/auth/custom-token, POST /api/auth/session & GET /api/auth/session — custom token RBAC, ID token & session cookie', async () => {
|
|
532
|
+
const unauthCustomTokenRes = await apiFetch(
|
|
533
|
+
'/api/auth/custom-token',
|
|
534
|
+
{
|
|
535
|
+
method: 'POST',
|
|
536
|
+
headers: { 'content-type': 'application/json' },
|
|
537
|
+
body: JSON.stringify({ uid: testAuthUserId })
|
|
538
|
+
},
|
|
539
|
+
'unauth-custom-token'
|
|
540
|
+
)
|
|
541
|
+
assert.equal(unauthCustomTokenRes.status, 401)
|
|
542
|
+
|
|
543
|
+
const nonAdminCustomTokenRes = await apiFetch(
|
|
544
|
+
'/api/auth/custom-token',
|
|
545
|
+
{
|
|
546
|
+
method: 'POST',
|
|
547
|
+
headers: { 'content-type': 'application/json', cookie: initialEmailCookie, origin: BASE_URL },
|
|
548
|
+
body: JSON.stringify({ uid: testAuthUserId })
|
|
549
|
+
},
|
|
550
|
+
'user-custom-token-403'
|
|
551
|
+
)
|
|
552
|
+
assert.equal(nonAdminCustomTokenRes.status, 403)
|
|
553
|
+
|
|
554
|
+
const customTokenRes = await apiFetch('/api/auth/custom-token', {
|
|
555
|
+
method: 'POST',
|
|
556
|
+
headers: bearerHeaders({ 'content-type': 'application/json' }),
|
|
557
|
+
body: JSON.stringify({ uid: testAuthUserId })
|
|
558
|
+
})
|
|
559
|
+
assert.equal(customTokenRes.status, 200)
|
|
560
|
+
const { uid, customToken } = (await customTokenRes.json()) as { uid: string; customToken: string }
|
|
561
|
+
assert.equal(uid, testAuthUserId)
|
|
562
|
+
assert.ok(customToken.length > 20)
|
|
563
|
+
|
|
564
|
+
const exchangeRes = await fetch(
|
|
565
|
+
`https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${WEB_API_KEY}`,
|
|
566
|
+
{
|
|
567
|
+
method: 'POST',
|
|
568
|
+
headers: { 'content-type': 'application/json' },
|
|
569
|
+
body: JSON.stringify({ token: customToken, returnSecureToken: true })
|
|
570
|
+
}
|
|
571
|
+
)
|
|
572
|
+
assert.equal(exchangeRes.status, 200)
|
|
573
|
+
const { idToken } = (await exchangeRes.json()) as { idToken: string }
|
|
574
|
+
assert.ok(idToken.length > 20)
|
|
575
|
+
|
|
576
|
+
const idTokenColdRes = await apiFetch(
|
|
577
|
+
'/api/auth/session',
|
|
578
|
+
{ headers: { authorization: `Bearer ${idToken}` } },
|
|
579
|
+
'id-token-cold'
|
|
580
|
+
)
|
|
581
|
+
assert.equal(idTokenColdRes.status, 200)
|
|
582
|
+
assert.deepEqual(await idTokenColdRes.json(), { userId: testAuthUserId })
|
|
583
|
+
|
|
584
|
+
const idTokenWarmRes = await apiFetch(
|
|
585
|
+
'/api/files',
|
|
586
|
+
{ headers: { authorization: `Bearer ${idToken}` } },
|
|
587
|
+
'id-token-warm'
|
|
588
|
+
)
|
|
589
|
+
assert.equal(idTokenWarmRes.status, 200)
|
|
590
|
+
assert.ok(Number(idTokenWarmRes.headers.get('x-auth-time-ms')) < 5)
|
|
591
|
+
|
|
592
|
+
const badSessionRes = await apiFetch(
|
|
593
|
+
'/api/auth/session',
|
|
594
|
+
{
|
|
595
|
+
method: 'POST',
|
|
596
|
+
headers: { 'content-type': 'application/json' },
|
|
597
|
+
body: JSON.stringify({ idToken: 'invalid-id-token' })
|
|
598
|
+
},
|
|
599
|
+
'invalid-id-token'
|
|
600
|
+
)
|
|
601
|
+
assert.equal(badSessionRes.status, 401)
|
|
602
|
+
|
|
603
|
+
const sessionRes = await apiFetch(
|
|
604
|
+
'/api/auth/session',
|
|
605
|
+
{
|
|
606
|
+
method: 'POST',
|
|
607
|
+
headers: { 'content-type': 'application/json', origin: BASE_URL },
|
|
608
|
+
body: JSON.stringify({ idToken })
|
|
609
|
+
},
|
|
610
|
+
'mint-cookie'
|
|
611
|
+
)
|
|
612
|
+
assert.equal(sessionRes.status, 200)
|
|
613
|
+
const sessionUser = (await sessionRes.json()) as { id: string }
|
|
614
|
+
assert.equal(sessionUser.id, testAuthUserId)
|
|
615
|
+
|
|
616
|
+
const setCookies = sessionRes.headers.getSetCookie?.() ?? [sessionRes.headers.get('set-cookie') ?? '']
|
|
617
|
+
testAuthCookie = setCookies.map((c) => c.split(';')[0]).filter(Boolean).join('; ')
|
|
618
|
+
assert.ok(testAuthCookie.includes(`${core.internals.auth.sessionCookie}=`))
|
|
619
|
+
|
|
620
|
+
const getSessionRes = await apiFetch('/api/auth/session', { headers: userHeaders() }, 'cookie-strict')
|
|
621
|
+
assert.equal(getSessionRes.status, 200)
|
|
622
|
+
assert.deepEqual(await getSessionRes.json(), { userId: testAuthUserId })
|
|
623
|
+
assert.ok(Number(getSessionRes.headers.get('x-auth-time-ms')) < 5)
|
|
624
|
+
|
|
625
|
+
const cookieWarmRes = await apiFetch('/api/files', { headers: userHeaders() }, 'cookie-warm')
|
|
626
|
+
assert.equal(cookieWarmRes.status, 200)
|
|
627
|
+
assert.ok(Number(cookieWarmRes.headers.get('x-auth-time-ms')) < 5)
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
test('GET /api/auth/users — enforce admin RBAC (403 for user, 200 for admin)', async () => {
|
|
631
|
+
const nonAdminUsersRes = await apiFetch('/api/auth/users', { headers: userHeaders() }, 'user-rbac')
|
|
632
|
+
assert.equal(nonAdminUsersRes.status, 403)
|
|
633
|
+
|
|
634
|
+
const adminUsersRes = await apiFetch('/api/auth/users', { headers: bearerHeaders() }, 'admin-rbac')
|
|
635
|
+
assert.equal(adminUsersRes.status, 200)
|
|
636
|
+
const usersList = (await adminUsersRes.json()) as { id: string }[]
|
|
637
|
+
assert.ok(usersList.some((u) => u.id === testAuthUserId))
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
test('Better-Auth Suite — Email Verification (SHA-256 OTP + 5-attempt limit), Social OAuth (Google/Apple), Recovery Email, Password Reset & Full Account Recovery', async () => {
|
|
641
|
+
// 1. Send email verification & verify SHA-256 hash storage + 5-attempt brute-force invalidation
|
|
642
|
+
const sendVerifyRes = await apiJson<{ sent: boolean; expiresInMinutes: number }>(
|
|
643
|
+
'/api/auth/email/send-verification',
|
|
644
|
+
{
|
|
645
|
+
method: 'POST',
|
|
646
|
+
headers: { 'content-type': 'application/json' },
|
|
647
|
+
body: JSON.stringify({})
|
|
648
|
+
},
|
|
649
|
+
false,
|
|
650
|
+
'send-verify-1'
|
|
651
|
+
)
|
|
652
|
+
assert.equal(sendVerifyRes.status, 200)
|
|
653
|
+
assert.equal(sendVerifyRes.data.sent, true)
|
|
654
|
+
|
|
655
|
+
const verifyPayload1 = core.internals.auth.getLastDevEmail(testEmail, 'verify_email')
|
|
656
|
+
assert.ok(verifyPayload1, 'Expected dev email outbox to capture verify_email payload')
|
|
657
|
+
assert.match(verifyPayload1.code, /^\d{6}$/)
|
|
658
|
+
|
|
659
|
+
const rawTokenSnap = await core.db.firestore(true).collection('auth_tokens').doc(`${testAuthUserId}_verify_email`).get()
|
|
660
|
+
assert.equal(rawTokenSnap.exists, true)
|
|
661
|
+
const rawTokenData = rawTokenSnap.data()!
|
|
662
|
+
assert.equal(rawTokenData.tokenHash, core.internals.auth.hashToken(verifyPayload1.code))
|
|
663
|
+
assert.notEqual(rawTokenData.tokenHash, verifyPayload1.code)
|
|
664
|
+
assert.equal('code' in rawTokenData, false)
|
|
665
|
+
|
|
666
|
+
for (let attempt = 1; attempt <= 5; attempt++) {
|
|
667
|
+
const badVerifyRes = await apiJson<{ code?: string }>(
|
|
668
|
+
'/api/auth/email/verify',
|
|
669
|
+
{
|
|
670
|
+
method: 'POST',
|
|
671
|
+
headers: { 'content-type': 'application/json' },
|
|
672
|
+
body: JSON.stringify({ email: testEmail, codeOrToken: '000000' })
|
|
673
|
+
},
|
|
674
|
+
false,
|
|
675
|
+
`bad-verify-${attempt}`
|
|
676
|
+
)
|
|
677
|
+
assert.equal(badVerifyRes.status, 400)
|
|
678
|
+
if (attempt === 5) {
|
|
679
|
+
assert.equal(badVerifyRes.data.code, 'TOKEN_MAX_ATTEMPTS_EXCEEDED')
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Even the right code must now be rejected after 5 failed attempts
|
|
684
|
+
const exhaustedRes = await apiFetch(
|
|
685
|
+
'/api/auth/email/verify',
|
|
686
|
+
{
|
|
687
|
+
method: 'POST',
|
|
688
|
+
headers: { 'content-type': 'application/json' },
|
|
689
|
+
body: JSON.stringify({ email: testEmail, codeOrToken: verifyPayload1.code })
|
|
690
|
+
},
|
|
691
|
+
'exhausted-verify'
|
|
692
|
+
)
|
|
693
|
+
assert.equal(exhaustedRes.status, 400)
|
|
694
|
+
|
|
695
|
+
// Re-issue verification OTP and verify with valid 6-digit code
|
|
696
|
+
const resendVerifyRes = await apiJson<{ sent: boolean }>(
|
|
697
|
+
'/api/auth/email/send-verification',
|
|
698
|
+
{
|
|
699
|
+
method: 'POST',
|
|
700
|
+
headers: { 'content-type': 'application/json' },
|
|
701
|
+
body: JSON.stringify({})
|
|
702
|
+
},
|
|
703
|
+
false,
|
|
704
|
+
'send-verify-2'
|
|
705
|
+
)
|
|
706
|
+
assert.equal(resendVerifyRes.status, 200)
|
|
707
|
+
const verifyPayload2 = core.internals.auth.getLastDevEmail(testEmail, 'verify_email')!
|
|
708
|
+
|
|
709
|
+
const okVerifyRes = await apiJson<{ verified: boolean; userId: string }>(
|
|
710
|
+
'/api/auth/email/verify',
|
|
711
|
+
{
|
|
712
|
+
method: 'POST',
|
|
713
|
+
headers: { 'content-type': 'application/json' },
|
|
714
|
+
body: JSON.stringify({ email: testEmail, codeOrToken: verifyPayload2.code })
|
|
715
|
+
},
|
|
716
|
+
false,
|
|
717
|
+
'ok-verify'
|
|
718
|
+
)
|
|
719
|
+
assert.equal(okVerifyRes.status, 200)
|
|
720
|
+
assert.equal(okVerifyRes.data.verified, true)
|
|
721
|
+
assert.equal(okVerifyRes.data.userId, testAuthUserId)
|
|
722
|
+
|
|
723
|
+
const verifiedUser = await core.auth.getUser(testAuthUserId)
|
|
724
|
+
assert.equal(verifiedUser?.emailVerified, true)
|
|
725
|
+
|
|
726
|
+
// 2. Social OAuth (Google & Apple) — OIDC signature verification, nonce check, account linking & Apple first-time name
|
|
727
|
+
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
|
|
728
|
+
const pubJwk = { ...(publicKey.export({ format: 'jwk' }) as JsonWebKey), kid: 'test-oidc-key' }
|
|
729
|
+
core.internals.auth.registerTestOidcPublicKey(pubJwk)
|
|
730
|
+
|
|
731
|
+
const signTestJwt = (claims: Record<string, unknown>) => {
|
|
732
|
+
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT', kid: 'test-oidc-key' })).toString(
|
|
733
|
+
'base64url'
|
|
734
|
+
)
|
|
735
|
+
const payload = Buffer.from(
|
|
736
|
+
JSON.stringify({
|
|
737
|
+
iat: Math.floor(Date.now() / 1000) - 10,
|
|
738
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
739
|
+
...claims
|
|
740
|
+
})
|
|
741
|
+
).toString('base64url')
|
|
742
|
+
const sig = cryptoSign('RSA-SHA256', Buffer.from(`${header}.${payload}`, 'utf8'), privateKey).toString(
|
|
743
|
+
'base64url'
|
|
744
|
+
)
|
|
745
|
+
return `${header}.${payload}.${sig}`
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
let appleCreatedUserId = ''
|
|
749
|
+
try {
|
|
750
|
+
// 2a. Google OAuth linking to existing verified email user
|
|
751
|
+
const googleSub = `google_sub_${Date.now()}`
|
|
752
|
+
const googleIdToken = signTestJwt({
|
|
753
|
+
iss: 'https://accounts.google.com',
|
|
754
|
+
sub: googleSub,
|
|
755
|
+
aud: 'test-google-client',
|
|
756
|
+
email: testEmail,
|
|
757
|
+
email_verified: true,
|
|
758
|
+
name: 'E2E User',
|
|
759
|
+
picture: 'https://example.com/avatar.png'
|
|
760
|
+
})
|
|
761
|
+
|
|
762
|
+
const googleSignInRes = await apiFetch(
|
|
763
|
+
'/api/auth/sign-in/social',
|
|
764
|
+
{
|
|
765
|
+
method: 'POST',
|
|
766
|
+
headers: { 'content-type': 'application/json', origin: BASE_URL },
|
|
767
|
+
body: JSON.stringify({ provider: 'google', idToken: googleIdToken })
|
|
768
|
+
},
|
|
769
|
+
'social-google-link'
|
|
770
|
+
)
|
|
771
|
+
assert.equal(googleSignInRes.status, 200)
|
|
772
|
+
const googleUser = (await googleSignInRes.json()) as {
|
|
773
|
+
id: string
|
|
774
|
+
email: string
|
|
775
|
+
emailVerified: boolean
|
|
776
|
+
linkedProviders?: { provider: string; providerUserId: string }[]
|
|
777
|
+
}
|
|
778
|
+
assert.equal(googleUser.id, testAuthUserId)
|
|
779
|
+
assert.equal(googleUser.emailVerified, true)
|
|
780
|
+
assert.ok(googleUser.linkedProviders?.some((p) => p.provider === 'google' && p.providerUserId === googleSub))
|
|
781
|
+
|
|
782
|
+
// 2b. Apple OAuth with raw nonce + first-time Capacitor profile.name persistence
|
|
783
|
+
const rawNonce = `nonce_${Date.now()}`
|
|
784
|
+
const nonceSha256 = createHash('sha256').update(rawNonce).digest('hex')
|
|
785
|
+
const appleSub = `apple_sub_${Date.now()}`
|
|
786
|
+
const appleEmail = `apple-${Date.now()}@test.local`
|
|
787
|
+
const appleIdToken = signTestJwt({
|
|
788
|
+
iss: 'https://appleid.apple.com',
|
|
789
|
+
sub: appleSub,
|
|
790
|
+
aud: 'com.example.capacitor',
|
|
791
|
+
email: appleEmail,
|
|
792
|
+
email_verified: 'true',
|
|
793
|
+
nonce: nonceSha256
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
// Nonce mismatch must fail with 401
|
|
797
|
+
const badNonceRes = await apiFetch(
|
|
798
|
+
'/api/auth/sign-in/social',
|
|
799
|
+
{
|
|
800
|
+
method: 'POST',
|
|
801
|
+
headers: { 'content-type': 'application/json', origin: BASE_URL },
|
|
802
|
+
body: JSON.stringify({
|
|
803
|
+
provider: 'apple',
|
|
804
|
+
idToken: appleIdToken,
|
|
805
|
+
nonce: 'wrong-nonce'
|
|
806
|
+
})
|
|
807
|
+
},
|
|
808
|
+
'social-apple-bad-nonce'
|
|
809
|
+
)
|
|
810
|
+
assert.equal(badNonceRes.status, 401)
|
|
811
|
+
|
|
812
|
+
const appleSignInRes = await apiFetch(
|
|
813
|
+
'/api/auth/sign-in/social',
|
|
814
|
+
{
|
|
815
|
+
method: 'POST',
|
|
816
|
+
headers: { 'content-type': 'application/json', origin: BASE_URL },
|
|
817
|
+
body: JSON.stringify({
|
|
818
|
+
provider: 'apple',
|
|
819
|
+
idToken: appleIdToken,
|
|
820
|
+
nonce: rawNonce,
|
|
821
|
+
profile: { name: 'Apple First-Auth Name' }
|
|
822
|
+
})
|
|
823
|
+
},
|
|
824
|
+
'social-apple-first-auth'
|
|
825
|
+
)
|
|
826
|
+
assert.equal(appleSignInRes.status, 200)
|
|
827
|
+
const appleUser = (await appleSignInRes.json()) as { id: string; name: string; email: string }
|
|
828
|
+
appleCreatedUserId = appleUser.id
|
|
829
|
+
assert.equal(appleUser.name, 'Apple First-Auth Name')
|
|
830
|
+
assert.equal(appleUser.email, appleEmail)
|
|
831
|
+
|
|
832
|
+
// Subsequent Apple sign-in (without profile.name or email in token) resolves by (provider, sub) and keeps name
|
|
833
|
+
const appleSubsequentToken = signTestJwt({
|
|
834
|
+
iss: 'https://appleid.apple.com',
|
|
835
|
+
sub: appleSub,
|
|
836
|
+
aud: 'com.example.capacitor'
|
|
837
|
+
})
|
|
838
|
+
const appleSubsequentRes = await apiFetch(
|
|
839
|
+
'/api/auth/sign-in/social',
|
|
840
|
+
{
|
|
841
|
+
method: 'POST',
|
|
842
|
+
headers: { 'content-type': 'application/json', origin: BASE_URL },
|
|
843
|
+
body: JSON.stringify({ provider: 'apple', idToken: appleSubsequentToken })
|
|
844
|
+
},
|
|
845
|
+
'social-apple-subsequent'
|
|
846
|
+
)
|
|
847
|
+
assert.equal(appleSubsequentRes.status, 200)
|
|
848
|
+
const appleUser2 = (await appleSubsequentRes.json()) as { id: string; name: string }
|
|
849
|
+
assert.equal(appleUser2.id, appleCreatedUserId)
|
|
850
|
+
assert.equal(appleUser2.name, 'Apple First-Auth Name')
|
|
851
|
+
} finally {
|
|
852
|
+
core.internals.auth.registerTestOidcPublicKey(null)
|
|
853
|
+
if (appleCreatedUserId) {
|
|
854
|
+
await apiFetch(
|
|
855
|
+
'/api/erase-user',
|
|
856
|
+
{
|
|
857
|
+
method: 'POST',
|
|
858
|
+
headers: bearerHeaders({ 'content-type': 'application/json' }),
|
|
859
|
+
body: JSON.stringify({ userId: appleCreatedUserId, backupBeforeErase: false })
|
|
860
|
+
},
|
|
861
|
+
'cleanup-apple-user'
|
|
862
|
+
).catch(() => {})
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// 3. Secondary Recovery Email Binding
|
|
867
|
+
const recoveryEmail = `recovery-${Date.now()}@test.local`
|
|
868
|
+
const sameEmailRes = await apiFetch(
|
|
869
|
+
'/api/auth/recovery-email/set',
|
|
870
|
+
{
|
|
871
|
+
method: 'POST',
|
|
872
|
+
headers: userHeaders({ 'content-type': 'application/json' }),
|
|
873
|
+
body: JSON.stringify({ recoveryEmail: testEmail })
|
|
874
|
+
},
|
|
875
|
+
'recovery-same-as-primary'
|
|
876
|
+
)
|
|
877
|
+
assert.equal(sameEmailRes.status, 400)
|
|
878
|
+
|
|
879
|
+
const setRecoveryRes = await apiJson<{
|
|
880
|
+
recoveryEmail: string
|
|
881
|
+
recoveryEmailVerified: boolean
|
|
882
|
+
sent: boolean
|
|
883
|
+
}>(
|
|
884
|
+
'/api/auth/recovery-email/set',
|
|
885
|
+
{
|
|
886
|
+
method: 'POST',
|
|
887
|
+
headers: { 'content-type': 'application/json' },
|
|
888
|
+
body: JSON.stringify({ recoveryEmail })
|
|
889
|
+
},
|
|
890
|
+
false,
|
|
891
|
+
'set-recovery-email'
|
|
892
|
+
)
|
|
893
|
+
assert.equal(setRecoveryRes.status, 200)
|
|
894
|
+
assert.equal(setRecoveryRes.data.recoveryEmail, recoveryEmail)
|
|
895
|
+
assert.equal(setRecoveryRes.data.recoveryEmailVerified, false)
|
|
896
|
+
|
|
897
|
+
const recoveryVerifyPayload = core.internals.auth.getLastDevEmail(recoveryEmail, 'verify_recovery_email')
|
|
898
|
+
assert.ok(recoveryVerifyPayload)
|
|
899
|
+
|
|
900
|
+
const verifyRecoveryRes = await apiJson<{
|
|
901
|
+
recoveryEmail: string
|
|
902
|
+
recoveryEmailVerified: boolean
|
|
903
|
+
verified: boolean
|
|
904
|
+
}>(
|
|
905
|
+
'/api/auth/recovery-email/verify',
|
|
906
|
+
{
|
|
907
|
+
method: 'POST',
|
|
908
|
+
headers: { 'content-type': 'application/json' },
|
|
909
|
+
body: JSON.stringify({ codeOrToken: recoveryVerifyPayload.code })
|
|
910
|
+
},
|
|
911
|
+
false,
|
|
912
|
+
'verify-recovery-email'
|
|
913
|
+
)
|
|
914
|
+
assert.equal(verifyRecoveryRes.status, 200)
|
|
915
|
+
assert.equal(verifyRecoveryRes.data.recoveryEmailVerified, true)
|
|
916
|
+
|
|
917
|
+
// 4. Password Forgot & Reset (with anti-enumeration & global session revocation)
|
|
918
|
+
const unknownForgotRes = await apiJson<{ sent: boolean }>(
|
|
919
|
+
'/api/auth/password/forgot',
|
|
920
|
+
{
|
|
921
|
+
method: 'POST',
|
|
922
|
+
headers: { 'content-type': 'application/json' },
|
|
923
|
+
body: JSON.stringify({ email: 'nonexistent-user@test.local' })
|
|
924
|
+
},
|
|
925
|
+
false,
|
|
926
|
+
'forgot-nonexistent'
|
|
927
|
+
)
|
|
928
|
+
assert.equal(unknownForgotRes.status, 200)
|
|
929
|
+
assert.equal(unknownForgotRes.data.sent, true)
|
|
930
|
+
|
|
931
|
+
const forgotRes = await apiJson<{ sent: boolean }>(
|
|
932
|
+
'/api/auth/password/forgot',
|
|
933
|
+
{
|
|
934
|
+
method: 'POST',
|
|
935
|
+
headers: { 'content-type': 'application/json' },
|
|
936
|
+
body: JSON.stringify({ email: testEmail })
|
|
937
|
+
},
|
|
938
|
+
false,
|
|
939
|
+
'forgot-password'
|
|
940
|
+
)
|
|
941
|
+
assert.equal(forgotRes.status, 200)
|
|
942
|
+
const resetPayload = core.internals.auth.getLastDevEmail(testEmail, 'reset_password')
|
|
943
|
+
assert.ok(resetPayload)
|
|
944
|
+
|
|
945
|
+
const resetPasswordVal = 'ResetPassword456!'
|
|
946
|
+
const resetRes = await apiJson<{ reset: boolean }>(
|
|
947
|
+
'/api/auth/password/reset',
|
|
948
|
+
{
|
|
949
|
+
method: 'POST',
|
|
950
|
+
headers: { 'content-type': 'application/json' },
|
|
951
|
+
body: JSON.stringify({
|
|
952
|
+
email: testEmail,
|
|
953
|
+
codeOrToken: resetPayload.token,
|
|
954
|
+
newPassword: resetPasswordVal
|
|
955
|
+
})
|
|
956
|
+
},
|
|
957
|
+
false,
|
|
958
|
+
'reset-password'
|
|
959
|
+
)
|
|
960
|
+
assert.equal(resetRes.status, 200)
|
|
961
|
+
assert.equal(resetRes.data.reset, true)
|
|
962
|
+
|
|
963
|
+
// Previous session cookie must be revoked after password reset
|
|
964
|
+
const revokedAfterResetRes = await apiFetch(
|
|
965
|
+
'/api/auth/session',
|
|
966
|
+
{ headers: { cookie: testAuthCookie, origin: BASE_URL } },
|
|
967
|
+
'session-revoked-after-reset'
|
|
968
|
+
)
|
|
969
|
+
assert.equal(revokedAfterResetRes.status, 401)
|
|
970
|
+
|
|
971
|
+
// 5. Full Account Recovery via Verified Secondary Recovery Email
|
|
972
|
+
const initiateRecoveryRes = await apiJson<{ initiated: boolean }>(
|
|
973
|
+
'/api/auth/account-recovery/initiate',
|
|
974
|
+
{
|
|
975
|
+
method: 'POST',
|
|
976
|
+
headers: { 'content-type': 'application/json' },
|
|
977
|
+
body: JSON.stringify({ primaryEmail: testEmail, recoveryEmail })
|
|
978
|
+
},
|
|
979
|
+
false,
|
|
980
|
+
'initiate-account-recovery'
|
|
981
|
+
)
|
|
982
|
+
assert.equal(initiateRecoveryRes.status, 200)
|
|
983
|
+
assert.equal(initiateRecoveryRes.data.initiated, true)
|
|
984
|
+
|
|
985
|
+
const accountRecoveryPayload = core.internals.auth.getLastDevEmail(recoveryEmail, 'account_recovery')
|
|
986
|
+
assert.ok(accountRecoveryPayload)
|
|
987
|
+
|
|
988
|
+
const recoveredPrimaryEmail = `recovered-${Date.now()}@test.local`
|
|
989
|
+
const completeRecoveryRes = await apiFetch(
|
|
990
|
+
'/api/auth/account-recovery/complete',
|
|
991
|
+
{
|
|
992
|
+
method: 'POST',
|
|
993
|
+
headers: { 'content-type': 'application/json', origin: BASE_URL },
|
|
994
|
+
body: JSON.stringify({
|
|
995
|
+
primaryEmail: testEmail,
|
|
996
|
+
recoveryEmail,
|
|
997
|
+
codeOrToken: accountRecoveryPayload.code,
|
|
998
|
+
newPrimaryEmail: recoveredPrimaryEmail,
|
|
999
|
+
newPassword: testPassword
|
|
1000
|
+
})
|
|
1001
|
+
},
|
|
1002
|
+
'complete-account-recovery'
|
|
1003
|
+
)
|
|
1004
|
+
assert.equal(completeRecoveryRes.status, 200)
|
|
1005
|
+
const recoveredUser = (await completeRecoveryRes.json()) as {
|
|
1006
|
+
id: string
|
|
1007
|
+
email: string
|
|
1008
|
+
emailVerified: boolean
|
|
1009
|
+
}
|
|
1010
|
+
assert.equal(recoveredUser.id, testAuthUserId)
|
|
1011
|
+
assert.equal(recoveredUser.email, recoveredPrimaryEmail)
|
|
1012
|
+
assert.equal(recoveredUser.emailVerified, true)
|
|
1013
|
+
|
|
1014
|
+
const recoveryCookies =
|
|
1015
|
+
completeRecoveryRes.headers.getSetCookie?.() ?? [completeRecoveryRes.headers.get('set-cookie') ?? '']
|
|
1016
|
+
testAuthCookie = recoveryCookies.map((c) => c.split(';')[0]).filter(Boolean).join('; ')
|
|
1017
|
+
assert.ok(testAuthCookie.includes(`${core.internals.auth.sessionCookie}=`))
|
|
1018
|
+
|
|
1019
|
+
const postRecoverySessionRes = await apiFetch(
|
|
1020
|
+
'/api/auth/session',
|
|
1021
|
+
{ headers: userHeaders() },
|
|
1022
|
+
'post-recovery-session'
|
|
1023
|
+
)
|
|
1024
|
+
assert.equal(postRecoverySessionRes.status, 200)
|
|
1025
|
+
})
|
|
1026
|
+
})
|
|
1027
|
+
|
|
1028
|
+
describe('2. Files CRUD (/api/files, /api/files/:ref)', () => {
|
|
1029
|
+
test('POST /api/files & GET /api/files/:ref — validation (400), missing (404) & cross-tenant guard (403)', async () => {
|
|
1030
|
+
const badForm = new FormData()
|
|
1031
|
+
badForm.append('other', 'not-a-file')
|
|
1032
|
+
const badRes = await apiFetch('/api/files', { method: 'POST', headers: userHeaders(), body: badForm }, 'missing-file')
|
|
1033
|
+
assert.equal(badRes.status, 400)
|
|
1034
|
+
|
|
1035
|
+
const badMimeForm = new FormData()
|
|
1036
|
+
badMimeForm.append('file', new File([new TextEncoder().encode('hello')], 'note.txt', { type: 'text/plain' }))
|
|
1037
|
+
const badMimeRes = await apiFetch(
|
|
1038
|
+
'/api/files',
|
|
1039
|
+
{ method: 'POST', headers: userHeaders(), body: badMimeForm },
|
|
1040
|
+
'bad-mime'
|
|
1041
|
+
)
|
|
1042
|
+
assert.equal(badMimeRes.status, 400)
|
|
1043
|
+
|
|
1044
|
+
const missingFileRes = await apiFetch(
|
|
1045
|
+
`/api/files/${testAuthUserId}_000000000000000000000000`,
|
|
1046
|
+
{ headers: userHeaders() },
|
|
1047
|
+
'missing-ref'
|
|
1048
|
+
)
|
|
1049
|
+
assert.equal(missingFileRes.status, 404)
|
|
1050
|
+
|
|
1051
|
+
const crossTenantRes = await apiFetch(
|
|
1052
|
+
'/api/files/otheruser_000000000000000000000000',
|
|
1053
|
+
{ headers: userHeaders() },
|
|
1054
|
+
'cross-tenant'
|
|
1055
|
+
)
|
|
1056
|
+
assert.equal(crossTenantRes.status, 403)
|
|
1057
|
+
})
|
|
1058
|
+
|
|
1059
|
+
test('POST /api/files, GET /api/files/:ref & GET /api/files/:ref?meta=true — upload, download & metadata across all fixtures', async () => {
|
|
1060
|
+
await Promise.all(
|
|
1061
|
+
fixtures.map(async (fixture) => {
|
|
1062
|
+
const form = new FormData()
|
|
1063
|
+
form.append('file', new File([Buffer.from(fixture.bytes)], fixture.filename, { type: fixture.mime }))
|
|
1064
|
+
const res = await apiFetch(
|
|
1065
|
+
'/api/files',
|
|
1066
|
+
{ method: 'POST', headers: userHeaders(), body: form },
|
|
1067
|
+
fixture.filename
|
|
1068
|
+
)
|
|
1069
|
+
assert.equal(res.status, 200)
|
|
1070
|
+
const { ref } = (await res.json()) as { ref: string }
|
|
1071
|
+
assert.match(ref, new RegExp(`^${testAuthUserId}_[a-f0-9]{24}$`))
|
|
1072
|
+
uploadedRefs.set(fixture.type, ref)
|
|
1073
|
+
|
|
1074
|
+
const getRes = await apiFetch(`/api/files/${ref}`, { headers: userHeaders() }, fixture.type)
|
|
1075
|
+
assert.equal(getRes.status, 200)
|
|
1076
|
+
assert.equal(getRes.headers.get('content-type'), fixture.mime)
|
|
1077
|
+
assert.ok((getRes.headers.get('cache-control') ?? '').includes('immutable'))
|
|
1078
|
+
const downloaded = new Uint8Array(await getRes.arrayBuffer())
|
|
1079
|
+
assert.equal(downloaded.byteLength, fixture.bytes.byteLength)
|
|
1080
|
+
|
|
1081
|
+
const metaRes = await apiFetch(`/api/files/${ref}?meta=true`, { headers: userHeaders() }, `${fixture.type}:meta`)
|
|
1082
|
+
assert.equal(metaRes.status, 200)
|
|
1083
|
+
const meta = (await metaRes.json()) as { id: string; fileName: string; deleted: boolean; userId: string }
|
|
1084
|
+
assert.equal(meta.id, ref)
|
|
1085
|
+
assert.equal(meta.userId, testAuthUserId)
|
|
1086
|
+
assert.equal(meta.deleted, false)
|
|
1087
|
+
})
|
|
1088
|
+
)
|
|
1089
|
+
|
|
1090
|
+
const sampleRef = uploadedRefs.get(fixtures[0]!.type)!
|
|
1091
|
+
const signedRes = await apiFetch(`/api/files/${sampleRef}?signed=true`, { headers: userHeaders() }, 'signed-download')
|
|
1092
|
+
assert.equal(signedRes.status, 200)
|
|
1093
|
+
const signedData = (await signedRes.json()) as { ref: string; url: string; expiresAt: string }
|
|
1094
|
+
assert.equal(signedData.ref, sampleRef)
|
|
1095
|
+
assert.ok(signedData.url.startsWith('https://storage.googleapis.com/'))
|
|
1096
|
+
assert.ok(Date.parse(signedData.expiresAt) > Date.now())
|
|
1097
|
+
|
|
1098
|
+
const userCore = new Core({ env: ENV, userId: testAuthUserId, role: 'user' })
|
|
1099
|
+
const signedUp = await userCore.files.getSignedUploadUrl({
|
|
1100
|
+
name: 'direct-upload.pdf',
|
|
1101
|
+
type: 'application/pdf',
|
|
1102
|
+
sha256: 'a'.repeat(64)
|
|
1103
|
+
})
|
|
1104
|
+
assert.equal(signedUp.ref, `${testAuthUserId}_${'a'.repeat(24)}`)
|
|
1105
|
+
assert.equal(signedUp.deduplicated, false)
|
|
1106
|
+
assert.ok(signedUp.url.startsWith('https://storage.googleapis.com/'))
|
|
1107
|
+
assert.ok(Date.parse(signedUp.expiresAt) > Date.now())
|
|
1108
|
+
})
|
|
1109
|
+
|
|
1110
|
+
test('GET /api/files & POST /api/files — list files & verify SHA-256 deduplication', async () => {
|
|
1111
|
+
const listFilesRes = await apiFetch('/api/files', { headers: userHeaders() })
|
|
1112
|
+
assert.equal(listFilesRes.status, 200)
|
|
1113
|
+
const listedFiles = (await listFilesRes.json()) as { id: string }[]
|
|
1114
|
+
assert.ok(listedFiles.length >= fixtures.length)
|
|
1115
|
+
|
|
1116
|
+
const firstFixture = fixtures[0]!
|
|
1117
|
+
const dupForm = new FormData()
|
|
1118
|
+
dupForm.append('file', new File([Buffer.from(firstFixture.bytes)], firstFixture.filename, { type: firstFixture.mime }))
|
|
1119
|
+
const dupRes = await apiFetch('/api/files', { method: 'POST', headers: userHeaders(), body: dupForm }, 'dedupe')
|
|
1120
|
+
assert.equal(dupRes.status, 200)
|
|
1121
|
+
const { ref: dupRef } = (await dupRes.json()) as { ref: string }
|
|
1122
|
+
assert.equal(dupRef, uploadedRefs.get(firstFixture.type)!)
|
|
1123
|
+
})
|
|
1124
|
+
|
|
1125
|
+
test('DELETE /api/files/:ref & POST /api/files — soft-delete file, verify 404 & restore on re-upload', async () => {
|
|
1126
|
+
const tempBytes = buildSimpleJpeg(24)
|
|
1127
|
+
const tempForm = new FormData()
|
|
1128
|
+
tempForm.append('file', new File([Buffer.from(tempBytes)], 'disposable.jpeg', { type: 'image/jpeg' }))
|
|
1129
|
+
const tempUp = await apiFetch('/api/files', { method: 'POST', headers: userHeaders(), body: tempForm }, 'disposable')
|
|
1130
|
+
assert.equal(tempUp.status, 200)
|
|
1131
|
+
const { ref: disposableRef } = (await tempUp.json()) as { ref: string }
|
|
1132
|
+
|
|
1133
|
+
const delRes = await apiFetch(`/api/files/${disposableRef}`, { method: 'DELETE', headers: userHeaders() }, 'soft-del')
|
|
1134
|
+
assert.equal(delRes.status, 200)
|
|
1135
|
+
assert.deepEqual(await delRes.json(), { ref: disposableRef, deleted: true })
|
|
1136
|
+
|
|
1137
|
+
const afterDelRes = await apiFetch(`/api/files/${disposableRef}`, { headers: userHeaders() }, 'after-del')
|
|
1138
|
+
assert.equal(afterDelRes.status, 404)
|
|
1139
|
+
|
|
1140
|
+
const restoreForm = new FormData()
|
|
1141
|
+
restoreForm.append('file', new File([Buffer.from(tempBytes)], 'disposable.jpeg', { type: 'image/jpeg' }))
|
|
1142
|
+
const restoreRes = await apiFetch(
|
|
1143
|
+
'/api/files',
|
|
1144
|
+
{ method: 'POST', headers: userHeaders(), body: restoreForm },
|
|
1145
|
+
'restore'
|
|
1146
|
+
)
|
|
1147
|
+
assert.equal(restoreRes.status, 200)
|
|
1148
|
+
const afterRestoreRes = await apiFetch(`/api/files/${disposableRef}`, { headers: userHeaders() }, 'after-restore')
|
|
1149
|
+
assert.equal(afterRestoreRes.status, 200)
|
|
1150
|
+
await apiFetch(`/api/files/${disposableRef}`, { method: 'DELETE', headers: userHeaders() }, 'cleanup-disposable')
|
|
1151
|
+
})
|
|
1152
|
+
})
|
|
1153
|
+
|
|
1154
|
+
describe('3. Route Catalog & Attachment Scanning (/api/routes, /api/scan-attachment)', () => {
|
|
1155
|
+
test('GET /api/routes & GET /api/routes?format=md — verify all core/domain routes & deterministic API_CONTRACT.md introspection', async () => {
|
|
1156
|
+
const catalogRes = await apiJson<RoutesIntrospectionResponse | DiscoveredRouteItem[]>('/api/routes', {}, true)
|
|
1157
|
+
assert.equal(catalogRes.status, 200)
|
|
1158
|
+
|
|
1159
|
+
const routeList: DiscoveredRouteItem[] = Array.isArray(catalogRes.data)
|
|
1160
|
+
? catalogRes.data
|
|
1161
|
+
: (catalogRes.data.routes as DiscoveredRouteItem[])
|
|
1162
|
+
if (!Array.isArray(catalogRes.data)) {
|
|
1163
|
+
assert.equal(catalogRes.data.appName, CORE_CONFIG.appName)
|
|
1164
|
+
assert.ok(catalogRes.data.collections && 'chat' in catalogRes.data.collections)
|
|
1165
|
+
assert.ok('user_auth_meta' in catalogRes.data.collections!)
|
|
1166
|
+
assert.ok('auth_tokens' in catalogRes.data.collections!)
|
|
1167
|
+
assert.ok('email_log' in catalogRes.data.collections!)
|
|
1168
|
+
assert.ok('email_suppression' in catalogRes.data.collections!)
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
const registeredIds = new Set(routeList.map((r) => r.id))
|
|
1172
|
+
for (const coreId of Object.keys(core.internals.context.routeDefaults)) {
|
|
1173
|
+
assert.ok(registeredIds.has(coreId), `Expected core route ${coreId} in GET /api/routes`)
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
discoveredDomainRoutes = routeList.filter((r) => !(r.id in core.internals.context.routeDefaults))
|
|
1177
|
+
assert.equal(discoveredDomainRoutes.length, Object.keys(customRoutes).length)
|
|
1178
|
+
|
|
1179
|
+
// Verify deterministic Markdown rendering (GET /api/routes?format=md)
|
|
1180
|
+
const mdRes = await apiFetch('/api/routes?format=md', { headers: bearerHeaders() }, 'routes-md')
|
|
1181
|
+
assert.equal(mdRes.status, 200)
|
|
1182
|
+
assert.ok((mdRes.headers.get('content-type') ?? '').includes('text/markdown'))
|
|
1183
|
+
const mdText = await mdRes.text()
|
|
1184
|
+
assert.ok(mdText.includes(`# API Contract & Schema Reference — ${CORE_CONFIG.appName}`))
|
|
1185
|
+
assert.ok(mdText.includes('POST /api/auth/sign-in/social'))
|
|
1186
|
+
assert.ok(mdText.includes('POST /api/auth/email/send-verification'))
|
|
1187
|
+
assert.ok(mdText.includes('POST /api/auth/email/verify'))
|
|
1188
|
+
assert.ok(mdText.includes('POST /api/auth/password/forgot'))
|
|
1189
|
+
assert.ok(mdText.includes('POST /api/auth/password/reset'))
|
|
1190
|
+
assert.ok(mdText.includes('POST /api/auth/recovery-email/set'))
|
|
1191
|
+
assert.ok(mdText.includes('POST /api/auth/recovery-email/verify'))
|
|
1192
|
+
assert.ok(mdText.includes('POST /api/auth/account-recovery/initiate'))
|
|
1193
|
+
assert.ok(mdText.includes('POST /api/auth/account-recovery/complete'))
|
|
1194
|
+
assert.ok(mdText.includes('POST /api/emails/send'))
|
|
1195
|
+
assert.ok(mdText.includes('POST /api/emails/batch'))
|
|
1196
|
+
assert.ok(mdText.includes('GET /api/emails/logs'))
|
|
1197
|
+
assert.ok(mdText.includes('POST /api/emails/webhooks/:provider'))
|
|
1198
|
+
})
|
|
1199
|
+
|
|
1200
|
+
test('POST /api/scan-attachment — OCR, classify & extract structured fields for 1 single attachment', async () => {
|
|
1201
|
+
const singleFixture = fixtures[0]!
|
|
1202
|
+
const ref = uploadedRefs.get(singleFixture.type)!
|
|
1203
|
+
const { status, data: scan } = await apiJson<{
|
|
1204
|
+
type: string
|
|
1205
|
+
confidence: number
|
|
1206
|
+
fields: Record<string, unknown>
|
|
1207
|
+
markdown: string
|
|
1208
|
+
}>(
|
|
1209
|
+
'/api/scan-attachment',
|
|
1210
|
+
{
|
|
1211
|
+
method: 'POST',
|
|
1212
|
+
headers: { 'content-type': 'application/json' },
|
|
1213
|
+
body: JSON.stringify({ ref })
|
|
1214
|
+
},
|
|
1215
|
+
false,
|
|
1216
|
+
singleFixture.type
|
|
1217
|
+
)
|
|
1218
|
+
assert.equal(status, 200)
|
|
1219
|
+
assert.equal(scan.type, singleFixture.type)
|
|
1220
|
+
assert.ok(scan.confidence > 0)
|
|
1221
|
+
assert.ok(scan.markdown.length > 0)
|
|
1222
|
+
assert.deepEqual(Object.keys(scan.fields).sort(), singleFixture.expectedKeys)
|
|
1223
|
+
if (singleFixture.schema) {
|
|
1224
|
+
assert.equal(singleFixture.schema.safeParse(scan.fields).success, true)
|
|
1225
|
+
}
|
|
1226
|
+
})
|
|
1227
|
+
|
|
1228
|
+
test('POST /api/scan-attachment — verify Firestore scan cache short-circuit & negative caching (scanError)', async () => {
|
|
1229
|
+
const sampleFixture = fixtures[0]!
|
|
1230
|
+
const sampleRef = uploadedRefs.get(sampleFixture.type)!
|
|
1231
|
+
const cachedRes = await apiJson<{ type: string; fields: Record<string, unknown>; markdown: string }>(
|
|
1232
|
+
'/api/scan-attachment',
|
|
1233
|
+
{
|
|
1234
|
+
method: 'POST',
|
|
1235
|
+
headers: { 'content-type': 'application/json' },
|
|
1236
|
+
body: JSON.stringify({ ref: sampleRef })
|
|
1237
|
+
},
|
|
1238
|
+
false,
|
|
1239
|
+
'cached'
|
|
1240
|
+
)
|
|
1241
|
+
assert.equal(cachedRes.status, 200)
|
|
1242
|
+
assert.equal(cachedRes.data.type, sampleFixture.type)
|
|
1243
|
+
assert.deepEqual(Object.keys(cachedRes.data.fields).sort(), sampleFixture.expectedKeys)
|
|
1244
|
+
|
|
1245
|
+
const corruptedBytes = new TextEncoder().encode('%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\n%%EOF-CORRUPTED')
|
|
1246
|
+
const badForm = new FormData()
|
|
1247
|
+
badForm.append('file', new File([Buffer.from(corruptedBytes)], 'corrupted.pdf', { type: 'application/pdf' }))
|
|
1248
|
+
const upBadRes = await apiFetch('/api/files', { method: 'POST', headers: userHeaders(), body: badForm }, 'corrupted-pdf')
|
|
1249
|
+
assert.equal(upBadRes.status, 200)
|
|
1250
|
+
const { ref: corruptedRef } = (await upBadRes.json()) as { ref: string }
|
|
1251
|
+
|
|
1252
|
+
const scanBadRes = await apiJson<{ error: string; code: string }>(
|
|
1253
|
+
'/api/scan-attachment',
|
|
1254
|
+
{
|
|
1255
|
+
method: 'POST',
|
|
1256
|
+
headers: { 'content-type': 'application/json' },
|
|
1257
|
+
body: JSON.stringify({ ref: corruptedRef })
|
|
1258
|
+
},
|
|
1259
|
+
false,
|
|
1260
|
+
'scan-corrupted'
|
|
1261
|
+
)
|
|
1262
|
+
assert.equal(scanBadRes.status, 400)
|
|
1263
|
+
assert.ok(scanBadRes.data.error.length > 10)
|
|
1264
|
+
|
|
1265
|
+
const badMetaRes = await apiJson<{
|
|
1266
|
+
id: string
|
|
1267
|
+
scanError?: { status: string; reason: string; agentHint: string }
|
|
1268
|
+
}>(`/api/files/${corruptedRef}?meta=true`, {}, false, 'corrupted-meta')
|
|
1269
|
+
assert.equal(badMetaRes.status, 200)
|
|
1270
|
+
assert.equal(badMetaRes.data.scanError?.status, 'unparseable')
|
|
1271
|
+
assert.ok((badMetaRes.data.scanError?.agentHint ?? '').length > 10)
|
|
1272
|
+
|
|
1273
|
+
const t0 = performance.now()
|
|
1274
|
+
const shortCircuitBatch = await apiJson<{
|
|
1275
|
+
attachments: { ref: string; scan?: { type: string }; error?: string }[]
|
|
1276
|
+
}>(
|
|
1277
|
+
'/api/process-attachments',
|
|
1278
|
+
{
|
|
1279
|
+
method: 'POST',
|
|
1280
|
+
headers: { 'content-type': 'application/json' },
|
|
1281
|
+
body: JSON.stringify({ refs: [sampleRef, corruptedRef] })
|
|
1282
|
+
},
|
|
1283
|
+
false,
|
|
1284
|
+
'negative-cache-short-circuit'
|
|
1285
|
+
)
|
|
1286
|
+
const elapsedMs = performance.now() - t0
|
|
1287
|
+
assert.equal(shortCircuitBatch.status, 200)
|
|
1288
|
+
assert.equal(shortCircuitBatch.data.attachments[0]?.scan?.type, sampleFixture.type)
|
|
1289
|
+
assert.equal(shortCircuitBatch.data.attachments[1]?.error, badMetaRes.data.scanError?.agentHint)
|
|
1290
|
+
assert.ok(elapsedMs < 1500)
|
|
1291
|
+
|
|
1292
|
+
await apiFetch(`/api/files/${corruptedRef}`, { method: 'DELETE', headers: userHeaders() }, 'cleanup-corrupted')
|
|
1293
|
+
})
|
|
1294
|
+
})
|
|
1295
|
+
|
|
1296
|
+
describe('4. Batch Attachment Processing & Async Task Lifecycle (/api/process-attachments, /api/tasks/:taskId)', () => {
|
|
1297
|
+
test('POST /api/process-attachments — 20-shard parallel fan-out across all attachment types + other.jpeg', async () => {
|
|
1298
|
+
const allRefs = fixtures.map((f) => uploadedRefs.get(f.type)!)
|
|
1299
|
+
assert.equal(allRefs.length, Object.keys(ACTIVE_ATTACHMENT_TYPES).length + 1)
|
|
1300
|
+
|
|
1301
|
+
const procRes = await apiJson<{
|
|
1302
|
+
attachments: {
|
|
1303
|
+
ref: string
|
|
1304
|
+
scan?: { type: string; confidence: number; fields: Record<string, unknown>; markdown: string }
|
|
1305
|
+
error?: string
|
|
1306
|
+
}[]
|
|
1307
|
+
}>(
|
|
1308
|
+
'/api/process-attachments',
|
|
1309
|
+
{
|
|
1310
|
+
method: 'POST',
|
|
1311
|
+
headers: { 'content-type': 'application/json' },
|
|
1312
|
+
body: JSON.stringify({ refs: allRefs })
|
|
1313
|
+
},
|
|
1314
|
+
false,
|
|
1315
|
+
`all-${allRefs.length}-attachments`
|
|
1316
|
+
)
|
|
1317
|
+
assert.equal(procRes.status, 200)
|
|
1318
|
+
assert.equal(procRes.data.attachments.length, allRefs.length)
|
|
1319
|
+
procRes.data.attachments.forEach((item, idx) => {
|
|
1320
|
+
const fixture = fixtures[idx]!
|
|
1321
|
+
assert.equal(item.ref, allRefs[idx]!)
|
|
1322
|
+
assert.equal(item.error, undefined)
|
|
1323
|
+
assert.ok(item.scan)
|
|
1324
|
+
assert.equal(item.scan.type, fixture.type)
|
|
1325
|
+
assert.ok(item.scan.confidence > 0)
|
|
1326
|
+
assert.ok(item.scan.markdown.length > 0)
|
|
1327
|
+
assert.deepEqual(Object.keys(item.scan.fields).sort(), fixture.expectedKeys)
|
|
1328
|
+
if (fixture.schema) {
|
|
1329
|
+
assert.equal(fixture.schema.safeParse(item.scan.fields).success, true)
|
|
1330
|
+
}
|
|
1331
|
+
})
|
|
1332
|
+
})
|
|
1333
|
+
|
|
1334
|
+
test('POST /api/process-attachments — partial error isolation with missing ref', async () => {
|
|
1335
|
+
const firstRef = uploadedRefs.get(fixtures[0]!.type)!
|
|
1336
|
+
const missingRef = `${testAuthUserId}_000000000000000000000000`
|
|
1337
|
+
const partialErrRes = await apiJson<{
|
|
1338
|
+
attachments: { ref: string; scan?: { type: string }; error?: string }[]
|
|
1339
|
+
}>(
|
|
1340
|
+
'/api/process-attachments',
|
|
1341
|
+
{
|
|
1342
|
+
method: 'POST',
|
|
1343
|
+
headers: { 'content-type': 'application/json' },
|
|
1344
|
+
body: JSON.stringify({ refs: [firstRef, missingRef] })
|
|
1345
|
+
},
|
|
1346
|
+
false,
|
|
1347
|
+
'partial-error'
|
|
1348
|
+
)
|
|
1349
|
+
assert.equal(partialErrRes.status, 200)
|
|
1350
|
+
assert.equal(partialErrRes.data.attachments[0]?.scan?.type, fixtures[0]!.type)
|
|
1351
|
+
assert.equal(typeof partialErrRes.data.attachments[1]?.error, 'string')
|
|
1352
|
+
})
|
|
1353
|
+
|
|
1354
|
+
test('POST /api/process-attachments?async=true — async dispatch (202 Accepted)', async () => {
|
|
1355
|
+
const allRefs = fixtures.map((f) => uploadedRefs.get(f.type)!)
|
|
1356
|
+
const asyncRes = await apiJson<{ taskId: string; status: string; pollUrl: string }>(
|
|
1357
|
+
'/api/process-attachments?async=true',
|
|
1358
|
+
{
|
|
1359
|
+
method: 'POST',
|
|
1360
|
+
headers: { 'content-type': 'application/json', prefer: 'respond-async' },
|
|
1361
|
+
body: JSON.stringify({ refs: allRefs.slice(0, 2) })
|
|
1362
|
+
},
|
|
1363
|
+
false,
|
|
1364
|
+
'respond-async'
|
|
1365
|
+
)
|
|
1366
|
+
assert.equal(asyncRes.status, 202)
|
|
1367
|
+
assert.equal(asyncRes.headers.get('preference-applied'), 'respond-async')
|
|
1368
|
+
assert.ok(asyncRes.data.taskId.length > 5)
|
|
1369
|
+
assert.equal(asyncRes.data.pollUrl, `/api/tasks/${asyncRes.data.taskId}`)
|
|
1370
|
+
asyncTaskId = asyncRes.data.taskId
|
|
1371
|
+
})
|
|
1372
|
+
|
|
1373
|
+
test('POST /api/tasks/:taskId/signal — send signal to task', async () => {
|
|
1374
|
+
const sigRes = await apiJson<{ taskId: string; signaled: boolean }>(
|
|
1375
|
+
`/api/tasks/${asyncTaskId}/signal`,
|
|
1376
|
+
{
|
|
1377
|
+
method: 'POST',
|
|
1378
|
+
headers: { 'content-type': 'application/json' },
|
|
1379
|
+
body: JSON.stringify({ signal: 'ping', payload: { ok: true } })
|
|
1380
|
+
},
|
|
1381
|
+
false,
|
|
1382
|
+
'signal'
|
|
1383
|
+
)
|
|
1384
|
+
assert.equal(sigRes.status, 200)
|
|
1385
|
+
assert.deepEqual(sigRes.data, { taskId: asyncTaskId, signaled: true })
|
|
1386
|
+
})
|
|
1387
|
+
|
|
1388
|
+
test('GET /api/tasks/:taskId?envelope=true — poll task status until completed', async () => {
|
|
1389
|
+
let taskCompleted = false
|
|
1390
|
+
for (let i = 0; i < 40; i++) {
|
|
1391
|
+
const pollRes = await apiJson<{ status?: string; response?: { attachments: unknown[] } }>(
|
|
1392
|
+
`/api/tasks/${asyncTaskId}?envelope=true`,
|
|
1393
|
+
{},
|
|
1394
|
+
false,
|
|
1395
|
+
`poll-${i + 1}`
|
|
1396
|
+
)
|
|
1397
|
+
if (pollRes.status === 200 && pollRes.data.status === 'completed') {
|
|
1398
|
+
assert.equal(pollRes.data.response?.attachments.length, 2)
|
|
1399
|
+
taskCompleted = true
|
|
1400
|
+
break
|
|
1401
|
+
}
|
|
1402
|
+
await sleep(100)
|
|
1403
|
+
}
|
|
1404
|
+
assert.equal(taskCompleted, true)
|
|
1405
|
+
})
|
|
1406
|
+
|
|
1407
|
+
test('DELETE /api/tasks/:taskId — cancel task endpoint', async () => {
|
|
1408
|
+
const cancelRes = await apiJson<{ taskId: string; status: string }>(
|
|
1409
|
+
`/api/tasks/${asyncTaskId}`,
|
|
1410
|
+
{ method: 'DELETE' },
|
|
1411
|
+
false,
|
|
1412
|
+
'cancel'
|
|
1413
|
+
)
|
|
1414
|
+
assert.equal(cancelRes.status, 200)
|
|
1415
|
+
assert.deepEqual(cancelRes.data, { taskId: asyncTaskId, status: 'cancelled' })
|
|
1416
|
+
})
|
|
1417
|
+
})
|
|
1418
|
+
|
|
1419
|
+
describe('5. Dynamic Custom Domain Endpoints (/api/*)', () => {
|
|
1420
|
+
test('Verify & execute all dynamically discovered custom domain routes (401 auth, 403 guard & 200 schema-validated execution)', async () => {
|
|
1421
|
+
assert.ok(discoveredDomainRoutes.length > 0, 'Expected at least 1 custom domain route from GET /api/routes')
|
|
1422
|
+
|
|
1423
|
+
for (const route of discoveredDomainRoutes) {
|
|
1424
|
+
const localCfg = (customRoutes as Record<string, RouteConfig>)[route.id]
|
|
1425
|
+
assert.ok(localCfg, `Discovered route ${route.id} should exist in @config/routes`)
|
|
1426
|
+
assert.ok(route.path.startsWith('/api/'))
|
|
1427
|
+
|
|
1428
|
+
if (localCfg.auth !== 'public' && localCfg.auth !== 'none' && localCfg.auth !== false) {
|
|
1429
|
+
const unauthRes = await apiFetch(route.path, { method: route.method }, `${route.id}:unauth`)
|
|
1430
|
+
assert.equal(unauthRes.status, 401)
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
const props = route.inputSchema?.properties ?? {}
|
|
1434
|
+
if ('refs' in props || 'ref' in props) {
|
|
1435
|
+
const badPayload =
|
|
1436
|
+
'refs' in props
|
|
1437
|
+
? { refs: ['otheruser_000000000000000000000000'] }
|
|
1438
|
+
: { ref: 'otheruser_000000000000000000000000' }
|
|
1439
|
+
const guardRes = await apiJson<{ error: string; code: string; status: number }>(
|
|
1440
|
+
route.path,
|
|
1441
|
+
{
|
|
1442
|
+
method: route.method,
|
|
1443
|
+
headers: { 'content-type': 'application/json' },
|
|
1444
|
+
body: JSON.stringify(badPayload)
|
|
1445
|
+
},
|
|
1446
|
+
false,
|
|
1447
|
+
`${route.id}:guard`
|
|
1448
|
+
)
|
|
1449
|
+
assert.equal(guardRes.status, 403)
|
|
1450
|
+
assert.equal(guardRes.data.code, 'FORBIDDEN')
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
const validPayload = synthesizeDomainInput(route)
|
|
1454
|
+
const execRes = await apiJson<unknown>(
|
|
1455
|
+
route.path,
|
|
1456
|
+
{
|
|
1457
|
+
method: route.method,
|
|
1458
|
+
headers: { 'content-type': 'application/json' },
|
|
1459
|
+
...(route.method !== 'GET' ? { body: JSON.stringify(validPayload) } : {})
|
|
1460
|
+
},
|
|
1461
|
+
false,
|
|
1462
|
+
`${route.id}:exec`
|
|
1463
|
+
)
|
|
1464
|
+
assert.equal(execRes.status, 200, `Custom domain route ${route.method} ${route.path} failed`)
|
|
1465
|
+
if (localCfg.output) {
|
|
1466
|
+
const parsed = localCfg.output.safeParse(execRes.data)
|
|
1467
|
+
assert.equal(parsed.success, true, `Output validation failed for ${route.id}`)
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
})
|
|
1471
|
+
})
|
|
1472
|
+
|
|
1473
|
+
describe('6. MCP Server (/api/mcp Streamable HTTP JSON-RPC)', () => {
|
|
1474
|
+
let toolNames = new Set<string>()
|
|
1475
|
+
|
|
1476
|
+
test('POST /api/mcp (initialize & tools/list) — handshake & tool discovery', async () => {
|
|
1477
|
+
const initResult = await mcpCall<{
|
|
1478
|
+
serverInfo: { name: string; version: string }
|
|
1479
|
+
capabilities: { tools?: Record<string, unknown> }
|
|
1480
|
+
}>('initialize', {
|
|
1481
|
+
protocolVersion: '2025-03-26',
|
|
1482
|
+
capabilities: {},
|
|
1483
|
+
clientInfo: { name: 'ai-sdk-e2e', version: '1.0.0' }
|
|
1484
|
+
})
|
|
1485
|
+
assert.equal(initResult.serverInfo.name, CORE_CONFIG.appName)
|
|
1486
|
+
assert.notEqual(initResult.capabilities.tools, undefined)
|
|
1487
|
+
|
|
1488
|
+
const toolsList = await mcpCall<{ tools: { name: string; description: string; inputSchema: unknown }[] }>(
|
|
1489
|
+
'tools/list'
|
|
1490
|
+
)
|
|
1491
|
+
toolNames = new Set(toolsList.tools.map((t) => t.name))
|
|
1492
|
+
assert.ok(toolNames.has('scan-attachment'))
|
|
1493
|
+
assert.ok(toolNames.has('process-attachments'))
|
|
1494
|
+
})
|
|
1495
|
+
|
|
1496
|
+
test('POST /api/mcp (tools/call) — execute scan-attachment & process-attachments', async () => {
|
|
1497
|
+
const targetFixture = fixtures[0]!
|
|
1498
|
+
const targetRef = uploadedRefs.get(targetFixture.type)!
|
|
1499
|
+
const mcpScan = await mcpCall<{ content: { type: string; text: string }[]; isError?: boolean }>('tools/call', {
|
|
1500
|
+
name: 'scan-attachment',
|
|
1501
|
+
arguments: { ref: targetRef }
|
|
1502
|
+
})
|
|
1503
|
+
assert.equal(mcpScan.isError, undefined)
|
|
1504
|
+
const mcpScanOut = JSON.parse(mcpScan.content[0]!.text) as { type: string }
|
|
1505
|
+
assert.equal(mcpScanOut.type, targetFixture.type)
|
|
1506
|
+
|
|
1507
|
+
const mcpProc = await mcpCall<{ content: { type: string; text: string }[]; isError?: boolean }>('tools/call', {
|
|
1508
|
+
name: 'process-attachments',
|
|
1509
|
+
arguments: { refs: [targetRef, uploadedRefs.get('other')!] }
|
|
1510
|
+
})
|
|
1511
|
+
assert.equal(mcpProc.isError, undefined)
|
|
1512
|
+
const mcpProcOut = JSON.parse(mcpProc.content[0]!.text) as { attachments: { ref: string }[] }
|
|
1513
|
+
assert.equal(mcpProcOut.attachments.length, 2)
|
|
1514
|
+
})
|
|
1515
|
+
|
|
1516
|
+
test('POST /api/mcp (tools/call) — dynamically execute all custom domain MCP tools', async () => {
|
|
1517
|
+
for (const route of discoveredDomainRoutes) {
|
|
1518
|
+
const localCfg = (customRoutes as Record<string, RouteConfig>)[route.id]
|
|
1519
|
+
if (!localCfg?.mcp) continue
|
|
1520
|
+
const toolName = route.workflowName ?? route.id
|
|
1521
|
+
assert.ok(toolNames.has(toolName), `Expected custom domain MCP tool ${toolName} in tools/list`)
|
|
1522
|
+
|
|
1523
|
+
const mcpDomainRes = await mcpCall<{ content: { type: string; text: string }[]; isError?: boolean }>(
|
|
1524
|
+
'tools/call',
|
|
1525
|
+
{
|
|
1526
|
+
name: toolName,
|
|
1527
|
+
arguments: synthesizeDomainInput(route)
|
|
1528
|
+
}
|
|
1529
|
+
)
|
|
1530
|
+
assert.equal(mcpDomainRes.isError, undefined)
|
|
1531
|
+
const parsedOut = JSON.parse(mcpDomainRes.content[0]!.text)
|
|
1532
|
+
if (localCfg.output) {
|
|
1533
|
+
assert.equal(localCfg.output.safeParse(parsedOut).success, true)
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
})
|
|
1537
|
+
})
|
|
1538
|
+
|
|
1539
|
+
describe('7. Chat Streaming & Chat CRUD (/api/chats/:chatId, /api/chats)', () => {
|
|
1540
|
+
test('POST /api/chats/:chatId & validation — reject unknown agent (404), legacy messages array (400), empty message (400) & bad data URL (400)', async () => {
|
|
1541
|
+
const tempChatId = `chat_${Date.now().toString(36)}`
|
|
1542
|
+
const unknownRes = await apiFetch(`/api/chats/${tempChatId}`, {
|
|
1543
|
+
method: 'POST',
|
|
1544
|
+
headers: userHeaders({ 'content-type': 'application/json' }),
|
|
1545
|
+
body: JSON.stringify({
|
|
1546
|
+
agentId: 'unknown-agent',
|
|
1547
|
+
message: { role: 'user', content: 'Hello' }
|
|
1548
|
+
})
|
|
1549
|
+
})
|
|
1550
|
+
assert.equal(unknownRes.status, 404)
|
|
1551
|
+
|
|
1552
|
+
const legacyArrayRes = await apiFetch(`/api/chats/${tempChatId}`, {
|
|
1553
|
+
method: 'POST',
|
|
1554
|
+
headers: userHeaders({ 'content-type': 'application/json' }),
|
|
1555
|
+
body: JSON.stringify({
|
|
1556
|
+
messages: [{ role: 'user', content: 'Legacy history array must be rejected' }]
|
|
1557
|
+
})
|
|
1558
|
+
})
|
|
1559
|
+
assert.equal(legacyArrayRes.status, 400)
|
|
1560
|
+
|
|
1561
|
+
const emptyMsgRes = await apiFetch(`/api/chats/${tempChatId}`, {
|
|
1562
|
+
method: 'POST',
|
|
1563
|
+
headers: userHeaders({ 'content-type': 'application/json' }),
|
|
1564
|
+
body: JSON.stringify({
|
|
1565
|
+
message: { role: 'user', content: ' ' }
|
|
1566
|
+
})
|
|
1567
|
+
})
|
|
1568
|
+
assert.equal(emptyMsgRes.status, 400)
|
|
1569
|
+
|
|
1570
|
+
const badDataUrlRes = await apiFetch(`/api/chats/${tempChatId}`, {
|
|
1571
|
+
method: 'POST',
|
|
1572
|
+
headers: userHeaders({ 'content-type': 'application/json' }),
|
|
1573
|
+
body: JSON.stringify({
|
|
1574
|
+
message: {
|
|
1575
|
+
role: 'user',
|
|
1576
|
+
parts: [
|
|
1577
|
+
{ type: 'text', text: 'Check this file' },
|
|
1578
|
+
{
|
|
1579
|
+
type: 'file',
|
|
1580
|
+
mediaType: 'text/plain',
|
|
1581
|
+
filename: 'bad.txt',
|
|
1582
|
+
url: 'data:text/plain;base64,SGVsbG8='
|
|
1583
|
+
}
|
|
1584
|
+
]
|
|
1585
|
+
}
|
|
1586
|
+
})
|
|
1587
|
+
})
|
|
1588
|
+
assert.equal(badDataUrlRes.status, 400)
|
|
1589
|
+
})
|
|
1590
|
+
|
|
1591
|
+
test('POST /api/chats/:chatId — stream single-attachment chat & auto-persist to subcollection', async () => {
|
|
1592
|
+
const pdfFixture = fixtures[0]!
|
|
1593
|
+
const targetChatId = `chat_${Date.now().toString(36)}_1`
|
|
1594
|
+
const singleChatRes = await apiFetch(
|
|
1595
|
+
`/api/chats/${targetChatId}`,
|
|
1596
|
+
{
|
|
1597
|
+
method: 'POST',
|
|
1598
|
+
headers: userHeaders({ 'content-type': 'application/json' }),
|
|
1599
|
+
body: JSON.stringify({
|
|
1600
|
+
agentId: DEFAULT_AGENT_ID,
|
|
1601
|
+
message: {
|
|
1602
|
+
role: 'user',
|
|
1603
|
+
parts: [
|
|
1604
|
+
{ type: 'text', text: 'What type of document is attached? Reply briefly in one sentence.' },
|
|
1605
|
+
{
|
|
1606
|
+
type: 'file',
|
|
1607
|
+
mediaType: pdfFixture.mime,
|
|
1608
|
+
filename: pdfFixture.filename,
|
|
1609
|
+
url: pdfFixture.dataUrl
|
|
1610
|
+
}
|
|
1611
|
+
]
|
|
1612
|
+
}
|
|
1613
|
+
}),
|
|
1614
|
+
signal: AbortSignal.timeout(180_000)
|
|
1615
|
+
},
|
|
1616
|
+
'single-attachment'
|
|
1617
|
+
)
|
|
1618
|
+
assert.equal(singleChatRes.status, 200)
|
|
1619
|
+
assert.ok((singleChatRes.headers.get('content-type') ?? '').includes('text/event-stream'))
|
|
1620
|
+
chatId1 = singleChatRes.headers.get('x-chat-id') ?? ''
|
|
1621
|
+
assert.equal(chatId1, targetChatId)
|
|
1622
|
+
const singleStreamText = await singleChatRes.text()
|
|
1623
|
+
assert.ok(singleStreamText.includes('data: '))
|
|
1624
|
+
assert.ok(singleStreamText.length > 20)
|
|
1625
|
+
await sleep(600)
|
|
1626
|
+
})
|
|
1627
|
+
|
|
1628
|
+
test('GET /api/chats/:chatId, GET /api/chats/:chatId/messages & GET /api/chats — read metadata & subcollection messages', async () => {
|
|
1629
|
+
const getChat1 = await apiJson<{
|
|
1630
|
+
id: string
|
|
1631
|
+
title: string
|
|
1632
|
+
agentId: string
|
|
1633
|
+
model: string
|
|
1634
|
+
messageCount: number
|
|
1635
|
+
activeGenerationId: string | null
|
|
1636
|
+
messages?: unknown
|
|
1637
|
+
deleted: boolean
|
|
1638
|
+
userId: string
|
|
1639
|
+
}>(`/api/chats/${chatId1}`)
|
|
1640
|
+
assert.equal(getChat1.status, 200)
|
|
1641
|
+
assert.equal(getChat1.data.id, chatId1)
|
|
1642
|
+
assert.equal(getChat1.data.userId, testAuthUserId)
|
|
1643
|
+
assert.equal(getChat1.data.deleted, false)
|
|
1644
|
+
assert.equal(getChat1.data.messages, undefined)
|
|
1645
|
+
assert.equal(getChat1.data.messageCount, 2)
|
|
1646
|
+
assert.equal(getChat1.data.activeGenerationId, null)
|
|
1647
|
+
|
|
1648
|
+
const getMessages1 = await apiJson<
|
|
1649
|
+
{
|
|
1650
|
+
id: string
|
|
1651
|
+
chatId: string
|
|
1652
|
+
userId: string
|
|
1653
|
+
role: string
|
|
1654
|
+
content: string
|
|
1655
|
+
parts: unknown[]
|
|
1656
|
+
embedding?: unknown
|
|
1657
|
+
}[]
|
|
1658
|
+
>(`/api/chats/${chatId1}/messages`)
|
|
1659
|
+
assert.equal(getMessages1.status, 200)
|
|
1660
|
+
assert.equal(getMessages1.data.length, 2)
|
|
1661
|
+
assert.equal(getMessages1.data[0]?.role, 'user')
|
|
1662
|
+
assert.equal(getMessages1.data[1]?.role, 'assistant')
|
|
1663
|
+
assert.equal(getMessages1.data[0]?.embedding, undefined)
|
|
1664
|
+
assert.equal(getMessages1.data[1]?.embedding, undefined)
|
|
1665
|
+
assert.ok((getMessages1.data[1]?.content ?? '').length > 0)
|
|
1666
|
+
|
|
1667
|
+
const listChatsRes = await apiJson<{ id: string; messageCount: number }[]>('/api/chats')
|
|
1668
|
+
assert.equal(listChatsRes.status, 200)
|
|
1669
|
+
assert.ok(listChatsRes.data.some((c) => c.id === chatId1 && c.messageCount === 2))
|
|
1670
|
+
})
|
|
1671
|
+
|
|
1672
|
+
test('POST /api/chats & POST /api/chats/:chatId/cancel — rename chat metadata & cancel endpoint', async () => {
|
|
1673
|
+
const updateChatRes = await apiJson<{ id: string; title: string }>('/api/chats', {
|
|
1674
|
+
method: 'POST',
|
|
1675
|
+
headers: { 'content-type': 'application/json' },
|
|
1676
|
+
body: JSON.stringify({ id: chatId1, title: 'Renamed E2E Chat' })
|
|
1677
|
+
})
|
|
1678
|
+
assert.equal(updateChatRes.status, 200)
|
|
1679
|
+
assert.equal(updateChatRes.data.title, 'Renamed E2E Chat')
|
|
1680
|
+
|
|
1681
|
+
const cancelChatRes = await apiJson<{ chatId: string; cancelled: boolean }>(
|
|
1682
|
+
`/api/chats/${chatId1}/cancel`,
|
|
1683
|
+
{ method: 'POST' },
|
|
1684
|
+
false,
|
|
1685
|
+
'cancel-chat'
|
|
1686
|
+
)
|
|
1687
|
+
assert.equal(cancelChatRes.status, 200)
|
|
1688
|
+
assert.deepEqual(cancelChatRes.data, { chatId: chatId1, cancelled: true })
|
|
1689
|
+
})
|
|
1690
|
+
|
|
1691
|
+
test('DELETE /api/chats/:chatId — soft-delete chat & verify 404', async () => {
|
|
1692
|
+
const delChatRes = await apiJson<{ id: string; deleted: boolean }>(`/api/chats/${chatId1}`, {
|
|
1693
|
+
method: 'DELETE'
|
|
1694
|
+
})
|
|
1695
|
+
assert.equal(delChatRes.status, 200)
|
|
1696
|
+
assert.deepEqual(delChatRes.data, { id: chatId1, deleted: true })
|
|
1697
|
+
|
|
1698
|
+
const getDeletedChat = await apiFetch(`/api/chats/${chatId1}`, { headers: userHeaders() }, 'deleted-chat')
|
|
1699
|
+
assert.equal(getDeletedChat.status, 404)
|
|
1700
|
+
})
|
|
1701
|
+
|
|
1702
|
+
test('POST /api/chats/:chatId — stream multi-attachment chat (inline dataUrl + /api/files/:ref)', async () => {
|
|
1703
|
+
const secondPdf = fixtures[1]!
|
|
1704
|
+
const otherFixture = fixtures.find((f) => f.type === 'other')!
|
|
1705
|
+
const existingRef = uploadedRefs.get(otherFixture.type)!
|
|
1706
|
+
const multiChatId = `chat_${Date.now().toString(36)}_2`
|
|
1707
|
+
const multiChatRes = await apiFetch(
|
|
1708
|
+
`/api/chats/${multiChatId}`,
|
|
1709
|
+
{
|
|
1710
|
+
method: 'POST',
|
|
1711
|
+
headers: userHeaders({ 'content-type': 'application/json' }),
|
|
1712
|
+
body: JSON.stringify({
|
|
1713
|
+
agentId: DEFAULT_AGENT_ID,
|
|
1714
|
+
message: {
|
|
1715
|
+
role: 'user',
|
|
1716
|
+
parts: [
|
|
1717
|
+
{ type: 'text', text: 'How many attachments did I include? Reply in one short sentence.' },
|
|
1718
|
+
{
|
|
1719
|
+
type: 'file',
|
|
1720
|
+
mediaType: secondPdf.mime,
|
|
1721
|
+
filename: secondPdf.filename,
|
|
1722
|
+
url: secondPdf.dataUrl
|
|
1723
|
+
},
|
|
1724
|
+
{
|
|
1725
|
+
type: 'file',
|
|
1726
|
+
mediaType: otherFixture.mime,
|
|
1727
|
+
filename: otherFixture.filename,
|
|
1728
|
+
url: `/api/files/${existingRef}`
|
|
1729
|
+
}
|
|
1730
|
+
]
|
|
1731
|
+
}
|
|
1732
|
+
}),
|
|
1733
|
+
signal: AbortSignal.timeout(180_000)
|
|
1734
|
+
},
|
|
1735
|
+
'multi-attachment'
|
|
1736
|
+
)
|
|
1737
|
+
assert.equal(multiChatRes.status, 200)
|
|
1738
|
+
assert.ok((multiChatRes.headers.get('content-type') ?? '').includes('text/event-stream'))
|
|
1739
|
+
const multiStreamText = await multiChatRes.text()
|
|
1740
|
+
assert.ok(multiStreamText.includes('data: '))
|
|
1741
|
+
assert.ok(multiStreamText.length > 20)
|
|
1742
|
+
await sleep(600)
|
|
1743
|
+
})
|
|
1744
|
+
})
|
|
1745
|
+
|
|
1746
|
+
describe('8. OpenAI-Compatible Endpoints (/api/v1/models, /api/v1/chat/completions)', () => {
|
|
1747
|
+
test('GET /api/v1/models — list registered agent models', async () => {
|
|
1748
|
+
const modelsRes = await apiJson<{ object: string; data: { id: string; object: string; owned_by: string }[] }>(
|
|
1749
|
+
'/api/v1/models'
|
|
1750
|
+
)
|
|
1751
|
+
assert.equal(modelsRes.status, 200)
|
|
1752
|
+
assert.equal(modelsRes.data.object, 'list')
|
|
1753
|
+
for (const agentId of AGENT_IDS) {
|
|
1754
|
+
assert.ok(modelsRes.data.data.some((m) => m.id === agentId && m.object === 'model'))
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
const badCompletionRes = await apiFetch('/api/v1/chat/completions', {
|
|
1758
|
+
method: 'POST',
|
|
1759
|
+
headers: userHeaders({ 'content-type': 'application/json' }),
|
|
1760
|
+
body: JSON.stringify({ model: DEFAULT_AGENT_ID, messages: [] })
|
|
1761
|
+
})
|
|
1762
|
+
assert.equal(badCompletionRes.status, 400)
|
|
1763
|
+
})
|
|
1764
|
+
|
|
1765
|
+
test('POST /api/v1/chat/completions (stream: false) — non-streaming completion with attached PDF', async () => {
|
|
1766
|
+
const pdfFixture = fixtures[0]!
|
|
1767
|
+
const completionRes = await apiJson<{
|
|
1768
|
+
id: string
|
|
1769
|
+
object: string
|
|
1770
|
+
model: string
|
|
1771
|
+
choices: { index: number; message: { role: string; content: string }; finish_reason: string }[]
|
|
1772
|
+
usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number }
|
|
1773
|
+
}>(
|
|
1774
|
+
'/api/v1/chat/completions',
|
|
1775
|
+
{
|
|
1776
|
+
method: 'POST',
|
|
1777
|
+
headers: { 'content-type': 'application/json' },
|
|
1778
|
+
body: JSON.stringify({
|
|
1779
|
+
model: DEFAULT_AGENT_ID,
|
|
1780
|
+
stream: false,
|
|
1781
|
+
messages: [
|
|
1782
|
+
{
|
|
1783
|
+
role: 'user',
|
|
1784
|
+
content: [
|
|
1785
|
+
{ type: 'text', text: 'What document type is attached? Answer in a few words.' },
|
|
1786
|
+
{
|
|
1787
|
+
type: 'file',
|
|
1788
|
+
file: {
|
|
1789
|
+
file_data: pdfFixture.dataUrl,
|
|
1790
|
+
filename: pdfFixture.filename
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
]
|
|
1794
|
+
}
|
|
1795
|
+
]
|
|
1796
|
+
})
|
|
1797
|
+
},
|
|
1798
|
+
false,
|
|
1799
|
+
'json'
|
|
1800
|
+
)
|
|
1801
|
+
assert.equal(completionRes.status, 200)
|
|
1802
|
+
assert.ok(completionRes.data.id.startsWith('chatcmpl-'))
|
|
1803
|
+
assert.equal(completionRes.data.object, 'chat.completion')
|
|
1804
|
+
assert.equal(completionRes.data.model, DEFAULT_AGENT_NAME)
|
|
1805
|
+
assert.equal(completionRes.data.choices[0]?.message.role, 'assistant')
|
|
1806
|
+
assert.ok((completionRes.data.choices[0]?.message.content ?? '').length > 0)
|
|
1807
|
+
assert.equal(completionRes.data.choices[0]?.finish_reason, 'stop')
|
|
1808
|
+
assert.equal(typeof completionRes.data.usage.total_tokens, 'number')
|
|
1809
|
+
})
|
|
1810
|
+
|
|
1811
|
+
test('POST /api/v1/chat/completions (stream: true) — streaming SSE completion with PDF + JPEG attachments', async () => {
|
|
1812
|
+
const pdfFixture = fixtures[0]!
|
|
1813
|
+
const otherFixture = fixtures.find((f) => f.type === 'other')!
|
|
1814
|
+
const streamRes = await apiFetch(
|
|
1815
|
+
'/api/v1/chat/completions',
|
|
1816
|
+
{
|
|
1817
|
+
method: 'POST',
|
|
1818
|
+
headers: userHeaders({ 'content-type': 'application/json' }),
|
|
1819
|
+
body: JSON.stringify({
|
|
1820
|
+
model: DEFAULT_AGENT_ID,
|
|
1821
|
+
stream: true,
|
|
1822
|
+
stream_options: { include_usage: true },
|
|
1823
|
+
messages: [
|
|
1824
|
+
{
|
|
1825
|
+
role: 'user',
|
|
1826
|
+
content: [
|
|
1827
|
+
{ type: 'text', text: 'How many attachments did I include? Reply in one short sentence.' },
|
|
1828
|
+
{
|
|
1829
|
+
type: 'file',
|
|
1830
|
+
file: {
|
|
1831
|
+
file_data: pdfFixture.dataUrl,
|
|
1832
|
+
filename: pdfFixture.filename
|
|
1833
|
+
}
|
|
1834
|
+
},
|
|
1835
|
+
{
|
|
1836
|
+
type: 'image_url',
|
|
1837
|
+
image_url: {
|
|
1838
|
+
url: otherFixture.dataUrl
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
]
|
|
1842
|
+
}
|
|
1843
|
+
]
|
|
1844
|
+
}),
|
|
1845
|
+
signal: AbortSignal.timeout(180_000)
|
|
1846
|
+
},
|
|
1847
|
+
'sse'
|
|
1848
|
+
)
|
|
1849
|
+
assert.equal(streamRes.status, 200)
|
|
1850
|
+
assert.ok((streamRes.headers.get('content-type') ?? '').startsWith('text/event-stream'))
|
|
1851
|
+
const rawSse = await streamRes.text()
|
|
1852
|
+
const frames = rawSse
|
|
1853
|
+
.split('\n\n')
|
|
1854
|
+
.map((f) => f.trim())
|
|
1855
|
+
.filter(Boolean)
|
|
1856
|
+
assert.equal(frames.at(-1), 'data: [DONE]')
|
|
1857
|
+
|
|
1858
|
+
const chunks = frames.slice(0, -1).map(
|
|
1859
|
+
(f) =>
|
|
1860
|
+
JSON.parse(f.replace(/^data:\s*/, '')) as {
|
|
1861
|
+
object?: string
|
|
1862
|
+
choices?: { index: number; delta: { role?: string; content?: string }; finish_reason: string | null }[]
|
|
1863
|
+
usage?: { total_tokens: number }
|
|
1864
|
+
}
|
|
1865
|
+
)
|
|
1866
|
+
assert.equal(chunks[0]?.object, 'chat.completion.chunk')
|
|
1867
|
+
assert.equal(chunks[0]?.choices?.[0]?.delta.role, 'assistant')
|
|
1868
|
+
const streamedContent = chunks.map((c) => c.choices?.[0]?.delta.content ?? '').join('')
|
|
1869
|
+
assert.ok(streamedContent.length > 0)
|
|
1870
|
+
assert.ok(chunks.some((c) => c.choices?.[0]?.finish_reason === 'stop'))
|
|
1871
|
+
assert.ok(chunks.some((c) => c.usage !== undefined))
|
|
1872
|
+
})
|
|
1873
|
+
})
|
|
1874
|
+
|
|
1875
|
+
describe('9. Core SDK Primitives, Sign-Out, GDPR Erase & Zero-Leftover Verification', () => {
|
|
1876
|
+
test('Step runner durable replay checkpointing & per-step rollback', async () => {
|
|
1877
|
+
const steps: Record<string, unknown> = {}
|
|
1878
|
+
let ocrRuns = 0
|
|
1879
|
+
let classifyRuns = 0
|
|
1880
|
+
let extractRuns = 0
|
|
1881
|
+
let rolledBackStep = ''
|
|
1882
|
+
const runPipeline = async (failExtract: boolean) => {
|
|
1883
|
+
const step = core.internals.workflows.createStepRunner('rest', undefined, steps)
|
|
1884
|
+
try {
|
|
1885
|
+
const ocr = await step('ocr', async () => (++ocrRuns, 'OCR_TEXT'))
|
|
1886
|
+
const classify = await step('classify', async () => (++classifyRuns, { type: 'passport', ocr }))
|
|
1887
|
+
await step('temp-write', {
|
|
1888
|
+
fn: async () => 'temp-val',
|
|
1889
|
+
rollback: async (v) => {
|
|
1890
|
+
rolledBackStep = v
|
|
1891
|
+
}
|
|
1892
|
+
})
|
|
1893
|
+
return await step('extract', async () => {
|
|
1894
|
+
++extractRuns
|
|
1895
|
+
if (failExtract) throw new Error('Transient extract failure')
|
|
1896
|
+
return { passportNumber: 'P123', classifyType: classify.type }
|
|
1897
|
+
})
|
|
1898
|
+
} catch (e) {
|
|
1899
|
+
await step.onError()
|
|
1900
|
+
throw e
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
await assert.rejects(() => runPipeline(true), /Transient extract failure/)
|
|
1904
|
+
assert.equal(ocrRuns, 1)
|
|
1905
|
+
assert.equal(classifyRuns, 1)
|
|
1906
|
+
assert.equal(extractRuns, 1)
|
|
1907
|
+
assert.equal(rolledBackStep, 'temp-val')
|
|
1908
|
+
assert.equal('temp-write' in steps, false)
|
|
1909
|
+
|
|
1910
|
+
const replayOut = await runPipeline(false)
|
|
1911
|
+
assert.deepEqual(replayOut, { passportNumber: 'P123', classifyType: 'passport' })
|
|
1912
|
+
assert.equal(ocrRuns, 1)
|
|
1913
|
+
assert.equal(classifyRuns, 1)
|
|
1914
|
+
assert.equal(extractRuns, 2)
|
|
1915
|
+
})
|
|
1916
|
+
|
|
1917
|
+
test('allWithRollback parallel sibling compensation', async () => {
|
|
1918
|
+
let rolledBackLegA = ''
|
|
1919
|
+
let rolledBackLegB = ''
|
|
1920
|
+
await assert.rejects(
|
|
1921
|
+
() =>
|
|
1922
|
+
core.internals.workflows.allWithRollback([
|
|
1923
|
+
{
|
|
1924
|
+
fn: async () => 'legA-committed',
|
|
1925
|
+
rollback: async (val) => {
|
|
1926
|
+
rolledBackLegA = val
|
|
1927
|
+
}
|
|
1928
|
+
},
|
|
1929
|
+
{
|
|
1930
|
+
fn: async () => 'legB-committed',
|
|
1931
|
+
rollback: async (val) => {
|
|
1932
|
+
rolledBackLegB = val
|
|
1933
|
+
}
|
|
1934
|
+
},
|
|
1935
|
+
{
|
|
1936
|
+
fn: async () => {
|
|
1937
|
+
throw new Error('legC-failed')
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
]),
|
|
1941
|
+
/legC-failed/
|
|
1942
|
+
)
|
|
1943
|
+
assert.equal(rolledBackLegA, 'legA-committed')
|
|
1944
|
+
assert.equal(rolledBackLegB, 'legB-committed')
|
|
1945
|
+
})
|
|
1946
|
+
|
|
1947
|
+
test('Universal Auth-User Collection Connector (1:1 & M:N), updateEntry validation, cursor pagination & cascadeEraseUserCollections', async () => {
|
|
1948
|
+
core.internals.registry.registerCollections({
|
|
1949
|
+
test_profile: defineCollection({
|
|
1950
|
+
name: 'test_profile',
|
|
1951
|
+
schema: z.object({
|
|
1952
|
+
bio: z.string(),
|
|
1953
|
+
age: z.number().optional()
|
|
1954
|
+
}),
|
|
1955
|
+
userRelation: { type: '1:1' }
|
|
1956
|
+
}),
|
|
1957
|
+
test_shared_org: defineCollection({
|
|
1958
|
+
name: 'test_shared_org',
|
|
1959
|
+
schema: z.object({
|
|
1960
|
+
name: z.string(),
|
|
1961
|
+
userIds: z.array(z.string())
|
|
1962
|
+
}),
|
|
1963
|
+
userRelation: { type: 'M:N', userIdsField: 'userIds', deleteWhenEmpty: true }
|
|
1964
|
+
})
|
|
1965
|
+
})
|
|
1966
|
+
const connectorUid = `conn_${Date.now().toString(36).padEnd(15, 'x').slice(0, 15)}`
|
|
1967
|
+
try {
|
|
1968
|
+
const profileDoc = await core.db.create('test_profile', { id: connectorUid, bio: 'E2E Profile', age: 30 })
|
|
1969
|
+
|
|
1970
|
+
// Verify core.db.update validates against def.schema.partial() and strips immutable fields (id, createdAt)
|
|
1971
|
+
await assert.rejects(
|
|
1972
|
+
() => core.db.update('test_profile', connectorUid, { age: 'not-a-number' as unknown as number }),
|
|
1973
|
+
/ZodError|expected number/i
|
|
1974
|
+
)
|
|
1975
|
+
const originalCreatedAt = profileDoc.createdAt
|
|
1976
|
+
await core.db.update('test_profile', connectorUid, {
|
|
1977
|
+
id: 'hacked_id',
|
|
1978
|
+
createdAt: new Date(0),
|
|
1979
|
+
bio: 'Updated Bio'
|
|
1980
|
+
} as Record<string, unknown>)
|
|
1981
|
+
const updatedProfile = await core.db.read('test_profile', connectorUid)
|
|
1982
|
+
assert.equal(updatedProfile?.id, connectorUid)
|
|
1983
|
+
assert.equal(updatedProfile?.bio, 'Updated Bio')
|
|
1984
|
+
assert.equal(updatedProfile?.age, 30)
|
|
1985
|
+
assert.equal(updatedProfile?.createdAt.getTime(), originalCreatedAt.getTime())
|
|
1986
|
+
|
|
1987
|
+
const sharedOrg = await core.db.create('test_shared_org', {
|
|
1988
|
+
name: 'Shared Org 1',
|
|
1989
|
+
userIds: [connectorUid]
|
|
1990
|
+
})
|
|
1991
|
+
const sharedOrg2 = await core.db.create('test_shared_org', {
|
|
1992
|
+
name: 'Shared Org 2',
|
|
1993
|
+
userIds: [connectorUid]
|
|
1994
|
+
})
|
|
1995
|
+
const deletedOrg = await core.db.create('test_shared_org', {
|
|
1996
|
+
name: 'Deleted Org',
|
|
1997
|
+
userIds: [connectorUid]
|
|
1998
|
+
})
|
|
1999
|
+
await core.db.delete('test_shared_org', deletedOrg.id)
|
|
2000
|
+
|
|
2001
|
+
assert.equal(await core.db.canAccess('test_profile', profileDoc, connectorUid), true)
|
|
2002
|
+
assert.equal(await core.db.canAccess('test_shared_org', sharedOrg, connectorUid), true)
|
|
2003
|
+
|
|
2004
|
+
// Verify cursor pagination (core.db.list & core.db.listPage) and soft-delete exclusion
|
|
2005
|
+
const page1 = await core.db.listPage('test_shared_org', { userId: connectorUid, limit: 1 })
|
|
2006
|
+
assert.equal(page1.items.length, 1)
|
|
2007
|
+
assert.ok(page1.nextCursor)
|
|
2008
|
+
const page2 = await core.db.list('test_shared_org', {
|
|
2009
|
+
userId: connectorUid,
|
|
2010
|
+
limit: 1,
|
|
2011
|
+
cursor: page1.nextCursor
|
|
2012
|
+
})
|
|
2013
|
+
assert.equal(page2.length, 1)
|
|
2014
|
+
assert.notEqual(page1.items[0]?.id, page2[0]?.id)
|
|
2015
|
+
|
|
2016
|
+
const allActiveOrgs = await core.db.list('test_shared_org', { userId: connectorUid })
|
|
2017
|
+
assert.equal(allActiveOrgs.length, 2)
|
|
2018
|
+
|
|
2019
|
+
const cascadeRes = await core.internals.registry.cascadeEraseUserCollections(
|
|
2020
|
+
connectorUid,
|
|
2021
|
+
core.internals.workflows.createStepRunner('rest')
|
|
2022
|
+
)
|
|
2023
|
+
assert.equal(cascadeRes.erasedCollections.test_profile, 1)
|
|
2024
|
+
assert.equal(cascadeRes.erasedCollections.test_shared_org, 3)
|
|
2025
|
+
assert.equal(await core.db.read('test_profile', connectorUid, true), undefined)
|
|
2026
|
+
assert.equal(await core.db.read('test_shared_org', sharedOrg.id, true), undefined)
|
|
2027
|
+
assert.equal(await core.db.read('test_shared_org', sharedOrg2.id, true), undefined)
|
|
2028
|
+
assert.equal(await core.db.read('test_shared_org', deletedOrg.id, true), undefined)
|
|
2029
|
+
} finally {
|
|
2030
|
+
await core.internals.registry
|
|
2031
|
+
.cascadeEraseUserCollections(connectorUid, core.internals.workflows.createStepRunner('rest'))
|
|
2032
|
+
.catch(() => {})
|
|
2033
|
+
core.internals.registry.unregisterCollections(['test_profile', 'test_shared_org'])
|
|
2034
|
+
}
|
|
2035
|
+
})
|
|
2036
|
+
|
|
2037
|
+
test('Workflow NOT_FOUND error propagation & child workflow context inheritance', async () => {
|
|
2038
|
+
const prevRuntime = core.internals.workflows.getRuntime()
|
|
2039
|
+
const tempRegistry = {
|
|
2040
|
+
...prevRuntime.registry,
|
|
2041
|
+
'child-echo-context': {
|
|
2042
|
+
mode: 'workflow' as const,
|
|
2043
|
+
deadlineSeconds: 60,
|
|
2044
|
+
runTask: async (_input: unknown) => {
|
|
2045
|
+
const active = useCore()
|
|
2046
|
+
return {
|
|
2047
|
+
userId: active.userId,
|
|
2048
|
+
userRole: active.role,
|
|
2049
|
+
isDev: active.env.isDev
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
},
|
|
2053
|
+
'fail-not-found-doc': {
|
|
2054
|
+
mode: 'workflow' as const,
|
|
2055
|
+
deadlineSeconds: 60,
|
|
2056
|
+
runTask: async () => {
|
|
2057
|
+
throw new Error('5 NOT_FOUND: Document projects/test/databases/default/documents/missing/123 does not exist')
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
core.internals.workflows.registerRuntime(tempRegistry)
|
|
2062
|
+
|
|
2063
|
+
try {
|
|
2064
|
+
const parentStep = core.internals.workflows.createStepRunner('rest')
|
|
2065
|
+
const childResult = await core.internals.context.run(
|
|
2066
|
+
{ env: ENV, userId: testAuthUserId, role: 'user', step: parentStep },
|
|
2067
|
+
() =>
|
|
2068
|
+
parentStep.invoke<{ ping: string }, { userId: string; userRole: string; isDev: boolean }>(
|
|
2069
|
+
'call-child',
|
|
2070
|
+
'child-echo-context',
|
|
2071
|
+
{ ping: 'ok' }
|
|
2072
|
+
)
|
|
2073
|
+
)
|
|
2074
|
+
assert.equal(childResult.userId, testAuthUserId)
|
|
2075
|
+
assert.equal(childResult.userRole, 'user')
|
|
2076
|
+
assert.equal(childResult.isDev, true)
|
|
2077
|
+
|
|
2078
|
+
const nfDocRef = core.db.firestore(true).collection('_tasks').doc()
|
|
2079
|
+
const nfTaskId = nfDocRef.id
|
|
2080
|
+
await nfDocRef.set({
|
|
2081
|
+
taskId: nfTaskId,
|
|
2082
|
+
workflowName: 'fail-not-found-doc',
|
|
2083
|
+
status: 'running',
|
|
2084
|
+
params: {},
|
|
2085
|
+
steps: {},
|
|
2086
|
+
userId: testAuthUserId,
|
|
2087
|
+
userRole: 'user',
|
|
2088
|
+
keepAlive: true,
|
|
2089
|
+
createdAt: new Date().toISOString(),
|
|
2090
|
+
updatedAt: new Date().toISOString()
|
|
2091
|
+
})
|
|
2092
|
+
await core.internals.workflows.execute(
|
|
2093
|
+
{
|
|
2094
|
+
taskId: nfTaskId,
|
|
2095
|
+
workflowName: 'fail-not-found-doc',
|
|
2096
|
+
params: {},
|
|
2097
|
+
isDev: true
|
|
2098
|
+
},
|
|
2099
|
+
tempRegistry
|
|
2100
|
+
)
|
|
2101
|
+
const nfTaskDoc = await nfDocRef.get()
|
|
2102
|
+
assert.equal(nfTaskDoc.data()?.status, 'errored')
|
|
2103
|
+
assert.ok(String(nfTaskDoc.data()?.error ?? '').includes('NOT_FOUND'))
|
|
2104
|
+
await nfDocRef.delete()
|
|
2105
|
+
} finally {
|
|
2106
|
+
core.internals.workflows.registerRuntime(prevRuntime.registry)
|
|
2107
|
+
}
|
|
2108
|
+
})
|
|
2109
|
+
|
|
2110
|
+
test('singleflight tenant-scoped isolation, global coalescing & acquireHttpLimit (429 Retry-After)', async () => {
|
|
2111
|
+
let sfRuns = 0
|
|
2112
|
+
const sfResults = await Promise.all(
|
|
2113
|
+
Array.from({ length: 5 }, () =>
|
|
2114
|
+
core.internals.resilience.singleflight('test-sf-key', async () => {
|
|
2115
|
+
++sfRuns
|
|
2116
|
+
await sleep(20)
|
|
2117
|
+
return 'coalesced-value'
|
|
2118
|
+
})
|
|
2119
|
+
)
|
|
2120
|
+
)
|
|
2121
|
+
assert.equal(sfRuns, 1)
|
|
2122
|
+
assert.deepEqual(sfResults, Array(5).fill('coalesced-value'))
|
|
2123
|
+
|
|
2124
|
+
let tenantRuns = 0
|
|
2125
|
+
const [tenantA, tenantB] = await Promise.all([
|
|
2126
|
+
core.internals.context.run({ env: ENV, userId: 'tenant_user_a', role: 'user' }, () =>
|
|
2127
|
+
useCore().internals.resilience.singleflight('shared-op', async () => {
|
|
2128
|
+
++tenantRuns
|
|
2129
|
+
await sleep(20)
|
|
2130
|
+
return useCore().userId
|
|
2131
|
+
})
|
|
2132
|
+
),
|
|
2133
|
+
core.internals.context.run({ env: ENV, userId: 'tenant_user_b', role: 'user' }, () =>
|
|
2134
|
+
useCore().internals.resilience.singleflight('shared-op', async () => {
|
|
2135
|
+
++tenantRuns
|
|
2136
|
+
await sleep(20)
|
|
2137
|
+
return useCore().userId
|
|
2138
|
+
})
|
|
2139
|
+
)
|
|
2140
|
+
])
|
|
2141
|
+
assert.equal(tenantRuns, 2)
|
|
2142
|
+
assert.equal(tenantA, 'tenant_user_a')
|
|
2143
|
+
assert.equal(tenantB, 'tenant_user_b')
|
|
2144
|
+
|
|
2145
|
+
let globalRuns = 0
|
|
2146
|
+
const [globalA, globalB] = await Promise.all([
|
|
2147
|
+
core.internals.context.run({ env: ENV, userId: 'tenant_user_a', role: 'user' }, () =>
|
|
2148
|
+
useCore().internals.resilience.singleflight(
|
|
2149
|
+
'global-op',
|
|
2150
|
+
async () => {
|
|
2151
|
+
++globalRuns
|
|
2152
|
+
await sleep(20)
|
|
2153
|
+
return useCore().userId
|
|
2154
|
+
},
|
|
2155
|
+
{ scope: 'global' }
|
|
2156
|
+
)
|
|
2157
|
+
),
|
|
2158
|
+
core.internals.context.run({ env: ENV, userId: 'tenant_user_b', role: 'user' }, () =>
|
|
2159
|
+
useCore().internals.resilience.singleflight(
|
|
2160
|
+
'global-op',
|
|
2161
|
+
async () => {
|
|
2162
|
+
++globalRuns
|
|
2163
|
+
await sleep(20)
|
|
2164
|
+
return useCore().userId
|
|
2165
|
+
},
|
|
2166
|
+
{ scope: 'global' }
|
|
2167
|
+
)
|
|
2168
|
+
)
|
|
2169
|
+
])
|
|
2170
|
+
assert.equal(globalRuns, 1)
|
|
2171
|
+
assert.equal(globalA, CORE_CONFIG.auth.adminUserId)
|
|
2172
|
+
assert.equal(globalB, CORE_CONFIG.auth.adminUserId)
|
|
2173
|
+
|
|
2174
|
+
const rateKey = `test-limit-${Date.now()}`
|
|
2175
|
+
core.internals.resilience.acquireHttpLimit(rateKey, 2, 60_000)
|
|
2176
|
+
core.internals.resilience.acquireHttpLimit(rateKey, 2, 60_000)
|
|
2177
|
+
try {
|
|
2178
|
+
core.internals.resilience.acquireHttpLimit(rateKey, 2, 60_000)
|
|
2179
|
+
assert.fail('Should have thrown 429')
|
|
2180
|
+
} catch (err: any) {
|
|
2181
|
+
const res = err.getResponse() as Response
|
|
2182
|
+
assert.equal(res.status, 429)
|
|
2183
|
+
assert.ok(Number(res.headers.get('retry-after')) > 0)
|
|
2184
|
+
const body = (await res.json()) as { code: string; retryAfterMs: number }
|
|
2185
|
+
assert.equal(body.code, 'RATE_LIMITED')
|
|
2186
|
+
assert.ok(body.retryAfterMs > 0)
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
const coreBarrel = (await import('@core')) as Record<string, unknown>
|
|
2190
|
+
for (const internalSymbol of [
|
|
2191
|
+
'decideService',
|
|
2192
|
+
'visionService',
|
|
2193
|
+
'processAttachments',
|
|
2194
|
+
'createEntry',
|
|
2195
|
+
'readEntry',
|
|
2196
|
+
'listEntries',
|
|
2197
|
+
'withCircuitBreaker',
|
|
2198
|
+
'getDb',
|
|
2199
|
+
'getBucket',
|
|
2200
|
+
'runWithCore'
|
|
2201
|
+
]) {
|
|
2202
|
+
assert.equal(internalSymbol in coreBarrel, false, `@core must not leak internal symbol ${internalSymbol}`)
|
|
2203
|
+
}
|
|
2204
|
+
assert.equal(Object.keys(core).includes('internals'), false, 'core.internals must be non-enumerable')
|
|
2205
|
+
assert.deepEqual(
|
|
2206
|
+
Object.keys(core.internals).sort(),
|
|
2207
|
+
['auth', 'context', 'emails', 'payments', 'registry', 'resilience', 'workflows']
|
|
2208
|
+
)
|
|
2209
|
+
})
|
|
2210
|
+
|
|
2211
|
+
test('POST /api/emails/send, POST /api/emails/batch, GET /api/emails/logs & POST /api/emails/webhooks/:provider — E2E email routes, templates, attachments & suppression', async () => {
|
|
2212
|
+
core.internals.registry.registerEmailTemplates(
|
|
2213
|
+
core.define.emailTemplates({
|
|
2214
|
+
e2e_summary: {
|
|
2215
|
+
description: 'E2E summary notification',
|
|
2216
|
+
variables: z.object({ title: z.string(), count: z.number() }),
|
|
2217
|
+
subject: ({ title }) => `[E2E] ${title}`,
|
|
2218
|
+
html: ({ title, count }) => `<p>${title}: ${count} items processed.</p>`
|
|
2219
|
+
}
|
|
2220
|
+
})
|
|
2221
|
+
)
|
|
2222
|
+
|
|
2223
|
+
const firstRef = uploadedRefs.get(fixtures[0]!.type)!
|
|
2224
|
+
const idemKey = `e2e_email_${Date.now()}`
|
|
2225
|
+
const sendRes1 = await apiJson<{
|
|
2226
|
+
emailId: string
|
|
2227
|
+
status: string
|
|
2228
|
+
provider: string
|
|
2229
|
+
deduplicated?: boolean
|
|
2230
|
+
}>(
|
|
2231
|
+
'/api/emails/send',
|
|
2232
|
+
{
|
|
2233
|
+
method: 'POST',
|
|
2234
|
+
headers: { 'content-type': 'application/json' },
|
|
2235
|
+
body: JSON.stringify({
|
|
2236
|
+
to: 'e2e-recipient@test.local',
|
|
2237
|
+
template: 'e2e_summary',
|
|
2238
|
+
variables: { title: 'Batch Scan', count: fixtures.length },
|
|
2239
|
+
attachments: [{ filename: 'fixture.pdf', fileRef: firstRef }],
|
|
2240
|
+
idempotencyKey: idemKey
|
|
2241
|
+
})
|
|
2242
|
+
},
|
|
2243
|
+
true,
|
|
2244
|
+
'emails-send-1'
|
|
2245
|
+
)
|
|
2246
|
+
assert.equal(sendRes1.status, 200)
|
|
2247
|
+
assert.equal(sendRes1.data.status, 'sent')
|
|
2248
|
+
assert.equal(sendRes1.data.deduplicated, undefined)
|
|
2249
|
+
|
|
2250
|
+
// Verify core.emails.send idempotency deduplication in email_log
|
|
2251
|
+
const directDedup = await core.emails.send(
|
|
2252
|
+
{
|
|
2253
|
+
to: 'e2e-recipient@test.local',
|
|
2254
|
+
template: 'e2e_summary',
|
|
2255
|
+
variables: { title: 'Batch Scan', count: fixtures.length },
|
|
2256
|
+
idempotencyKey: idemKey
|
|
2257
|
+
},
|
|
2258
|
+
{ userId: testAuthUserId }
|
|
2259
|
+
)
|
|
2260
|
+
assert.equal(directDedup.emailId, sendRes1.data.emailId)
|
|
2261
|
+
assert.equal(directDedup.deduplicated, true)
|
|
2262
|
+
|
|
2263
|
+
// Clean up admin-owned sendRes1 log so only testAuthUserId logs remain
|
|
2264
|
+
await core.db.hardDelete('email_log', sendRes1.data.emailId, { bypassRbac: true })
|
|
2265
|
+
|
|
2266
|
+
// Create a user-scoped email log for testAuthUserId to verify GET /api/emails/logs & cascadeEraseUserCollections
|
|
2267
|
+
const userScopedSend = await core.emails.send(
|
|
2268
|
+
{
|
|
2269
|
+
to: 'user-scoped@test.local',
|
|
2270
|
+
template: 'e2e_summary',
|
|
2271
|
+
variables: { title: 'User Scoped', count: 1 }
|
|
2272
|
+
},
|
|
2273
|
+
{ userId: testAuthUserId }
|
|
2274
|
+
)
|
|
2275
|
+
|
|
2276
|
+
// Verify POST /api/emails/batch
|
|
2277
|
+
const batchRes = await apiJson<{
|
|
2278
|
+
total: number
|
|
2279
|
+
sent: number
|
|
2280
|
+
failed: number
|
|
2281
|
+
results: { emailId: string; status: string }[]
|
|
2282
|
+
}>(
|
|
2283
|
+
'/api/emails/batch',
|
|
2284
|
+
{
|
|
2285
|
+
method: 'POST',
|
|
2286
|
+
headers: { 'content-type': 'application/json' },
|
|
2287
|
+
body: JSON.stringify({
|
|
2288
|
+
emails: [
|
|
2289
|
+
{ to: 'batch-a@test.local', subject: 'Batch A', text: 'Hello A' },
|
|
2290
|
+
{ to: 'batch-b@test.local', subject: 'Batch B', text: 'Hello B' }
|
|
2291
|
+
]
|
|
2292
|
+
})
|
|
2293
|
+
},
|
|
2294
|
+
true,
|
|
2295
|
+
'emails-batch'
|
|
2296
|
+
)
|
|
2297
|
+
assert.equal(batchRes.status, 200)
|
|
2298
|
+
assert.equal(batchRes.data.total, 2)
|
|
2299
|
+
assert.equal(batchRes.data.sent, 2)
|
|
2300
|
+
await Promise.all(
|
|
2301
|
+
batchRes.data.results.map((r) => core.db.hardDelete('email_log', r.emailId, { bypassRbac: true }))
|
|
2302
|
+
)
|
|
2303
|
+
|
|
2304
|
+
// Verify GET /api/emails/logs filtered by userId
|
|
2305
|
+
const logsRes = await apiJson<{ id: string; to: string; userId: string }[]>(
|
|
2306
|
+
`/api/emails/logs?userId=${testAuthUserId}&limit=20`,
|
|
2307
|
+
{},
|
|
2308
|
+
true,
|
|
2309
|
+
'emails-logs'
|
|
2310
|
+
)
|
|
2311
|
+
assert.equal(logsRes.status, 200)
|
|
2312
|
+
assert.ok(logsRes.data.some((l) => l.id === userScopedSend.emailId))
|
|
2313
|
+
|
|
2314
|
+
// Verify POST /api/emails/webhooks/resend rejects unsigned/invalid signature with 401
|
|
2315
|
+
const badWebhookRes = await apiFetch(
|
|
2316
|
+
'/api/emails/webhooks/resend',
|
|
2317
|
+
{
|
|
2318
|
+
method: 'POST',
|
|
2319
|
+
headers: {
|
|
2320
|
+
'content-type': 'application/json',
|
|
2321
|
+
'svix-id': 'msg_bad',
|
|
2322
|
+
'svix-timestamp': String(Math.floor(Date.now() / 1000)),
|
|
2323
|
+
'svix-signature': 'v1,bad_sig'
|
|
2324
|
+
},
|
|
2325
|
+
body: JSON.stringify({ type: 'email.delivered', data: { email_id: 'dev_123' } })
|
|
2326
|
+
},
|
|
2327
|
+
'emails-webhook-invalid'
|
|
2328
|
+
)
|
|
2329
|
+
assert.equal(badWebhookRes.status, 401)
|
|
2330
|
+
})
|
|
2331
|
+
|
|
2332
|
+
test('GET /api/payments/status, POST /api/payments/subscribe, POST /api/payments/checkout, POST /api/payments/charge & POST /api/payments/webhooks/:provider — E2E payment routes & usage metering', async () => {
|
|
2333
|
+
core.internals.registry.registerPlans(
|
|
2334
|
+
core.define.plans({
|
|
2335
|
+
free: {
|
|
2336
|
+
name: 'Free Plan',
|
|
2337
|
+
interval: 'free',
|
|
2338
|
+
amount: 0,
|
|
2339
|
+
quotas: { aiTokens: 100_000 }
|
|
2340
|
+
},
|
|
2341
|
+
pro: {
|
|
2342
|
+
name: 'Pro Plan',
|
|
2343
|
+
interval: 'month',
|
|
2344
|
+
amount: 2_900,
|
|
2345
|
+
currency: 'usd',
|
|
2346
|
+
quotas: { aiTokens: 1_000_000, overage: { enabled: true } }
|
|
2347
|
+
}
|
|
2348
|
+
})
|
|
2349
|
+
)
|
|
2350
|
+
core.internals.registry.registerProducts(
|
|
2351
|
+
core.define.products({
|
|
2352
|
+
e2e_token_pack: {
|
|
2353
|
+
name: 'E2E 5,000 Token Pack',
|
|
2354
|
+
amount: 500,
|
|
2355
|
+
currency: 'usd',
|
|
2356
|
+
grants: { aiTokens: 5_000, credits: 5 }
|
|
2357
|
+
}
|
|
2358
|
+
})
|
|
2359
|
+
)
|
|
2360
|
+
|
|
2361
|
+
const subRes = await apiJson<{ subscriptionId: string; planId: string; status: string }>(
|
|
2362
|
+
'/api/payments/subscribe',
|
|
2363
|
+
{
|
|
2364
|
+
method: 'POST',
|
|
2365
|
+
headers: userHeaders({ 'content-type': 'application/json' }),
|
|
2366
|
+
body: JSON.stringify({ plan: 'free' })
|
|
2367
|
+
},
|
|
2368
|
+
false,
|
|
2369
|
+
'payments-subscribe'
|
|
2370
|
+
)
|
|
2371
|
+
assert.equal(subRes.status, 200)
|
|
2372
|
+
assert.equal(subRes.data.planId, 'free')
|
|
2373
|
+
assert.equal(subRes.data.status, 'active')
|
|
2374
|
+
|
|
2375
|
+
const chkRes = await apiJson<{ checkoutId: string; url: string; provider: string }>(
|
|
2376
|
+
'/api/payments/checkout',
|
|
2377
|
+
{
|
|
2378
|
+
method: 'POST',
|
|
2379
|
+
headers: userHeaders({ 'content-type': 'application/json' }),
|
|
2380
|
+
body: JSON.stringify({ custom: { name: 'E2E Custom Add-on', amount: 1_500, currency: 'usd' } })
|
|
2381
|
+
},
|
|
2382
|
+
false,
|
|
2383
|
+
'payments-checkout'
|
|
2384
|
+
)
|
|
2385
|
+
assert.equal(chkRes.status, 200)
|
|
2386
|
+
assert.ok(chkRes.data.checkoutId)
|
|
2387
|
+
assert.ok(chkRes.data.url.startsWith('http'))
|
|
2388
|
+
|
|
2389
|
+
const chargeRes = await apiJson<{ chargeId: string; status: string; amount: number }>(
|
|
2390
|
+
'/api/payments/charge',
|
|
2391
|
+
{
|
|
2392
|
+
method: 'POST',
|
|
2393
|
+
headers: userHeaders({ 'content-type': 'application/json' }),
|
|
2394
|
+
body: JSON.stringify({ product: 'e2e_token_pack', idempotencyKey: 'e2e-charge-idem-1' })
|
|
2395
|
+
},
|
|
2396
|
+
false,
|
|
2397
|
+
'payments-charge'
|
|
2398
|
+
)
|
|
2399
|
+
assert.equal(chargeRes.status, 200)
|
|
2400
|
+
assert.equal(chargeRes.data.amount, 500)
|
|
2401
|
+
|
|
2402
|
+
const retryChargeRes = await apiJson<{ chargeId: string; status: string; amount: number }>(
|
|
2403
|
+
'/api/payments/charge',
|
|
2404
|
+
{
|
|
2405
|
+
method: 'POST',
|
|
2406
|
+
headers: userHeaders({ 'content-type': 'application/json' }),
|
|
2407
|
+
body: JSON.stringify({ product: 'e2e_token_pack', idempotencyKey: 'e2e-charge-idem-1' })
|
|
2408
|
+
},
|
|
2409
|
+
false,
|
|
2410
|
+
'payments-charge-retry'
|
|
2411
|
+
)
|
|
2412
|
+
assert.equal(retryChargeRes.status, 200)
|
|
2413
|
+
assert.equal(retryChargeRes.data.chargeId, chargeRes.data.chargeId)
|
|
2414
|
+
|
|
2415
|
+
await core.payments.recordUsage(
|
|
2416
|
+
{ metric: 'ai_tokens', amount: 250, model: 'e2e-test-model' },
|
|
2417
|
+
{ userId: testAuthUserId }
|
|
2418
|
+
)
|
|
2419
|
+
|
|
2420
|
+
const statusRes = await apiJson<{
|
|
2421
|
+
subscription: { userId: string; planId: string; status: string; bonusAiTokens: number; credits: number }
|
|
2422
|
+
usage: { userId: string; aiTokensUsed: number; aiTokensLimit: number | null }
|
|
2423
|
+
}>('/api/payments/status', { headers: userHeaders() }, false, 'payments-status')
|
|
2424
|
+
assert.equal(statusRes.status, 200)
|
|
2425
|
+
assert.equal(statusRes.data.subscription.userId, testAuthUserId)
|
|
2426
|
+
assert.equal(statusRes.data.subscription.planId, 'free')
|
|
2427
|
+
assert.equal(statusRes.data.subscription.credits, 5, 'Expected idempotent charge retry to grant credits only once')
|
|
2428
|
+
assert.ok(statusRes.data.usage.aiTokensUsed >= 0)
|
|
2429
|
+
|
|
2430
|
+
const badPayWhRes = await apiFetch(
|
|
2431
|
+
'/api/payments/webhooks/stripe',
|
|
2432
|
+
{
|
|
2433
|
+
method: 'POST',
|
|
2434
|
+
headers: {
|
|
2435
|
+
'content-type': 'application/json',
|
|
2436
|
+
'stripe-signature': `t=${Math.floor(Date.now() / 1000)},v1=invalid`
|
|
2437
|
+
},
|
|
2438
|
+
body: JSON.stringify({ id: 'evt_bad_e2e', type: 'checkout.session.completed' })
|
|
2439
|
+
},
|
|
2440
|
+
'payments-webhook-invalid'
|
|
2441
|
+
)
|
|
2442
|
+
assert.equal(badPayWhRes.status, 401)
|
|
2443
|
+
})
|
|
2444
|
+
|
|
2445
|
+
test('DELETE /api/files/:ref & POST /api/auth/sign-out — soft-delete all files & revoke session', async () => {
|
|
2446
|
+
await Promise.all(
|
|
2447
|
+
[...uploadedRefs.values()].map(async (ref) => {
|
|
2448
|
+
const delRes = await apiFetch(`/api/files/${ref}`, { method: 'DELETE', headers: userHeaders() }, 'soft-del')
|
|
2449
|
+
assert.equal(delRes.status, 200)
|
|
2450
|
+
})
|
|
2451
|
+
)
|
|
2452
|
+
|
|
2453
|
+
const firstRef = uploadedRefs.get(fixtures[0]!.type)!
|
|
2454
|
+
const afterDelRes = await apiFetch(`/api/files/${firstRef}`, { headers: bearerHeaders() }, 'verify-404')
|
|
2455
|
+
assert.equal(afterDelRes.status, 404)
|
|
2456
|
+
|
|
2457
|
+
const signOutRes = await apiFetch('/api/auth/sign-out', {
|
|
2458
|
+
method: 'POST',
|
|
2459
|
+
headers: userHeaders({ 'content-type': 'application/json' }),
|
|
2460
|
+
body: JSON.stringify({ deleteUser: true })
|
|
2461
|
+
})
|
|
2462
|
+
assert.equal(signOutRes.status, 200)
|
|
2463
|
+
|
|
2464
|
+
const revokedCookieRes = await apiFetch('/api/auth/session', { headers: userHeaders() }, 'revoked-cookie')
|
|
2465
|
+
assert.equal(revokedCookieRes.status, 401)
|
|
2466
|
+
})
|
|
2467
|
+
|
|
2468
|
+
test('POST /api/erase-user & Final Zero-Leftover Verification (Auth, Firestore file/chat/_tasks & Cloud Storage)', async () => {
|
|
2469
|
+
const eraseRes = await apiJson<{
|
|
2470
|
+
userId: string
|
|
2471
|
+
erasedFiles: number
|
|
2472
|
+
erasedChats: number
|
|
2473
|
+
erasedUser: boolean
|
|
2474
|
+
}>(
|
|
2475
|
+
'/api/erase-user',
|
|
2476
|
+
{
|
|
2477
|
+
method: 'POST',
|
|
2478
|
+
headers: { 'content-type': 'application/json' },
|
|
2479
|
+
body: JSON.stringify({ userId: testAuthUserId, backupBeforeErase: false })
|
|
2480
|
+
},
|
|
2481
|
+
true
|
|
2482
|
+
)
|
|
2483
|
+
assert.equal(eraseRes.status, 200)
|
|
2484
|
+
assert.equal(eraseRes.data.userId, testAuthUserId)
|
|
2485
|
+
assert.ok(eraseRes.data.erasedFiles >= fixtures.length)
|
|
2486
|
+
assert.ok(eraseRes.data.erasedChats >= 2)
|
|
2487
|
+
assert.equal(eraseRes.data.erasedUser, true)
|
|
2488
|
+
userErased = true
|
|
2489
|
+
|
|
2490
|
+
const db = core.db.firestore(true)
|
|
2491
|
+
const [
|
|
2492
|
+
leftoverAuthUser,
|
|
2493
|
+
leftoverAuthMetaDoc,
|
|
2494
|
+
leftoverAuthTokenDocs,
|
|
2495
|
+
leftoverFileDocs,
|
|
2496
|
+
leftoverChatDocs,
|
|
2497
|
+
leftoverChatMsgDocs,
|
|
2498
|
+
leftoverTaskDocs,
|
|
2499
|
+
leftoverEmailLogDocs,
|
|
2500
|
+
leftoverPaymentCustomerDoc,
|
|
2501
|
+
leftoverPaymentUsageDocs,
|
|
2502
|
+
leftoverPaymentTxDocs,
|
|
2503
|
+
[leftoverStorageBlobs]
|
|
2504
|
+
] = await Promise.all([
|
|
2505
|
+
core.auth.getUser(testAuthUserId),
|
|
2506
|
+
db.collection('user_auth_meta').doc(testAuthUserId).get(),
|
|
2507
|
+
db.collection('auth_tokens').where('userId', '==', testAuthUserId).get(),
|
|
2508
|
+
db.collection('file').where('userId', '==', testAuthUserId).get(),
|
|
2509
|
+
db.collection('chat').where('userId', '==', testAuthUserId).get(),
|
|
2510
|
+
db.collectionGroup('messages').where('userId', '==', testAuthUserId).get(),
|
|
2511
|
+
db.collection('_tasks').where('userId', '==', testAuthUserId).get(),
|
|
2512
|
+
db.collection('email_log').where('userId', '==', testAuthUserId).get(),
|
|
2513
|
+
db.collection('payment_customer').doc(testAuthUserId).get(),
|
|
2514
|
+
db.collection('payment_usage').where('userId', '==', testAuthUserId).get(),
|
|
2515
|
+
db.collection('payment_transaction').where('userId', '==', testAuthUserId).get(),
|
|
2516
|
+
core.files.bucket(true).getFiles({ prefix: `${testAuthUserId}_` })
|
|
2517
|
+
])
|
|
2518
|
+
assert.equal(leftoverAuthUser, undefined, 'Expected user to be completely deleted from Firebase Auth')
|
|
2519
|
+
assert.equal(leftoverAuthMetaDoc.exists, false, 'Expected 0 leftover user_auth_meta doc in Firestore')
|
|
2520
|
+
assert.equal(leftoverAuthTokenDocs.size, 0, 'Expected 0 leftover auth_tokens docs in Firestore')
|
|
2521
|
+
assert.equal(leftoverFileDocs.size, 0, 'Expected 0 leftover file docs in Firestore')
|
|
2522
|
+
assert.equal(leftoverChatDocs.size, 0, 'Expected 0 leftover chat docs in Firestore')
|
|
2523
|
+
assert.equal(leftoverChatMsgDocs.size, 0, 'Expected 0 leftover chat message subcollection docs in Firestore')
|
|
2524
|
+
assert.equal(leftoverTaskDocs.size, 0, 'Expected 0 leftover _tasks docs in Firestore')
|
|
2525
|
+
assert.equal(leftoverEmailLogDocs.size, 0, 'Expected 0 leftover email_log docs in Firestore')
|
|
2526
|
+
assert.equal(leftoverPaymentCustomerDoc.exists, false, 'Expected 0 leftover payment_customer doc in Firestore')
|
|
2527
|
+
assert.equal(leftoverPaymentUsageDocs.size, 0, 'Expected 0 leftover payment_usage docs in Firestore')
|
|
2528
|
+
assert.equal(leftoverPaymentTxDocs.size, 0, 'Expected 0 leftover payment_transaction docs in Firestore')
|
|
2529
|
+
assert.equal(leftoverStorageBlobs.length, 0, 'Expected 0 leftover blobs in Cloud Storage')
|
|
2530
|
+
})
|
|
2531
|
+
})
|