218 lines
8.8 KiB
TypeScript
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)
|
|
})
|
|
})
|