@@ -0,0 +1,217 @@
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user