Files
template-app/test/convex-env-sync.test.ts
T
2026-11-14 09:56:45 +00:00

218 lines
8.8 KiB
TypeScript

// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"
import type { AddressInfo } from "node:net"
import { afterEach, describe, expect, it, vi } from "vitest"
import { syncPlatformEnvironment } from "../profiles/convex/sync-env.mjs"
const servers: Server[] = []
afterEach(async () => {
await Promise.all(
servers.splice(0).map(
(server) =>
new Promise<void>((resolve) => {
server.closeAllConnections()
server.close(() => resolve())
}),
),
)
})
/** A promise the test resolves when something it waits on has happened (the tests' lib is ES2023). */
function signal() {
let resolve = () => {}
const promise = new Promise<void>((done) => {
resolve = done
})
return { promise, resolve }
}
async function listen(handler: (req: IncomingMessage, res: ServerResponse) => void) {
const server = createServer(handler)
servers.push(server)
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
return `http://127.0.0.1:${(server.address() as AddressInfo).port}`
}
async function fixture(options: { queryStatus?: number; updateStatus?: number } = {}) {
const values = new Map([
["EXISTING", "fixture-original"],
["UNLISTED", "fixture-untouched"],
])
const requests: string[] = []
let authenticated = true
const url = await listen((req, res) => {
requests.push(req.url ?? "")
authenticated &&= req.headers.authorization === "Convex fixture-placeholder"
const chunks: Buffer[] = []
req.on("data", (chunk) => chunks.push(chunk))
req.on("end", () => {
if (req.url === "/api/query") {
const body = JSON.parse(Buffer.concat(chunks).toString())
if (body.path !== "_system/cli/queryEnvironmentVariables" || req.method !== "POST") {
res.writeHead(400).end()
return
}
res.writeHead(options.queryStatus ?? 200, { "Content-Type": "application/json" })
res.end(
JSON.stringify({
status: "success",
value: [...values].map(([name, value]) => ({ name, value })),
}),
)
} else if (req.url === "/api/update_environment_variables") {
if (options.updateStatus) {
res.writeHead(options.updateStatus).end("fixture-sensitive-error-body")
return
}
const body = JSON.parse(Buffer.concat(chunks).toString())
for (const entry of body.changes) values.set(entry.name, entry.value)
res.writeHead(200).end()
} else {
res.writeHead(404).end()
}
})
})
// The sync's own default deadline (30 s) is only a hang bound here: these cases end on the
// fixture's answers, and the deadline itself is tested with a faked clock below.
const sync = (variables: Record<string, string>) =>
syncPlatformEnvironment({ url, adminKey: "fixture-placeholder", variables })
return { values, requests, sync, authenticated: () => authenticated }
}
describe("Convex deployment environment sync through HTTP-only relays", () => {
it("creates missing values, skips equal values and preserves unlisted values", async () => {
const backend = await fixture()
await backend.sync({ NEW: "fixture-new", EXISTING: "fixture-original" })
expect(backend.values.get("NEW")).toBe("fixture-new")
expect(backend.values.get("EXISTING")).toBe("fixture-original")
expect(backend.values.get("UNLISTED")).toBe("fixture-untouched")
expect(backend.requests).toEqual(["/api/query", "/api/update_environment_variables"])
expect(backend.authenticated()).toBe(true)
await backend.sync({ NEW: "fixture-new", EXISTING: "fixture-original" })
expect(backend.requests).toEqual(["/api/query", "/api/update_environment_variables", "/api/query"])
})
it("refuses the entire batch on a conflict, without overwriting or adding values", async () => {
const backend = await fixture()
await expect(backend.sync({ NEW: "fixture-new", EXISTING: "fixture-conflict" })).rejects.toThrow(
"Convex platform service environment sync failed",
)
expect(backend.values.has("NEW")).toBe(false)
expect(backend.values.get("EXISTING")).toBe("fixture-original")
expect(backend.requests).toEqual(["/api/query"])
})
it.each([401, 403, 500])("fails a refused query (%i) without update or automatic retries", async (queryStatus) => {
const backend = await fixture({ queryStatus })
await expect(backend.sync({ NEW: "fixture-new" })).rejects.toThrow(
"Convex platform service environment sync failed",
)
expect(backend.requests).toEqual(["/api/query"])
expect(backend.values.has("NEW")).toBe(false)
})
it("fails an update without exposing the response body or retrying it", async () => {
const backend = await fixture({ updateStatus: 503 })
await expect(backend.sync({ NEW: "fixture-new" })).rejects.toThrow(
/^Convex platform service environment sync failed$/,
)
expect(backend.requests).toEqual(["/api/query", "/api/update_environment_variables"])
expect(backend.values.has("NEW")).toBe(false)
})
it("bounds the whole query and update sequence with one deadline", async () => {
// The deadline is the sync's own timer, so the test owns the clock: only setTimeout and
// clearTimeout are faked, and each response is held until the test has moved time. Nothing
// here depends on how fast the host runs; a former real-time version (80 ms responses, a 120 ms
// deadline) failed whenever the query alone took longer than the deadline.
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] })
try {
const requests: string[] = []
const arrived = { query: signal(), update: signal() }
let releaseQuery = () => {}
const url = await listen((req, res) => {
requests.push(req.url ?? "")
req.resume()
req.on("end", () => {
if (req.url === "/api/query") {
releaseQuery = () => {
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ status: "success", value: [] }))
}
arrived.query.resolve()
} else {
// Held: only the deadline can end this request.
arrived.update.resolve()
}
})
})
const sync = syncPlatformEnvironment({
url,
adminKey: "fixture-placeholder",
variables: { NEW: "fixture-new" },
timeoutMs: 1000,
})
let settled = false
sync.then(
() => (settled = true),
() => (settled = true),
)
await arrived.query.promise
vi.advanceTimersByTime(600)
releaseQuery()
await arrived.update.promise
// 600 ms of the 1000 ms are spent. One more turn of the event loop must not end the sync.
vi.advanceTimersByTime(399)
await new Promise((resolve) => setImmediate(resolve))
expect(settled).toBe(false)
// The 1000th millisecond ends it; a deadline restarted for the update would still have
// 600 ms left, and the test would stop at vitest's hang bound instead.
vi.advanceTimersByTime(1)
await expect(sync).rejects.toThrow("Convex platform service environment sync failed")
expect(requests).toEqual(["/api/query", "/api/update_environment_variables"])
} finally {
vi.useRealTimers()
}
})
it("aborts a stalled response body instead of waiting indefinitely", async () => {
// As above, the test owns the deadline's clock: time passes only once the stalled body has
// been written, however long the host takes to get the request there.
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] })
try {
let requests = 0
const written = signal()
const url = await listen((_req, res) => {
requests++
res.writeHead(200, { "Content-Type": "application/json" })
res.write('{"status":"success","value":', () => written.resolve())
})
const sync = syncPlatformEnvironment({
url,
adminKey: "fixture-placeholder",
variables: { NEW: "fixture-new" },
timeoutMs: 50,
})
await written.promise
vi.advanceTimersByTime(50)
await expect(sync).rejects.toThrow("Convex platform service environment sync failed")
expect(requests).toBe(1)
} finally {
vi.useRealTimers()
}
})
it("refuses redirects before any credential can reach another host", async () => {
let foreignRequests = 0
const foreign = await listen((_req, res) => {
foreignRequests++
res.writeHead(200).end()
})
const url = await listen((_req, res) => res.writeHead(307, { Location: foreign }).end())
await expect(
syncPlatformEnvironment({ url, adminKey: "fixture-placeholder", variables: { NEW: "fixture-new" } }),
).rejects.toThrow("Convex platform service environment sync failed")
expect(foreignRequests).toBe(0)
})
})