spine-framework 0.4.4 → 0.5.0-rc.5
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/.env.example +13 -3
- package/.framework/cli/commands/bootstrap.ts +392 -0
- package/.framework/cli/commands/dev.ts +91 -218
- package/.framework/cli/commands/doctor.ts +132 -26
- package/.framework/cli/commands/generate.ts +38 -298
- package/.framework/cli/commands/init.ts +20 -1
- package/.framework/cli/commands/install-app.ts +183 -13
- package/.framework/cli/commands/migrate.ts +36 -30
- package/.framework/cli/commands/migrations.ts +16 -56
- package/.framework/cli/commands/upgrade.ts +35 -0
- package/.framework/cli/commands/validate-app.ts +127 -1
- package/.framework/cli/commands/validate-schema.ts +147 -0
- package/.framework/cli/context.ts +223 -110
- package/.framework/cli/index.ts +6 -0
- package/.framework/cli/lib/project-files.ts +241 -1
- package/.framework/functions/_shared/app-manifest.ts +2 -0
- package/.framework/functions/_shared/hash-api-key.ts +183 -79
- package/.framework/functions/_shared/logging.ts +562 -224
- package/.framework/functions/_shared/middleware.ts +47 -44
- package/.framework/functions/_shared/permissions.ts +4 -0
- package/.framework/functions/admin-data.ts +51 -28
- package/.framework/functions/ai-agents.ts +10 -6
- package/.framework/functions/api-keys.ts +301 -122
- package/.framework/functions/apps.ts +63 -29
- package/.framework/functions/cil-ingest.ts +48 -15
- package/.framework/functions/debug-auth.ts +14 -8
- package/.framework/functions/deliveries.ts +39 -12
- package/.framework/functions/embeddings.ts +149 -78
- package/.framework/functions/integration-outbound.ts +67 -27
- package/.framework/functions/integrations.ts +254 -127
- package/.framework/functions/logs.ts +273 -133
- package/.framework/functions/observability.ts +278 -148
- package/.framework/functions/pipeline-executions.ts +1 -1
- package/.framework/functions/pipelines.ts +14 -9
- package/.framework/functions/prompt-configs.ts +7 -4
- package/.framework/functions/roles.ts +8 -5
- package/.framework/functions/security.ts +30 -17
- package/.framework/functions/system-cron.ts +240 -127
- package/.framework/functions/tests.ts +7 -6
- package/.framework/functions/timers.ts +310 -107
- package/.framework/functions/triggers.ts +28 -13
- package/.framework/functions/types.ts +13 -10
- package/.framework/migrations/069_log_stats_window.sql +54 -0
- package/.framework/migrations/070_app_type_schema_rpcs.sql +166 -0
- package/.framework/migrations/071_timer_interval_keys.sql +96 -0
- package/.framework/migrations/072_slug_autofill.sql +77 -0
- package/.framework/migrations/073_seed_member_roles.sql +26 -0
- package/.framework/migrations/074_app_route_prefix_domains.sql +168 -0
- package/.framework/migrations/075_security_definer_grants.sql +157 -0
- package/.framework/src/apps/admin/index.tsx +1141 -775
- package/.framework/src/components/ErrorBoundary.tsx +136 -0
- package/.framework/src/components/admin/AdminListPage.tsx +81 -73
- package/.framework/src/components/admin/AdminSidebar.tsx +88 -79
- package/.framework/src/components/admin/EntitySelect.tsx +181 -0
- package/.framework/src/components/admin/PageInstructions.tsx +86 -127
- package/.framework/src/components/app-shell/GenericDetailPage.tsx +37 -13
- package/.framework/src/components/app-shell/GenericListPage.tsx +28 -32
- package/.framework/src/components/layout/Sidebar.tsx +300 -177
- package/.framework/src/components/runtime/DataDetailPage.tsx +67 -35
- package/.framework/src/components/runtime/DataHeader.tsx +96 -48
- package/.framework/src/components/runtime/DataListPage.tsx +3 -26
- package/.framework/src/components/runtime/DataStats.tsx +10 -10
- package/.framework/src/components/runtime/DataTable.tsx +12 -10
- package/.framework/src/components/ui/sheet.tsx +24 -26
- package/.framework/src/components/ui/textarea.tsx +74 -48
- package/.framework/src/contexts/AppContext.tsx +5 -12
- package/.framework/src/contexts/AuthContext.tsx +17 -9
- package/.framework/src/hooks/useApps.ts +18 -22
- package/.framework/src/hooks/useEntityList.ts +12 -9
- package/.framework/src/hooks/useListSchema.ts +108 -58
- package/.framework/src/instructions/README.md +74 -0
- package/.framework/src/instructions/accounts.md +97 -0
- package/.framework/src/instructions/ai-agents.md +69 -0
- package/.framework/src/instructions/alerts.md +80 -0
- package/.framework/src/instructions/api-keys.md +87 -0
- package/.framework/src/instructions/apps.md +77 -0
- package/.framework/src/instructions/concepts/ai-and-prompts.md +150 -0
- package/.framework/src/instructions/concepts/apps-and-renderers.md +228 -0
- package/.framework/src/instructions/concepts/auth-and-api-keys.md +133 -0
- package/.framework/src/instructions/concepts/automation.md +186 -0
- package/.framework/src/instructions/concepts/concurrency-and-idempotency.md +125 -0
- package/.framework/src/instructions/concepts/core-vs-custom.md +124 -0
- package/.framework/src/instructions/concepts/design-schema.md +152 -0
- package/.framework/src/instructions/concepts/integrations-and-webhooks.md +127 -0
- package/.framework/src/instructions/concepts/observability.md +116 -0
- package/.framework/src/instructions/concepts/permissions.md +150 -0
- package/.framework/src/instructions/concepts/runtime-records.md +111 -0
- package/.framework/src/instructions/concepts/scope-and-accounts.md +112 -0
- package/.framework/src/instructions/concepts/spine-way.md +172 -0
- package/.framework/src/instructions/deliveries.md +59 -0
- package/.framework/src/instructions/guide/00-orientation.md +106 -0
- package/.framework/src/instructions/guide/01-install-and-first-run.md +253 -0
- package/.framework/src/instructions/guide/02-project-map.md +147 -0
- package/.framework/src/instructions/guide/03-domain-model.md +223 -0
- package/.framework/src/instructions/guide/04-first-app.md +227 -0
- package/.framework/src/instructions/guide/05-glossary.md +183 -0
- package/.framework/src/instructions/integrations.md +96 -0
- package/.framework/src/instructions/internal/cil-markers.md +83 -0
- package/.framework/src/instructions/internal/docs-contract.md +158 -0
- package/.framework/src/instructions/invites.md +60 -0
- package/.framework/src/instructions/kb.md +80 -0
- package/.framework/src/instructions/logs.md +77 -0
- package/.framework/src/instructions/notifications.md +77 -0
- package/.framework/src/instructions/observability.md +61 -0
- package/.framework/src/instructions/operations/deployment.md +115 -0
- package/.framework/src/instructions/operations/migrations.md +125 -0
- package/.framework/src/instructions/operations/security-checklist.md +170 -0
- package/.framework/src/instructions/operations/troubleshooting.md +170 -0
- package/.framework/src/instructions/operations/upgrade.md +116 -0
- package/.framework/src/instructions/people.md +91 -0
- package/.framework/src/instructions/pipeline-executions.md +62 -0
- package/.framework/src/instructions/pipelines.md +78 -0
- package/.framework/src/instructions/prompts.md +95 -0
- package/.framework/src/instructions/reference/api/admin-data.md +162 -0
- package/.framework/src/instructions/reference/api/auth.md +113 -0
- package/.framework/src/instructions/reference/api/errors-and-envelopes.md +113 -0
- package/.framework/src/instructions/reference/app-manifest.md +184 -0
- package/.framework/src/instructions/reference/cli.md +124 -0
- package/.framework/src/instructions/reference/database.md +132 -0
- package/.framework/src/instructions/reference/design-schema.md +280 -0
- package/.framework/src/instructions/reference/env-vars.md +133 -0
- package/.framework/src/instructions/reference/frontend-sdk.md +207 -0
- package/.framework/src/instructions/reference/functions/account-nodes.md +75 -0
- package/.framework/src/instructions/reference/functions/admin-data.md +197 -0
- package/.framework/src/instructions/reference/functions/admin-funnel.md +90 -0
- package/.framework/src/instructions/reference/functions/admin-install-funnel.md +107 -0
- package/.framework/src/instructions/reference/functions/admin-signals.md +102 -0
- package/.framework/src/instructions/reference/functions/ai-agents.md +148 -0
- package/.framework/src/instructions/reference/functions/ai-interactions.md +156 -0
- package/.framework/src/instructions/reference/functions/api-keys.md +130 -0
- package/.framework/src/instructions/reference/functions/apps.md +140 -0
- package/.framework/src/instructions/reference/functions/auth.md +99 -0
- package/.framework/src/instructions/reference/functions/debug-auth.md +91 -0
- package/.framework/src/instructions/reference/functions/deliveries.md +110 -0
- package/.framework/src/instructions/reference/functions/embeddings.md +166 -0
- package/.framework/src/instructions/reference/functions/eval-harness.md +149 -0
- package/.framework/src/instructions/reference/functions/index.md +103 -0
- package/.framework/src/instructions/reference/functions/integration-outbound.md +200 -0
- package/.framework/src/instructions/reference/functions/integration-routes.md +151 -0
- package/.framework/src/instructions/reference/functions/integrations.md +130 -0
- package/.framework/src/instructions/reference/functions/invites.md +182 -0
- package/.framework/src/instructions/reference/functions/item-progress.md +107 -0
- package/.framework/src/instructions/reference/functions/kb.md +166 -0
- package/.framework/src/instructions/reference/functions/logs.md +141 -0
- package/.framework/src/instructions/reference/functions/observability.md +105 -0
- package/.framework/src/instructions/reference/functions/pipeline-executions.md +132 -0
- package/.framework/src/instructions/reference/functions/pipeline-runner-background.md +121 -0
- package/.framework/src/instructions/reference/functions/pipelines.md +136 -0
- package/.framework/src/instructions/reference/functions/prompt-configs.md +121 -0
- package/.framework/src/instructions/reference/functions/roles.md +121 -0
- package/.framework/src/instructions/reference/functions/security.md +140 -0
- package/.framework/src/instructions/reference/functions/system-cron.md +136 -0
- package/.framework/src/instructions/reference/functions/system.md +106 -0
- package/.framework/src/instructions/reference/functions/tests.md +97 -0
- package/.framework/src/instructions/reference/functions/timers.md +117 -0
- package/.framework/src/instructions/reference/functions/trigger-scheduler.md +102 -0
- package/.framework/src/instructions/reference/functions/triggers.md +140 -0
- package/.framework/src/instructions/reference/functions/types.md +146 -0
- package/.framework/src/instructions/reference/seeds.md +126 -0
- package/.framework/src/instructions/reference/shared-modules.md +127 -0
- package/.framework/src/instructions/reference/spine-config.md +114 -0
- package/.framework/src/instructions/roles.md +80 -0
- package/.framework/src/instructions/runtime-collaboration.md +106 -0
- package/.framework/src/instructions/runtime-detail.md +70 -0
- package/.framework/src/instructions/runtime-list.md +77 -0
- package/.framework/src/instructions/security.md +67 -0
- package/.framework/src/instructions/tasks/add-a-pipeline.md +110 -0
- package/.framework/src/instructions/tasks/add-a-role.md +96 -0
- package/.framework/src/instructions/tasks/add-a-timer.md +114 -0
- package/.framework/src/instructions/tasks/add-a-trigger.md +106 -0
- package/.framework/src/instructions/tasks/add-a-type.md +151 -0
- package/.framework/src/instructions/tasks/add-a-webhook.md +108 -0
- package/.framework/src/instructions/tasks/add-an-ai-agent.md +108 -0
- package/.framework/src/instructions/tasks/add-an-integration.md +124 -0
- package/.framework/src/instructions/tasks/build-a-custom-page.md +118 -0
- package/.framework/src/instructions/tasks/document-your-app.md +117 -0
- package/.framework/src/instructions/tasks/package-and-install-an-app.md +136 -0
- package/.framework/src/instructions/tasks/register-an-app.md +137 -0
- package/.framework/src/instructions/tasks/test-your-code.md +108 -0
- package/.framework/src/instructions/tasks/write-a-function.md +132 -0
- package/.framework/src/instructions/tasks/write-a-migration.md +110 -0
- package/.framework/src/instructions/testing.md +59 -0
- package/.framework/src/instructions/timers.md +84 -0
- package/.framework/src/instructions/triggers.md +69 -0
- package/.framework/src/instructions/types.md +93 -0
- package/.framework/src/lib/app-mount.ts +88 -0
- package/.framework/src/pages/NotFoundPage.tsx +62 -31
- package/.framework/src/pages/admin/AIAgentDetailPage.tsx +117 -106
- package/.framework/src/pages/admin/AIAgentsPage.tsx +86 -48
- package/.framework/src/pages/admin/APIKeyDetailPage.tsx +153 -152
- package/.framework/src/pages/admin/APIKeysPage.tsx +91 -90
- package/.framework/src/pages/admin/AlertsConfigPage.tsx +29 -18
- package/.framework/src/pages/admin/AppDetailPage.tsx +6 -60
- package/.framework/src/pages/admin/AppsPage.tsx +104 -66
- package/.framework/src/pages/admin/DeliveriesPage.tsx +5 -5
- package/.framework/src/pages/admin/EmbeddingDetailPage.tsx +5 -23
- package/.framework/src/pages/admin/EmbeddingsPage.tsx +5 -26
- package/.framework/src/pages/admin/IntegrationDetailPage.tsx +7 -7
- package/.framework/src/pages/admin/IntegrationsPage.tsx +85 -43
- package/.framework/src/pages/admin/LogsPage.tsx +144 -76
- package/.framework/src/pages/admin/ObservabilityDashboard.tsx +223 -140
- package/.framework/src/pages/admin/PipelineDetailPage.tsx +134 -113
- package/.framework/src/pages/admin/PipelineExecutionsPage.tsx +108 -45
- package/.framework/src/pages/admin/PipelinesPage.tsx +160 -115
- package/.framework/src/pages/admin/PromptConfigDetailPage.tsx +96 -98
- package/.framework/src/pages/admin/PromptConfigsPage.tsx +101 -59
- package/.framework/src/pages/admin/RoleDetailPage.tsx +90 -79
- package/.framework/src/pages/admin/RolesPage.tsx +86 -43
- package/.framework/src/pages/admin/SecurityPage.tsx +1 -1
- package/.framework/src/pages/admin/TestingDashboard.tsx +16 -28
- package/.framework/src/pages/admin/TimerDetailPage.tsx +117 -100
- package/.framework/src/pages/admin/TimersPage.tsx +90 -62
- package/.framework/src/pages/admin/TriggerDetailPage.tsx +90 -83
- package/.framework/src/pages/admin/TriggersPage.tsx +124 -99
- package/.framework/src/pages/admin/TypeDetailPage.tsx +152 -138
- package/.framework/src/pages/admin/TypesPage.tsx +90 -48
- package/.framework/src/pages/admin/cil/CILAgentPage.tsx +17 -25
- package/.framework/src/pages/admin/cil/CILChunkDetailPage.tsx +17 -27
- package/.framework/src/pages/admin/cil/CILDashboardPage.tsx +72 -40
- package/.framework/src/pages/admin/cil/CILFileListPage.tsx +321 -129
- package/.framework/src/pages/admin/cil/CILFileSelectPage.tsx +18 -29
- package/.framework/src/pages/admin/cil/CILGraphPage.tsx +26 -38
- package/.framework/src/pages/admin/cil/CILIngestionPage.tsx +34 -46
- package/.framework/src/pages/admin/cil/CILProjectsPage.tsx +189 -103
- package/.framework/src/pages/admin/cil/CILSearchPage.tsx +246 -136
- package/.framework/src/pages/admin/cil/CILSuggestionsPage.tsx +11 -22
- package/.framework/src/pages/admin/cil/CILVersionsPage.tsx +9 -20
- package/.framework/src/pages/admin/cil/FlexiblePipelinePage.tsx +26 -38
- package/.framework/src/pages/admin/kb/KBAdminPage.tsx +7 -26
- package/.framework/src/pages/auth/LoginPage.tsx +3 -2
- package/.framework/src/pages/auth/RegisterPage.tsx +16 -8
- package/.framework/templates/agent/AGENTS.md +167 -0
- package/.framework/templates/agent/skills/spine-add-ai-agent/SKILL.md +58 -0
- package/.framework/templates/agent/skills/spine-add-automation/SKILL.md +62 -0
- package/.framework/templates/agent/skills/spine-add-integration/SKILL.md +63 -0
- package/.framework/templates/agent/skills/spine-architect/SKILL.md +86 -0
- package/.framework/templates/agent/skills/spine-install-app/SKILL.md +79 -0
- package/.framework/templates/agent/skills/spine-migration/SKILL.md +75 -0
- package/.framework/templates/agent/skills/spine-new-app/SKILL.md +79 -0
- package/.framework/templates/agent/skills/spine-new-function/SKILL.md +71 -0
- package/.framework/templates/agent/skills/spine-new-type/SKILL.md +72 -0
- package/.framework/templates/agent/skills/spine-schema/SKILL.md +77 -0
- package/.framework/templates/agent/skills/spine-test/SKILL.md +67 -0
- package/.framework/templates/agent/skills/spine-troubleshoot/SKILL.md +74 -0
- package/.framework/templates/agent/skills/spine-upgrade/SKILL.md +69 -0
- package/.framework/templates/project.json +4 -1
- package/.framework/tests/unit/account-scoping-gates.test.ts +394 -0
- package/.framework/tests/unit/agent-files.test.ts +355 -0
- package/.framework/tests/unit/agent-runner.test.ts +479 -44
- package/.framework/tests/unit/api-keys-create-scope-gate.test.ts +160 -0
- package/.framework/tests/unit/api-keys-omit-secret-fields.test.ts +38 -0
- package/.framework/tests/unit/app-mount.test.ts +80 -0
- package/.framework/tests/unit/bootstrap.test.ts +242 -0
- package/.framework/tests/unit/cli-context-api-key.test.ts +114 -0
- package/.framework/tests/unit/debug-auth.test.ts +38 -0
- package/.framework/tests/unit/deliveries-account-scoping.test.ts +185 -0
- package/.framework/tests/unit/integration-outbound-credentials.test.ts +167 -0
- package/.framework/tests/unit/integrations-audit-redaction.test.ts +130 -0
- package/.framework/tests/unit/logging-app-id.test.ts +75 -0
- package/.framework/tests/unit/security-definer-grants.test.ts +154 -0
- package/.framework/tests/unit/system-context-denial.test.ts +145 -0
- package/.framework/tests/unit/system-cron.test.ts +240 -0
- package/.framework/tests/unit/timers.test.ts +159 -0
- package/README.md +125 -166
- package/config/tsconfig.cli.json +2 -2
- package/package.json +15 -4
- package/templates/project/package-lock.json +2 -2
- package/.framework/docs/admin-routes.md +0 -76
- package/.framework/docs/api-docs-progress.md +0 -38
- package/.framework/docs/api-governance.md +0 -146
- package/.framework/docs/api-testing-results.md +0 -212
- package/.framework/docs/assembly-launch-guide.md +0 -138
- package/.framework/docs/audit-results.md +0 -590
- package/.framework/docs/db-api-inventory.md +0 -95
- package/.framework/docs/gap-remediation-backlog.md +0 -103
- package/.framework/docs/ui-adoption-verification.md +0 -111
- package/.framework/docs/ui-api-coverage.md +0 -84
- package/.framework/docs/v2-compatibility-audit.md +0 -228
- package/.framework/mcp/index.ts +0 -133
- package/.framework/mcp/load-env.ts +0 -60
- package/.framework/mcp/shared/app-manifest.ts +0 -275
- package/.framework/mcp/shared/db.ts +0 -477
- package/.framework/mcp/shared/kb.ts +0 -118
- package/.framework/mcp/tools/architect.ts +0 -431
- package/.framework/mcp/tools/cil/context.ts +0 -345
- package/.framework/mcp/tools/cil/graph.ts +0 -242
- package/.framework/mcp/tools/cil/ingest.ts +0 -1217
- package/.framework/mcp/tools/cil/pipeline-orchestrator.ts +0 -693
- package/.framework/mcp/tools/cil/pipeline.ts +0 -767
- package/.framework/mcp/tools/cil/project.ts +0 -791
- package/.framework/mcp/tools/cil/retrieval.ts +0 -336
- package/.framework/mcp/tools/cil/search-core.ts +0 -454
- package/.framework/mcp/tools/cil/search.ts +0 -97
- package/.framework/mcp/tools/cil/suggest.ts +0 -186
- package/.framework/mcp/tools/cil/validate.ts +0 -1369
- package/.framework/mcp/tools/kb/delete.ts +0 -139
- package/.framework/mcp/tools/kb/index.ts +0 -52
- package/.framework/mcp/tools/kb/ingest.ts +0 -84
- package/.framework/mcp/tools/kb/list-types.ts +0 -102
- package/.framework/mcp/tools/kb/publish.ts +0 -100
- package/.framework/mcp/tools/kb/reindex.ts +0 -86
- package/.framework/mcp/tools/kb/revoke.ts +0 -85
- package/.framework/mcp/tools/kb/search.ts +0 -101
- package/.framework/mcp/tools/kb/upsert.ts +0 -125
- package/.framework/mcp/tools/observability.ts +0 -510
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { readFileSync, readdirSync } from 'fs'
|
|
3
|
+
import { join } from 'path'
|
|
4
|
+
|
|
5
|
+
// Regression guard for the SECURITY DEFINER RPC exposure fixed in migration
|
|
6
|
+
// 075. Postgres grants EXECUTE on new functions to PUBLIC by default, and
|
|
7
|
+
// Supabase's anon/authenticated roles inherit it — so every definer function
|
|
8
|
+
// ships callable at /rest/v1/rpc/* with the anon key unless the migration
|
|
9
|
+
// explicitly revokes it.
|
|
10
|
+
//
|
|
11
|
+
// This lint statically scans non-archived migrations and asserts that every
|
|
12
|
+
// SECURITY DEFINER function either:
|
|
13
|
+
// a) is an allowlisted RLS helper (policies evaluate it as the querying
|
|
14
|
+
// role, so anon/authenticated MUST keep EXECUTE), or
|
|
15
|
+
// b) is revoked from PUBLIC/anon/authenticated in some migration
|
|
16
|
+
// (literal REVOKE statement or an entry in a revoke-list DO block), or
|
|
17
|
+
// c) was dropped.
|
|
18
|
+
// It also asserts every definer function pins search_path — in its own body
|
|
19
|
+
// or via a later ALTER FUNCTION — to close schema-shadowing on privileged code.
|
|
20
|
+
|
|
21
|
+
const MIGRATIONS_DIR = join(__dirname, '../../migrations')
|
|
22
|
+
|
|
23
|
+
// anon/authenticated must keep EXECUTE on these: every tenant RLS policy
|
|
24
|
+
// evaluates them as the querying role. Revoking them would break isolation
|
|
25
|
+
// schema-wide — get_accessible_accounts is pinned internally instead (075).
|
|
26
|
+
const EXEC_ALLOWLIST = new Set([
|
|
27
|
+
'current_actor_id',
|
|
28
|
+
'get_accessible_accounts',
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
function migrationFiles(): string[] {
|
|
32
|
+
return readdirSync(MIGRATIONS_DIR)
|
|
33
|
+
.filter((f) => /^\d+_.*\.sql$/.test(f))
|
|
34
|
+
.sort()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface DefinerDef {
|
|
38
|
+
name: string
|
|
39
|
+
file: string
|
|
40
|
+
hasSearchPath: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Find CREATE [OR REPLACE] FUNCTION public.name(...) blocks and keep those
|
|
44
|
+
// declared SECURITY DEFINER.
|
|
45
|
+
function findDefinerFunctions(file: string, content: string): DefinerDef[] {
|
|
46
|
+
const out: DefinerDef[] = []
|
|
47
|
+
const re = /CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+public\.(\w+)\s*\(/gi
|
|
48
|
+
let m: RegExpExecArray | null
|
|
49
|
+
while ((m = re.exec(content))) {
|
|
50
|
+
// A function body never spans another top-level CREATE; bound the slice.
|
|
51
|
+
const rest = content.slice(m.index)
|
|
52
|
+
const endMatch = rest.slice(1).search(/CREATE\s+(?:OR\s+REPLACE\s+)?(?:FUNCTION|TRIGGER|POLICY|UNIQUE|INDEX)|CREATE\s+(?:TABLE|POLICY|TRIGGER|INDEX)/i)
|
|
53
|
+
const body = endMatch === -1 ? rest : rest.slice(0, endMatch + 1)
|
|
54
|
+
if (/SECURITY\s+DEFINER/i.test(body)) {
|
|
55
|
+
out.push({
|
|
56
|
+
name: m[1],
|
|
57
|
+
file,
|
|
58
|
+
hasSearchPath: /search_path\s*(?:=|TO)\s*'?public'?/i.test(body),
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Names that appear in any literal REVOKE ... ON FUNCTION public.name(...),
|
|
66
|
+
// any DROP FUNCTION public.name, any ALTER FUNCTION public.name SET
|
|
67
|
+
// search_path, or any DO-block that performs those via format()/EXECUTE with
|
|
68
|
+
// signature entries like 'name(arg,arg)'.
|
|
69
|
+
function collectCovered(
|
|
70
|
+
files: string[],
|
|
71
|
+
contents: Map<string, string>,
|
|
72
|
+
): { revoked: Set<string>; dropped: Set<string>; pathed: Set<string> } {
|
|
73
|
+
const revoked = new Set<string>()
|
|
74
|
+
const dropped = new Set<string>()
|
|
75
|
+
const pathed = new Set<string>()
|
|
76
|
+
|
|
77
|
+
for (const file of files) {
|
|
78
|
+
const content = contents.get(file)!
|
|
79
|
+
|
|
80
|
+
for (const m of content.matchAll(/REVOKE\s+\w+\s+ON\s+FUNCTION\s+public\.(\w+)\s*\(/gi)) {
|
|
81
|
+
revoked.add(m[1])
|
|
82
|
+
}
|
|
83
|
+
for (const m of content.matchAll(/DROP\s+FUNCTION\s+(?:IF\s+EXISTS\s+)?public\.(\w+)\s*\(/gi)) {
|
|
84
|
+
dropped.add(m[1])
|
|
85
|
+
}
|
|
86
|
+
for (const m of content.matchAll(/ALTER\s+FUNCTION\s+public\.(\w+)\s*\([^)]*\)\s*SET\s+search_path/gi)) {
|
|
87
|
+
pathed.add(m[1])
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// DO $$ ... $$ blocks that EXECUTE grant-management statements against a
|
|
91
|
+
// signature list: the signature literals inside the block name the covered
|
|
92
|
+
// functions (075 pattern).
|
|
93
|
+
for (const block of content.matchAll(/DO\s+\$\$[\s\S]*?END\s+\$\$;/gi)) {
|
|
94
|
+
const b = block[0]
|
|
95
|
+
const doesRevoke = /REVOKE/i.test(b)
|
|
96
|
+
const doesPath = /ALTER\s+FUNCTION[\s\S]*search_path|search_path[\s\S]*ALTER\s+FUNCTION/i.test(b)
|
|
97
|
+
if (!doesRevoke && !doesPath) continue
|
|
98
|
+
for (const m of b.matchAll(/'([a-z_]+)\(([^']*)\)'/g)) {
|
|
99
|
+
if (doesRevoke) revoked.add(m[1])
|
|
100
|
+
if (doesPath) pathed.add(m[1])
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { revoked, dropped, pathed }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe('SECURITY DEFINER grant hygiene', () => {
|
|
109
|
+
const files = migrationFiles()
|
|
110
|
+
const contents = new Map(files.map((f) => [f, readFileSync(join(MIGRATIONS_DIR, f), 'utf8')]))
|
|
111
|
+
const { revoked, dropped, pathed } = collectCovered(files, contents)
|
|
112
|
+
|
|
113
|
+
const definers: DefinerDef[] = files.flatMap((f) => findDefinerFunctions(f, contents.get(f)!))
|
|
114
|
+
const uniqueNames = [...new Set(definers.map((d) => d.name))]
|
|
115
|
+
|
|
116
|
+
it('finds the definer functions we know about', () => {
|
|
117
|
+
expect(uniqueNames).toEqual(
|
|
118
|
+
expect.arrayContaining([
|
|
119
|
+
'match_embeddings',
|
|
120
|
+
'get_account_apps',
|
|
121
|
+
'get_accessible_accounts',
|
|
122
|
+
'current_actor_id',
|
|
123
|
+
'rebuild_kb_cache',
|
|
124
|
+
'revoke_embeddings_for_item',
|
|
125
|
+
'cleanup_old_logs',
|
|
126
|
+
]),
|
|
127
|
+
)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('every definer function is revoked, dropped, or an allowlisted RLS helper', () => {
|
|
131
|
+
const exposed = uniqueNames.filter(
|
|
132
|
+
(n) => !EXEC_ALLOWLIST.has(n) && !revoked.has(n) && !dropped.has(n),
|
|
133
|
+
)
|
|
134
|
+
expect(
|
|
135
|
+
exposed,
|
|
136
|
+
`SECURITY DEFINER functions still executable by PUBLIC/anon/authenticated — ` +
|
|
137
|
+
`add "REVOKE ALL ON FUNCTION public.<sig> FROM PUBLIC, anon, authenticated" ` +
|
|
138
|
+
`to a migration, or extend the EXEC_ALLOWLIST if it is an RLS helper:\n ${exposed.join('\n ')}`,
|
|
139
|
+
).toEqual([])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('every definer function pins search_path', () => {
|
|
143
|
+
const missing = uniqueNames.filter((n) => {
|
|
144
|
+
const defs = definers.filter((d) => d.name === n)
|
|
145
|
+
const anyDefHasPath = defs.some((d) => d.hasSearchPath)
|
|
146
|
+
return !anyDefHasPath && !pathed.has(n)
|
|
147
|
+
})
|
|
148
|
+
expect(
|
|
149
|
+
missing,
|
|
150
|
+
`SECURITY DEFINER functions without a pinned search_path — ` +
|
|
151
|
+
`add SET search_path = public to the definition or ALTER FUNCTION:\n ${missing.join('\n ')}`,
|
|
152
|
+
).toEqual([])
|
|
153
|
+
})
|
|
154
|
+
})
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// F-051: requireSystemContextWithAudit's ctx overload used to return a
|
|
4
|
+
// Netlify response object on denial; handlers returned it as `result`, so
|
|
5
|
+
// createHandler wrapped it as {data:{statusCode:403}} under HTTP 200. It now
|
|
6
|
+
// throws a statusCode-carrying error which the catch path maps to a real
|
|
7
|
+
// HTTP status. These tests exercise the real createHandler end-to-end for
|
|
8
|
+
// security.ts and tests.ts.
|
|
9
|
+
|
|
10
|
+
const principalRef = vi.hoisted(() => ({ principal: null as any }))
|
|
11
|
+
|
|
12
|
+
vi.mock('../../functions/_shared/principal.ts', () => ({
|
|
13
|
+
resolvePrincipal: vi.fn(async () => principalRef.principal),
|
|
14
|
+
isSystemAdmin: (p: any) =>
|
|
15
|
+
!!p?.roles?.some((r: any) => r === 'system_admin' || r?.slug === 'system_admin'),
|
|
16
|
+
getPrincipalDb: () => ({}),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
vi.mock('../../functions/_shared/db.ts', () => ({
|
|
20
|
+
adminDb: {
|
|
21
|
+
from: () => {
|
|
22
|
+
const builder: any = {}
|
|
23
|
+
for (const m of [
|
|
24
|
+
'select', 'eq', 'in', 'is', 'ilike', 'filter', 'not', 'order', 'limit',
|
|
25
|
+
'update', 'insert', 'upsert', 'delete', 'neq', 'or', 'contains', 'gte',
|
|
26
|
+
'lte', 'lt', 'range', 'textSearch',
|
|
27
|
+
]) {
|
|
28
|
+
builder[m] = () => builder
|
|
29
|
+
}
|
|
30
|
+
builder.single = () => Promise.resolve({ data: null, error: null })
|
|
31
|
+
builder.maybeSingle = () => Promise.resolve({ data: null, error: null })
|
|
32
|
+
builder.then = (res: any, rej: any) =>
|
|
33
|
+
Promise.resolve({ data: [], error: null }).then(res, rej)
|
|
34
|
+
return builder
|
|
35
|
+
},
|
|
36
|
+
rpc: vi.fn(async () => ({ data: null, error: null })),
|
|
37
|
+
auth: { admin: {} },
|
|
38
|
+
},
|
|
39
|
+
getUserDb: vi.fn(() => ({})),
|
|
40
|
+
}))
|
|
41
|
+
|
|
42
|
+
vi.mock('../../functions/_shared/audit.ts', () => ({
|
|
43
|
+
emitAudit: vi.fn(async () => {}),
|
|
44
|
+
emitSpan: vi.fn(async () => {}),
|
|
45
|
+
emitLog: vi.fn(async () => {}),
|
|
46
|
+
emitActivity: vi.fn(async () => {}),
|
|
47
|
+
}))
|
|
48
|
+
|
|
49
|
+
vi.mock('../../functions/_shared/security.ts', () => ({
|
|
50
|
+
isBlockedRequest: vi.fn(async () => null),
|
|
51
|
+
isBlockedIp: vi.fn(async () => false),
|
|
52
|
+
touchPersonGeo: vi.fn(async () => {}),
|
|
53
|
+
}))
|
|
54
|
+
|
|
55
|
+
vi.mock('../../functions/_shared/geo.ts', () => ({
|
|
56
|
+
getClientGeo: vi.fn(() => ({ ip: '127.0.0.1' })),
|
|
57
|
+
}))
|
|
58
|
+
|
|
59
|
+
import { requireSystemContextWithAudit } from '../../functions/_shared/middleware'
|
|
60
|
+
import { handler as securityHandler } from '../../functions/security'
|
|
61
|
+
import { handler as testsHandler } from '../../functions/tests'
|
|
62
|
+
|
|
63
|
+
const memberPrincipal = {
|
|
64
|
+
id: 'p-member',
|
|
65
|
+
type: 'human',
|
|
66
|
+
accountId: 'acct-1',
|
|
67
|
+
roles: ['member'],
|
|
68
|
+
provenance: { sourceType: 'jwt', createdBy: null, invokedAt: new Date().toISOString() },
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const adminPrincipal = {
|
|
72
|
+
...memberPrincipal,
|
|
73
|
+
id: 'p-admin',
|
|
74
|
+
roles: ['system_admin'],
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const event = (queryStringParameters: Record<string, string> = {}) => ({
|
|
78
|
+
headers: {},
|
|
79
|
+
httpMethod: 'GET',
|
|
80
|
+
path: '/api/x',
|
|
81
|
+
queryStringParameters,
|
|
82
|
+
body: null,
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('requireSystemContextWithAudit ctx overload (F-051)', () => {
|
|
86
|
+
it('throws a statusCode 403 error for a non-admin principal', () => {
|
|
87
|
+
const ctx = { principal: memberPrincipal, accountId: 'acct-1' }
|
|
88
|
+
try {
|
|
89
|
+
requireSystemContextWithAudit(ctx as any)
|
|
90
|
+
expect.unreachable('should have thrown')
|
|
91
|
+
} catch (err: any) {
|
|
92
|
+
expect(err).toBeInstanceOf(Error)
|
|
93
|
+
expect(err.statusCode).toBe(403)
|
|
94
|
+
expect(err.message).toBe('System context required')
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('throws a statusCode 401 error for an anonymous principal', () => {
|
|
99
|
+
const ctx = { principal: { id: 'anonymous', type: 'machine' }, accountId: null }
|
|
100
|
+
try {
|
|
101
|
+
requireSystemContextWithAudit(ctx as any)
|
|
102
|
+
expect.unreachable('should have thrown')
|
|
103
|
+
} catch (err: any) {
|
|
104
|
+
expect(err.statusCode).toBe(401)
|
|
105
|
+
expect(err.message).toBe('Authentication required')
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('returns null and stamps triggeredBy for a system_admin principal', () => {
|
|
110
|
+
const ctx: any = { principal: { ...adminPrincipal }, accountId: 'acct-1' }
|
|
111
|
+
expect(requireSystemContextWithAudit(ctx, 'suite')).toBeNull()
|
|
112
|
+
expect(ctx.triggeredBy).toBe('suite')
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe('denied responses are real HTTP errors (F-051)', () => {
|
|
117
|
+
it('tests.ts returns HTTP 403 — not a 200-wrapped response object', async () => {
|
|
118
|
+
principalRef.principal = memberPrincipal
|
|
119
|
+
const res = await testsHandler(event({ action: 'list' }), {})
|
|
120
|
+
expect(res.statusCode).toBe(403)
|
|
121
|
+
const body = JSON.parse(res.body)
|
|
122
|
+
expect(body.error).toBe('System context required')
|
|
123
|
+
expect(body.data).toBeNull()
|
|
124
|
+
// Regression shape: previously {statusCode:200, body:{data:{statusCode:403}}}
|
|
125
|
+
expect(body.data?.statusCode).toBeUndefined()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('security.ts returns HTTP 403 — not a 200-wrapped response object', async () => {
|
|
129
|
+
principalRef.principal = memberPrincipal
|
|
130
|
+
const res = await securityHandler(event({ action: 'blocklist' }), {})
|
|
131
|
+
expect(res.statusCode).toBe(403)
|
|
132
|
+
const body = JSON.parse(res.body)
|
|
133
|
+
expect(body.error).toBe('System context required')
|
|
134
|
+
expect(body.data).toBeNull()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('tests.ts still serves a system_admin principal with HTTP 200', async () => {
|
|
138
|
+
principalRef.principal = adminPrincipal
|
|
139
|
+
const res = await testsHandler(event({ action: 'list' }), {})
|
|
140
|
+
expect(res.statusCode).toBe(200)
|
|
141
|
+
const body = JSON.parse(res.body)
|
|
142
|
+
expect(body.error).toBeNull()
|
|
143
|
+
expect(body.data.data).toEqual([])
|
|
144
|
+
})
|
|
145
|
+
})
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// Tests for system-cron's per-tick maintenance blocks (F-052).
|
|
2
|
+
// Regression: when get_due_schedules returns zero rows the handler used to
|
|
3
|
+
// return early, skipping due timers, threshold alerts, delivery retries,
|
|
4
|
+
// and log cleanup. These tests exercise the handler with all shared deps
|
|
5
|
+
// mocked; createHandler is stubbed to identity so a fabricated
|
|
6
|
+
// RequestContext flows straight into the handler body.
|
|
7
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
rpcMock,
|
|
11
|
+
fromMock,
|
|
12
|
+
emitAuditMock,
|
|
13
|
+
runPipelineMock,
|
|
14
|
+
sweepMock,
|
|
15
|
+
retryMock,
|
|
16
|
+
} = vi.hoisted(() => ({
|
|
17
|
+
rpcMock: vi.fn(),
|
|
18
|
+
fromMock: vi.fn(),
|
|
19
|
+
emitAuditMock: vi.fn(),
|
|
20
|
+
runPipelineMock: vi.fn(),
|
|
21
|
+
sweepMock: vi.fn(),
|
|
22
|
+
retryMock: vi.fn(),
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
vi.mock('../../functions/_shared/middleware.ts', () => ({
|
|
26
|
+
createHandler: (fn: any) => fn,
|
|
27
|
+
}))
|
|
28
|
+
|
|
29
|
+
vi.mock('../../functions/_shared/db.ts', () => ({
|
|
30
|
+
adminDb: { rpc: rpcMock, from: fromMock },
|
|
31
|
+
}))
|
|
32
|
+
|
|
33
|
+
vi.mock('../../functions/_shared/audit.ts', () => ({
|
|
34
|
+
emitAudit: emitAuditMock,
|
|
35
|
+
}))
|
|
36
|
+
|
|
37
|
+
vi.mock('../../functions/_shared/pipeline-runner.ts', () => ({
|
|
38
|
+
runPipeline: runPipelineMock,
|
|
39
|
+
sweepStaleExecutions: sweepMock,
|
|
40
|
+
}))
|
|
41
|
+
|
|
42
|
+
vi.mock('../../functions/_shared/retry-deliveries.ts', () => ({
|
|
43
|
+
retryPendingDeliveries: retryMock,
|
|
44
|
+
}))
|
|
45
|
+
|
|
46
|
+
import { handler } from '../../functions/system-cron.ts'
|
|
47
|
+
|
|
48
|
+
function makeCtx() {
|
|
49
|
+
return {
|
|
50
|
+
requestId: 'test-req',
|
|
51
|
+
principal: {
|
|
52
|
+
id: 'system',
|
|
53
|
+
type: 'machine',
|
|
54
|
+
machineType: 'internal',
|
|
55
|
+
accountId: null,
|
|
56
|
+
scopes: ['*:*'],
|
|
57
|
+
isInternal: true,
|
|
58
|
+
},
|
|
59
|
+
db: {},
|
|
60
|
+
accountId: null,
|
|
61
|
+
appId: null,
|
|
62
|
+
query: {},
|
|
63
|
+
requestPath: '/.netlify/functions/system-cron',
|
|
64
|
+
headers: {},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Per-RPC-name responses; anything not listed resolves to an empty list.
|
|
69
|
+
function rpcImpl(overrides: Record<string, any> = {}) {
|
|
70
|
+
return (name: string) => {
|
|
71
|
+
if (name in overrides) {
|
|
72
|
+
const v = overrides[name]
|
|
73
|
+
return v instanceof Error ? Promise.reject(v) : Promise.resolve(v)
|
|
74
|
+
}
|
|
75
|
+
return Promise.resolve({ data: [], error: null })
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Chainable thenable query builder; terminal result is keyed by table name.
|
|
80
|
+
function fromImpl(tableData: Record<string, any> = {}) {
|
|
81
|
+
return (table: string) => {
|
|
82
|
+
const result = table in tableData ? tableData[table] : { data: [], error: null }
|
|
83
|
+
const chain: any = {}
|
|
84
|
+
for (const m of ['select', 'eq', 'neq', 'lt', 'gt', 'or', 'in', 'order', 'limit', 'insert', 'update', 'single', 'maybeSingle']) {
|
|
85
|
+
chain[m] = vi.fn(() => chain)
|
|
86
|
+
}
|
|
87
|
+
chain.then = (onF: any, onR: any) => Promise.resolve(result).then(onF, onR)
|
|
88
|
+
return chain
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const RETRY_SUMMARY = {
|
|
93
|
+
outbound_retried: 0,
|
|
94
|
+
outbound_succeeded: 0,
|
|
95
|
+
outbound_failed: 0,
|
|
96
|
+
inbound_retried: 0,
|
|
97
|
+
inbound_succeeded: 0,
|
|
98
|
+
inbound_failed: 0,
|
|
99
|
+
errors: [],
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe('system-cron', () => {
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
vi.clearAllMocks()
|
|
105
|
+
sweepMock.mockResolvedValue(undefined)
|
|
106
|
+
retryMock.mockResolvedValue(RETRY_SUMMARY)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('runs per-tick maintenance when no schedules are due', async () => {
|
|
110
|
+
rpcMock.mockImplementation(rpcImpl({
|
|
111
|
+
get_due_schedules: { data: [], error: null },
|
|
112
|
+
get_due_timers: { data: [], error: null },
|
|
113
|
+
}))
|
|
114
|
+
fromMock.mockImplementation(fromImpl({
|
|
115
|
+
threshold_alerts: { data: [], error: null },
|
|
116
|
+
}))
|
|
117
|
+
|
|
118
|
+
const res = await handler(makeCtx())
|
|
119
|
+
expect(res.statusCode).toBe(200)
|
|
120
|
+
|
|
121
|
+
// Every per-tick block still ran despite the empty schedule list.
|
|
122
|
+
expect(sweepMock).toHaveBeenCalledOnce()
|
|
123
|
+
expect(rpcMock).toHaveBeenCalledWith('get_due_timers', expect.anything())
|
|
124
|
+
expect(fromMock).toHaveBeenCalledWith('threshold_alerts')
|
|
125
|
+
expect(retryMock).toHaveBeenCalledOnce()
|
|
126
|
+
|
|
127
|
+
const body = JSON.parse(res.body)
|
|
128
|
+
expect(body.schedules_due).toBe(0)
|
|
129
|
+
expect(body.executed).toBe(0)
|
|
130
|
+
expect(body.results).toEqual([])
|
|
131
|
+
expect(body.thresholds_evaluated).toBe(0)
|
|
132
|
+
expect(body.deliveries_retried).toEqual(RETRY_SUMMARY)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('executes due timers on a tick with no due schedules', async () => {
|
|
136
|
+
const timer = {
|
|
137
|
+
timer_id: 't1',
|
|
138
|
+
pipeline_id: 'p1',
|
|
139
|
+
account_id: 'a1',
|
|
140
|
+
name: 'nightly',
|
|
141
|
+
execution_count: 3,
|
|
142
|
+
created_by: 'u1',
|
|
143
|
+
app_id: null,
|
|
144
|
+
}
|
|
145
|
+
rpcMock.mockImplementation(rpcImpl({
|
|
146
|
+
get_due_schedules: { data: [], error: null },
|
|
147
|
+
get_due_timers: { data: [timer], error: null },
|
|
148
|
+
update_timer_after_run: { data: null, error: null },
|
|
149
|
+
}))
|
|
150
|
+
fromMock.mockImplementation(fromImpl({
|
|
151
|
+
threshold_alerts: { data: [], error: null },
|
|
152
|
+
}))
|
|
153
|
+
runPipelineMock.mockResolvedValue({ status: 'completed' })
|
|
154
|
+
|
|
155
|
+
const res = await handler(makeCtx())
|
|
156
|
+
expect(res.statusCode).toBe(200)
|
|
157
|
+
|
|
158
|
+
expect(runPipelineMock).toHaveBeenCalledWith(
|
|
159
|
+
'p1',
|
|
160
|
+
expect.objectContaining({ timer_id: 't1' }),
|
|
161
|
+
expect.objectContaining({ accountId: 'a1' }),
|
|
162
|
+
)
|
|
163
|
+
expect(rpcMock).toHaveBeenCalledWith('update_timer_after_run',
|
|
164
|
+
expect.objectContaining({ p_timer_id: 't1', p_success: true }))
|
|
165
|
+
|
|
166
|
+
const body = JSON.parse(res.body)
|
|
167
|
+
expect(body.schedules_due).toBe(0)
|
|
168
|
+
expect(body.executed).toBe(1)
|
|
169
|
+
expect(body.success).toBe(1)
|
|
170
|
+
expect(body.results[0].scheduleId).toBe('t1')
|
|
171
|
+
expect(retryMock).toHaveBeenCalledOnce()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('still reaches delivery retries when the timer block throws', async () => {
|
|
175
|
+
rpcMock.mockImplementation(rpcImpl({
|
|
176
|
+
get_due_schedules: { data: [], error: null },
|
|
177
|
+
get_due_timers: new Error('rpc down'),
|
|
178
|
+
}))
|
|
179
|
+
fromMock.mockImplementation(fromImpl({
|
|
180
|
+
threshold_alerts: { data: [], error: null },
|
|
181
|
+
}))
|
|
182
|
+
|
|
183
|
+
const res = await handler(makeCtx())
|
|
184
|
+
expect(res.statusCode).toBe(200)
|
|
185
|
+
expect(retryMock).toHaveBeenCalledOnce()
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('still processes due schedules and then runs maintenance', async () => {
|
|
189
|
+
const schedule = {
|
|
190
|
+
schedule_id: 's1',
|
|
191
|
+
action_id: 'act1',
|
|
192
|
+
account_id: 'a1',
|
|
193
|
+
machine_principal_id: 'm1',
|
|
194
|
+
delegated_scopes: ['*:*'],
|
|
195
|
+
config: {},
|
|
196
|
+
}
|
|
197
|
+
const action = {
|
|
198
|
+
id: 'act1',
|
|
199
|
+
handler: 'send_email',
|
|
200
|
+
handler_module: 'functions',
|
|
201
|
+
config: {},
|
|
202
|
+
required_scopes: ['*:*'],
|
|
203
|
+
}
|
|
204
|
+
const machine = {
|
|
205
|
+
id: 'm1',
|
|
206
|
+
account_id: 'a1',
|
|
207
|
+
scopes: ['*:*'],
|
|
208
|
+
machine_type: 'internal',
|
|
209
|
+
is_internal: true,
|
|
210
|
+
created_by: 'u1',
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
rpcMock.mockImplementation(rpcImpl({
|
|
214
|
+
get_due_schedules: { data: [schedule], error: null },
|
|
215
|
+
get_due_timers: { data: [], error: null },
|
|
216
|
+
validate_schedule_creator: { data: { is_valid: true }, error: null },
|
|
217
|
+
update_schedule_after_run: { data: null, error: null },
|
|
218
|
+
}))
|
|
219
|
+
fromMock.mockImplementation(fromImpl({
|
|
220
|
+
actions: { data: action, error: null },
|
|
221
|
+
api_keys: { data: machine, error: null },
|
|
222
|
+
threshold_alerts: { data: [], error: null },
|
|
223
|
+
schedule_executions: { data: null, error: null },
|
|
224
|
+
}))
|
|
225
|
+
|
|
226
|
+
const res = await handler(makeCtx())
|
|
227
|
+
expect(res.statusCode).toBe(200)
|
|
228
|
+
|
|
229
|
+
const body = JSON.parse(res.body)
|
|
230
|
+
expect(body.schedules_due).toBe(1)
|
|
231
|
+
expect(body.executed).toBe(1)
|
|
232
|
+
expect(body.success).toBe(1)
|
|
233
|
+
expect(body.results[0].scheduleId).toBe('s1')
|
|
234
|
+
|
|
235
|
+
// Maintenance blocks still ran after schedule processing.
|
|
236
|
+
expect(rpcMock).toHaveBeenCalledWith('get_due_timers', expect.anything())
|
|
237
|
+
expect(fromMock).toHaveBeenCalledWith('threshold_alerts')
|
|
238
|
+
expect(retryMock).toHaveBeenCalledOnce()
|
|
239
|
+
})
|
|
240
|
+
})
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Tests for F-056: timers created/activated via the API must be armed
|
|
2
|
+
// (next_execution set) or they can never fire — get_due_timers requires
|
|
3
|
+
// next_execution IS NOT NULL and system-cron is the only consumer.
|
|
4
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
5
|
+
|
|
6
|
+
const { fromMock } = vi.hoisted(() => ({ fromMock: vi.fn() }))
|
|
7
|
+
|
|
8
|
+
vi.mock('../../functions/_shared/db.ts', () => ({
|
|
9
|
+
adminDb: { from: fromMock, rpc: vi.fn() },
|
|
10
|
+
joins: {}
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
vi.mock('../../functions/_shared/audit.ts', () => ({
|
|
14
|
+
emitLog: vi.fn().mockResolvedValue(undefined),
|
|
15
|
+
emitAudit: vi.fn().mockResolvedValue(undefined),
|
|
16
|
+
emitSpan: vi.fn().mockResolvedValue(undefined)
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
vi.mock('../../functions/_shared/permissions.ts', () => ({
|
|
20
|
+
sanitizeRecordData: vi.fn((_ctx: any, record: any) => Promise.resolve(record))
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
import { create, update, toggle } from '../../functions/timers.ts'
|
|
24
|
+
|
|
25
|
+
// Chainable stand-in for the supabase-js query builder. Every call on a
|
|
26
|
+
// table gets its own builder via a results queue on `db.from`, and all
|
|
27
|
+
// insert/update payloads are recorded in `captured`.
|
|
28
|
+
function makeBuilder(result: any, captured: Array<{ method: string; args: any[] }>) {
|
|
29
|
+
const b: any = {}
|
|
30
|
+
for (const m of ['select', 'insert', 'update', 'delete', 'eq', 'order']) {
|
|
31
|
+
b[m] = vi.fn((...args: any[]) => { captured.push({ method: m, args }); return b })
|
|
32
|
+
}
|
|
33
|
+
b.single = vi.fn(() => Promise.resolve(result))
|
|
34
|
+
b.maybeSingle = vi.fn(() => Promise.resolve(result))
|
|
35
|
+
b.then = (onFulfilled: any, onRejected?: any) => Promise.resolve(result).then(onFulfilled, onRejected)
|
|
36
|
+
return b
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let captured: Array<{ method: string; args: any[] }>
|
|
40
|
+
let fromResults: any[]
|
|
41
|
+
|
|
42
|
+
function queueResult(result: any) {
|
|
43
|
+
fromResults.push(result)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const ctx: any = {
|
|
47
|
+
requestId: 'req-timer-test',
|
|
48
|
+
principal: { id: 'person-1', type: 'person', accountId: 'acct-1', roles: [{ slug: 'system_admin' }] },
|
|
49
|
+
accountId: 'acct-1',
|
|
50
|
+
appId: null,
|
|
51
|
+
db: {},
|
|
52
|
+
query: {}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
captured = []
|
|
57
|
+
fromResults = []
|
|
58
|
+
fromMock.mockReset()
|
|
59
|
+
fromMock.mockImplementation(() =>
|
|
60
|
+
makeBuilder(fromResults.shift() ?? { data: { id: 'timer-1' }, error: null }, captured))
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
function insertPayload() {
|
|
64
|
+
return captured.find(c => c.method === 'insert')?.args[0]
|
|
65
|
+
}
|
|
66
|
+
function updatePayload() {
|
|
67
|
+
return captured.find(c => c.method === 'update')?.args[0]
|
|
68
|
+
}
|
|
69
|
+
const MIN = 60_000
|
|
70
|
+
|
|
71
|
+
describe('timers.create — F-056 arming', () => {
|
|
72
|
+
it('arms next_execution from config.interval_minutes', async () => {
|
|
73
|
+
const before = Date.now()
|
|
74
|
+
await create(ctx, { name: 'n', timer_type: 'interval', config: { interval_minutes: 15 } })
|
|
75
|
+
const ts = Date.parse(insertPayload().next_execution)
|
|
76
|
+
expect(ts).toBeGreaterThanOrEqual(before + 15 * MIN)
|
|
77
|
+
expect(ts).toBeLessThanOrEqual(Date.now() + 15 * MIN + 1000)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('arms next_execution from legacy config.interval_seconds', async () => {
|
|
81
|
+
const before = Date.now()
|
|
82
|
+
await create(ctx, { name: 'n', timer_type: 'interval', config: { interval_seconds: 3600 } })
|
|
83
|
+
const ts = Date.parse(insertPayload().next_execution)
|
|
84
|
+
expect(ts).toBeGreaterThanOrEqual(before + 60 * MIN)
|
|
85
|
+
expect(ts).toBeLessThanOrEqual(Date.now() + 60 * MIN + 1000)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('lets an explicit next_execution win over the computed value', async () => {
|
|
89
|
+
const explicit = '2030-01-01T00:00:00.000Z'
|
|
90
|
+
await create(ctx, { name: 'n', timer_type: 'interval', config: { interval_minutes: 5 }, next_execution: explicit })
|
|
91
|
+
expect(insertPayload().next_execution).toBe(explicit)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('arms a once timer from config.run_at', async () => {
|
|
95
|
+
const runAt = '2030-06-15T12:00:00.000Z'
|
|
96
|
+
await create(ctx, { name: 'n', timer_type: 'once', config: { run_at: runAt } })
|
|
97
|
+
expect(insertPayload().next_execution).toBe(runAt)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('leaves next_execution null when the timer is created inactive', async () => {
|
|
101
|
+
await create(ctx, { name: 'n', timer_type: 'interval', is_active: false, config: { interval_minutes: 15 } })
|
|
102
|
+
expect(insertPayload().next_execution).toBeNull()
|
|
103
|
+
expect(insertPayload().is_active).toBe(false)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('leaves next_execution null when no schedule is computable', async () => {
|
|
107
|
+
await create(ctx, { name: 'n', timer_type: 'cron', config: { schedule: '*/5 * * * *' } })
|
|
108
|
+
expect(insertPayload().next_execution).toBeNull()
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('timers.update — F-056 arming', () => {
|
|
113
|
+
it('re-arms next_execution when config changes', async () => {
|
|
114
|
+
queueResult({ data: { config: { interval_minutes: 60 }, is_active: true }, error: null })
|
|
115
|
+
const before = Date.now()
|
|
116
|
+
await update(ctx, { id: 't1', config: { interval_minutes: 10 } })
|
|
117
|
+
const ts = Date.parse(updatePayload().next_execution)
|
|
118
|
+
expect(ts).toBeGreaterThanOrEqual(before + 10 * MIN)
|
|
119
|
+
expect(ts).toBeLessThanOrEqual(Date.now() + 10 * MIN + 1000)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('arms next_execution when an inactive timer is activated via update', async () => {
|
|
123
|
+
queueResult({ data: { config: { interval_seconds: 600 }, is_active: false }, error: null })
|
|
124
|
+
const before = Date.now()
|
|
125
|
+
await update(ctx, { id: 't1', is_active: true })
|
|
126
|
+
const ts = Date.parse(updatePayload().next_execution)
|
|
127
|
+
expect(ts).toBeGreaterThanOrEqual(before + 10 * MIN)
|
|
128
|
+
expect(ts).toBeLessThanOrEqual(Date.now() + 10 * MIN + 1000)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('passes through an explicit next_execution', async () => {
|
|
132
|
+
const explicit = '2031-03-03T03:03:03.000Z'
|
|
133
|
+
await update(ctx, { id: 't1', next_execution: explicit })
|
|
134
|
+
expect(updatePayload().next_execution).toBe(explicit)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('does not touch next_execution for unrelated field updates', async () => {
|
|
138
|
+
await update(ctx, { id: 't1', name: 'renamed' })
|
|
139
|
+
expect(updatePayload().next_execution).toBeUndefined()
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('timers.toggle — F-056 arming', () => {
|
|
144
|
+
it('arms next_execution when activating a never-scheduled timer', async () => {
|
|
145
|
+
queueResult({ data: { is_active: false, name: 'n', config: { interval_minutes: 30 }, next_execution: null }, error: null })
|
|
146
|
+
const before = Date.now()
|
|
147
|
+
await toggle(ctx, { id: 't1' })
|
|
148
|
+
const ts = Date.parse(updatePayload().next_execution)
|
|
149
|
+
expect(ts).toBeGreaterThanOrEqual(before + 30 * MIN)
|
|
150
|
+
expect(ts).toBeLessThanOrEqual(Date.now() + 30 * MIN + 1000)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('does not clear next_execution when deactivating', async () => {
|
|
154
|
+
queueResult({ data: { is_active: true, name: 'n', config: { interval_minutes: 30 }, next_execution: '2030-01-01T00:00:00.000Z' }, error: null })
|
|
155
|
+
await toggle(ctx, { id: 't1' })
|
|
156
|
+
expect(updatePayload().is_active).toBe(false)
|
|
157
|
+
expect(updatePayload().next_execution).toBeUndefined()
|
|
158
|
+
})
|
|
159
|
+
})
|