@supatype/cli 0.2.1 → 0.3.1
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 +193 -157
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/api-config-cache.d.ts +34 -0
- package/dist/api-config-cache.d.ts.map +1 -0
- package/dist/api-config-cache.js +123 -0
- package/dist/api-config-cache.js.map +1 -0
- package/dist/cache-identity-scope.d.ts +38 -0
- package/dist/cache-identity-scope.d.ts.map +1 -0
- package/dist/cache-identity-scope.js +70 -0
- package/dist/cache-identity-scope.js.map +1 -0
- package/dist/cache-provider.d.ts +30 -0
- package/dist/cache-provider.d.ts.map +1 -0
- package/dist/cache-provider.js +60 -0
- package/dist/cache-provider.js.map +1 -0
- package/dist/cli-version-embedded.js +1 -1
- package/dist/commands/add.js +1 -1
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/admin.d.ts.map +1 -1
- package/dist/commands/admin.js +21 -4
- package/dist/commands/admin.js.map +1 -1
- package/dist/commands/cache.d.ts +1 -1
- package/dist/commands/cache.js +3 -3
- package/dist/commands/cache.js.map +1 -1
- package/dist/commands/cloud.d.ts.map +1 -1
- package/dist/commands/cloud.js +2 -1
- package/dist/commands/cloud.js.map +1 -1
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +2 -1
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +61 -8
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/diff.d.ts.map +1 -1
- package/dist/commands/diff.js +2 -1
- package/dist/commands/diff.js.map +1 -1
- package/dist/commands/functions.d.ts.map +1 -1
- package/dist/commands/functions.js +6 -2
- package/dist/commands/functions.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +13 -9
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/keys.d.ts +35 -0
- package/dist/commands/keys.d.ts.map +1 -1
- package/dist/commands/keys.js +90 -6
- package/dist/commands/keys.js.map +1 -1
- package/dist/commands/migrate.d.ts.map +1 -1
- package/dist/commands/migrate.js +2 -1
- package/dist/commands/migrate.js.map +1 -1
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +61 -1
- package/dist/commands/push.js.map +1 -1
- package/dist/compose-services.d.ts.map +1 -1
- package/dist/compose-services.js +2 -1
- package/dist/compose-services.js.map +1 -1
- package/dist/dev-compose.d.ts +13 -0
- package/dist/dev-compose.d.ts.map +1 -1
- package/dist/dev-compose.js +119 -16
- package/dist/dev-compose.js.map +1 -1
- package/dist/engine-floor.d.ts +11 -1
- package/dist/engine-floor.d.ts.map +1 -1
- package/dist/engine-floor.js +49 -16
- package/dist/engine-floor.js.map +1 -1
- package/dist/functions-deno-types.d.ts +9 -0
- package/dist/functions-deno-types.d.ts.map +1 -1
- package/dist/functions-deno-types.js +45 -1
- package/dist/functions-deno-types.js.map +1 -1
- package/dist/functions-router-gen.d.ts.map +1 -1
- package/dist/functions-router-gen.js +113 -81
- package/dist/functions-router-gen.js.map +1 -1
- package/dist/kong-config.d.ts +1 -1
- package/dist/model-cache.d.ts +44 -0
- package/dist/model-cache.d.ts.map +1 -0
- package/dist/model-cache.js +106 -0
- package/dist/model-cache.js.map +1 -0
- package/dist/model-hooks.d.ts +5 -0
- package/dist/model-hooks.d.ts.map +1 -1
- package/dist/model-hooks.js +29 -1
- package/dist/model-hooks.js.map +1 -1
- package/dist/model-versioning.d.ts +80 -0
- package/dist/model-versioning.d.ts.map +1 -0
- package/dist/model-versioning.js +140 -0
- package/dist/model-versioning.js.map +1 -0
- package/dist/postgres-ctl.d.ts +55 -0
- package/dist/postgres-ctl.d.ts.map +1 -1
- package/dist/postgres-ctl.js +84 -8
- package/dist/postgres-ctl.js.map +1 -1
- package/dist/preview-config-check.d.ts +21 -0
- package/dist/preview-config-check.d.ts.map +1 -0
- package/dist/preview-config-check.js +47 -0
- package/dist/preview-config-check.js.map +1 -0
- package/dist/project-config.d.ts +182 -5
- package/dist/project-config.d.ts.map +1 -1
- package/dist/project-config.js +125 -8
- package/dist/project-config.js.map +1 -1
- package/dist/resolve-target.d.ts +9 -0
- package/dist/resolve-target.d.ts.map +1 -1
- package/dist/resolve-target.js.map +1 -1
- package/dist/rest-cache-admin.d.ts.map +1 -1
- package/dist/rest-cache-admin.js +6 -2
- package/dist/rest-cache-admin.js.map +1 -1
- package/dist/schema-ast-v2.d.ts +32 -1
- package/dist/schema-ast-v2.d.ts.map +1 -1
- package/dist/schema-ast-v2.js +14 -1
- package/dist/schema-ast-v2.js.map +1 -1
- package/dist/self-host-compose.d.ts +20 -1
- package/dist/self-host-compose.d.ts.map +1 -1
- package/dist/self-host-compose.js +227 -29
- package/dist/self-host-compose.js.map +1 -1
- package/dist/studio-admin-roles.d.ts +8 -1
- package/dist/studio-admin-roles.d.ts.map +1 -1
- package/dist/studio-admin-roles.js +16 -2
- package/dist/studio-admin-roles.js.map +1 -1
- package/dist/type-extractor.d.ts +7 -0
- package/dist/type-extractor.d.ts.map +1 -1
- package/dist/type-extractor.js +234 -3
- package/dist/type-extractor.js.map +1 -1
- package/package.json +2 -2
- package/src/api-config-cache.ts +142 -0
- package/src/cache-identity-scope.ts +92 -0
- package/src/cache-provider.ts +61 -0
- package/src/cli-version-embedded.ts +1 -1
- package/src/commands/add.ts +1 -1
- package/src/commands/admin.ts +23 -6
- package/src/commands/cache.ts +3 -3
- package/src/commands/cloud.ts +2 -1
- package/src/commands/deploy.ts +2 -1
- package/src/commands/dev.ts +68 -7
- package/src/commands/diff.ts +2 -1
- package/src/commands/functions.ts +6 -2
- package/src/commands/init.ts +13 -9
- package/src/commands/keys.ts +112 -7
- package/src/commands/migrate.ts +2 -1
- package/src/commands/push.ts +68 -1
- package/src/compose-services.ts +2 -1
- package/src/dev-compose.ts +151 -15
- package/src/engine-floor.ts +73 -16
- package/src/functions-deno-types.ts +48 -1
- package/src/functions-router-gen.ts +113 -81
- package/src/kong-config.ts +1 -1
- package/src/model-cache.ts +122 -0
- package/src/model-hooks.ts +26 -2
- package/src/model-versioning.ts +167 -0
- package/src/postgres-ctl.ts +103 -9
- package/src/preview-config-check.ts +52 -0
- package/src/project-config.ts +273 -11
- package/src/resolve-target.ts +11 -1
- package/src/rest-cache-admin.ts +8 -2
- package/src/schema-ast-v2.ts +47 -0
- package/src/self-host-compose.ts +250 -29
- package/src/studio-admin-roles.ts +16 -2
- package/src/type-extractor.ts +281 -2
- package/tests/api-config-cache-seed.test.ts +180 -0
- package/tests/cache-identity-scope.test.ts +61 -0
- package/tests/cache-provider-config.test.ts +132 -0
- package/tests/engine-floor.test.ts +43 -0
- package/tests/external-database-compose.test.ts +74 -3
- package/tests/field-masking-tier.test.ts +3 -3
- package/tests/fixtures/identity-scope-corpus.json +42 -0
- package/tests/functions-concurrency.test.ts +71 -0
- package/tests/init.test.ts +36 -2
- package/tests/keys-write.test.ts +148 -0
- package/tests/model-cache-declaration.test.ts +229 -0
- package/tests/model-cache-manifest.test.ts +99 -0
- package/tests/model-hooks.test.ts +55 -0
- package/tests/model-versions.test.ts +229 -0
- package/tests/native-keyspace.test.ts +130 -0
- package/tests/preview-config-check.test.ts +104 -0
- package/tests/rest-cache-admin.test.ts +36 -0
- package/tests/runtime-contract.test.ts +176 -22
- package/tests/studio-admin-roles.test.ts +32 -0
- package/tests/type-extractor.test.ts +113 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { validateProjectConfig } from "../src/project-config.js"
|
|
3
|
+
import { cacheProvider } from "../src/cache-provider.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `cache.provider` picks the RESP server the response cache and Kong's ACME
|
|
7
|
+
* certificates share. Every rejection here is a case where accepting the
|
|
8
|
+
* config would produce a stack that runs, and runs something other than what
|
|
9
|
+
* was asked for.
|
|
10
|
+
*/
|
|
11
|
+
const validate = (cache: unknown, database: unknown = { provider: "docker" }) =>
|
|
12
|
+
validateProjectConfig(
|
|
13
|
+
{
|
|
14
|
+
project: { name: "acme" },
|
|
15
|
+
database,
|
|
16
|
+
server: { mode: "dev" },
|
|
17
|
+
app: { mode: "none" },
|
|
18
|
+
...(cache === undefined ? {} : { cache }),
|
|
19
|
+
},
|
|
20
|
+
"supatype.config.ts",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
describe("cache.provider", () => {
|
|
24
|
+
it("accepts both providers, and no cache section at all", () => {
|
|
25
|
+
expect(() => validate(undefined)).not.toThrow()
|
|
26
|
+
expect(() => validate({})).not.toThrow()
|
|
27
|
+
expect(() => validate({ provider: "valkey" })).not.toThrow()
|
|
28
|
+
expect(() => validate({ provider: "pg_keyspace" })).not.toThrow()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("refuses a provider it does not have", () => {
|
|
32
|
+
expect(() => validate({ provider: "redis" })).toThrow(/cache\.provider must be/)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// Rejected rather than silently downgraded to Valkey: a stack that quietly
|
|
36
|
+
// runs the thing you switched away from is worse than one that will not start.
|
|
37
|
+
it("refuses pg_keyspace against a database this stack does not start", () => {
|
|
38
|
+
expect(() =>
|
|
39
|
+
validate({ provider: "pg_keyspace" }, { external: { url: "postgres://u:p@h:5432/d" } }),
|
|
40
|
+
).toThrow(/needs the Postgres this stack starts/)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("leaves valkey alone with an external database", () => {
|
|
44
|
+
expect(() =>
|
|
45
|
+
validate({ provider: "valkey" }, { external: { url: "postgres://u:p@h:5432/d" } }),
|
|
46
|
+
).not.toThrow()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe("cache.durablePrefixes", () => {
|
|
51
|
+
it("accepts a list of prefixes", () => {
|
|
52
|
+
expect(() => validate({ provider: "pg_keyspace", durablePrefixes: ["session:"] })).not.toThrow()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("refuses anything that is not a list of non-empty strings", () => {
|
|
56
|
+
for (const bad of ["session:", [""], [3], [null]]) {
|
|
57
|
+
expect(() => validate({ provider: "pg_keyspace", durablePrefixes: bad })).toThrow(
|
|
58
|
+
/must be an array of non-empty strings/,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// `,` separates entries and `=` separates a prefix from its tier in
|
|
64
|
+
// pg_keyspace.durability_overrides. A prefix containing either would be read
|
|
65
|
+
// as two settings, and the first sign of it would be a database that will
|
|
66
|
+
// not start — long after the config was written.
|
|
67
|
+
it("refuses a prefix carrying one of the separators", () => {
|
|
68
|
+
for (const bad of ["a,b", "a=b"]) {
|
|
69
|
+
expect(() => validate({ provider: "pg_keyspace", durablePrefixes: [bad] })).toThrow(
|
|
70
|
+
/cannot contain "," or "="/,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe("which provider a project actually runs", () => {
|
|
77
|
+
/*
|
|
78
|
+
* P9 flipped the default. The resolver is one function because the answer depends on two fields:
|
|
79
|
+
* an absent `cache.provider` means pg_keyspace beside a database this stack starts and Valkey
|
|
80
|
+
* beside an external one. A default written into the parser would have to be wrong for one of
|
|
81
|
+
* them, or applied twice — and applied twice is how the compose file and the native path end up
|
|
82
|
+
* disagreeing about which server is running, which is a cache that silently never hits and, with
|
|
83
|
+
* TLS on, a Kong that cannot store its certificate.
|
|
84
|
+
*/
|
|
85
|
+
const project = (over: Record<string, unknown>) =>
|
|
86
|
+
({
|
|
87
|
+
project: { name: "acme" },
|
|
88
|
+
database: { provider: "docker" },
|
|
89
|
+
server: { mode: "dev" },
|
|
90
|
+
app: { mode: "none" },
|
|
91
|
+
...over,
|
|
92
|
+
}) as unknown as Parameters<typeof cacheProvider>[0]
|
|
93
|
+
|
|
94
|
+
it("runs the keyspace in Postgres when nothing says otherwise", () => {
|
|
95
|
+
expect(cacheProvider(project({}))).toBe("pg_keyspace")
|
|
96
|
+
expect(cacheProvider(project({ cache: {} }))).toBe("pg_keyspace")
|
|
97
|
+
expect(cacheProvider(project({ cache: { durablePrefixes: ["session:"] } }))).toBe("pg_keyspace")
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("keeps Valkey for a project that pins its Postgres image", () => {
|
|
101
|
+
// The image honours SUPATYPE_KEYSPACE_ENABLED from the release that introduced it, and an
|
|
102
|
+
// older one ignores it and starts with no RESP listener — no error anywhere, just a cache that
|
|
103
|
+
// never hits. A pin says this project's image is fixed; a default cannot assume something
|
|
104
|
+
// about a fixed image it has no way to check. Saying `pg_keyspace` explicitly still works, and
|
|
105
|
+
// is the whole instruction for a project whose pin does carry the toggle.
|
|
106
|
+
expect(cacheProvider(project({ versions: { postgres: "17.2" } }))).toBe("valkey")
|
|
107
|
+
expect(
|
|
108
|
+
cacheProvider(project({ versions: { postgres: "17.2" }, cache: { provider: "pg_keyspace" } })),
|
|
109
|
+
).toBe("pg_keyspace")
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it("is unaffected by a pin on anything else", () => {
|
|
113
|
+
// Only the Postgres image decides whether a keyspace can be served.
|
|
114
|
+
expect(cacheProvider(project({ versions: { engine: "0.4.2", server: "0.1.0" } }))).toBe("pg_keyspace")
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it("honours an explicit choice, including the one that is no longer the default", () => {
|
|
118
|
+
// Selectable, not deprecated. A project that wrote `valkey` down keeps the sidecar.
|
|
119
|
+
expect(cacheProvider(project({ cache: { provider: "valkey" } }))).toBe("valkey")
|
|
120
|
+
expect(cacheProvider(project({ cache: { provider: "pg_keyspace" } }))).toBe("pg_keyspace")
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("resolves to Valkey for a database this stack does not start", () => {
|
|
124
|
+
// Not a preference: pg_keyspace is loaded through shared_preload_libraries, so against an
|
|
125
|
+
// unmanaged Postgres there is nothing to configure and Valkey is the only thing that can run.
|
|
126
|
+
// Asking for pg_keyspace explicitly there is refused by validation rather than resolved here.
|
|
127
|
+
expect(cacheProvider(project({ database: { external: { url: "postgres://u:p@h:5432/d" } } }))).toBe("valkey")
|
|
128
|
+
expect(
|
|
129
|
+
cacheProvider(project({ database: { external: { url: "postgres://u:p@h:5432/d" } }, cache: {} })),
|
|
130
|
+
).toBe("valkey")
|
|
131
|
+
})
|
|
132
|
+
})
|
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
boundsRequiringHelpers,
|
|
5
5
|
compareVersions,
|
|
6
6
|
ENGINE_MIN_FOR_BOUNDS,
|
|
7
|
+
ENGINE_MIN_FOR_VERSIONS,
|
|
8
|
+
modelsRequiringVersions,
|
|
7
9
|
} from "../src/engine-floor.js"
|
|
8
10
|
import type { ExtractedSchemaAstV2, FieldAstV2, ModelAstV2 } from "../src/schema-ast-v2.js"
|
|
9
11
|
|
|
@@ -100,3 +102,44 @@ describe("assertEngineSupportsSchema", () => {
|
|
|
100
102
|
expect(() => assertEngineSupportsSchema(WITH_BOUND, "1.0.0")).not.toThrow()
|
|
101
103
|
})
|
|
102
104
|
})
|
|
105
|
+
|
|
106
|
+
describe("the versions floor", () => {
|
|
107
|
+
const versionedModel = (): ModelAstV2 => ({
|
|
108
|
+
...model("Post", { title: field() }),
|
|
109
|
+
options: { versions: { drafts: true, keep: 20 } },
|
|
110
|
+
})
|
|
111
|
+
const VERSIONED = schema([versionedModel()])
|
|
112
|
+
const PLAIN = schema([model("Post", { title: field() })])
|
|
113
|
+
|
|
114
|
+
it("names the models that declare it", () => {
|
|
115
|
+
expect(modelsRequiringVersions(VERSIONED)).toEqual(["Post"])
|
|
116
|
+
expect(modelsRequiringVersions(PLAIN)).toEqual([])
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it("refuses a pin older than the release that emits the layer", () => {
|
|
120
|
+
// The failure this guards is *silent*: an older engine does not reject `versions`, it ignores
|
|
121
|
+
// the key, so the push reports success and the model has no snapshot table and no drafts.
|
|
122
|
+
expect(() => { assertEngineSupportsSchema(VERSIONED, "0.2.0") }).toThrow(
|
|
123
|
+
/`versions`[\s\S]*0\.3\.0[\s\S]*saves into nothing/,
|
|
124
|
+
)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("allows the release itself and anything newer", () => {
|
|
128
|
+
expect(() => { assertEngineSupportsSchema(VERSIONED, ENGINE_MIN_FOR_VERSIONS) }).not.toThrow()
|
|
129
|
+
expect(() => { assertEngineSupportsSchema(VERSIONED, "1.0.0") }).not.toThrow()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it("says nothing about a schema that declares no versions", () => {
|
|
133
|
+
// The floor must not spread to projects that never asked for the feature.
|
|
134
|
+
expect(() => { assertEngineSupportsSchema(PLAIN, "0.1.0") }).not.toThrow()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it("leaves an unpinned or local project alone", () => {
|
|
138
|
+
expect(() => { assertEngineSupportsSchema(VERSIONED, undefined) }).not.toThrow()
|
|
139
|
+
expect(() => { assertEngineSupportsSchema(VERSIONED, "local") }).not.toThrow()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it("still refuses bounds on an older pin, so the second rule did not shadow the first", () => {
|
|
143
|
+
expect(() => { assertEngineSupportsSchema(WITH_BOUND, "0.1.9") }).toThrow(/bounds/)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
@@ -61,7 +61,7 @@ describe("external database: compose", () => {
|
|
|
61
61
|
it("interpolates the URL rather than baking the password into the file", () => {
|
|
62
62
|
// The compose file is generated into .supatype/, which operators do commit.
|
|
63
63
|
const compose = renderSelfHostCompose(external())
|
|
64
|
-
// "secret" alone would match
|
|
64
|
+
// "secret" alone would match the object store's own S3 credentials, so match the userinfo form.
|
|
65
65
|
expect(compose).not.toContain("owner:secret@")
|
|
66
66
|
expect(compose).not.toContain("://owner")
|
|
67
67
|
expect(compose).toContain("${DATABASE_URL:?DATABASE_URL is missing from .env")
|
|
@@ -122,7 +122,7 @@ describe("external database: compose", () => {
|
|
|
122
122
|
|
|
123
123
|
it("still declares the volumes it does own", () => {
|
|
124
124
|
const compose = renderSelfHostCompose(external())
|
|
125
|
-
expect(compose).toContain("
|
|
125
|
+
expect(compose).toContain(" storage-data:")
|
|
126
126
|
expect(compose).toContain(" valkey-data:")
|
|
127
127
|
})
|
|
128
128
|
|
|
@@ -137,7 +137,7 @@ describe("external database: compose", () => {
|
|
|
137
137
|
"realtime:",
|
|
138
138
|
"control-plane:",
|
|
139
139
|
"server:",
|
|
140
|
-
"
|
|
140
|
+
"seaweedfs:",
|
|
141
141
|
"schema-engine:",
|
|
142
142
|
"studio:",
|
|
143
143
|
"valkey:",
|
|
@@ -182,3 +182,74 @@ describe("a loopback external URL", () => {
|
|
|
182
182
|
expect(loopbackExternalHost(managed())).toBeUndefined()
|
|
183
183
|
})
|
|
184
184
|
})
|
|
185
|
+
|
|
186
|
+
describe("the draft schema in generated compose", () => {
|
|
187
|
+
it("reaches PGRST_DB_SCHEMA when a model is versioned", () => {
|
|
188
|
+
// The wiring, not the list: a correct `apiSchemaList` behind a placeholder that never gets the
|
|
189
|
+
// flag is exactly the failure shape this project has hit before, and it reads as "there is no
|
|
190
|
+
// draft" rather than as an error.
|
|
191
|
+
const compose = renderSelfHostCompose(managed(), process.cwd(), { drafts: true })
|
|
192
|
+
expect(compose).toMatch(/PGRST_DB_SCHEMA: "public, draft, /)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it("leaves it off when nothing is versioned", () => {
|
|
196
|
+
const compose = renderSelfHostCompose(managed(), process.cwd(), { drafts: false })
|
|
197
|
+
expect(compose).toContain('PGRST_DB_SCHEMA: "public, supatype, graphql_public, auth"')
|
|
198
|
+
expect(compose).not.toContain("draft")
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
describe("the server's own configuration keys", () => {
|
|
203
|
+
// `renderSelfHostCompose` is pure over its config, so one render serves every
|
|
204
|
+
// assertion here and the fixture lives in one place.
|
|
205
|
+
const compose = renderSelfHostCompose(managed())
|
|
206
|
+
|
|
207
|
+
it("uses the SUPATYPE_ prefix the server actually reads", () => {
|
|
208
|
+
// The server's config prefix is "supatype", so a GOTRUE_ key is read by nothing. It does not
|
|
209
|
+
// warn about the ones it ignores: it dies on the first *required* key it cannot find, so
|
|
210
|
+
// self-host failed to start with a message naming a variable that was present in .env under
|
|
211
|
+
// another spelling, and no test noticed because none asserted these.
|
|
212
|
+
for (const key of [
|
|
213
|
+
"SUPATYPE_API_EXTERNAL_URL",
|
|
214
|
+
"SUPATYPE_API_HOST",
|
|
215
|
+
"SUPATYPE_API_PORT",
|
|
216
|
+
"SUPATYPE_DB_DRIVER",
|
|
217
|
+
"SUPATYPE_DB_DATABASE_URL",
|
|
218
|
+
"SUPATYPE_SITE_URL",
|
|
219
|
+
"SUPATYPE_JWT_SECRET",
|
|
220
|
+
"SUPATYPE_DISABLE_SIGNUP",
|
|
221
|
+
"SUPATYPE_MAILER_AUTOCONFIRM",
|
|
222
|
+
]) {
|
|
223
|
+
expect(compose).toContain(`${key}:`)
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it("sets no GOTRUE_ key on the server, since none of them are read", () => {
|
|
228
|
+
const serverBlock = compose.split("\n server:")[1]?.split("\n kong:")[0] ?? ""
|
|
229
|
+
const stale = serverBlock
|
|
230
|
+
.split("\n")
|
|
231
|
+
.map((line) => line.trim())
|
|
232
|
+
.filter((line) => /^GOTRUE_[A-Z_]+:/.test(line))
|
|
233
|
+
expect(stale).toEqual([])
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it("reads its override from the current name too, not the retired one", () => {
|
|
237
|
+
// Interpolating from GOTRUE_MAILER_AUTOCONFIRM would require the operator to keep that key in
|
|
238
|
+
// `.env` — and the server reads config files into its environment before checking for retired
|
|
239
|
+
// names, so its presence there is fatal. The old spelling cannot be the escape hatch when
|
|
240
|
+
// having it set at all is the failure.
|
|
241
|
+
expect(compose).toContain("SUPATYPE_MAILER_AUTOCONFIRM: ${SUPATYPE_MAILER_AUTOCONFIRM:-true}")
|
|
242
|
+
expect(compose).not.toContain("${GOTRUE_MAILER_AUTOCONFIRM")
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it("raises the header buffer, because localhost cookies are not port-scoped", () => {
|
|
246
|
+
// Every project a developer runs on localhost shares one cookie jar, so this gateway is sent
|
|
247
|
+
// cookies belonging to entirely unrelated stacks, and nginx's default buffers are not sized for
|
|
248
|
+
// that. The answer is "Request header or cookie too large" from a component nobody configured.
|
|
249
|
+
//
|
|
250
|
+
// The value is pinned rather than merely present, because Studio's own nginx must carry the
|
|
251
|
+
// same one: a request that clears the gateway and fails at the page is worse to diagnose than
|
|
252
|
+
// one that fails outright. packages/studio/nginx.conf holds the reasoning and its own test.
|
|
253
|
+
expect(compose).toContain('KONG_NGINX_HTTP_LARGE_CLIENT_HEADER_BUFFERS: "4 32k"')
|
|
254
|
+
})
|
|
255
|
+
})
|
|
@@ -118,7 +118,7 @@ describe("the exposed schema list", () => {
|
|
|
118
118
|
it("serves from api under tier 2, and drops the managed schema", () => {
|
|
119
119
|
// Both halves matter. A client picks its schema per request with `Accept-Profile`, so leaving the
|
|
120
120
|
// managed schema exposed would let any caller read the unmasked table.
|
|
121
|
-
const list = apiSchemaList(project({ provider: "native" }), "views")
|
|
121
|
+
const list = apiSchemaList(project({ provider: "native" }), { tier: "views" })
|
|
122
122
|
expect(list).toBe("api, supatype, graphql_public, auth")
|
|
123
123
|
// Entries, not substrings: `graphql_public` legitimately contains "public".
|
|
124
124
|
expect(list.split(", ")).not.toContain("public")
|
|
@@ -126,7 +126,7 @@ describe("the exposed schema list", () => {
|
|
|
126
126
|
|
|
127
127
|
it("is unchanged for tier 1 and for a schema with no field rules", () => {
|
|
128
128
|
for (const tier of ["extension", "none", undefined] as const) {
|
|
129
|
-
expect(apiSchemaList(project({ provider: "docker" }), tier)).toBe(
|
|
129
|
+
expect(apiSchemaList(project({ provider: "docker" }), { tier })).toBe(
|
|
130
130
|
"public, supatype, graphql_public, auth",
|
|
131
131
|
)
|
|
132
132
|
}
|
|
@@ -138,7 +138,7 @@ describe("the exposed schema list", () => {
|
|
|
138
138
|
const cfg = project({ external: { url: "postgres://u@h:5432/d" } }, {
|
|
139
139
|
schema: { api_schemas: ["public", "supatype"] },
|
|
140
140
|
})
|
|
141
|
-
expect(apiSchemaList(cfg, "views")).toBe("public, supatype")
|
|
141
|
+
expect(apiSchemaList(cfg, { tier: "views" })).toBe("public, supatype")
|
|
142
142
|
})
|
|
143
143
|
})
|
|
144
144
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Shared corpus for the identity-dependence rule, which exists twice: here, for supatype push (packages/cli/src/cache-identity-scope.ts), and in supatype-server (internal/studiobootstrap.IsIdentityDependent) for the runtime cache-scope decision. Two implementations of one rule drift silently — a push that says 'shareable' against a runtime that says 'not', or the reverse, which is a data leak. Both sides run these cases. Add a case here when you change either.",
|
|
3
|
+
"cases": [
|
|
4
|
+
{ "name": "no rule at all", "rule": null, "identityDependent": false },
|
|
5
|
+
{ "name": "Public", "rule": { "type": "public" }, "identityDependent": false },
|
|
6
|
+
{ "name": "Private", "rule": { "type": "private" }, "identityDependent": false },
|
|
7
|
+
|
|
8
|
+
{ "name": "LoggedIn", "rule": { "type": "authenticated" }, "identityDependent": true },
|
|
9
|
+
{ "name": "Role<admin>", "rule": { "type": "role" }, "identityDependent": true },
|
|
10
|
+
{ "name": "Owner<user_id>", "rule": { "type": "owner", "field": "user_id" }, "identityDependent": true },
|
|
11
|
+
{ "name": "In<...>", "rule": { "type": "in" }, "identityDependent": true },
|
|
12
|
+
{ "name": "Exists<...>", "rule": { "type": "exists" }, "identityDependent": true },
|
|
13
|
+
|
|
14
|
+
{ "name": "Eq<user_id, CurrentUser>", "rule": { "type": "compare", "left": { "kind": "column" }, "right": { "kind": "authUid" } }, "identityDependent": true },
|
|
15
|
+
{ "name": "CurrentUser on the left", "rule": { "type": "compare", "left": { "kind": "authUid" }, "right": { "kind": "column" } }, "identityDependent": true },
|
|
16
|
+
{ "name": "compare against a claim", "rule": { "type": "compare", "left": { "kind": "column" }, "right": { "kind": "claim" } }, "identityDependent": true },
|
|
17
|
+
{ "name": "compare against authRole", "rule": { "type": "compare", "left": { "kind": "column" }, "right": { "kind": "authRole" } }, "identityDependent": true },
|
|
18
|
+
|
|
19
|
+
{ "name": "Lte<published_at, Now> — row-dependent but shareable", "rule": { "type": "compare", "left": { "kind": "column" }, "right": { "kind": "now" } }, "identityDependent": false },
|
|
20
|
+
{ "name": "column vs literal", "rule": { "type": "compare", "left": { "kind": "column" }, "right": { "kind": "literal" } }, "identityDependent": false },
|
|
21
|
+
{ "name": "column vs column", "rule": { "type": "compare", "left": { "kind": "column" }, "right": { "kind": "column" } }, "identityDependent": false },
|
|
22
|
+
|
|
23
|
+
{ "name": "IsNull on a column", "rule": { "type": "nullCheck", "operand": { "kind": "column" } }, "identityDependent": false },
|
|
24
|
+
{ "name": "IsNull on a claim", "rule": { "type": "nullCheck", "operand": { "kind": "claim" } }, "identityDependent": true },
|
|
25
|
+
|
|
26
|
+
{ "name": "Any of two shareable", "rule": { "type": "any", "rules": [ { "type": "public" }, { "type": "compare", "left": { "kind": "column" }, "right": { "kind": "now" } } ] }, "identityDependent": false },
|
|
27
|
+
{ "name": "Any with one caller-varying", "rule": { "type": "any", "rules": [ { "type": "public" }, { "type": "owner" } ] }, "identityDependent": true },
|
|
28
|
+
{ "name": "All of two shareable", "rule": { "type": "all", "rules": [ { "type": "public" }, { "type": "private" } ] }, "identityDependent": false },
|
|
29
|
+
{ "name": "All with one caller-varying", "rule": { "type": "all", "rules": [ { "type": "public" }, { "type": "authenticated" } ] }, "identityDependent": true },
|
|
30
|
+
{ "name": "Any with no rules", "rule": { "type": "any", "rules": [] }, "identityDependent": false },
|
|
31
|
+
|
|
32
|
+
{ "name": "Not over shareable", "rule": { "type": "not", "rule": { "type": "public" } }, "identityDependent": false },
|
|
33
|
+
{ "name": "Not over caller-varying", "rule": { "type": "not", "rule": { "type": "owner" } }, "identityDependent": true },
|
|
34
|
+
{ "name": "Not over nothing", "rule": { "type": "not" }, "identityDependent": false },
|
|
35
|
+
|
|
36
|
+
{ "name": "nested any/all/not, shareable throughout", "rule": { "type": "all", "rules": [ { "type": "any", "rules": [ { "type": "public" } ] }, { "type": "not", "rule": { "type": "compare", "left": { "kind": "column" }, "right": { "kind": "now" } } } ] }, "identityDependent": false },
|
|
37
|
+
{ "name": "caller-varying buried three deep", "rule": { "type": "all", "rules": [ { "type": "any", "rules": [ { "type": "not", "rule": { "type": "compare", "left": { "kind": "column" }, "right": { "kind": "authUid" } } } ] } ] }, "identityDependent": true },
|
|
38
|
+
|
|
39
|
+
{ "name": "unrecognised rule type", "rule": { "type": "somethingNew" }, "identityDependent": true },
|
|
40
|
+
{ "name": "rule with no type", "rule": {}, "identityDependent": true }
|
|
41
|
+
]
|
|
42
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { generateFunctionsRouterSource } from "../src/functions-router-gen.js"
|
|
3
|
+
|
|
4
|
+
// The router the CLI generates for `supatype dev` used to serialise every invocation.
|
|
5
|
+
//
|
|
6
|
+
// Per-invocation configuration was delivered by mutating `Deno.env`, which is process-global, so
|
|
7
|
+
// two concurrent calls would read each other's values — including a service-role key granted to one
|
|
8
|
+
// route and not the other. The defence was a lock held across the whole handler, which meant one
|
|
9
|
+
// invocation at a time: a function waiting two seconds on a third-party API blocked every other
|
|
10
|
+
// function for two seconds.
|
|
11
|
+
//
|
|
12
|
+
// It was invisible because the deployed worker had the same lock, so local behaved exactly like
|
|
13
|
+
// production and there was nothing to compare against. Measured after the fix: four 300ms
|
|
14
|
+
// invocations complete in about 430ms through the worker and 655ms through this router, against
|
|
15
|
+
// ~1200ms serial.
|
|
16
|
+
//
|
|
17
|
+
// These assert the shape rather than the timing. A timing test here would need a Deno runtime and
|
|
18
|
+
// would be the slowest test in the suite; what actually regresses is somebody reintroducing the
|
|
19
|
+
// global mutation, and that is visible in the source.
|
|
20
|
+
|
|
21
|
+
const ROUTER = generateFunctionsRouterSource("/project/.supatype/functions-router.ts", [
|
|
22
|
+
{ name: "hello", entrypoint: "/project/functions/hello/index.ts" },
|
|
23
|
+
])
|
|
24
|
+
|
|
25
|
+
describe("the generated functions router", () => {
|
|
26
|
+
it("holds no lock across a handler", () => {
|
|
27
|
+
// Any of these names coming back means the serialisation is back.
|
|
28
|
+
for (const banned of ["envLock", "withEnvLock", "runWithScopedEnv"]) {
|
|
29
|
+
expect(ROUTER).not.toContain(banned)
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("does not write per-invocation values into the process environment", () => {
|
|
34
|
+
// `Deno.env.set` is the mechanism, and there is no safe amount of it: the environment is shared
|
|
35
|
+
// by every invocation in the isolate, so one write is enough to need the lock back.
|
|
36
|
+
expect(ROUTER).not.toContain("Deno.env.set")
|
|
37
|
+
expect(ROUTER).not.toContain("Deno.env.delete")
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("passes the call's configuration to the handler instead", () => {
|
|
41
|
+
expect(ROUTER).toContain("ctx: FunctionContext")
|
|
42
|
+
expect(ROUTER).toContain("handlers[fnName]!(req, context)")
|
|
43
|
+
// The service-role key reaches a route through the context or not at all.
|
|
44
|
+
expect(ROUTER).toContain("serviceRoleKey")
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("keeps each invocation's identity in async context, not in a module variable", () => {
|
|
48
|
+
// `console` is global and calls now genuinely interleave, so a captured line has to be
|
|
49
|
+
// attributed to the invocation that wrote it. A module-level "current request" would
|
|
50
|
+
// mis-attribute every interleaved line.
|
|
51
|
+
expect(ROUTER).toContain("AsyncLocalStorage")
|
|
52
|
+
expect(ROUTER).toContain("invocationStore.run(")
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("logs one JSON object per line, with the fields the cloud pipeline indexes on", () => {
|
|
56
|
+
// Promtail parses each line as JSON and labels on these, taking the timestamp from a `timestamp`
|
|
57
|
+
// field. A plain-text line is collected, matches no expression, and is indexed under no project.
|
|
58
|
+
for (const field of ["timestamp", "level", "service", "project_ref", "request_id", "duration_ms"]) {
|
|
59
|
+
expect(ROUTER).toContain(field)
|
|
60
|
+
}
|
|
61
|
+
expect(ROUTER).toContain("JSON.stringify(line)")
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("emits source that is valid to embed, with no stray template escapes", () => {
|
|
65
|
+
// The generator builds this with a template literal, so an unescaped backtick or `${` silently
|
|
66
|
+
// ends the literal and emits broken code. Cheap to assert, and it has bitten twice.
|
|
67
|
+
expect(ROUTER).not.toContain("\\`")
|
|
68
|
+
expect(ROUTER).not.toContain("\\${")
|
|
69
|
+
expect(ROUTER).toContain('import handler_0 from "')
|
|
70
|
+
})
|
|
71
|
+
})
|
package/tests/init.test.ts
CHANGED
|
@@ -291,7 +291,11 @@ describe("scaffold()", () => {
|
|
|
291
291
|
expect(existsSync(join(tmpRoot, "functions/.env.local"))).toBe(true)
|
|
292
292
|
expect(existsSync(join(tmpRoot, "functions/deno.d.ts"))).toBe(true)
|
|
293
293
|
expect(existsSync(join(tmpRoot, "functions/tsconfig.json"))).toBe(true)
|
|
294
|
-
|
|
294
|
+
// The scaffold teaches the context, not the environment. It used to read Deno.env.get, which is
|
|
295
|
+
// the process-global delivery that forced the worker to run one invocation at a time.
|
|
296
|
+
const hello = readFileSync(join(tmpRoot, "functions/hello/index.ts"), "utf8")
|
|
297
|
+
expect(hello).toContain("ctx: FunctionContext")
|
|
298
|
+
expect(hello).not.toContain("Deno.env.get")
|
|
295
299
|
const pkg = readFileSync(join(tmpRoot, "package.json"), "utf8")
|
|
296
300
|
expect(pkg).toContain("supatype functions serve")
|
|
297
301
|
})
|
|
@@ -329,7 +333,37 @@ describe("scaffold()", () => {
|
|
|
329
333
|
expect(config).toContain('provider: "local"')
|
|
330
334
|
expect(config).toContain("Production storage: external S3")
|
|
331
335
|
const env = readFileSync(join(tmpRoot, ".env"), "utf8")
|
|
332
|
-
expect(env).toContain("local development,
|
|
336
|
+
expect(env).toContain("local development, SeaweedFS")
|
|
333
337
|
expect(env).toContain("production, external bucket")
|
|
334
338
|
})
|
|
339
|
+
|
|
340
|
+
// The compose generator has had this guard since SeaweedFS replaced MinIO; the .env generator
|
|
341
|
+
// did not, which is the whole reason it kept naming a withdrawn image and a port nothing serves.
|
|
342
|
+
// A scaffolded .env is the first thing a new project reads, so it gets the same guard.
|
|
343
|
+
it("a scaffolded .env never names MinIO or its port", () => {
|
|
344
|
+
for (const storage of ["local", "s3"] as const) {
|
|
345
|
+
scaffold(tmpRoot, {
|
|
346
|
+
...defaultScaffoldOptions("my-app"),
|
|
347
|
+
storageLocal: storage,
|
|
348
|
+
storageProduction: storage,
|
|
349
|
+
})
|
|
350
|
+
const env = readFileSync(join(tmpRoot, ".env"), "utf8")
|
|
351
|
+
expect(env.toLowerCase()).not.toContain("minio")
|
|
352
|
+
expect(env).not.toContain("9000")
|
|
353
|
+
rmSync(tmpRoot, { recursive: true, force: true })
|
|
354
|
+
mkdirSync(tmpRoot, { recursive: true })
|
|
355
|
+
}
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
// The object store publishes 8333, and the storage service reads S3_ENDPOINT from this file.
|
|
359
|
+
// A local .env pointing anywhere else is a stack that starts and then fails on first upload.
|
|
360
|
+
it("local storage points at the port the object store actually publishes", () => {
|
|
361
|
+
scaffold(tmpRoot, {
|
|
362
|
+
...defaultScaffoldOptions("my-app"),
|
|
363
|
+
storageLocal: "local",
|
|
364
|
+
storageProduction: "local",
|
|
365
|
+
})
|
|
366
|
+
const env = readFileSync(join(tmpRoot, ".env"), "utf8")
|
|
367
|
+
expect(env).toContain("S3_ENDPOINT=http://localhost:8333")
|
|
368
|
+
})
|
|
335
369
|
})
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `supatype keys --write` writes the pair it mints.
|
|
3
|
+
*
|
|
4
|
+
* The command printed the keys and wrote nothing, while every README in this repository says it
|
|
5
|
+
* mints them into `.env`. Following the docs therefore produced a front end built with
|
|
6
|
+
* `anonKey: undefined` and a gateway refusing its every request, with nothing in the output saying
|
|
7
|
+
* so.
|
|
8
|
+
*
|
|
9
|
+
* Writing is opt-in rather than the default: a command that reads a secret and edits a file on
|
|
10
|
+
* sight is worse than one that has to be asked. What it writes is blanks only, because an anon key
|
|
11
|
+
* already in `.env` is held by clients this command cannot reach and reissuing it locks them out.
|
|
12
|
+
* `--force` is how someone says they meant to rotate.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest"
|
|
15
|
+
import { mkdtempSync, rmSync, readFileSync, writeFileSync } from "node:fs"
|
|
16
|
+
import { tmpdir } from "node:os"
|
|
17
|
+
import { join } from "node:path"
|
|
18
|
+
import { anonKeyEnvUpdates, writeKeysToEnv } from "../src/commands/keys.js"
|
|
19
|
+
|
|
20
|
+
const PAIR = { anonKey: "anon.jwt.value", serviceKey: "service.jwt.value" }
|
|
21
|
+
|
|
22
|
+
function envValue(dir: string, key: string): string | undefined {
|
|
23
|
+
const line = readFileSync(join(dir, ".env"), "utf8")
|
|
24
|
+
.split("\n")
|
|
25
|
+
.find((l) => l.startsWith(`${key}=`))
|
|
26
|
+
return line?.slice(key.length + 1).trim()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("writeKeysToEnv", () => {
|
|
30
|
+
let dir: string
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
dir = mkdtempSync(join(tmpdir(), "supatype-keys-"))
|
|
34
|
+
})
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
rmSync(dir, { recursive: true, force: true })
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("fills blank keys", () => {
|
|
40
|
+
writeFileSync(join(dir, ".env"), "JWT_SECRET=s\nANON_KEY=\nSERVICE_ROLE_KEY=\n")
|
|
41
|
+
const result = writeKeysToEnv(dir, PAIR, false)
|
|
42
|
+
|
|
43
|
+
expect(result.written).toContain("ANON_KEY")
|
|
44
|
+
expect(result.written).toContain("SERVICE_ROLE_KEY")
|
|
45
|
+
expect(envValue(dir, "ANON_KEY")).toBe(PAIR.anonKey)
|
|
46
|
+
expect(envValue(dir, "SERVICE_ROLE_KEY")).toBe(PAIR.serviceKey)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("adds keys that are absent entirely", () => {
|
|
50
|
+
writeFileSync(join(dir, ".env"), "JWT_SECRET=s\n")
|
|
51
|
+
writeKeysToEnv(dir, PAIR, false)
|
|
52
|
+
|
|
53
|
+
expect(envValue(dir, "ANON_KEY")).toBe(PAIR.anonKey)
|
|
54
|
+
expect(envValue(dir, "VITE_SUPATYPE_ANON_KEY")).toBe(PAIR.anonKey)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("treats a declared-but-blank line as a blank to fill", () => {
|
|
58
|
+
// The templates ship `ANON_KEY=` with nothing after it, which is exactly the line to write.
|
|
59
|
+
writeFileSync(join(dir, ".env"), "JWT_SECRET=s\nVITE_SUPATYPE_ANON_KEY=\n")
|
|
60
|
+
const result = writeKeysToEnv(dir, PAIR, false)
|
|
61
|
+
|
|
62
|
+
expect(result.written).toContain("VITE_SUPATYPE_ANON_KEY")
|
|
63
|
+
expect(envValue(dir, "VITE_SUPATYPE_ANON_KEY")).toBe(PAIR.anonKey)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("leaves a key that already holds a value, and says which", () => {
|
|
67
|
+
writeFileSync(join(dir, ".env"), "JWT_SECRET=s\nANON_KEY=in-use-by-clients\nSERVICE_ROLE_KEY=\n")
|
|
68
|
+
const result = writeKeysToEnv(dir, PAIR, false)
|
|
69
|
+
|
|
70
|
+
expect(result.kept).toContain("ANON_KEY")
|
|
71
|
+
expect(result.written).not.toContain("ANON_KEY")
|
|
72
|
+
expect(envValue(dir, "ANON_KEY")).toBe("in-use-by-clients")
|
|
73
|
+
expect(envValue(dir, "SERVICE_ROLE_KEY")).toBe(PAIR.serviceKey)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("replaces every key it owns under --force, not only the blank ones", () => {
|
|
77
|
+
writeFileSync(
|
|
78
|
+
join(dir, ".env"),
|
|
79
|
+
"JWT_SECRET=s\nANON_KEY=old-anon\nSERVICE_ROLE_KEY=old-service\nVITE_SUPATYPE_ANON_KEY=old-vite\n",
|
|
80
|
+
)
|
|
81
|
+
const result = writeKeysToEnv(dir, PAIR, true)
|
|
82
|
+
|
|
83
|
+
expect(result.kept).toEqual([])
|
|
84
|
+
expect(envValue(dir, "ANON_KEY")).toBe(PAIR.anonKey)
|
|
85
|
+
expect(envValue(dir, "SERVICE_ROLE_KEY")).toBe(PAIR.serviceKey)
|
|
86
|
+
expect(envValue(dir, "VITE_SUPATYPE_ANON_KEY")).toBe(PAIR.anonKey)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("does not rewrite the file, even under --force", () => {
|
|
90
|
+
// `.env` holds JWT_SECRET, which these keys are derived from, plus the database password and
|
|
91
|
+
// whatever else the project keeps there. Replacing the file would leave a project whose keys
|
|
92
|
+
// cannot be regenerated and whose stack cannot start.
|
|
93
|
+
writeFileSync(
|
|
94
|
+
join(dir, ".env"),
|
|
95
|
+
"# a comment someone wrote\nJWT_SECRET=s\nPOSTGRES_PASSWORD=pw\nANON_KEY=old\nCUSTOM_THING=mine\n",
|
|
96
|
+
)
|
|
97
|
+
writeKeysToEnv(dir, PAIR, true)
|
|
98
|
+
|
|
99
|
+
const content = readFileSync(join(dir, ".env"), "utf8")
|
|
100
|
+
expect(content).toContain("# a comment someone wrote")
|
|
101
|
+
expect(envValue(dir, "JWT_SECRET")).toBe("s")
|
|
102
|
+
expect(envValue(dir, "POSTGRES_PASSWORD")).toBe("pw")
|
|
103
|
+
expect(envValue(dir, "CUSTOM_THING")).toBe("mine")
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("carries the API URL through when the project records one", () => {
|
|
107
|
+
writeFileSync(join(dir, ".env"), "JWT_SECRET=s\nPUBLIC_SUPATYPE_URL=http://localhost:18475\n")
|
|
108
|
+
writeKeysToEnv(dir, PAIR, true)
|
|
109
|
+
|
|
110
|
+
expect(envValue(dir, "VITE_SUPATYPE_URL")).toBe("http://localhost:18475")
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("writes no URL names when the project records no URL", () => {
|
|
114
|
+
// An empty `VITE_SUPATYPE_URL` is worse than an absent one: the client reads it, finds a blank
|
|
115
|
+
// string, and requests against the page origin without saying why.
|
|
116
|
+
writeFileSync(join(dir, ".env"), "JWT_SECRET=s\n")
|
|
117
|
+
writeKeysToEnv(dir, PAIR, false)
|
|
118
|
+
|
|
119
|
+
expect(readFileSync(join(dir, ".env"), "utf8")).not.toContain("VITE_SUPATYPE_URL")
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("reports no .env rather than creating one", () => {
|
|
123
|
+
const result = writeKeysToEnv(dir, PAIR, false)
|
|
124
|
+
|
|
125
|
+
expect(result.path).toBeNull()
|
|
126
|
+
expect(result.written).toEqual([])
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe("anonKeyEnvUpdates", () => {
|
|
131
|
+
it("is the one list both `keys` and `dev` write from", () => {
|
|
132
|
+
// They held separate lists, so a front end's prefix could be written by one and forgotten by
|
|
133
|
+
// the other, and which names a project's .env ended up with depended on which command ran last.
|
|
134
|
+
const names = Object.keys(anonKeyEnvUpdates("a", "s"))
|
|
135
|
+
expect(names).toEqual([
|
|
136
|
+
"ANON_KEY",
|
|
137
|
+
"SERVICE_ROLE_KEY",
|
|
138
|
+
"VITE_SUPATYPE_ANON_KEY",
|
|
139
|
+
"PUBLIC_SUPATYPE_ANON_KEY",
|
|
140
|
+
"EXPO_PUBLIC_SUPATYPE_ANON_KEY",
|
|
141
|
+
])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it("adds the URL names only when given a URL", () => {
|
|
145
|
+
expect(Object.keys(anonKeyEnvUpdates("a", "s", "http://x"))).toContain("VITE_SUPATYPE_URL")
|
|
146
|
+
expect(Object.keys(anonKeyEnvUpdates("a", "s"))).not.toContain("VITE_SUPATYPE_URL")
|
|
147
|
+
})
|
|
148
|
+
})
|