@supatype/cli 0.1.5 → 0.1.7
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/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +143 -93
- package/.turbo/turbo-typecheck.log +1 -1
- package/deno.d.ts +52 -0
- package/dist/augmentation-generator.d.ts +22 -0
- package/dist/augmentation-generator.d.ts.map +1 -1
- package/dist/augmentation-generator.js +43 -7
- package/dist/augmentation-generator.js.map +1 -1
- package/dist/binary-cache.d.ts +14 -0
- package/dist/binary-cache.d.ts.map +1 -1
- package/dist/binary-cache.js +33 -13
- package/dist/binary-cache.js.map +1 -1
- package/dist/cache-clean.d.ts +15 -0
- package/dist/cache-clean.d.ts.map +1 -0
- package/dist/cache-clean.js +76 -0
- package/dist/cache-clean.js.map +1 -0
- package/dist/cache-pins.d.ts +51 -0
- package/dist/cache-pins.d.ts.map +1 -0
- package/dist/cache-pins.js +184 -0
- package/dist/cache-pins.js.map +1 -0
- package/dist/cli-install-method.d.ts +19 -0
- package/dist/cli-install-method.d.ts.map +1 -0
- package/dist/cli-install-method.js +60 -0
- package/dist/cli-install-method.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +6 -0
- package/dist/cli.js.map +1 -1
- package/dist/cloud-credentials.d.ts +34 -0
- package/dist/cloud-credentials.d.ts.map +1 -0
- package/dist/cloud-credentials.js +139 -0
- package/dist/cloud-credentials.js.map +1 -0
- package/dist/commands/admin.d.ts +11 -0
- package/dist/commands/admin.d.ts.map +1 -1
- package/dist/commands/admin.js +131 -22
- package/dist/commands/admin.js.map +1 -1
- package/dist/commands/cache.d.ts.map +1 -1
- package/dist/commands/cache.js +67 -71
- package/dist/commands/cache.js.map +1 -1
- package/dist/commands/cloud.d.ts.map +1 -1
- package/dist/commands/cloud.js +32 -8
- package/dist/commands/cloud.js.map +1 -1
- package/dist/commands/db.d.ts +1 -0
- package/dist/commands/db.d.ts.map +1 -1
- package/dist/commands/db.js +147 -1
- package/dist/commands/db.js.map +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +50 -10
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/doctor.d.ts +28 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +60 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/functions.d.ts +7 -0
- package/dist/commands/functions.d.ts.map +1 -1
- package/dist/commands/functions.js +40 -6
- package/dist/commands/functions.js.map +1 -1
- package/dist/commands/generate.d.ts.map +1 -1
- package/dist/commands/generate.js +5 -1
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/hooks.d.ts +12 -0
- package/dist/commands/hooks.d.ts.map +1 -0
- package/dist/commands/hooks.js +118 -0
- package/dist/commands/hooks.js.map +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +41 -7
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/internal.d.ts +6 -0
- package/dist/commands/internal.d.ts.map +1 -0
- package/dist/commands/internal.js +22 -0
- package/dist/commands/internal.js.map +1 -0
- package/dist/commands/keys.d.ts.map +1 -1
- package/dist/commands/keys.js +2 -0
- package/dist/commands/keys.js.map +1 -1
- package/dist/commands/link-helpers.d.ts.map +1 -1
- package/dist/commands/link-helpers.js +8 -2
- package/dist/commands/link-helpers.js.map +1 -1
- package/dist/commands/login.d.ts +7 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +110 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +110 -7
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/self-update.d.ts +7 -0
- package/dist/commands/self-update.d.ts.map +1 -1
- package/dist/commands/self-update.js +18 -11
- package/dist/commands/self-update.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +18 -3
- package/dist/config.js.map +1 -1
- package/dist/db-preflight.d.ts +78 -0
- package/dist/db-preflight.d.ts.map +1 -0
- package/dist/db-preflight.js +452 -0
- package/dist/db-preflight.js.map +1 -0
- package/dist/dev-compose.d.ts +1 -1
- package/dist/dev-compose.d.ts.map +1 -1
- package/dist/dev-compose.js +129 -72
- package/dist/dev-compose.js.map +1 -1
- package/dist/env-file.d.ts +40 -2
- package/dist/env-file.d.ts.map +1 -1
- package/dist/env-file.js +132 -11
- package/dist/env-file.js.map +1 -1
- package/dist/field-masking-tier.d.ts +50 -0
- package/dist/field-masking-tier.d.ts.map +1 -0
- package/dist/field-masking-tier.js +91 -0
- package/dist/field-masking-tier.js.map +1 -0
- package/dist/functions-deno-types.d.ts +24 -0
- package/dist/functions-deno-types.d.ts.map +1 -0
- package/dist/functions-deno-types.js +155 -0
- package/dist/functions-deno-types.js.map +1 -0
- package/dist/hook-upload.d.ts +30 -0
- package/dist/hook-upload.d.ts.map +1 -0
- package/dist/hook-upload.js +86 -0
- package/dist/hook-upload.js.map +1 -0
- package/dist/hooks-generator.d.ts +8 -0
- package/dist/hooks-generator.d.ts.map +1 -0
- package/dist/hooks-generator.js +332 -0
- package/dist/hooks-generator.js.map +1 -0
- package/dist/link.d.ts +4 -0
- package/dist/link.d.ts.map +1 -1
- package/dist/link.js +2 -0
- package/dist/link.js.map +1 -1
- package/dist/local-secrets.d.ts +71 -0
- package/dist/local-secrets.d.ts.map +1 -0
- package/dist/local-secrets.js +100 -0
- package/dist/local-secrets.js.map +1 -0
- package/dist/model-hooks.d.ts +73 -0
- package/dist/model-hooks.d.ts.map +1 -0
- package/dist/model-hooks.js +211 -0
- package/dist/model-hooks.js.map +1 -0
- package/dist/postgres-ctl.d.ts +11 -0
- package/dist/postgres-ctl.d.ts.map +1 -1
- package/dist/postgres-ctl.js +25 -2
- package/dist/postgres-ctl.js.map +1 -1
- package/dist/project-config.d.ts +121 -1
- package/dist/project-config.d.ts.map +1 -1
- package/dist/project-config.js +150 -1
- package/dist/project-config.js.map +1 -1
- package/dist/resolve-target.d.ts +4 -0
- package/dist/resolve-target.d.ts.map +1 -1
- package/dist/resolve-target.js +69 -73
- package/dist/resolve-target.js.map +1 -1
- package/dist/schema-ast-v2.d.ts +2 -1
- package/dist/schema-ast-v2.d.ts.map +1 -1
- package/dist/schema-ast-v2.js +4 -2
- package/dist/schema-ast-v2.js.map +1 -1
- package/dist/schema-push-lock.d.ts.map +1 -1
- package/dist/schema-push-lock.js +43 -16
- package/dist/schema-push-lock.js.map +1 -1
- package/dist/self-host-compose.d.ts +24 -0
- package/dist/self-host-compose.d.ts.map +1 -1
- package/dist/self-host-compose.js +301 -69
- package/dist/self-host-compose.js.map +1 -1
- package/dist/service-role-check.d.ts +21 -0
- package/dist/service-role-check.d.ts.map +1 -0
- package/dist/service-role-check.js +85 -0
- package/dist/service-role-check.js.map +1 -0
- package/dist/target-client.d.ts +17 -0
- package/dist/target-client.d.ts.map +1 -1
- package/dist/target-client.js +69 -17
- package/dist/target-client.js.map +1 -1
- package/dist/type-extractor.d.ts.map +1 -1
- package/dist/type-extractor.js +736 -32
- package/dist/type-extractor.js.map +1 -1
- package/dist/ui/runtime/command-chrome.js +1 -1
- package/dist/ui/runtime/command-chrome.js.map +1 -1
- package/dist/ui/runtime/stdin-after-ink.d.ts.map +1 -1
- package/dist/ui/runtime/stdin-after-ink.js +3 -1
- package/dist/ui/runtime/stdin-after-ink.js.map +1 -1
- package/package.json +4 -1
- package/scripts/generate-homebrew-formula.mjs +121 -0
- package/src/augmentation-generator.ts +46 -9
- package/src/binary-cache.ts +47 -15
- package/src/cache-clean.ts +81 -0
- package/src/cache-pins.ts +243 -0
- package/src/cli-install-method.ts +67 -0
- package/src/cli.ts +6 -0
- package/src/cloud-credentials.ts +183 -0
- package/src/commands/admin.ts +153 -28
- package/src/commands/cache.ts +90 -75
- package/src/commands/cloud.ts +32 -8
- package/src/commands/db.ts +162 -1
- package/src/commands/dev.ts +57 -8
- package/src/commands/doctor.ts +69 -3
- package/src/commands/functions.ts +42 -5
- package/src/commands/generate.ts +5 -1
- package/src/commands/hooks.ts +138 -0
- package/src/commands/init.ts +42 -8
- package/src/commands/internal.ts +23 -0
- package/src/commands/keys.ts +2 -0
- package/src/commands/link-helpers.ts +9 -2
- package/src/commands/login.ts +127 -0
- package/src/commands/push.ts +124 -7
- package/src/commands/self-update.ts +85 -13
- package/src/config.ts +22 -3
- package/src/db-preflight.ts +575 -0
- package/src/dev-compose.ts +144 -73
- package/src/env-file.ts +157 -11
- package/src/field-masking-tier.ts +107 -0
- package/src/functions-deno-types.ts +188 -0
- package/src/hook-upload.ts +107 -0
- package/src/hooks-generator.ts +352 -0
- package/src/link.ts +7 -0
- package/src/local-secrets.ts +104 -0
- package/src/model-hooks.ts +260 -0
- package/src/postgres-ctl.ts +29 -2
- package/src/project-config.ts +236 -2
- package/src/resolve-target.ts +113 -68
- package/src/schema-ast-v2.ts +5 -2
- package/src/schema-push-lock.ts +56 -17
- package/src/self-host-compose.ts +344 -68
- package/src/service-role-check.ts +111 -0
- package/src/target-client.ts +105 -18
- package/src/type-extractor.ts +883 -28
- package/src/ui/runtime/command-chrome.ts +1 -1
- package/src/ui/runtime/stdin-after-ink.ts +3 -1
- package/tests/access-composition.test.ts +667 -0
- package/tests/admin-studio-membership.test.ts +121 -0
- package/tests/api-schemas.test.ts +83 -0
- package/tests/archive-name-alias.test.ts +42 -0
- package/tests/augmentation-generator.test.ts +72 -4
- package/tests/cache-clean.test.ts +206 -0
- package/tests/cli-help.test.ts +3 -0
- package/tests/cli-install-method.test.ts +66 -0
- package/tests/command-chrome-coverage.test.ts +31 -26
- package/tests/command-chrome-wrap.test.ts +1 -0
- package/tests/db-preflight.test.ts +72 -0
- package/tests/doctor-render.test.ts +62 -0
- package/tests/doctor-service-role.test.ts +55 -0
- package/tests/env-file.test.ts +121 -0
- package/tests/external-database-compose.test.ts +184 -0
- package/tests/external-database-config.test.ts +154 -0
- package/tests/field-masking-tier.test.ts +184 -0
- package/tests/functions-deno-types.test.ts +197 -0
- package/tests/homebrew-formula.test.ts +58 -0
- package/tests/hook-upload.test.ts +125 -0
- package/tests/hooks-generator.test.ts +212 -0
- package/tests/init.test.ts +3 -0
- package/tests/local-secrets.test.ts +92 -0
- package/tests/model-hooks.test.ts +322 -0
- package/tests/runtime-contract.test.ts +113 -1
- package/tests/service-role-check.test.ts +109 -0
- package/tests/service-role-routes.test.ts +51 -0
- package/tests/target-client-refresh.test.ts +94 -0
- package/tests/type-extractor.test.ts +187 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/tests/engine-distribution.test.ts +0 -418
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { tmpdir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_HOOK_TIMEOUT_MS,
|
|
7
|
+
declaredHooks,
|
|
8
|
+
manifestHooks,
|
|
9
|
+
syncManifestHooks,
|
|
10
|
+
hooksReport,
|
|
11
|
+
validateModelHooks,
|
|
12
|
+
writeHooksModule,
|
|
13
|
+
} from "../src/model-hooks.js"
|
|
14
|
+
import { extractSchemaAstFromTypes } from "../src/type-extractor.js"
|
|
15
|
+
|
|
16
|
+
const dirs: string[] = []
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
function project(schema: string): { dir: string; ast: unknown } {
|
|
23
|
+
const dir = mkdtempSync(join(tmpdir(), "supatype-hooks-"))
|
|
24
|
+
dirs.push(dir)
|
|
25
|
+
const schemaPath = join(dir, "schema.ts")
|
|
26
|
+
writeFileSync(schemaPath, schema, "utf8")
|
|
27
|
+
return { dir, ast: extractSchemaAstFromTypes(schemaPath, dir) }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A function the worker would discover: a directory with an index.ts. */
|
|
31
|
+
function addFunction(dir: string, name: string): void {
|
|
32
|
+
mkdirSync(join(dir, "functions", name), { recursive: true })
|
|
33
|
+
writeFileSync(join(dir, "functions", name, "index.ts"), "export default () => new Response()", "utf8")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("model hooks — declaration", () => {
|
|
37
|
+
it("extracts both the shorthand and the options form", () => {
|
|
38
|
+
const { ast } = project(`
|
|
39
|
+
import type { Model, UUID } from "@supatype/types"
|
|
40
|
+
|
|
41
|
+
export type Post = Model<{ id: UUID; title: string }, {
|
|
42
|
+
tableName: "posts"
|
|
43
|
+
hooks: {
|
|
44
|
+
beforeChange: "moderate-post"
|
|
45
|
+
afterChange: { function: "index-post"; timeout: 5000; onUnavailable: "log" }
|
|
46
|
+
}
|
|
47
|
+
}>
|
|
48
|
+
`)
|
|
49
|
+
expect(declaredHooks(ast)).toEqual([
|
|
50
|
+
{ model: "Post", event: "afterChange", function: "index-post" },
|
|
51
|
+
{ model: "Post", event: "beforeChange", function: "moderate-post" },
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
const models = (ast as { models: { annotations: { platform: { hooks?: Record<string, unknown> } } }[] }).models
|
|
55
|
+
expect(models[0]?.annotations.platform.hooks).toEqual({
|
|
56
|
+
beforeChange: { function: "moderate-post" },
|
|
57
|
+
afterChange: { function: "index-post", timeout: 5000, onUnavailable: "log" },
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("carries no hooks annotation when a model declares none", () => {
|
|
62
|
+
const { ast } = project(`
|
|
63
|
+
import type { Model, UUID } from "@supatype/types"
|
|
64
|
+
|
|
65
|
+
export type Post = Model<{ id: UUID }, { tableName: "posts" }>
|
|
66
|
+
`)
|
|
67
|
+
const models = (ast as { models: { annotations: { platform: Record<string, unknown> } }[] }).models
|
|
68
|
+
expect(models[0]?.annotations.platform["hooks"]).toBeUndefined()
|
|
69
|
+
expect(declaredHooks(ast)).toEqual([])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("ignores an event name that is not a lifecycle hook", () => {
|
|
73
|
+
// `beforeRead` was dropped from the design. Extracting it would put a hook in the manifest that
|
|
74
|
+
// the server has no point at which to call, which reads as "declared and silently ignored".
|
|
75
|
+
const { ast } = project(`
|
|
76
|
+
import type { Model, UUID } from "@supatype/types"
|
|
77
|
+
|
|
78
|
+
export type Post = Model<{ id: UUID }, {
|
|
79
|
+
tableName: "posts"
|
|
80
|
+
hooks: { beforeRead: "nope"; beforeChange: "yes" }
|
|
81
|
+
}>
|
|
82
|
+
`)
|
|
83
|
+
expect(declaredHooks(ast).map((h) => h.event)).toEqual(["beforeChange"])
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe("model hooks — push-time validation", () => {
|
|
88
|
+
const schema = `
|
|
89
|
+
import type { Model, UUID } from "@supatype/types"
|
|
90
|
+
|
|
91
|
+
export type Post = Model<{ id: UUID }, {
|
|
92
|
+
tableName: "posts"
|
|
93
|
+
hooks: { beforeChange: "moderate-post" }
|
|
94
|
+
}>
|
|
95
|
+
`
|
|
96
|
+
|
|
97
|
+
it("passes when the named function exists", () => {
|
|
98
|
+
const { dir, ast } = project(schema)
|
|
99
|
+
addFunction(dir, "moderate-post")
|
|
100
|
+
expect(validateModelHooks(ast, join(dir, "functions"), dir)).toEqual([])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("reports the hook, the path searched, and what is available", () => {
|
|
104
|
+
// The failure this prevents: a typo'd name extracts cleanly, reaches the manifest, never fires,
|
|
105
|
+
// and the write it was meant to validate succeeds looking perfectly normal.
|
|
106
|
+
const { dir, ast } = project(schema)
|
|
107
|
+
addFunction(dir, "moderate-posts") // note the plural
|
|
108
|
+
const problems = validateModelHooks(ast, join(dir, "functions"), dir)
|
|
109
|
+
|
|
110
|
+
expect(problems.join("\n")).toContain("Post.beforeChange")
|
|
111
|
+
expect(problems.join("\n")).toContain('"moderate-post"')
|
|
112
|
+
expect(problems.join("\n")).toContain("moderate-posts")
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("says how to create one when the hooks directory is empty", () => {
|
|
116
|
+
const { dir, ast } = project(schema)
|
|
117
|
+
mkdirSync(join(dir, "hooks"), { recursive: true })
|
|
118
|
+
expect(validateModelHooks(ast, join(dir, "hooks"), dir).join("\n")).toContain(
|
|
119
|
+
"supatype hooks new",
|
|
120
|
+
)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("does not require a functions directory when no hooks are declared", () => {
|
|
124
|
+
const { dir, ast } = project(`
|
|
125
|
+
import type { Model, UUID } from "@supatype/types"
|
|
126
|
+
|
|
127
|
+
export type Post = Model<{ id: UUID }, { tableName: "posts" }>
|
|
128
|
+
`)
|
|
129
|
+
expect(validateModelHooks(ast, join(dir, "functions"), dir)).toEqual([])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it("ignores a file and an underscore directory when listing functions", () => {
|
|
133
|
+
// Matches how the worker discovers handlers: `_shared` is shared code, not a function.
|
|
134
|
+
const { dir, ast } = project(schema)
|
|
135
|
+
mkdirSync(join(dir, "functions", "_shared"), { recursive: true })
|
|
136
|
+
writeFileSync(join(dir, "functions", "_shared", "index.ts"), "export const x = 1", "utf8")
|
|
137
|
+
writeFileSync(join(dir, "functions", "readme.md"), "notes", "utf8")
|
|
138
|
+
|
|
139
|
+
const problems = validateModelHooks(ast, join(dir, "functions"), dir).join("\n")
|
|
140
|
+
expect(problems).toContain("No hooks found")
|
|
141
|
+
expect(problems).not.toContain("_shared")
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe("model hooks — manifest map", () => {
|
|
146
|
+
const schema = `
|
|
147
|
+
import type { Model, UUID } from "@supatype/types"
|
|
148
|
+
|
|
149
|
+
export type Post = Model<{ id: UUID }, {
|
|
150
|
+
tableName: "posts"
|
|
151
|
+
hooks: { beforeChange: "moderate-post"; afterChange: { function: "index-post"; timeout: 9000 } }
|
|
152
|
+
}>
|
|
153
|
+
`
|
|
154
|
+
|
|
155
|
+
it("keys by table name and fills the safe default per phase", () => {
|
|
156
|
+
const { ast } = project(schema)
|
|
157
|
+
// Keyed by table, not model: the server matches a request path.
|
|
158
|
+
expect(manifestHooks(ast)).toEqual({
|
|
159
|
+
posts: {
|
|
160
|
+
beforeChange: {
|
|
161
|
+
function: "moderate-post",
|
|
162
|
+
timeout: DEFAULT_HOOK_TIMEOUT_MS,
|
|
163
|
+
// Unreachable before a write must not let the write through...
|
|
164
|
+
onUnavailable: "reject",
|
|
165
|
+
},
|
|
166
|
+
afterChange: {
|
|
167
|
+
function: "index-post",
|
|
168
|
+
timeout: 9000,
|
|
169
|
+
// ...but after one there is nothing left to fail.
|
|
170
|
+
onUnavailable: "log",
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it("merges into an existing manifest and leaves its other keys alone", () => {
|
|
177
|
+
const { dir, ast } = project(schema)
|
|
178
|
+
mkdirSync(join(dir, ".supatype"), { recursive: true })
|
|
179
|
+
writeFileSync(
|
|
180
|
+
join(dir, ".supatype", "manifest.json"),
|
|
181
|
+
JSON.stringify({ schema: "tenant_7", functions_enabled: true }),
|
|
182
|
+
"utf8",
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
expect(syncManifestHooks(dir, ast)).toBe(true)
|
|
186
|
+
const manifest = JSON.parse(
|
|
187
|
+
readFileSync(join(dir, ".supatype", "manifest.json"), "utf8"),
|
|
188
|
+
) as Record<string, unknown>
|
|
189
|
+
expect(manifest["schema"]).toBe("tenant_7")
|
|
190
|
+
expect(manifest["functions_enabled"]).toBe(true)
|
|
191
|
+
expect(Object.keys(manifest["hooks"] as object)).toEqual(["posts"])
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it("never creates a manifest from scratch", () => {
|
|
195
|
+
// `functions_enabled` is a plain bool on the server's side, so a manifest carrying only hooks
|
|
196
|
+
// would read as functions *disabled* — the defect fixed one commit earlier, by another door.
|
|
197
|
+
const { dir, ast } = project(schema)
|
|
198
|
+
expect(syncManifestHooks(dir, ast)).toBe(false)
|
|
199
|
+
expect(existsSync(join(dir, ".supatype", "manifest.json"))).toBe(false)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it("removes the hook map when the last hook goes away", () => {
|
|
203
|
+
const { dir, ast } = project(`
|
|
204
|
+
import type { Model, UUID } from "@supatype/types"
|
|
205
|
+
|
|
206
|
+
export type Post = Model<{ id: UUID }, { tableName: "posts" }>
|
|
207
|
+
`)
|
|
208
|
+
mkdirSync(join(dir, ".supatype"), { recursive: true })
|
|
209
|
+
writeFileSync(
|
|
210
|
+
join(dir, ".supatype", "manifest.json"),
|
|
211
|
+
JSON.stringify({ functions_enabled: true, hooks: { posts: { beforeChange: { function: "gone" } } } }),
|
|
212
|
+
"utf8",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
expect(syncManifestHooks(dir, ast)).toBe(true)
|
|
216
|
+
const manifest = JSON.parse(
|
|
217
|
+
readFileSync(join(dir, ".supatype", "manifest.json"), "utf8"),
|
|
218
|
+
) as Record<string, unknown>
|
|
219
|
+
expect(manifest["hooks"]).toBeUndefined()
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
describe("model hooks — generated module on disk", () => {
|
|
224
|
+
it("removes a stale module when the last hook is deleted", () => {
|
|
225
|
+
// Otherwise a typed module sits there claiming tables are hooked, and a handler importing it
|
|
226
|
+
// keeps compiling while never running.
|
|
227
|
+
const { dir, ast } = project(`
|
|
228
|
+
import type { Model, UUID } from "@supatype/types"
|
|
229
|
+
|
|
230
|
+
export type Post = Model<{ id: UUID }, { tableName: "posts" }>
|
|
231
|
+
`)
|
|
232
|
+
const functionsDir = join(dir, "functions")
|
|
233
|
+
mkdirSync(join(functionsDir, "_supatype"), { recursive: true })
|
|
234
|
+
writeFileSync(join(functionsDir, "_supatype", "hooks.ts"), "export type Stale = true", "utf8")
|
|
235
|
+
|
|
236
|
+
expect(writeHooksModule(dir, functionsDir, ast)).toBeNull()
|
|
237
|
+
expect(existsSync(join(functionsDir, "_supatype", "hooks.ts"))).toBe(false)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it("writes the module beside the functions that import it", () => {
|
|
241
|
+
const { dir, ast } = project(`
|
|
242
|
+
import type { Model, UUID } from "@supatype/types"
|
|
243
|
+
|
|
244
|
+
export type Post = Model<{ id: UUID }, { tableName: "posts"; hooks: { beforeChange: "x" } }>
|
|
245
|
+
`)
|
|
246
|
+
const functionsDir = join(dir, "functions")
|
|
247
|
+
const written = writeHooksModule(dir, functionsDir, ast)
|
|
248
|
+
expect(written).toContain(join("functions", "_supatype", "hooks.ts"))
|
|
249
|
+
expect(readFileSync(join(functionsDir, "_supatype", "hooks.ts"), "utf8")).toContain("HookedTable")
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
describe("model hooks — doctor report", () => {
|
|
254
|
+
const schema = `
|
|
255
|
+
import type { Model, UUID } from "@supatype/types"
|
|
256
|
+
|
|
257
|
+
export type Post = Model<{ id: UUID }, {
|
|
258
|
+
tableName: "posts"
|
|
259
|
+
hooks: { beforeChange: "moderate-post" }
|
|
260
|
+
}>
|
|
261
|
+
`
|
|
262
|
+
|
|
263
|
+
it("reports nothing for a schema with no hooks", () => {
|
|
264
|
+
const { dir, ast } = project(`
|
|
265
|
+
import type { Model, UUID } from "@supatype/types"
|
|
266
|
+
|
|
267
|
+
export type Post = Model<{ id: UUID }, { tableName: "posts" }>
|
|
268
|
+
`)
|
|
269
|
+
expect(hooksReport(dir, join(dir, "functions"), ast).declared).toEqual([])
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it("marks a hook whose function is absent", () => {
|
|
273
|
+
const { dir, ast } = project(schema)
|
|
274
|
+
const report = hooksReport(dir, join(dir, "functions"), ast)
|
|
275
|
+
expect(report.declared).toHaveLength(1)
|
|
276
|
+
expect(report.missing).toHaveLength(1)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it("catches functions being switched off, which makes every hook inert silently", () => {
|
|
280
|
+
const { dir, ast } = project(schema)
|
|
281
|
+
addFunction(dir, "moderate-post")
|
|
282
|
+
mkdirSync(join(dir, ".supatype"), { recursive: true })
|
|
283
|
+
writeFileSync(
|
|
284
|
+
join(dir, ".supatype", "manifest.json"),
|
|
285
|
+
JSON.stringify({ functions_enabled: false, hooks: { posts: {} } }),
|
|
286
|
+
"utf8",
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
const report = hooksReport(dir, join(dir, "functions"), ast)
|
|
290
|
+
expect(report.missing).toEqual([])
|
|
291
|
+
expect(report.functionsDisabled).toBe(true)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it("catches a manifest with no hook map, so the server has nothing to call", () => {
|
|
295
|
+
const { dir, ast } = project(schema)
|
|
296
|
+
addFunction(dir, "moderate-post")
|
|
297
|
+
mkdirSync(join(dir, ".supatype"), { recursive: true })
|
|
298
|
+
writeFileSync(
|
|
299
|
+
join(dir, ".supatype", "manifest.json"),
|
|
300
|
+
JSON.stringify({ functions_enabled: true }),
|
|
301
|
+
"utf8",
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
const report = hooksReport(dir, join(dir, "functions"), ast)
|
|
305
|
+
expect(report.mapMissing).toBe(true)
|
|
306
|
+
expect(report.functionsDisabled).toBe(false)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it("is clean when the function exists and the manifest agrees", () => {
|
|
310
|
+
const { dir, ast } = project(schema)
|
|
311
|
+
addFunction(dir, "moderate-post")
|
|
312
|
+
mkdirSync(join(dir, ".supatype"), { recursive: true })
|
|
313
|
+
writeFileSync(
|
|
314
|
+
join(dir, ".supatype", "manifest.json"),
|
|
315
|
+
JSON.stringify({ functions_enabled: true, hooks: { posts: { beforeChange: { function: "moderate-post" } } } }),
|
|
316
|
+
"utf8",
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
const report = hooksReport(dir, join(dir, "functions"), ast)
|
|
320
|
+
expect(report).toMatchObject({ missing: [], functionsDisabled: false, mapMissing: false })
|
|
321
|
+
})
|
|
322
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
-
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
3
3
|
import { join } from "node:path"
|
|
4
4
|
import { tmpdir } from "node:os"
|
|
5
5
|
import { runtimeRouteSpec } from "../src/runtime-routes.js"
|
|
@@ -89,6 +89,25 @@ describe("runtime contract", () => {
|
|
|
89
89
|
expect(first).toBe(second)
|
|
90
90
|
})
|
|
91
91
|
|
|
92
|
+
it("self-host compose exposes the managed schema over REST, not a hardcoded public", () => {
|
|
93
|
+
// The literal here was the reason a non-default pg_schema pushed correctly and then answered
|
|
94
|
+
// PGRST106 on every request: the engine had moved and PostgREST was never told.
|
|
95
|
+
const compose = renderSelfHostCompose({
|
|
96
|
+
...baseConfig,
|
|
97
|
+
schema: { pg_schema: "app" },
|
|
98
|
+
})
|
|
99
|
+
expect(compose).toContain('PGRST_DB_SCHEMA: "app, supatype, graphql_public, auth"')
|
|
100
|
+
expect(compose).not.toContain('PGRST_DB_SCHEMA: "public,')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("self-host compose honours an explicit api_schemas list verbatim", () => {
|
|
104
|
+
const compose = renderSelfHostCompose({
|
|
105
|
+
...baseConfig,
|
|
106
|
+
schema: { pg_schema: "app", api_schemas: ["app", "reporting"] },
|
|
107
|
+
})
|
|
108
|
+
expect(compose).toContain('PGRST_DB_SCHEMA: "app, reporting"')
|
|
109
|
+
})
|
|
110
|
+
|
|
92
111
|
it("self-host compose does not inject a synthetic app-proxy service", () => {
|
|
93
112
|
const compose = renderSelfHostCompose({ ...baseConfig, app: { mode: "proxy", upstream: "http://app:3000" } })
|
|
94
113
|
expect(compose).not.toContain("ghcr.io/supatype/app-proxy")
|
|
@@ -109,6 +128,49 @@ describe("runtime contract", () => {
|
|
|
109
128
|
expect(compose).toContain("supatype/schema-engine:latest")
|
|
110
129
|
})
|
|
111
130
|
|
|
131
|
+
// PostgREST must connect as `authenticator`, never as POSTGRES_USER. POSTGRES_USER is
|
|
132
|
+
// `supatype_admin`, a superuser, and a superuser session may SET ROLE to any role in the
|
|
133
|
+
// cluster — so a request whose JWT named one got it. Verified against a live stack: a
|
|
134
|
+
// token with `role: "supatype_admin"` returned every row of an RLS-protected table.
|
|
135
|
+
it("self-host compose connects PostgREST as authenticator, not the superuser", () => {
|
|
136
|
+
const compose = renderSelfHostCompose(baseConfig)
|
|
137
|
+
expect(compose).toContain("PGRST_DB_URI: postgresql://authenticator:")
|
|
138
|
+
expect(compose).not.toMatch(/PGRST_DB_URI:.*POSTGRES_USER/)
|
|
139
|
+
expect(compose).not.toMatch(/PGRST_DB_URI:.*supatype_admin/)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// A separate credential, so rotating the operator's POSTGRES_PASSWORD cannot take the REST
|
|
143
|
+
// API down with it. Unset is a hard compose error rather than an empty password.
|
|
144
|
+
it("gives PostgREST its own credential, not POSTGRES_PASSWORD", () => {
|
|
145
|
+
const compose = renderSelfHostCompose(baseConfig)
|
|
146
|
+
expect(compose).toMatch(/PGRST_DB_URI:.*\$\{AUTHENTICATOR_PASSWORD:\?/)
|
|
147
|
+
expect(compose).not.toMatch(/PGRST_DB_URI:.*POSTGRES_PASSWORD/)
|
|
148
|
+
expect(compose).toMatch(/AUTHENTICATOR_PASSWORD: \$\{AUTHENTICATOR_PASSWORD:\?/)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
// `supatype dev --provider docker` renders *this* file, so a `${VAR:?}` with no default
|
|
152
|
+
// takes local dev down before anything starts — `docker compose` refuses to interpolate and
|
|
153
|
+
// exits 1. Adding one therefore means wiring it into `upsertDevComposeEnv` as well. This
|
|
154
|
+
// list is the reminder; if it fails, do that rather than just updating the list.
|
|
155
|
+
it("requires only variables the dev path also sets", () => {
|
|
156
|
+
const compose = renderSelfHostCompose(baseConfig)
|
|
157
|
+
const required = [...compose.matchAll(/\$\{([A-Z0-9_]+):\?/g)].map((m) => m[1])
|
|
158
|
+
expect([...new Set(required)].sort()).toEqual([
|
|
159
|
+
"AUTHENTICATOR_PASSWORD",
|
|
160
|
+
"JWT_SECRET",
|
|
161
|
+
"POSTGRES_PASSWORD",
|
|
162
|
+
])
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
// The whole point of requiring them: a service must never fall back to a secret published in
|
|
166
|
+
// this repository. That default used to reach PostgREST, storage and GoTrue.
|
|
167
|
+
it("never defaults a secret to a published constant", () => {
|
|
168
|
+
const compose = renderSelfHostCompose(baseConfig)
|
|
169
|
+
expect(compose).not.toContain("super-secret-jwt-token")
|
|
170
|
+
expect(compose).not.toMatch(/\$\{JWT_SECRET:-/)
|
|
171
|
+
expect(compose).not.toMatch(/\$\{POSTGRES_PASSWORD:-/)
|
|
172
|
+
})
|
|
173
|
+
|
|
112
174
|
it("composeDockerImageEnv maps pinned versions to SUPATYPE_*_IMAGE", () => {
|
|
113
175
|
expect(composeDockerImageEnv(baseConfig)).toEqual({
|
|
114
176
|
SUPATYPE_ENGINE_IMAGE: "supatype/schema-engine:v0.4.2",
|
|
@@ -251,6 +313,55 @@ describe("runtime contract", () => {
|
|
|
251
313
|
}
|
|
252
314
|
})
|
|
253
315
|
|
|
316
|
+
it("manifest switches functions on, because the compose file always runs a worker", () => {
|
|
317
|
+
// The generated compose file starts a `functions-worker` and hands the server
|
|
318
|
+
// SUPATYPE_FUNCTIONS_WORKER_URL, while the manifest beside it said functions were disabled. The
|
|
319
|
+
// server reads the manifest, so every function 404'd in a stack that was running a worker for
|
|
320
|
+
// them. Docker `supatype dev` renders this same file, so one flag broke both paths.
|
|
321
|
+
const dir = mkdtempSync(join(tmpdir(), "supatype-fn-manifest-"))
|
|
322
|
+
try {
|
|
323
|
+
writeSelfHostCompose(dir, baseConfig)
|
|
324
|
+
const compose = readFileSync(join(dir, ".supatype", "self-host", "docker-compose.yml"), "utf8")
|
|
325
|
+
expect(compose).toContain(" functions-worker:")
|
|
326
|
+
expect(compose).toContain("SUPATYPE_FUNCTIONS_WORKER_URL: http://functions-worker:8001")
|
|
327
|
+
|
|
328
|
+
const manifest = JSON.parse(
|
|
329
|
+
readFileSync(join(dir, ".supatype", "manifest.json"), "utf8"),
|
|
330
|
+
) as Record<string, unknown>
|
|
331
|
+
expect(manifest["functions_enabled"]).toBe(true)
|
|
332
|
+
expect(manifest["functions_worker_url"]).toBe("http://functions-worker:8001")
|
|
333
|
+
} finally {
|
|
334
|
+
rmSync(dir, { recursive: true, force: true })
|
|
335
|
+
}
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it("repairs a manifest written by an older CLI without touching its other keys", () => {
|
|
339
|
+
// Regenerating the compose file is the obvious fix to try, so it has to work: without the
|
|
340
|
+
// repair, only brand-new projects get functions and an existing stack stays broken.
|
|
341
|
+
const dir = mkdtempSync(join(tmpdir(), "supatype-fn-repair-"))
|
|
342
|
+
try {
|
|
343
|
+
mkdirSync(join(dir, ".supatype"), { recursive: true })
|
|
344
|
+
writeFileSync(
|
|
345
|
+
join(dir, ".supatype", "manifest.json"),
|
|
346
|
+
JSON.stringify({ schema: "tenant_7", postgrest_url: "http://pg:3000", functions_enabled: false }),
|
|
347
|
+
"utf8",
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
writeSelfHostCompose(dir, baseConfig)
|
|
351
|
+
|
|
352
|
+
const manifest = JSON.parse(
|
|
353
|
+
readFileSync(join(dir, ".supatype", "manifest.json"), "utf8"),
|
|
354
|
+
) as Record<string, unknown>
|
|
355
|
+
expect(manifest["functions_enabled"]).toBe(true)
|
|
356
|
+
expect(manifest["functions_worker_url"]).toBe("http://functions-worker:8001")
|
|
357
|
+
// Values `push` put there survive: the schema is not "public" and must stay as found.
|
|
358
|
+
expect(manifest["schema"]).toBe("tenant_7")
|
|
359
|
+
expect(manifest["postgrest_url"]).toBe("http://pg:3000")
|
|
360
|
+
} finally {
|
|
361
|
+
rmSync(dir, { recursive: true, force: true })
|
|
362
|
+
}
|
|
363
|
+
})
|
|
364
|
+
|
|
254
365
|
it("devLocal compose enables STUDIO_OPEN_DEV on supatype-server", () => {
|
|
255
366
|
const compose = renderSelfHostCompose(baseConfig, process.cwd(), { devLocal: true })
|
|
256
367
|
expect(compose).toContain('STUDIO_OPEN_DEV: "1"')
|
|
@@ -276,6 +387,7 @@ describe("runtime contract", () => {
|
|
|
276
387
|
expect(compose).toContain("\n functions-worker:\n")
|
|
277
388
|
expect(compose).toContain("SUPATYPE_FUNCTIONS_WORKER_URL: http://functions-worker:8001")
|
|
278
389
|
expect(compose).toContain("SUPATYPE_FUNCTIONS_ROOT: /project/functions")
|
|
390
|
+
expect(compose).toContain("SUPATYPE_URL: http://kong:8000")
|
|
279
391
|
expect(compose).not.toContain("deploy/functions")
|
|
280
392
|
expect(compose).not.toContain("supatype-functions")
|
|
281
393
|
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { tmpdir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { describe, expect, it } from "vitest"
|
|
5
|
+
|
|
6
|
+
import { checkServiceRoleRoutes, serviceRoleProblemLines } from "../src/service-role-check.js"
|
|
7
|
+
import type { SupatypeProjectConfig } from "../src/project-config.js"
|
|
8
|
+
import { DENO_RELEASE_PIN } from "../src/release-pins.js"
|
|
9
|
+
|
|
10
|
+
function config(serviceRole?: string[]): SupatypeProjectConfig {
|
|
11
|
+
return {
|
|
12
|
+
project: { name: "p" },
|
|
13
|
+
database: { provider: "docker" },
|
|
14
|
+
server: { mode: "dev" },
|
|
15
|
+
app: { mode: "none" },
|
|
16
|
+
versions: { engine: "0.4.2", server: "0.1.0", postgres: "17.2", deno: DENO_RELEASE_PIN },
|
|
17
|
+
...(serviceRole !== undefined && { functions: { serviceRole } }),
|
|
18
|
+
} as SupatypeProjectConfig
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A project whose functions/ holds the named directories, each with an index.ts. */
|
|
22
|
+
function projectWith(names: string[], bareFiles: string[] = []): string {
|
|
23
|
+
const cwd = mkdtempSync(join(tmpdir(), "supatype-svcrole-check-"))
|
|
24
|
+
const dir = join(cwd, "functions")
|
|
25
|
+
mkdirSync(dir, { recursive: true })
|
|
26
|
+
for (const name of names) {
|
|
27
|
+
mkdirSync(join(dir, name), { recursive: true })
|
|
28
|
+
writeFileSync(join(dir, name, "index.ts"), "export default () => new Response()\n", "utf8")
|
|
29
|
+
}
|
|
30
|
+
for (const file of bareFiles) {
|
|
31
|
+
writeFileSync(join(dir, `${file}.ts`), "export default () => new Response()\n", "utf8")
|
|
32
|
+
}
|
|
33
|
+
return cwd
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("checkServiceRoleRoutes", () => {
|
|
37
|
+
it("says nothing when a project grants nothing", () => {
|
|
38
|
+
const problems = checkServiceRoleRoutes(config(), projectWith([]))
|
|
39
|
+
expect(problems.errors).toEqual([])
|
|
40
|
+
expect(problems.warnings).toEqual([])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("accepts a name that matches a function directory", () => {
|
|
44
|
+
const problems = checkServiceRoleRoutes(config(["send-email"]), projectWith(["send-email"]))
|
|
45
|
+
expect(problems.errors).toEqual([])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("accepts a bare <name>.ts function, which the worker also serves", () => {
|
|
49
|
+
const problems = checkServiceRoleRoutes(config(["audit"]), projectWith([], ["audit"]))
|
|
50
|
+
expect(problems.errors).toEqual([])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("reports a typo, which would otherwise grant nothing in silence", () => {
|
|
54
|
+
// The failure this exists for: the grant fails closed, so the function reads no key at runtime in a
|
|
55
|
+
// deploy that reported success.
|
|
56
|
+
const problems = checkServiceRoleRoutes(config(["send-emial"]), projectWith(["send-email"]))
|
|
57
|
+
expect(problems.errors).toHaveLength(1)
|
|
58
|
+
expect(problems.errors[0]).toContain("send-emial")
|
|
59
|
+
// Structural too, so `doctor` can mark the entry without parsing the printed line back apart.
|
|
60
|
+
expect(problems.missing).toEqual(["send-emial"])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("reports a name whose function was renamed away", () => {
|
|
64
|
+
const problems = checkServiceRoleRoutes(config(["send-email"]), projectWith(["mailer"]))
|
|
65
|
+
expect(problems.errors).toHaveLength(1)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("warns rather than fails on a hook entry", () => {
|
|
69
|
+
// It was the documented form once, and the worker grants hooks whatever the list says — so it is
|
|
70
|
+
// harmless, and worth saying because a reader would assume the line is what grants.
|
|
71
|
+
const problems = checkServiceRoleRoutes(config(["hooks/moderate-post"]), projectWith([]))
|
|
72
|
+
expect(problems.errors).toEqual([])
|
|
73
|
+
expect(problems.warnings).toHaveLength(1)
|
|
74
|
+
expect(problems.warnings[0]).toContain("not needed")
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("ignores an index-less directory, matching how the worker discovers routes", () => {
|
|
78
|
+
const cwd = projectWith([])
|
|
79
|
+
mkdirSync(join(cwd, "functions", "half-written"), { recursive: true })
|
|
80
|
+
const problems = checkServiceRoleRoutes(config(["half-written"]), cwd)
|
|
81
|
+
expect(problems.errors).toHaveLength(1)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe("serviceRoleProblemLines", () => {
|
|
86
|
+
it("is empty when nothing is wrong", () => {
|
|
87
|
+
expect(serviceRoleProblemLines({ errors: [], missing: [], warnings: [], available: ["a"] })).toEqual([])
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it("lists what could have been named instead", () => {
|
|
91
|
+
const lines = serviceRoleProblemLines({
|
|
92
|
+
errors: [' "x" — no functions/x/index.ts'],
|
|
93
|
+
missing: ["x"],
|
|
94
|
+
warnings: [],
|
|
95
|
+
available: ["audit", "send-email"],
|
|
96
|
+
})
|
|
97
|
+
expect(lines.join("\n")).toContain("Functions found: audit, send-email")
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("says so when there are no functions at all", () => {
|
|
101
|
+
const lines = serviceRoleProblemLines({
|
|
102
|
+
errors: [' "x" — nope'],
|
|
103
|
+
missing: ["x"],
|
|
104
|
+
warnings: [],
|
|
105
|
+
available: [],
|
|
106
|
+
})
|
|
107
|
+
expect(lines.join("\n")).toContain("No functions found")
|
|
108
|
+
})
|
|
109
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { tmpdir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { describe, expect, it } from "vitest"
|
|
5
|
+
|
|
6
|
+
import { serviceRoleRoutesFor } from "../src/commands/functions.js"
|
|
7
|
+
import { DENO_RELEASE_PIN } from "../src/release-pins.js"
|
|
8
|
+
|
|
9
|
+
/** A config the loader accepts, plus whatever the test is actually about. */
|
|
10
|
+
function projectWith(extra: Record<string, unknown>): string {
|
|
11
|
+
const cwd = mkdtempSync(join(tmpdir(), "supatype-svcrole-"))
|
|
12
|
+
mkdirSync(cwd, { recursive: true })
|
|
13
|
+
const body = {
|
|
14
|
+
project: { name: "p" },
|
|
15
|
+
database: { provider: "docker" },
|
|
16
|
+
server: { mode: "dev" },
|
|
17
|
+
app: { mode: "none" },
|
|
18
|
+
versions: { engine: "0.4.2", server: "0.1.0", postgres: "17.2", deno: DENO_RELEASE_PIN },
|
|
19
|
+
schema: { path: "./schema/index.ts" },
|
|
20
|
+
...extra,
|
|
21
|
+
}
|
|
22
|
+
writeFileSync(join(cwd, "supatype.config.ts"), `export default ${JSON.stringify(body)}\n`, "utf8")
|
|
23
|
+
return cwd
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("serviceRoleRoutesFor", () => {
|
|
27
|
+
it("reads the declared list", () => {
|
|
28
|
+
const cwd = projectWith({ functions: { serviceRole: ["send-email"] } })
|
|
29
|
+
expect(serviceRoleRoutesFor(cwd)).toEqual(["send-email"])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("is an empty list when a project declares none", () => {
|
|
33
|
+
// Distinct from the case below: this project *has* said something, and it said nobody. The platform
|
|
34
|
+
// stores that as a real revocation.
|
|
35
|
+
expect(serviceRoleRoutesFor(projectWith({}))).toEqual([])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("is undefined when the config cannot be read at all", () => {
|
|
39
|
+
// Not []: the platform replaces its stored list wholesale whenever the field is present, so an
|
|
40
|
+
// unreadable config sending [] would revoke every grant the project has.
|
|
41
|
+
expect(serviceRoleRoutesFor(mkdtempSync(join(tmpdir(), "supatype-svcrole-none-")))).toBeUndefined()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("sends what the project declared, hook entries included", () => {
|
|
45
|
+
// Filtering hook routes out is the platform's job, not this one's: a hook is granted the key by the
|
|
46
|
+
// worker anyway, and dropping the entry here would hide a mistake in the config rather than making
|
|
47
|
+
// it harmless. Sorted, so an unchanged config produces an unchanged request.
|
|
48
|
+
const cwd = projectWith({ functions: { serviceRole: ["send-email", "hooks/moderate-post"] } })
|
|
49
|
+
expect(serviceRoleRoutesFor(cwd)).toEqual(["hooks/moderate-post", "send-email"])
|
|
50
|
+
})
|
|
51
|
+
})
|