Files
open-connector/scripts/d1-runtime-data.test.ts
l1shen b969ec0b4e feat: add cloud project OAuth and remote execution (#627)
## Summary

Add OOMOL cloud OAuth as a provider authorization source. Users can save
an encrypted project API key, select a cloud provider configuration,
authorize named connections, and run their actions and proxy requests
through SaaS. Existing connections retain their source when the provider
default changes, and third-party credentials remain in SaaS.

- Implement project discovery, OAuth request synchronization, remote
execution, and persistent cleanup of disconnected cloud accounts. Add
SQLite, PostgreSQL, and D1 migrations and storage support, with instance
reset and maintenance tooling.
- Add Console project and authorization settings, localized errors,
disconnect notifications, and styled OAuth completion pages that close
after successful authorization. Align local development callback origins
and proxy routes.
- Improve safe request diagnostics and retry transient project
health-check failures once. Execution requests are not automatically
replayed.
- Group storage implementations by database backend, share guarded HTTP
requests with Marketplace, update Zod to 4.6.5, and load provider icons
from the public catalog.
- Document setup, maintenance, and the manual acceptance checklist.

## Validation

- `npm run fix-check` passed.
- `npm run build:web` passed.
- Latest focused regression run: 346 tests passed across 33 files
covering Console, SaaS project/client/routes, OAuth completion, and
guarded HTTP requests.
- Earlier implementation validation included the broader runtime/storage
suites and a real PostgreSQL integration run. Full deployed
action/proxy, cleanup recovery, and D1 maintenance acceptance remain
documented manual checks; they are not claimed as completed by the unit
tests.

## Deployment notes

Apply migrations `0014_saas_project` and `0015_saas_cleanup_runtime`,
configure encryption and an explicit `OOMOL_CONNECT_ORIGIN`, and enable
the relevant cleanup scheduling. The remote SaaS deployment must support
the project OAuth and remote execution APIs described in
`docs/saas-oauth.md`.
2026-09-30 13:25:49 +08:00

181 lines
6.5 KiB
TypeScript

import { EventEmitter } from "node:events";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import worker from "../src/server/cloudflare/instance-maintenance-worker.ts";
import { D1RuntimeDatabase } from "../src/server/storage/d1/runtime-store.ts";
import { SqliteD1Database } from "../src/server/storage/d1/test-database.ts";
const mocks = vi.hoisted(() => ({ spawn: vi.fn(), readConfig: vi.fn() }));
vi.mock("node:child_process", () => ({ spawn: mocks.spawn }));
vi.mock("wrangler", () => ({ unstable_readConfig: mocks.readConfig }));
const directories: string[] = [];
const databases: SqliteD1Database[] = [];
const argv = process.argv;
const exitCode = process.exitCode;
afterEach(async () => {
process.argv = argv;
process.exitCode = exitCode;
vi.restoreAllMocks();
vi.unstubAllGlobals();
for (const database of databases.splice(0)) database.close();
await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true })));
});
async function setup() {
const directory = await mkdtemp(join(tmpdir(), "d1-maintenance-cli-"));
directories.push(directory);
const operationFile = join(directory, "operation.json");
const databaseId = crypto.randomUUID();
const accountId = "a".repeat(32);
const binding = new SqliteD1Database();
databases.push(binding);
const store = new D1RuntimeDatabase(binding).saasProjectStore;
const oldId = await store.getInstanceId();
mocks.readConfig.mockReturnValue({
account_id: accountId,
d1_databases: [{ binding: "DB", database_id: databaseId, database_name: "test-db" }],
});
let token = "";
let resetCalls = 0;
let deployed = false;
const behavior = { loseResponse: false, wrongDatabase: false, failCleanup: false };
mocks.spawn.mockImplementation((_bin: string, args: string[]) => {
const child = new EventEmitter();
const stdout = new EventEmitter();
const stderr = new EventEmitter();
return Object.assign(child, {
stdout,
stderr,
stdin: {
end(input?: string) {
void (async () => {
const configPath = args[args.indexOf("--config") + 1];
const config = JSON.parse(await readFile(configPath, "utf8"));
if (!directories.includes(resolve(configPath, ".."))) directories.push(resolve(configPath, ".."));
let output = "";
let code = 0;
if (args[1] === "d1")
output = JSON.stringify({
uuid: behavior.wrongDatabase ? crypto.randomUUID() : databaseId,
name: "test-db",
});
if (args[1] === "deploy") {
deployed = true;
output = `https://${config.name}.test.workers.dev`;
}
if (args[1] === "secret") {
token = input!;
expect(args).not.toContain(token);
}
if (args[1] === "delete") {
if (behavior.failCleanup) code = 1;
else deployed = false;
}
stdout.emit("data", Buffer.from(output));
child.emit("close", code);
})().catch((error: unknown) => child.emit("error", error));
},
},
});
});
vi.spyOn(console, "log").mockImplementation(() => {});
const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
vi.stubGlobal(
"fetch",
vi.fn<(url: string | URL | Request, init?: RequestInit) => Promise<Response>>(async (url, init) => {
if (init?.method === "POST") {
resetCalls++;
const saved = JSON.parse(await readFile(operationFile, "utf8"));
expect(saved.snapshot.instanceId).toBe(oldId);
expect(JSON.parse(String(init.body))).toMatchObject({
expectedInstanceId: saved.snapshot.instanceId,
newInstanceId: saved.newInstanceId,
});
}
const response = await worker.fetch(new Request(String(url), init), {
DB: binding,
TARGET_DATABASE_ID: databaseId,
MAINTENANCE_TOKEN: token,
});
if (init?.method === "POST" && behavior.loseResponse) {
behavior.loseResponse = false;
throw new Error("response lost after commit");
}
return response;
}),
);
const run = async (yes = true) => {
process.argv = [
process.execPath,
resolve("scripts/d1-runtime-data.ts"),
"reset-instance",
"--config",
"selected.jsonc",
"--remote",
"--operation-file",
operationFile,
];
if (yes) process.argv.push("--yes");
await vi.resetModules();
await import("./d1-runtime-data.ts");
};
return {
store,
oldId,
operationFile,
behavior,
run,
errorLog,
resetCalls: () => resetCalls,
deployed: () => deployed,
};
}
it("persists reset identities before sending and retries a lost response as the same operation", async () => {
const f = await setup();
f.behavior.loseResponse = true;
await expect(f.run()).rejects.toThrow("response lost");
expect(f.deployed()).toBe(false);
const saved = JSON.parse(await readFile(f.operationFile, "utf8"));
expect(await f.store.getInstanceId()).toBe(saved.newInstanceId);
expect(saved.completed).toBe(false);
await f.run();
expect(JSON.parse(await readFile(f.operationFile, "utf8"))).toMatchObject({
newInstanceId: saved.newInstanceId,
completed: true,
});
expect(f.resetCalls()).toBe(2);
expect(f.deployed()).toBe(false);
});
it("inspects without resetting when --yes is omitted", async () => {
const f = await setup();
await f.run(false);
expect(f.resetCalls()).toBe(0);
expect(await f.store.getInstanceId()).toBe(f.oldId);
expect(f.deployed()).toBe(false);
});
it("rejects the wrong remote database before deploying a maintenance endpoint", async () => {
const f = await setup();
f.behavior.wrongDatabase = true;
await expect(f.run()).rejects.toThrow("database identity");
expect(f.resetCalls()).toBe(0);
expect(f.deployed()).toBe(false);
});
it("retains recovery state and reports a failed temporary Worker cleanup", async () => {
const f = await setup();
f.behavior.failCleanup = true;
await f.run();
expect(process.exitCode).toBe(1);
expect(f.deployed()).toBe(true);
expect(f.errorLog).toHaveBeenCalledWith(expect.stringContaining("Temporary Worker cleanup failed"));
expect(JSON.parse(await readFile(f.operationFile, "utf8"))).toMatchObject({ completed: true });
f.behavior.failCleanup = false;
await f.run();
expect(f.deployed()).toBe(false);
});