mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
chore: lock the plugin boundary and remove 2.9.0 leftovers
This commit is contained in:
1 parent
6489b62ece
commit
2961297989
399 files changed
+3964
-5627
No files matched your search
@@ -1,7 +1,6 @@
|
||||
/**
|
||||
* The 2.8 login URLs identity providers and 2.8 clients still call. They
|
||||
* forward to whatever replaced them and, with the method's plugin gone, sign
|
||||
* nobody in.
|
||||
* The 2.8 login URLs 2.8 clients still call. They forward to the login
|
||||
* pipeline and, with the method's plugin gone, sign nobody in.
|
||||
*/
|
||||
|
||||
import http from "node:http";
|
||||
@@ -64,43 +63,6 @@ function registerSso() {
|
||||
});
|
||||
}
|
||||
|
||||
describe("the old identity provider URLs", () => {
|
||||
it("forwards the callback to the sso plugin, query intact", async () => {
|
||||
const response = await get("/users/oidc/callback?code=abc&state=xyz");
|
||||
expect(response.status).toBe(308);
|
||||
expect(response.headers.get("location")).toBe(
|
||||
"/plugin-api/sso/callback?code=abc&state=xyz",
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards a form-posted callback with a 308 so the body is resent", async () => {
|
||||
const response = await fetch(`${base}/users/oidc/callback`, {
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
});
|
||||
expect(response.status).toBe(308);
|
||||
});
|
||||
|
||||
it("forwards back-channel logout to the sso plugin", async () => {
|
||||
const response = await fetch(`${base}/users/oidc/backchannel-logout`, {
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
});
|
||||
expect(response.status).toBe(308);
|
||||
expect(response.headers.get("location")).toMatch(
|
||||
/\/plugin-api\/sso\/backchannel-logout$/,
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards the public config to the sso plugin", async () => {
|
||||
const response = await get("/users/oidc-config");
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.headers.get("location")).toMatch(
|
||||
/\/plugin-api\/sso\/config$/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the old client routes", () => {
|
||||
it("starts an SSO login through the oidc method", async () => {
|
||||
registerSso();
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* The OPKSSH, Step CA and Vault redirect URIs identity providers were registered with before
|
||||
* 2.9 must keep reaching the plugin's callback, query intact, and the Termix
|
||||
* ID resolver URLs servers fetch must keep reaching the plugin's resolver.
|
||||
*/
|
||||
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import express from "express";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
const { registerHostAuthCompatRoutes, registerTermixIdCompatRoutes } =
|
||||
await import("../../../database/routes/host-compat-routes.js");
|
||||
|
||||
let server: http.Server;
|
||||
let base: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const router = express.Router();
|
||||
registerHostAuthCompatRoutes(router);
|
||||
const app = express();
|
||||
app.use("/host", router);
|
||||
const termixId = express.Router();
|
||||
registerTermixIdCompatRoutes(termixId);
|
||||
app.use("/termix-id", termixId);
|
||||
server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
describe("the old OPKSSH callback", () => {
|
||||
it("forwards to the plugin's callback with the query", async () => {
|
||||
const response = await fetch(
|
||||
`${base}/host/opkssh-callback?code=abc&state=xyz`,
|
||||
{ redirect: "manual" },
|
||||
);
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.headers.get("location")).toBe(
|
||||
"/plugin-api/opkssh/callback?code=abc&state=xyz",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a subpath", async () => {
|
||||
const response = await fetch(`${base}/host/opkssh-callback/req-1/done`, {
|
||||
redirect: "manual",
|
||||
});
|
||||
expect(response.headers.get("location")).toBe(
|
||||
"/plugin-api/opkssh/callback/req-1/done",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the old Step CA callback", () => {
|
||||
it("forwards to the plugin's callback with the query", async () => {
|
||||
const response = await fetch(
|
||||
`${base}/host/step-ca-callback?code=abc&state=xyz`,
|
||||
{ redirect: "manual" },
|
||||
);
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.headers.get("location")).toBe(
|
||||
"/plugin-api/step-ca/callback?code=abc&state=xyz",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the old Vault callback", () => {
|
||||
it("forwards to the plugin's callback with the query", async () => {
|
||||
const { registerVaultCompatRoutes } =
|
||||
await import("../../../database/routes/host-compat-routes.js");
|
||||
const router = express.Router();
|
||||
registerVaultCompatRoutes(router);
|
||||
const app = express();
|
||||
app.use("/vault", router);
|
||||
const vaultServer = http.createServer(app);
|
||||
await new Promise<void>((resolve) => vaultServer.listen(0, resolve));
|
||||
const port = (vaultServer.address() as AddressInfo).port;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${port}/vault/oidc/callback?code=abc&state=xyz`,
|
||||
{ redirect: "manual" },
|
||||
);
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.headers.get("location")).toBe(
|
||||
"/plugin-api/vault/oidc/callback?code=abc&state=xyz",
|
||||
);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => vaultServer.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the old Termix ID resolver URLs", () => {
|
||||
it.each([
|
||||
["/termix-id/u/alice", "/plugin-api/termix-identity/u/alice"],
|
||||
[
|
||||
"/termix-id/u/alice/ED25519",
|
||||
"/plugin-api/termix-identity/u/alice/ED25519",
|
||||
],
|
||||
["/termix-id/u/alice/ca", "/plugin-api/termix-identity/u/alice/ca"],
|
||||
])("permanently redirects %s", async (path, target) => {
|
||||
const response = await fetch(`${base}${path}`, { redirect: "manual" });
|
||||
expect(response.status).toBe(308);
|
||||
expect(response.headers.get("location")).toBe(target);
|
||||
});
|
||||
|
||||
it("does not forward the old management routes", async () => {
|
||||
const response = await fetch(`${base}/termix-id/me`, {
|
||||
redirect: "manual",
|
||||
});
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* contributes.http.legacyPaths: an old URL under "/<plugin id>/" reaches the
|
||||
* plugin's router with that prefix removed, and nothing else is caught.
|
||||
* contributes.http.legacyRedirects: an old URL anywhere redirects to the
|
||||
* plugin's own URL, keeping the subpath and the query.
|
||||
*/
|
||||
|
||||
import express from "express";
|
||||
@@ -16,10 +18,20 @@ vi.mock("../../../plugins/http.js", () => ({
|
||||
const { mountPluginLegacyPaths } =
|
||||
await import("../../../database/routes/plugin-api-routes.js");
|
||||
|
||||
function appWith(active: Array<{ id: string; legacyPaths: string[] }>) {
|
||||
type Active = Parameters<typeof mountPluginLegacyPaths>[1] extends () => Array<
|
||||
infer T
|
||||
>
|
||||
? T
|
||||
: never;
|
||||
|
||||
function appWith(
|
||||
active: Array<Omit<Active, "legacyRedirects"> & Partial<Active>>,
|
||||
) {
|
||||
const app = express();
|
||||
app.get("/core/route", (_req, res) => res.json({ from: "core" }));
|
||||
mountPluginLegacyPaths(app, () => active);
|
||||
mountPluginLegacyPaths(app, () =>
|
||||
active.map((entry) => ({ legacyRedirects: [], ...entry })),
|
||||
);
|
||||
app.use((_req, res) => res.status(404).json({ from: "fallthrough" }));
|
||||
return app;
|
||||
}
|
||||
@@ -58,3 +70,37 @@ describe("mountPluginLegacyPaths", () => {
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy redirects", () => {
|
||||
const app = appWith([
|
||||
{
|
||||
id: "idp",
|
||||
legacyPaths: [],
|
||||
legacyRedirects: [
|
||||
{ from: "/host/idp-callback", to: "/callback" },
|
||||
{ from: "/old-id/u", to: "/u", status: 308 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
it("redirects to the plugin's URL with the query", async () => {
|
||||
const response = await request(app).get(
|
||||
"/host/idp-callback?code=abc&state=xyz",
|
||||
);
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.headers.location).toBe(
|
||||
"/plugin-api/idp/callback?code=abc&state=xyz",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a subpath and honours a permanent status", async () => {
|
||||
const response = await request(app).get("/old-id/u/alice/ca");
|
||||
expect(response.status).toBe(308);
|
||||
expect(response.headers.location).toBe("/plugin-api/idp/u/alice/ca");
|
||||
});
|
||||
|
||||
it("does not catch a path that only shares a prefix", async () => {
|
||||
const response = await request(app).get("/host/idp-callbacks");
|
||||
expect(response.body).toEqual({ from: "fallthrough" });
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,7 @@ const state = vi.hoisted(() => ({
|
||||
active: new Set<string>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../plugins/boot-migrations.js", () => ({
|
||||
vi.mock("../../../upgrade/boot-migrations.js", () => ({
|
||||
runPluginDataMigrations: async () => {},
|
||||
}));
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ function startBackend(
|
||||
let body = "";
|
||||
req.on("data", (d) => (body += d));
|
||||
req.on("end", () => {
|
||||
if (!opts.failAll && req.url?.endsWith("/ssh/downloadFileStream")) {
|
||||
if (!opts.failAll && req.url?.endsWith("/downloadFileStream")) {
|
||||
// Headers go out immediately so the client opens its temp file;
|
||||
// the body may be delayed to keep the transfer in flight.
|
||||
res.writeHead(200, { "Content-Length": payload.length });
|
||||
@@ -150,7 +150,7 @@ function startBackend(
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address() as { port: number };
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${address.port}/ssh/file_manager`,
|
||||
url: `http://127.0.0.1:${address.port}/plugin-api/file-manager`,
|
||||
seen,
|
||||
close: () => new Promise((r) => server.close(() => r())),
|
||||
});
|
||||
@@ -160,25 +160,25 @@ function startBackend(
|
||||
|
||||
describe("local-files transfer target resolution", () => {
|
||||
const resolve = localFiles.createTargetResolver({
|
||||
localBaseUrl: "http://127.0.0.1:30004/ssh/file_manager",
|
||||
localBaseUrl: "http://127.0.0.1:30001/plugin-api/file-manager",
|
||||
getRemoteSyncConfig: () => ({ serverUrl: "https://termix.example.com/" }),
|
||||
getRemoteSyncJwt: () => "remote-jwt",
|
||||
});
|
||||
|
||||
it("only reaches the two file-manager streaming routes on the local backend", () => {
|
||||
expect(resolve({ origin: "local", route: "uploadFileStream" })).toEqual({
|
||||
url: "http://127.0.0.1:30004/ssh/file_manager/ssh/uploadFileStream",
|
||||
url: "http://127.0.0.1:30001/plugin-api/file-manager/uploadFileStream",
|
||||
headers: { "X-Electron-App": "true" },
|
||||
});
|
||||
expect(resolve({ origin: "local", route: "downloadFileStream" }).url).toBe(
|
||||
"http://127.0.0.1:30004/ssh/file_manager/ssh/downloadFileStream",
|
||||
"http://127.0.0.1:30001/plugin-api/file-manager/downloadFileStream",
|
||||
);
|
||||
});
|
||||
|
||||
it("derives the remote target from the configured sync server and attaches its JWT itself", () => {
|
||||
const target = resolve({ origin: "remote", route: "downloadFileStream" });
|
||||
expect(target.url).toBe(
|
||||
"https://termix.example.com/ssh/file_manager/ssh/downloadFileStream",
|
||||
"https://termix.example.com/plugin-api/file-manager/downloadFileStream",
|
||||
);
|
||||
expect(target.headers.Authorization).toBe("Bearer remote-jwt");
|
||||
});
|
||||
@@ -242,7 +242,7 @@ describe("local-files transfer target resolution", () => {
|
||||
});
|
||||
expect(target.headers.Authorization).toBe(`Bearer ${jwt}`);
|
||||
expect(target.url).toBe(
|
||||
"http://127.0.0.1:30004/ssh/file_manager/ssh/uploadFileStream",
|
||||
"http://127.0.0.1:30001/plugin-api/file-manager/uploadFileStream",
|
||||
);
|
||||
// Absent or empty: no header at all (the session cookie is the fallback).
|
||||
for (const authToken of [undefined, null, ""]) {
|
||||
@@ -355,7 +355,7 @@ describe("local-files download boundary", () => {
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
const last = backend.seen[backend.seen.length - 1];
|
||||
expect(last.url).toBe("/ssh/file_manager/ssh/downloadFileStream");
|
||||
expect(last.url).toBe("/plugin-api/file-manager/downloadFileStream");
|
||||
expect(last.headers.authorization).toBeUndefined();
|
||||
expect(last.headers.cookie).toBeUndefined();
|
||||
expect(last.headers["x-electron-app"]).toBe("true");
|
||||
@@ -952,7 +952,7 @@ describe("local-files upload boundary", () => {
|
||||
const handlers = localFiles.createLocalFileHandlers({
|
||||
net: fakeNet,
|
||||
shell: {},
|
||||
localBaseUrl: `http://127.0.0.1:${port}/ssh/file_manager`,
|
||||
localBaseUrl: `http://127.0.0.1:${port}/plugin-api/file-manager`,
|
||||
});
|
||||
const result = await handlers[localFiles.IPC.UPLOAD](fakeEvent, {
|
||||
transferId: "up-1",
|
||||
@@ -964,7 +964,7 @@ describe("local-files upload boundary", () => {
|
||||
headers: { Authorization: "Bearer leaked" },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(received.url).toBe("/ssh/file_manager/ssh/uploadFileStream");
|
||||
expect(received.url).toBe("/plugin-api/file-manager/uploadFileStream");
|
||||
expect(received.headers?.authorization).toBeUndefined();
|
||||
expect(received.fields).toEqual({
|
||||
sessionId: "42",
|
||||
@@ -1006,7 +1006,7 @@ describe("local-files upload boundary", () => {
|
||||
const handlers = localFiles.createLocalFileHandlers({
|
||||
net: fakeNet,
|
||||
shell: {},
|
||||
localBaseUrl: `http://127.0.0.1:${port}/ssh/file_manager`,
|
||||
localBaseUrl: `http://127.0.0.1:${port}/plugin-api/file-manager`,
|
||||
});
|
||||
const withoutToken = await handlers[localFiles.IPC.UPLOAD](fakeEvent, {
|
||||
transferId: "up-auth-0",
|
||||
@@ -1105,7 +1105,7 @@ describe("local-files upload boundary", () => {
|
||||
const handlers = localFiles.createLocalFileHandlers({
|
||||
net: fakeNet,
|
||||
shell: {},
|
||||
localBaseUrl: `http://127.0.0.1:${port}/ssh/file_manager`,
|
||||
localBaseUrl: `http://127.0.0.1:${port}/plugin-api/file-manager`,
|
||||
});
|
||||
const results = await Promise.all(
|
||||
files.map((f) =>
|
||||
@@ -1131,7 +1131,7 @@ describe("local-files upload boundary", () => {
|
||||
const handlers = localFiles.createLocalFileHandlers({
|
||||
net: fakeNet,
|
||||
shell: {},
|
||||
localBaseUrl: "http://127.0.0.1:1/ssh/file_manager",
|
||||
localBaseUrl: "http://127.0.0.1:1/plugin-api/file-manager",
|
||||
});
|
||||
const result = await handlers[localFiles.IPC.UPLOAD](fakeEvent, {
|
||||
transferId: "up-2",
|
||||
|
||||
@@ -66,7 +66,7 @@ vi.mock("../../../hosts/ssh-connection-pool.js", () => ({
|
||||
|
||||
import {
|
||||
buildConnectConfig,
|
||||
getPurposeDefaults,
|
||||
getProfileDefaults,
|
||||
} from "../../../hosts/connect/build-connect-config.js";
|
||||
import {
|
||||
connectHost,
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
} from "../../../hosts/connect/connect-host.js";
|
||||
import {
|
||||
getSshAuthProvider,
|
||||
isQuickConnectAuthType,
|
||||
listCredentialTypes,
|
||||
registerSshAuthProvider,
|
||||
setSshAuthTypeOwnerSource,
|
||||
@@ -137,11 +138,12 @@ function registerSignInProvider(hasCert: boolean) {
|
||||
|
||||
async function build(
|
||||
target: SshConnectHost,
|
||||
purpose: Parameters<typeof buildConnectConfig>[1]["purpose"] = "terminal",
|
||||
profile: Parameters<typeof buildConnectConfig>[1]["profile"] = "terminal",
|
||||
) {
|
||||
return buildConnectConfig(target, {
|
||||
userId: "user-1",
|
||||
purpose,
|
||||
purpose: "test",
|
||||
profile,
|
||||
client: new FakeSshClient() as never,
|
||||
});
|
||||
}
|
||||
@@ -349,7 +351,7 @@ describe("buildConnectConfig per auth type", () => {
|
||||
it("jump hops skip keyboard-interactive for auth type none", async () => {
|
||||
const { config } = await build(
|
||||
host({ authType: "none", password: null }),
|
||||
"jump-host",
|
||||
"jump",
|
||||
);
|
||||
expect(config.tryKeyboard).toBe(false);
|
||||
expect(mocks.createHostVerifier).toHaveBeenCalledWith(
|
||||
@@ -364,29 +366,33 @@ describe("buildConnectConfig per auth type", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("purpose defaults", () => {
|
||||
describe("profile defaults", () => {
|
||||
it("keeps each transport's old keepalive numbers", () => {
|
||||
expect(getPurposeDefaults("terminal")).toMatchObject({
|
||||
expect(getProfileDefaults("terminal")).toMatchObject({
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 5,
|
||||
});
|
||||
expect(getPurposeDefaults("file-manager")).toMatchObject({
|
||||
expect(getProfileDefaults("session")).toMatchObject({
|
||||
keepaliveIntervalMs: 60000,
|
||||
keepaliveCountMax: 5,
|
||||
});
|
||||
expect(getPurposeDefaults("fleet")).toMatchObject({
|
||||
expect(getProfileDefaults("background")).toMatchObject({
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the background profile when none is given", () => {
|
||||
expect(getProfileDefaults()).toEqual(getProfileDefaults("background"));
|
||||
});
|
||||
|
||||
it("host keepalive settings apply where the transport honoured them", async () => {
|
||||
const withSettings = host({
|
||||
terminalConfig: { keepaliveInterval: 10, keepaliveCountMax: 2 },
|
||||
});
|
||||
const terminal = await build(withSettings, "terminal");
|
||||
expect(terminal.config.keepaliveInterval).toBe(10000);
|
||||
const fleet = await build(withSettings, "fleet");
|
||||
const fleet = await build(withSettings, "background");
|
||||
expect(fleet.config.keepaliveInterval).toBe(30000);
|
||||
});
|
||||
});
|
||||
@@ -415,6 +421,31 @@ describe("provider registry", () => {
|
||||
expect(outcome).toMatchObject({ code: "provider-missing" });
|
||||
});
|
||||
|
||||
it("offers core types and opted-in plugin types to Quick Connect", () => {
|
||||
ensureCoreSshAuthProviders();
|
||||
for (const type of ["password", "key", "credential", "agent", "none"]) {
|
||||
expect(isQuickConnectAuthType(type)).toBe(true);
|
||||
}
|
||||
const plain = registerSshAuthProvider({
|
||||
type: "needs-saved-host",
|
||||
pluginId: "fixture-plugin",
|
||||
labelKey: "x",
|
||||
prepare: async () => ({ status: "ready" }),
|
||||
});
|
||||
const quick = registerSshAuthProvider({
|
||||
type: "works-unsaved",
|
||||
pluginId: "fixture-plugin",
|
||||
labelKey: "x",
|
||||
quickConnect: true,
|
||||
prepare: async () => ({ status: "ready" }),
|
||||
});
|
||||
expect(isQuickConnectAuthType("needs-saved-host")).toBe(false);
|
||||
expect(isQuickConnectAuthType("works-unsaved")).toBe(true);
|
||||
expect(isQuickConnectAuthType("missing")).toBe(false);
|
||||
plain();
|
||||
quick();
|
||||
});
|
||||
|
||||
it("refuses to let a second plugin take over a type", () => {
|
||||
ensureCoreSshAuthProviders();
|
||||
expect(() =>
|
||||
|
||||
@@ -127,7 +127,7 @@ export async function bootCore(seed: Database.Database): Promise<BootedCore> {
|
||||
await initializePlugins();
|
||||
|
||||
const { runPluginDataMigrations } =
|
||||
await import("../../plugins/boot-migrations.js");
|
||||
await import("../../upgrade/boot-migrations.js");
|
||||
await runPluginDataMigrations();
|
||||
|
||||
return {
|
||||
|
||||
@@ -653,3 +653,36 @@ describe("named service providers", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("contributes.http.legacyRedirects", () => {
|
||||
const withRedirects = (legacyRedirects: unknown) =>
|
||||
validateManifest(base({ contributes: { http: { legacyRedirects } } }));
|
||||
|
||||
it("accepts an old URL outside the plugin's namespace", () => {
|
||||
expect(
|
||||
withRedirects([
|
||||
{ from: "/users/oidc/callback", to: "/callback", status: 308 },
|
||||
{ from: "/host/old-callback", to: "/callback" },
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses the plugin framework's own prefixes", () => {
|
||||
expect(
|
||||
withRedirects([{ from: "/plugin-api/other/x", to: "/x" }]).join(),
|
||||
).toMatch(/cannot be under plugin-api/);
|
||||
});
|
||||
|
||||
it("refuses a relative path, a bad status and unknown keys", () => {
|
||||
const errors = withRedirects([
|
||||
{ from: "users/x", to: "/x" },
|
||||
{ from: "/a", to: "x" },
|
||||
{ from: "/a", to: "/x", status: 302 },
|
||||
{ from: "/a", to: "/x", extra: true },
|
||||
]).join();
|
||||
expect(errors).toMatch(/from must be an absolute path/);
|
||||
expect(errors).toMatch(/to must be a path/);
|
||||
expect(errors).toMatch(/status must be 307 or 308/);
|
||||
expect(errors).toMatch(/extra/);
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,7 @@ const state = vi.hoisted(() => ({
|
||||
warnings: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/boot-migrations.js", () => ({
|
||||
vi.mock("../../upgrade/boot-migrations.js", () => ({
|
||||
runPluginDataMigrations: async () => {},
|
||||
}));
|
||||
|
||||
@@ -224,7 +224,7 @@ describe("namespace escalation is refused", () => {
|
||||
["admin.users.manage", /reserved core group/],
|
||||
["hosts.view", /reserved core group/],
|
||||
["credentials.read", /reserved core group/],
|
||||
["snippets.edit", /reserved core group/],
|
||||
["admin.plugins.manage", /reserved core group/],
|
||||
])("refuses the core name %s", (name, pattern) => {
|
||||
const { errors } = parseManifest(manifest("sample", [permission(name)]));
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* A user's rows in plugin tables travel with their export and come back on
|
||||
* import, found through the table definitions plugins registered rather than
|
||||
* any table name core knows.
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
defineTable,
|
||||
encryptedText,
|
||||
id,
|
||||
refUser,
|
||||
text,
|
||||
varchar,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
let sqlite: Database.Database;
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => drizzle(sqlite),
|
||||
}));
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { registerTable, resetPluginData } =
|
||||
await import("../../plugins/data.js");
|
||||
const {
|
||||
importUserPluginRows,
|
||||
listUserOwnedTables,
|
||||
readUserPluginRows,
|
||||
writeUserPluginTables,
|
||||
} = await import("../../plugins/user-data.js");
|
||||
const { createTableSql } = await import("../../plugins/table-builder.js");
|
||||
|
||||
// Stands for an adopted table; adoptLegacyTable only accepts real core names.
|
||||
const bookmarks = {
|
||||
...defineTable("bookmark", {
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
path: varchar(200).notNull(),
|
||||
note: text(),
|
||||
token: encryptedText(),
|
||||
}),
|
||||
adopts: "bookmarks_legacy",
|
||||
};
|
||||
const shared = defineTable("shared_thing", { id: id(), label: text() });
|
||||
|
||||
function createTables(target: Database.Database) {
|
||||
target.exec("CREATE TABLE users (id TEXT PRIMARY KEY)");
|
||||
target.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
|
||||
for (const definition of [bookmarks, shared]) {
|
||||
for (const statement of createTableSql("sqlite", "fixture", definition)) {
|
||||
target.exec(statement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sqlite = new Database(":memory:");
|
||||
createTables(sqlite);
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('u1'), ('u2')");
|
||||
sqlite.exec(`INSERT INTO p_fixture_bookmark (user_id, path, note, token)
|
||||
VALUES ('u1', '/etc', 'config', 'sealed'), ('u2', '/var', null, null)`);
|
||||
registerTable("fixture", bookmarks);
|
||||
registerTable("fixture", shared);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetPluginData();
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
describe("plugin user data", () => {
|
||||
it("lists only tables with a user column, without keys or secrets", () => {
|
||||
const [owned] = listUserOwnedTables();
|
||||
expect(listUserOwnedTables()).toHaveLength(1);
|
||||
expect(owned).toMatchObject({
|
||||
pluginId: "fixture",
|
||||
table: "p_fixture_bookmark",
|
||||
legacyName: "bookmarks_legacy",
|
||||
userColumn: "user_id",
|
||||
columns: ["user_id", "path", "note"],
|
||||
});
|
||||
});
|
||||
|
||||
it("reads one user's rows", async () => {
|
||||
expect(await readUserPluginRows("u1")).toEqual({
|
||||
p_fixture_bookmark: [{ user_id: "u1", path: "/etc", note: "config" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips through an export file into another user", async () => {
|
||||
// The export file has the user row, as database.ts writes it first.
|
||||
const file = new Database(":memory:");
|
||||
file.exec("CREATE TABLE users (id TEXT PRIMARY KEY)");
|
||||
file.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
|
||||
file.exec("INSERT INTO users (id) VALUES ('u1')");
|
||||
expect(await writeUserPluginTables(file, "u1")).toBe(1);
|
||||
|
||||
sqlite.exec("DELETE FROM p_fixture_bookmark");
|
||||
const result = await importUserPluginRows(file, "u2");
|
||||
expect(result).toEqual({ imported: 1, skipped: 0, errors: [] });
|
||||
expect(
|
||||
sqlite.prepare("SELECT user_id, path FROM p_fixture_bookmark").all(),
|
||||
).toEqual([{ user_id: "u2", path: "/etc" }]);
|
||||
|
||||
const again = await importUserPluginRows(file, "u2");
|
||||
expect(again).toMatchObject({ imported: 0, skipped: 1 });
|
||||
});
|
||||
|
||||
it("reads a pre-2.9 export by the legacy table name", async () => {
|
||||
const file = new Database(":memory:");
|
||||
file.exec(
|
||||
"CREATE TABLE bookmarks_legacy (id INTEGER PRIMARY KEY, user_id TEXT, path TEXT, note TEXT)",
|
||||
);
|
||||
file.exec(
|
||||
"INSERT INTO bookmarks_legacy (user_id, path, note) VALUES ('old', '/srv', 'x')",
|
||||
);
|
||||
const result = await importUserPluginRows(file, "u1");
|
||||
expect(result.imported).toBe(1);
|
||||
expect(
|
||||
sqlite
|
||||
.prepare(
|
||||
"SELECT user_id, note FROM p_fixture_bookmark WHERE path = '/srv'",
|
||||
)
|
||||
.get(),
|
||||
).toEqual({ user_id: "u1", note: "x" });
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -56,7 +56,7 @@ vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import { runAcmeSslSettingsMigration } from "../../utils/crypto-migration/acme-ssl-settings-migration.js";
|
||||
import { runAcmeSslSettingsMigration } from "../../upgrade/acme-ssl-settings-migration.js";
|
||||
|
||||
const value = (key: string) => {
|
||||
const row = state.pluginRows.find((candidate) => candidate.key === key);
|
||||
+1
-1
@@ -130,7 +130,7 @@ vi.mock("../../utils/logger.js", () => ({
|
||||
import {
|
||||
allowlistToText,
|
||||
runAiSettingsMigration,
|
||||
} from "../../utils/crypto-migration/ai-settings-migration.js";
|
||||
} from "../../upgrade/ai-settings-migration.js";
|
||||
|
||||
const value = (scope: string, scopeId: string | null, key: string) => {
|
||||
const row = state.pluginRows.find(
|
||||
+1
-1
@@ -66,7 +66,7 @@ vi.mock("../../utils/logger.js", () => ({
|
||||
import {
|
||||
dockerSettingsFromRow,
|
||||
runDockerSettingsMigration,
|
||||
} from "../../utils/crypto-migration/docker-settings-migration.js";
|
||||
} from "../../upgrade/docker-settings-migration.js";
|
||||
|
||||
const hostValues = (hostId: number) =>
|
||||
Object.fromEntries(
|
||||
+5
-8
@@ -1,13 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createAuthState,
|
||||
type AuthState,
|
||||
} from "../../auth/auth-test-helpers.js";
|
||||
import { createAuthState, type AuthState } from "../auth/auth-test-helpers.js";
|
||||
|
||||
const h = vi.hoisted(() => ({ state: null as unknown as AuthState }));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", async () => {
|
||||
const helpers = await import("../../auth/auth-test-helpers.js");
|
||||
vi.mock("../../database/repositories/factory.js", async () => {
|
||||
const helpers = await import("../auth/auth-test-helpers.js");
|
||||
const names = Object.keys(helpers.fakeFactory(helpers.createAuthState()));
|
||||
return Object.fromEntries(
|
||||
names.map((name) => [
|
||||
@@ -22,12 +19,12 @@ vi.mock("../../../database/repositories/factory.js", async () => {
|
||||
]),
|
||||
);
|
||||
});
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
const { runExternalIdentityMigration, parseLegacyIdentifier } =
|
||||
await import("../../../utils/crypto-migration/external-identity-migration.js");
|
||||
await import("../../upgrade/external-identity-migration.js");
|
||||
|
||||
function user(id: string, extra: Record<string, unknown>) {
|
||||
h.state.users.set(id, {
|
||||
+1
-1
@@ -80,7 +80,7 @@ vi.mock("../../database/db/index.js", () => ({
|
||||
}));
|
||||
|
||||
const { runFileManagerSettingsMigration } =
|
||||
await import("../../utils/crypto-migration/file-manager-settings-migration.js");
|
||||
await import("../../upgrade/file-manager-settings-migration.js");
|
||||
|
||||
function storedValue(hostId: number, key: string): unknown {
|
||||
const row = settingsRows.find(
|
||||
+1
-1
@@ -70,7 +70,7 @@ vi.mock("../../utils/logger.js", () => ({
|
||||
}));
|
||||
|
||||
const { runHostMetricsSettingsMigration, hostSettingsFromStatsConfig } =
|
||||
await import("../../utils/crypto-migration/host-metrics-settings-migration.js");
|
||||
await import("../../upgrade/host-metrics-settings-migration.js");
|
||||
|
||||
let sqlite: Database.Database;
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ vi.mock("../../utils/logger.js", () => {
|
||||
});
|
||||
|
||||
const { runLdapProviderMigration } =
|
||||
await import("../../utils/crypto-migration/ldap-provider-migration.js");
|
||||
await import("../../upgrade/ldap-provider-migration.js");
|
||||
|
||||
let sqlite: Database.Database;
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import { runOpksshConfigMigration } from "../../utils/crypto-migration/opkssh-config-migration.js";
|
||||
import { runOpksshConfigMigration } from "../../upgrade/opkssh-config-migration.js";
|
||||
|
||||
let dataDir: string;
|
||||
const legacy = () => path.join(dataDir, ".opk", "config.yml");
|
||||
+1
-1
@@ -81,7 +81,7 @@ vi.mock("../../database/db/index.js", () => ({
|
||||
}));
|
||||
|
||||
const { runProxmoxSettingsMigration } =
|
||||
await import("../../utils/crypto-migration/proxmox-settings-migration.js");
|
||||
await import("../../upgrade/proxmox-settings-migration.js");
|
||||
|
||||
function storedValue(hostId: number, key: string): unknown {
|
||||
const row = settingsRows.find(
|
||||
+1
-1
@@ -88,7 +88,7 @@ vi.mock("../../utils/logger.js", () => ({
|
||||
}));
|
||||
|
||||
const { runRemoteDesktopSettingsMigration, hostSettingsFromRow } =
|
||||
await import("../../utils/crypto-migration/remote-desktop-settings-migration.js");
|
||||
await import("../../upgrade/remote-desktop-settings-migration.js");
|
||||
|
||||
const value = (scope: string, scopeId: string | null, key: string) => {
|
||||
const row = find(scope, scopeId, key) as Row | undefined;
|
||||
+1
-1
@@ -117,7 +117,7 @@ vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import { runSecretSourcesTokenMigration } from "../../utils/crypto-migration/secret-sources-token-migration.js";
|
||||
import { runSecretSourcesTokenMigration } from "../../upgrade/secret-sources-token-migration.js";
|
||||
|
||||
const value = (scopeId: string | null, key: string) => {
|
||||
const row = state.pluginRows.find(
|
||||
+1
-1
@@ -73,7 +73,7 @@ vi.mock("../../database/db/index.js", () => ({
|
||||
}));
|
||||
|
||||
const { runSessionRecordingSettingsMigration } =
|
||||
await import("../../utils/crypto-migration/session-recording-settings-migration.js");
|
||||
await import("../../upgrade/session-recording-settings-migration.js");
|
||||
|
||||
function storedValue(hostId: number, key: string): unknown {
|
||||
const row = settingsRows.find(
|
||||
+1
-1
@@ -71,7 +71,7 @@ vi.mock("../../database/db/index.js", () => ({
|
||||
}));
|
||||
|
||||
const { runSessionSharingSettingsMigration } =
|
||||
await import("../../utils/crypto-migration/session-sharing-settings-migration.js");
|
||||
await import("../../upgrade/session-sharing-settings-migration.js");
|
||||
|
||||
function stored(scopeId: string | null, key: string): unknown {
|
||||
const row = settingsRows.find(
|
||||
+1
-1
@@ -96,7 +96,7 @@ vi.mock("../../utils/system-secret-crypto.js", () => ({
|
||||
}));
|
||||
|
||||
const { runSshTerminalSettingsMigration } =
|
||||
await import("../../utils/crypto-migration/ssh-terminal-settings-migration.js");
|
||||
await import("../../upgrade/ssh-terminal-settings-migration.js");
|
||||
|
||||
function admin(key: string): unknown {
|
||||
const row = find("ssh-terminal", "admin", null, key);
|
||||
+1
-1
@@ -44,7 +44,7 @@ vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import { runStepCaSettingsMigration } from "../../utils/crypto-migration/step-ca-settings-migration.js";
|
||||
import { runStepCaSettingsMigration } from "../../upgrade/step-ca-settings-migration.js";
|
||||
|
||||
const value = (key: string) => {
|
||||
const row = state.pluginRows.find((candidate) => candidate.key === key);
|
||||
+1
-1
@@ -86,7 +86,7 @@ vi.mock("../../utils/system-secret-crypto.js", () => ({
|
||||
}));
|
||||
|
||||
const { runTailscaleSettingsMigration } =
|
||||
await import("../../utils/crypto-migration/tailscale-settings-migration.js");
|
||||
await import("../../upgrade/tailscale-settings-migration.js");
|
||||
|
||||
function storedValue(key: string): unknown {
|
||||
const row = pluginRows.find((entry) => entry.key === key);
|
||||
+1
-1
@@ -29,7 +29,7 @@ vi.mock("../../utils/logger.js", () => {
|
||||
});
|
||||
|
||||
const { runTermixIdentityCaMigration } =
|
||||
await import("../../utils/crypto-migration/termix-identity-ca-migration.js");
|
||||
await import("../../upgrade/termix-identity-ca-migration.js");
|
||||
|
||||
const PEM = "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n";
|
||||
|
||||
+1
-1
@@ -72,7 +72,7 @@ vi.mock("../../database/db/index.js", () => ({
|
||||
}));
|
||||
|
||||
const { runTmuxMonitorSettingsMigration } =
|
||||
await import("../../utils/crypto-migration/tmux-monitor-settings-migration.js");
|
||||
await import("../../upgrade/tmux-monitor-settings-migration.js");
|
||||
|
||||
function storedValue(hostId: number, key: string): unknown {
|
||||
const row = settingsRows.find(
|
||||
+1
-2
@@ -29,8 +29,7 @@ vi.mock("../../utils/logger.js", () => {
|
||||
return { databaseLogger: log };
|
||||
});
|
||||
|
||||
const { runTotpMigration } =
|
||||
await import("../../utils/crypto-migration/totp-migration.js");
|
||||
const { runTotpMigration } = await import("../../upgrade/totp-migration.js");
|
||||
|
||||
let sqlite: Database.Database;
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ vi.mock("../../database/db/index.js", () => ({
|
||||
}));
|
||||
|
||||
const { runTunnelsSettingsMigration } =
|
||||
await import("../../utils/crypto-migration/tunnels-settings-migration.js");
|
||||
await import("../../upgrade/tunnels-settings-migration.js");
|
||||
|
||||
function storedValue(hostId: number, key: string): unknown {
|
||||
const row = settingsRows.find(
|
||||
+1
-1
@@ -69,7 +69,7 @@ vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import { runVaultSettingsMigration } from "../../utils/crypto-migration/vault-settings-migration.js";
|
||||
import { runVaultSettingsMigration } from "../../upgrade/vault-settings-migration.js";
|
||||
|
||||
const setting = (scopeId: string | null, key: string) =>
|
||||
state.pluginRows.find((row) => row.scopeId === scopeId && row.key === key)
|
||||
+1
-1
@@ -65,7 +65,7 @@ vi.mock("../../utils/logger.js", () => ({
|
||||
import {
|
||||
wakeOnLanSettingsFromRow,
|
||||
runWakeOnLanSettingsMigration,
|
||||
} from "../../utils/crypto-migration/wake-on-lan-settings-migration.js";
|
||||
} from "../../upgrade/wake-on-lan-settings-migration.js";
|
||||
|
||||
const hostValues = (hostId: number) =>
|
||||
Object.fromEntries(
|
||||
+1
-1
@@ -78,7 +78,7 @@ vi.mock("../../utils/logger.js", () => ({
|
||||
import {
|
||||
runWarpgateSettingsMigration,
|
||||
usesWarpgate,
|
||||
} from "../../utils/crypto-migration/warpgate-settings-migration.js";
|
||||
} from "../../upgrade/warpgate-settings-migration.js";
|
||||
|
||||
const enabled = (hostId: number) =>
|
||||
state.pluginRows.some(
|
||||
+1
-1
@@ -74,7 +74,7 @@ vi.mock("../../database/db/index.js", () => ({
|
||||
}));
|
||||
|
||||
const { runWebEndpointSettingsMigration } =
|
||||
await import("../../utils/crypto-migration/web-endpoint-settings-migration.js");
|
||||
await import("../../upgrade/web-endpoint-settings-migration.js");
|
||||
|
||||
function storedValue(hostId: number, key: string): unknown {
|
||||
const row = settingsRows.find(
|
||||
@@ -186,7 +186,6 @@ describe("analytics", () => {
|
||||
properties: expect.objectContaining({
|
||||
user_count: 0,
|
||||
host_count: 0,
|
||||
used_terminal: 0,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,26 +49,6 @@ function legacyDatabase(): Database.Database {
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE session_recordings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
access_id INTEGER,
|
||||
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ended_at TEXT,
|
||||
duration INTEGER,
|
||||
commands TEXT,
|
||||
dangerous_actions TEXT,
|
||||
recording_path TEXT,
|
||||
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||
format TEXT NOT NULL DEFAULT 'text',
|
||||
terminated_by_owner INTEGER DEFAULT 0,
|
||||
termination_reason TEXT,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username) VALUES ('u-1', 'alice'), ('u-2', 'bob');
|
||||
INSERT INTO ssh_data (id, name) VALUES (1, 'prod-db');
|
||||
|
||||
@@ -78,12 +58,6 @@ function legacyDatabase(): Database.Database {
|
||||
('u-1', 'alice', 'host.delete', 'host', '1', 1, '2026-07-01 10:00:00'),
|
||||
('u-1', 'alice', 'credential.view', 'credential', '9', 1, '2026-07-02 11:00:00'),
|
||||
('u-2', 'bob', 'host.create', 'host', '2', 1, '2026-07-03 12:00:00');
|
||||
|
||||
INSERT INTO session_recordings
|
||||
(host_id, user_id, started_at, recording_path, protocol, format)
|
||||
VALUES
|
||||
(1, 'u-1', '2026-07-01 10:00:00', '/rec/a.guac', 'ssh', 'text'),
|
||||
(1, 'u-2', '2026-07-03 12:00:00', '/rec/b.guac', 'ssh', 'text');
|
||||
`);
|
||||
return sqlite;
|
||||
}
|
||||
@@ -93,15 +67,10 @@ describe("audit retention migration", () => {
|
||||
db = legacyDatabase();
|
||||
|
||||
expect(userDeleteIsDestructive(db, "audit_logs")).toBe(true);
|
||||
expect(userDeleteIsDestructive(db, "session_recordings")).toBe(true);
|
||||
|
||||
expect(migrateAuditRetention(db)).toEqual([
|
||||
"audit_logs",
|
||||
"session_recordings",
|
||||
]);
|
||||
expect(migrateAuditRetention(db)).toEqual(["audit_logs"]);
|
||||
|
||||
expect(userDeleteIsDestructive(db, "audit_logs")).toBe(false);
|
||||
expect(userDeleteIsDestructive(db, "session_recordings")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the audit trail when the user is deleted", () => {
|
||||
@@ -126,23 +95,6 @@ describe("audit retention migration", () => {
|
||||
expect(rows[2].user_id).toBe("u-2");
|
||||
});
|
||||
|
||||
it("backfills a username onto recordings so they stay attributable", () => {
|
||||
db = legacyDatabase();
|
||||
migrateAuditRetention(db);
|
||||
|
||||
db.exec("DELETE FROM users WHERE id = 'u-1'");
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
"SELECT user_id, username, recording_path FROM session_recordings ORDER BY started_at",
|
||||
)
|
||||
.all() as { user_id: string | null; username: string | null }[];
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].user_id).toBeNull();
|
||||
expect(rows[0].username).toBe("alice");
|
||||
});
|
||||
|
||||
it("loses no data in the copy", () => {
|
||||
db = legacyDatabase();
|
||||
const before = db
|
||||
@@ -158,18 +110,6 @@ describe("audit retention migration", () => {
|
||||
expect(after).toEqual(before);
|
||||
});
|
||||
|
||||
it("still cascades recordings when their host is deleted", () => {
|
||||
db = legacyDatabase();
|
||||
migrateAuditRetention(db);
|
||||
|
||||
db.exec("PRAGMA foreign_keys = ON");
|
||||
db.exec("DELETE FROM ssh_data WHERE id = 1");
|
||||
|
||||
expect(
|
||||
db.prepare("SELECT COUNT(*) AS n FROM session_recordings").get(),
|
||||
).toEqual({ n: 0 });
|
||||
});
|
||||
|
||||
it("is idempotent and leaves an already-migrated database alone", () => {
|
||||
db = legacyDatabase();
|
||||
migrateAuditRetention(db);
|
||||
|
||||
Reference in new issue
Block a user