239 lines
12 KiB
JavaScript
239 lines
12 KiB
JavaScript
import { createServer } from "node:http"
|
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
import { tmpdir } from "node:os"
|
|
import { join, resolve } from "node:path"
|
|
import { spawn, spawnSync } from "node:child_process"
|
|
import assert from "node:assert/strict"
|
|
|
|
const root = resolve(new URL("..", import.meta.url).pathname)
|
|
const cli = join(root, "bin", "openwork.mjs")
|
|
const temp = mkdtempSync(join(tmpdir(), "openwork-bootstrap-test-"))
|
|
|
|
// spawnSync blocks this process's event loop entirely, so it cannot be used
|
|
// when the CLI subprocess needs to call back into an HTTP server hosted in
|
|
// THIS same process (the parent could never run its server callback while
|
|
// frozen inside spawnSync, and the child would hang waiting forever). Use
|
|
// async spawn + collect output instead for any test that runs a stub server.
|
|
function spawnAsync(command, args, options = {}) {
|
|
return new Promise((resolveSpawn) => {
|
|
const child = spawn(command, args, { ...options })
|
|
let stdout = ""
|
|
let stderr = ""
|
|
child.stdout?.on("data", (chunk) => { stdout += chunk })
|
|
child.stderr?.on("data", (chunk) => { stderr += chunk })
|
|
child.on("close", (status, signal) => resolveSpawn({ status, signal, stdout, stderr }))
|
|
})
|
|
}
|
|
|
|
async function withStubDenApi(handleBootstrapRequest, run) {
|
|
let bootstrapRequestBody = null
|
|
const server = createServer((req, res) => {
|
|
const chunks = []
|
|
req.on("data", (chunk) => chunks.push(chunk))
|
|
req.on("end", () => {
|
|
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : null
|
|
if (req.url === "/health") {
|
|
res.writeHead(200, { "content-type": "application/json" })
|
|
res.end(JSON.stringify({ ok: true }))
|
|
return
|
|
}
|
|
if (req.url === "/v1/bootstrap/workspace" && req.method === "POST") {
|
|
bootstrapRequestBody = body
|
|
res.writeHead(200, { "content-type": "application/json" })
|
|
res.end(JSON.stringify(handleBootstrapRequest(body)))
|
|
return
|
|
}
|
|
res.writeHead(404, { "content-type": "application/json" })
|
|
res.end(JSON.stringify({ error: "not_found" }))
|
|
})
|
|
})
|
|
|
|
await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen))
|
|
const { port } = server.address()
|
|
try {
|
|
await run(`http://127.0.0.1:${port}`, () => bootstrapRequestBody)
|
|
} finally {
|
|
await new Promise((resolveClose) => server.close(resolveClose))
|
|
}
|
|
}
|
|
|
|
try {
|
|
const installDir = join(temp, "install")
|
|
const binDir = join(temp, "bin")
|
|
const install = spawnSync(process.execPath, [cli, "install", "--install-dir", installDir, "--bin-dir", binDir, "--json"], {
|
|
encoding: "utf8",
|
|
})
|
|
assert.equal(install.status, 0, install.stderr)
|
|
const installJson = JSON.parse(install.stdout)
|
|
assert.equal(installJson.ok, true)
|
|
const executableName = process.platform === "win32" ? "openwork-bootstrap.cmd" : "openwork-bootstrap"
|
|
assert.equal(installJson.install.executable, join(binDir, executableName))
|
|
|
|
const doctor = spawnSync(join(binDir, executableName), ["doctor", "--install-dir", installDir, "--bin-dir", binDir, "--json"], {
|
|
encoding: "utf8",
|
|
})
|
|
assert.equal(doctor.status, 0, doctor.stderr)
|
|
const doctorJson = JSON.parse(doctor.stdout)
|
|
assert.equal(doctorJson.ok, true)
|
|
assert.equal(doctorJson.checks.every((check) => check.ok), true)
|
|
|
|
// cloud claim-link: retrieves the real claim URL from a prepared
|
|
// desktop-bootstrap.json, and never invents/leaks it elsewhere.
|
|
const bootstrapPath = join(temp, "desktop-bootstrap.json")
|
|
writeFileSync(
|
|
bootstrapPath,
|
|
JSON.stringify({
|
|
baseUrl: "https://api.openworklabs.com",
|
|
apiBaseUrl: "https://api.openworklabs.com",
|
|
requireSignin: false,
|
|
prepared: { orgId: "org_test", orgName: "Test Org", skillId: "cob_test", skillTitle: "Test Skill", skillPath: "/tmp/skill.md" },
|
|
claimLinks: [
|
|
{ id: "wcl_owner", role: "owner", token: "test-owner-token", url: "https://app.openworklabs.com/workspace-claim?token=test-owner-token", expiresAt: "2030-01-01T00:00:00.000Z" },
|
|
{ id: "wcl_member", role: "member", token: "test-member-token", url: "https://app.openworklabs.com/workspace-claim?token=test-member-token", expiresAt: "2030-01-01T00:00:00.000Z" },
|
|
],
|
|
}),
|
|
"utf8",
|
|
)
|
|
|
|
const claimLink = spawnSync(process.execPath, [cli, "cloud", "claim-link", "--desktop-bootstrap-path", bootstrapPath, "--json"], {
|
|
encoding: "utf8",
|
|
})
|
|
assert.equal(claimLink.status, 0, claimLink.stderr)
|
|
const claimLinkJson = JSON.parse(claimLink.stdout)
|
|
assert.equal(claimLinkJson.ok, true)
|
|
assert.equal(claimLinkJson.claimLinks.length, 2)
|
|
|
|
const claimLinkByRole = spawnSync(process.execPath, [cli, "cloud", "claim-link", "--desktop-bootstrap-path", bootstrapPath, "--role", "owner", "--json"], {
|
|
encoding: "utf8",
|
|
})
|
|
assert.equal(claimLinkByRole.status, 0, claimLinkByRole.stderr)
|
|
const claimLinkByRoleJson = JSON.parse(claimLinkByRole.stdout)
|
|
assert.equal(claimLinkByRoleJson.claimLinks.length, 1)
|
|
assert.equal(claimLinkByRoleJson.claimLinks[0].role, "owner")
|
|
assert.equal(claimLinkByRoleJson.claimLinks[0].url, "https://app.openworklabs.com/workspace-claim?token=test-owner-token")
|
|
|
|
const claimLinkMissingRole = spawnSync(process.execPath, [cli, "cloud", "claim-link", "--desktop-bootstrap-path", bootstrapPath, "--role", "admin"], {
|
|
encoding: "utf8",
|
|
})
|
|
assert.notEqual(claimLinkMissingRole.status, 0)
|
|
assert.match(claimLinkMissingRole.stderr, /no_claim_links_for_role/)
|
|
|
|
const claimLinkMissingFile = spawnSync(process.execPath, [cli, "cloud", "claim-link", "--desktop-bootstrap-path", join(temp, "does-not-exist.json")], {
|
|
encoding: "utf8",
|
|
})
|
|
assert.notEqual(claimLinkMissingFile.status, 0)
|
|
assert.match(claimLinkMissingFile.stderr, /desktop_bootstrap_not_found/)
|
|
|
|
// cloud bootstrap-workspace --owner-email: the CLI must forward ownerEmail
|
|
// in the request body when provided, and must NOT send the field at all
|
|
// when omitted (so older Den APIs without the field stay unaffected).
|
|
await withStubDenApi(
|
|
() => ({
|
|
ok: true,
|
|
organization: { id: "org_test", name: "Stub Org", slug: "org_test", status: "provisional" },
|
|
setup: { id: "wbt_test", expiresAt: "2030-01-01T00:00:00.000Z" },
|
|
skill: { id: "cob_test", title: "First OpenWork Skill", output: "OPENWORK_BOOTSTRAP_SKILL_TRIGGERED" },
|
|
claimLinks: [{ id: "wcl_test", role: "owner", token: "stub-token", url: "https://example.test/workspace-claim?token=stub-token", expiresAt: "2030-01-01T00:00:00.000Z" }],
|
|
}),
|
|
async (baseUrl, getRequestBody) => {
|
|
const withEmail = await spawnAsync(
|
|
process.execPath,
|
|
[cli, "cloud", "bootstrap-workspace", "--base-url", baseUrl, "--workspace-name", "Owner Email Test", "--owner-email", "founder@example.com", "--json"],
|
|
)
|
|
assert.equal(withEmail.status, 0, withEmail.stderr)
|
|
assert.equal(getRequestBody().ownerEmail, "founder@example.com")
|
|
|
|
const withoutEmail = await spawnAsync(
|
|
process.execPath,
|
|
[cli, "cloud", "bootstrap-workspace", "--base-url", baseUrl, "--workspace-name", "No Email Test", "--json"],
|
|
)
|
|
assert.equal(withoutEmail.status, 0, withoutEmail.stderr)
|
|
assert.equal("ownerEmail" in getRequestBody(), false, "ownerEmail must be omitted entirely, not sent as undefined/null")
|
|
},
|
|
)
|
|
|
|
// cloud bootstrap-workspace --teammate-emails: the CLI must forward
|
|
// teammateEmails in the request body when provided, and must NOT send the
|
|
// field at all when omitted (so older Den APIs without the field stay
|
|
// unaffected).
|
|
await withStubDenApi(
|
|
() => ({
|
|
ok: true,
|
|
organization: { id: "org_test", name: "Stub Org", slug: "org_test", status: "provisional" },
|
|
setup: { id: "wbt_test", expiresAt: "2030-01-01T00:00:00.000Z" },
|
|
skill: { id: "cob_test", title: "First OpenWork Skill", output: "OPENWORK_BOOTSTRAP_SKILL_TRIGGERED" },
|
|
claimLinks: [{ id: "wcl_test", role: "owner", token: "stub-token", url: "https://example.test/workspace-claim?token=stub-token", expiresAt: "2030-01-01T00:00:00.000Z" }],
|
|
}),
|
|
async (baseUrl, getRequestBody) => {
|
|
const withTeammates = await spawnAsync(
|
|
process.execPath,
|
|
[cli, "cloud", "bootstrap-workspace", "--base-url", baseUrl, "--workspace-name", "Teammate Emails Test", "--teammate-emails", "alice@example.com,bob@example.com", "--json"],
|
|
)
|
|
assert.equal(withTeammates.status, 0, withTeammates.stderr)
|
|
assert.deepEqual(getRequestBody().teammateEmails, ["alice@example.com", "bob@example.com"])
|
|
|
|
const withoutTeammates = await spawnAsync(
|
|
process.execPath,
|
|
[cli, "cloud", "bootstrap-workspace", "--base-url", baseUrl, "--workspace-name", "No Teammates Test", "--json"],
|
|
)
|
|
assert.equal(withoutTeammates.status, 0, withoutTeammates.stderr)
|
|
assert.equal("teammateEmails" in getRequestBody(), false, "teammateEmails must be omitted entirely, not sent as undefined/null/empty array")
|
|
},
|
|
)
|
|
|
|
// Regression test: the desktop-bootstrap.json `baseUrl` field is the
|
|
// browser-facing web origin (used by the app's Sign In button / claim
|
|
// links). It must NOT silently become the API origin. Previously
|
|
// --prepare-desktop wrote the same `--base-url` value into both `baseUrl`
|
|
// and `apiBaseUrl`, so a normal `--base-url https://api.openworklabs.com`
|
|
// run broke the desktop app's Sign In flow (it opened
|
|
// `https://api.openworklabs.com/?mode=sign-in...` and showed raw API JSON).
|
|
await withStubDenApi(
|
|
() => ({
|
|
ok: true,
|
|
organization: { id: "org_test", name: "Stub Org", slug: "org_test", status: "provisional" },
|
|
setup: { id: "wbt_test", expiresAt: "2030-01-01T00:00:00.000Z" },
|
|
skill: { id: "cob_test", title: "First OpenWork Skill", output: "OPENWORK_BOOTSTRAP_SKILL_TRIGGERED" },
|
|
claimLinks: [{ id: "wcl_test", role: "owner", token: "stub-token", url: "https://example.test/workspace-claim?token=stub-token", expiresAt: "2030-01-01T00:00:00.000Z" }],
|
|
}),
|
|
async (baseUrl) => {
|
|
const explicitWebBaseUrlPath = join(temp, "desktop-bootstrap-explicit-web.json")
|
|
writeFileSync(
|
|
explicitWebBaseUrlPath,
|
|
`${JSON.stringify({ brandAppName: "OpenWork Demo A" })}\n`,
|
|
"utf8",
|
|
)
|
|
const withExplicitWebBaseUrl = await spawnAsync(
|
|
process.execPath,
|
|
[
|
|
cli, "cloud", "bootstrap-workspace",
|
|
"--base-url", baseUrl,
|
|
"--workspace-name", "Web Base URL Override Test",
|
|
"--web-base-url", "https://app.example.test",
|
|
"--prepare-desktop",
|
|
"--desktop-bootstrap-path", explicitWebBaseUrlPath,
|
|
"--device-key-path", join(temp, "device-key-web-override.json"),
|
|
"--skills-dir", join(temp, "skills-web-override"),
|
|
"--json",
|
|
],
|
|
)
|
|
assert.equal(withExplicitWebBaseUrl.status, 0, withExplicitWebBaseUrl.stderr)
|
|
const writtenExplicit = JSON.parse(readFileSync(explicitWebBaseUrlPath, "utf8"))
|
|
assert.equal(writtenExplicit.baseUrl, "https://app.example.test", "an explicit --web-base-url must be written as baseUrl")
|
|
assert.equal(writtenExplicit.apiBaseUrl, baseUrl, "apiBaseUrl must stay the real API origin, independent of the web base URL")
|
|
assert.notEqual(writtenExplicit.baseUrl, writtenExplicit.apiBaseUrl, "baseUrl and apiBaseUrl must not collapse into the same value")
|
|
assert.equal(writtenExplicit.brandAppName, "OpenWork Demo A", "prepared desktop setup must preserve its profile identity")
|
|
},
|
|
)
|
|
|
|
// The main bootstrap-workspace JSON output must never echo a real claim URL
|
|
// — that string only exists in this test fixture, not in stdout redaction code.
|
|
const helpOutput = spawnSync(process.execPath, [cli, "--help"], { encoding: "utf8" })
|
|
assert.match(helpOutput.stdout, /cloud claim-link/)
|
|
assert.match(helpOutput.stdout, /--owner-email/)
|
|
assert.match(helpOutput.stdout, /--teammate-emails/)
|
|
assert.match(helpOutput.stdout, /--web-base-url/)
|
|
} finally {
|
|
rmSync(temp, { recursive: true, force: true })
|
|
}
|