chore: merge dev-2.9.1 into dev-26.10.0

This commit is contained in:
LukeGus committed 2026-10-03 16:01:42 -05:00
commit 77bcbdb3d6
1310 files changed
+102834 -51010

No files matched your search

@@ -521,6 +521,89 @@ describe("external identities", () => {
).rejects.toMatchObject({ code: "user_not_allowed" });
});
it.each(["true", "false"])(
"does not duplicate an unscoped migrated account with provisioning %s",
async (enabled) => {
addUser({
id: "old",
username: "Alice",
isOidc: true,
passwordHash: "",
oidcIdentifier: "sub-1",
ssoProviderId: null,
});
const { runExternalIdentityMigration } =
await import("../../upgrade/external-identity-migration.js");
await runExternalIdentityMigration();
h.state.settings.set("oidc_auto_provision", enabled);
await expect(
runLogin(fakeRequest() as never, external, {
methodId: "oidc",
rememberMe: false,
}),
).rejects.toMatchObject({ code: "legacy_identity_conflict" });
expect([...h.state.users.keys()]).toEqual(["old"]);
expect(h.state.identities).toHaveLength(1);
expect(h.state.identities[0]).toMatchObject({
userId: "old",
providerId: "legacy-oidc",
subject: "sub-1",
});
},
);
it("keeps equal subjects on explicitly different providers separate", async () => {
addUser({
id: "other",
username: "Other Alice",
isOidc: true,
passwordHash: "",
oidcIdentifier: "sub-1",
ssoProviderId: 9,
});
h.state.identities.push({
id: 1,
userId: "other",
providerId: "9",
subject: "sub-1",
email: null,
});
h.state.settings.set("oidc_auto_provision", "true");
const result = await runLogin(fakeRequest() as never, external, {
methodId: "oidc",
rememberMe: false,
});
expect(result.kind).toBe("session");
expect(h.state.users.size).toBe(2);
expect(
h.state.identities.find((link) => link.providerId === "3")?.userId,
).not.toBe("other");
});
it("uses a verified provider link even when the old provider column is empty", async () => {
addUser({
id: "old",
username: "Alice",
isOidc: true,
passwordHash: "",
oidcIdentifier: "sub-1",
ssoProviderId: null,
});
h.state.identities.push({
id: 1,
userId: "old",
providerId: "3",
subject: "sub-1",
email: null,
});
const result = await runLogin(fakeRequest() as never, external, {
methodId: "oidc",
rememberMe: false,
});
expect(result.kind).toBe("session");
expect(h.state.users.size).toBe(1);
});
it("re-checks the allowed list for existing users", async () => {
addUser({ id: "u9", username: "Alice", isOidc: true, passwordHash: "" });
h.state.identities.push({
@@ -4,12 +4,13 @@ import { DATABASE_DIALECT_ENV } from "../../../database/db/dialect.js";
// getDb() throws unless a database was initialized; the context's handle is
// not what this file is about.
vi.mock("../../../database/db/index.js", () => ({
getDb: () => ({}),
getDb: vi.fn(() => ({})),
getSqlite: () => ({}),
DatabaseSaveTrigger: { forceSave: vi.fn(), triggerSave: vi.fn() },
}));
const {
createCurrentSessionRepository,
createCurrentRepositoryContext,
createCurrentRepositoryWriteHook,
createCurrentRepositoryLazyWriteHook,
@@ -86,3 +87,35 @@ describe("createCurrentRepositoryLazyWriteHook", () => {
}
});
});
it("wires SQLite activity to the periodic flush and token changes to immediate persistence", async () => {
const saved = process.env[DATABASE_DIALECT_ENV];
process.env[DATABASE_DIALECT_ENV] = "sqlite";
const { getDb } = await import("../../../database/db/index.js");
const { DatabaseSaveTrigger } =
await import("../../../utils/database-save-trigger.js");
const dirty = vi
.spyOn(DatabaseSaveTrigger, "markDirty")
.mockImplementation(() => {});
const force = vi.spyOn(DatabaseSaveTrigger, "forceSave").mockResolvedValue();
const query = {
update: () => query,
set: () => query,
where: async () => ({ changes: 1 }),
};
vi.mocked(getDb).mockReturnValue(
query as unknown as ReturnType<typeof getDb>,
);
try {
const repo = createCurrentSessionRepository();
await repo.touch("session-1");
expect(dirty).toHaveBeenCalledOnce();
expect(force).not.toHaveBeenCalled();
await repo.updateToken("session-1", "new-token");
expect(force).toHaveBeenCalledOnce();
} finally {
vi.restoreAllMocks();
if (saved === undefined) delete process.env[DATABASE_DIALECT_ENV];
else process.env[DATABASE_DIALECT_ENV] = saved;
}
});
@@ -20,6 +20,7 @@ describe("UserRepository and SessionRepository", () => {
async function createRepositories(options: {
onUserWrite?: () => void | Promise<void>;
onSessionWrite?: () => void | Promise<void>;
onSessionActivity?: () => void | Promise<void>;
}): Promise<{
users: UserRepository;
sessions: SessionRepository;
@@ -28,6 +29,7 @@ describe("UserRepository and SessionRepository", () => {
options: {
onUserWrite?: () => void | Promise<void>;
onSessionWrite?: () => void | Promise<void>;
onSessionActivity?: () => void | Promise<void>;
} = {},
): Promise<{
users: UserRepository;
@@ -38,7 +40,11 @@ describe("UserRepository and SessionRepository", () => {
return {
users: new UserRepository(context, options.onUserWrite),
sessions: new SessionRepository(context, options.onSessionWrite),
sessions: new SessionRepository(
context,
options.onSessionWrite,
options.onSessionActivity,
),
};
}
@@ -228,6 +234,46 @@ describe("UserRepository and SessionRepository", () => {
);
});
it("separates activity persistence from security-critical session writes", async () => {
let writes = 0,
activity = 0;
const repo = await createRepositories({
onSessionWrite: () => {
writes++;
},
onSessionActivity: () => {
activity++;
},
});
await repo.users.create({
id: "user-1",
username: "user",
passwordHash: "hash",
isAdmin: false,
isOidc: false,
});
await repo.sessions.create({
id: "session-1",
userId: "user-1",
jwtToken: "token",
deviceType: "desktop",
deviceInfo: "Firefox",
createdAt: "2026-06-26T00:00:00.000Z",
expiresAt: "2026-06-27T00:00:00.000Z",
lastActiveAt: "2026-06-26T00:00:00.000Z",
});
expect(writes).toBe(1);
await repo.sessions.touch("session-1", "2026-06-26T00:01:00.000Z");
expect(activity).toBe(1);
expect(writes).toBe(1);
await repo.sessions.touch("session-1", "2026-06-26T00:01:10.000Z");
expect(activity).toBe(1);
await repo.sessions.updateToken("session-1", "renewed");
expect(writes).toBe(2);
await repo.sessions.revoke("session-1");
expect(writes).toBe(3);
});
it("revokes all user sessions except an optional current session", async () => {
const repo = await createRepositories();
await repo.users.create({
@@ -0,0 +1,193 @@
/**
* A key credential whose private key is a secret reference (op://...) is
* saved as given and resolved when connecting (#1394). Parsing the reference
* as a key used to reject it, and an update kept the old private key.
*/
import http from "node:http";
import type { AddressInfo } from "node:net";
import express from "express";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
created: [] as Array<Record<string, unknown>>,
updated: [] as Array<Record<string, unknown>>,
existing: null as Record<string, unknown> | null,
}));
const pass = (_req: unknown, _res: unknown, next: () => void) => next();
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (req: { userId?: string }, _res: unknown, next: () => void) => {
req.userId = "u1";
next();
},
createDataAccessMiddleware: () => pass,
}),
},
}));
vi.mock("../../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({ requirePermission: () => pass }),
},
}));
vi.mock("../../../sync/shared-copy-guard.js", () => ({
rejectSharedCopyWrites: () => pass,
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: async () => {},
getAuditUsername: async () => "u1",
getRequestMeta: () => ({ ipAddress: null, userAgent: null }),
}));
vi.mock("../../../utils/logger.js", () => {
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
debug: vi.fn(),
};
return { authLogger: log, sshLogger: log, databaseLogger: log };
});
vi.mock("../../../database/routes/credential-key-routes.js", () => ({
registerCredentialKeyRoutes: () => {},
}));
vi.mock("../../../database/routes/credential-deploy-routes.js", () => ({
registerCredentialDeployRoutes: () => {},
}));
vi.mock("../../../database/routes/credential-bulk-routes.js", () => ({
registerCredentialBulkRoutes: () => {},
}));
vi.mock("../../../hosts/delete-credential.js", () => ({
deleteOwnedCredential: async () => {},
}));
vi.mock("../../../hosts/connect/core-providers.js", () => ({
ensureCoreSshAuthProviders: () => {},
}));
vi.mock("../../../hosts/connect/auth-provider-registry.js", () => ({
listCredentialTypes: () => ["password", "key"],
}));
vi.mock("../../../utils/shared-host-secrets-manager.js", () => ({
SharedHostSecretsManager: {
getInstance: () => ({ resyncHostsForCredential: async () => {} }),
},
}));
vi.mock("../../../utils/shared-credential-secrets-manager.js", () => ({
SharedCredentialSecretsManager: {
getInstance: () => ({ resyncCredential: async () => {} }),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentCredentialRepository: () => ({
createEncryptedForUser: async (
userId: string,
data: Record<string, unknown>,
) => {
h.created.push(data);
return { id: 1, userId, ...data };
},
findById: async () => (h.existing ? { id: 1, userId: "u1" } : null),
findDecryptedByIdForUser: async () => h.existing,
updateEncryptedForUser: async (
_userId: string,
_id: number,
fields: Record<string, unknown>,
) => {
h.updated.push(fields);
return { ...h.existing, ...fields };
},
}),
createCurrentUserRepository: () => ({}),
createCurrentCredentialAccessRepository: () => ({}),
createCurrentRoleRepository: () => ({}),
createCurrentHostResolutionRepository: () => ({}),
createCurrentHostRepository: () => ({}),
}));
const { default: router } =
await import("../../../database/routes/credentials.js");
// Never parsed here: only the update replacing it matters.
const REAL_KEY = "stored-key-material";
let server: http.Server;
let base: string;
beforeEach(async () => {
h.created = [];
h.updated = [];
h.existing = null;
const app = express();
app.use(express.json());
app.use("/credentials", router);
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()));
});
function send(method: string, path: string, body: unknown) {
return fetch(`${base}/credentials${path}`, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
describe("credentials with a secret reference key", () => {
it("saves an op:// private key as given", async () => {
const response = await send("POST", "", {
name: "vault key",
authType: "key",
username: "root",
key: "op://vault/item/private_key",
});
expect(response.status).toBe(201);
expect(h.created[0]).toMatchObject({
key: "op://vault/item/private_key",
privateKey: "op://vault/item/private_key",
publicKey: null,
detectedKeyType: null,
});
});
it("still rejects a key that is neither a key nor a reference", async () => {
const response = await send("POST", "", {
name: "bad",
authType: "key",
key: "not a key",
});
expect(response.status).toBe(400);
});
it("replaces the stored private key when updating to a reference", async () => {
h.existing = {
id: 1,
userId: "u1",
name: "k",
authType: "key",
key: REAL_KEY,
privateKey: REAL_KEY,
publicKey: "ssh-ed25519 AAAA old",
};
const response = await send("PUT", "/1", {
key: "op://vault/item/private_key",
});
expect(response.status).toBe(200);
expect(h.updated[0]).toMatchObject({
key: "op://vault/item/private_key",
privateKey: "op://vault/item/private_key",
publicKey: null,
detectedKeyType: null,
});
});
});
@@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({
update: vi.fn(),
list: vi.fn(),
applyPluginSettings: vi.fn(),
applyDefaults: vi.fn(async () => {}),
}));
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
applyPluginHostImportSettings: mocks.applyPluginSettings,
@@ -14,6 +15,7 @@ vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
vi.mock("../../../hosts/defaults/index.js", () => ({
applyHostDefaultsToWrite: async () => ({ core: [] }),
applyDefaultsAfterHostWrite: async () => {},
applyDefaultsAfterHostWrites: mocks.applyDefaults,
}));
vi.mock("../../../hosts/defaults/overrides.js", () => ({
changeHostOverrides: async () => {},
@@ -141,6 +143,9 @@ it.each([20, "20"])(
]);
expect(mocks.create.mock.calls[1][1]).not.toHaveProperty("exportId");
expect(mocks.create.mock.calls[1][1]).not.toHaveProperty("id");
// One defaults pass for the whole import, not one per host.
expect(mocks.applyDefaults).toHaveBeenCalledTimes(1);
expect(mocks.applyDefaults).toHaveBeenCalledWith([501, 502]);
},
);
@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import request from "supertest";
const settings = vi.hoisted(() => new Map<string, string>());
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({
get: async (key: string) => settings.get(key) ?? null,
set: async (key: string, value: string) => {
settings.set(key, value);
},
}),
}));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: { error: vi.fn() },
}));
import { registerHostTagRoutes } from "../../../database/routes/host-tag-routes.js";
function app() {
const server = express();
server.use(express.json());
registerHostTagRoutes(
server,
(req, res, next) =>
req.headers.authorization ? next() : void res.sendStatus(401),
(req, res, next) =>
req.headers.authorization === "admin" ? next() : void res.sendStatus(403),
);
return server;
}
beforeEach(() => settings.clear());
describe("instance host tag catalog", () => {
it("allows authenticated users to read the same admin-managed catalog", async () => {
const server = app();
await request(server)
.put("/tags")
.set("Authorization", "admin")
.send({ tags: [" Linux ", "Domain Controller", "Linux"] })
.expect(200, { tags: ["Linux", "Domain Controller"] });
await request(server)
.get("/tags")
.set("Authorization", "member")
.expect(200, { tags: ["Linux", "Domain Controller"] });
});
it("rejects anonymous reads and non-admin writes", async () => {
await request(app()).get("/tags").expect(401);
await request(app())
.put("/tags")
.set("Authorization", "member")
.send({ tags: ["test"] })
.expect(403);
expect(settings.size).toBe(0);
});
it.each([null, [42], [" "], ["x".repeat(101)], Array(501).fill("tag")])(
"rejects invalid catalogs",
async (tags) => {
await request(app())
.put("/tags")
.set("Authorization", "admin")
.send({ tags })
.expect(400);
expect(settings.size).toBe(0);
},
);
it("allows clearing suggestions without modifying hosts", async () => {
settings.set("host_tag_catalog", '["Linux"]');
await request(app())
.put("/tags")
.set("Authorization", "admin")
.send({ tags: [] })
.expect(200, { tags: [] });
});
it("reads a damaged catalog as empty instead of failing", async () => {
settings.set("host_tag_catalog", "{not json");
await request(app())
.get("/tags")
.set("Authorization", "member")
.expect(200, { tags: [] });
});
});
@@ -201,6 +201,65 @@ describe("linking an external account to a password account", () => {
});
expect(response.status).toBe(400);
});
it("merges a 2.9.0 duplicate back into its 2.8 SSO account", async () => {
h.users.set("orig", {
id: "orig",
username: "bob",
isAdmin: false,
isOidc: true,
passwordHash: "",
oidcIdentifier: "sub-9",
});
h.users.set("dup", {
id: "dup",
username: "bob-1",
isAdmin: false,
isOidc: true,
passwordHash: "",
oidcIdentifier: null,
});
h.identities.push(
{ userId: "orig", providerId: "legacy-oidc", subject: "sub-9" },
{ userId: "dup", providerId: "1", subject: "sub-9" },
);
const response = await post("/link-external-to-password", {
externalUserId: "dup",
targetUsername: "bob",
});
expect(response.status).toBe(200);
expect(h.deleted).toEqual(["dup"]);
expect(h.users.get("orig")!.oidcIdentifier).toBe("sub-9");
expect(
h.identities
.filter((row) => row.userId === "orig")
.map((row) => row.providerId),
).toEqual(["legacy-oidc", "1"]);
});
it("does not treat an unrelated SSO account as a duplicate target", async () => {
h.users.set("other", {
id: "other",
username: "carol",
isAdmin: false,
isOidc: true,
passwordHash: "",
oidcIdentifier: "sub-x",
});
h.identities.push({
userId: "other",
providerId: "legacy-oidc",
subject: "sub-x",
});
const response = await post("/link-external-to-password", {
externalUserId: "sso",
targetUsername: "carol",
});
expect(response.status).toBe(400);
});
});
describe("unlinking", () => {
+32 -1
View File
@@ -5,9 +5,14 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const { copyPluginSdk } =
const { copyPluginSdk, resourcesDirFor } =
require("../../../../packaging/build/after-pack.cjs") as {
copyPluginSdk: (resourcesDir: string, sdkDir?: string) => string;
resourcesDirFor: (context: {
electronPlatformName: string;
appOutDir: string;
packager: { appInfo: { productFilename: string } };
}) => string;
};
let dir: string;
@@ -53,4 +58,30 @@ describe("afterPack plugin SDK copy", () => {
"npm run build:sdk",
);
});
it.each(["darwin", "mas"])(
"copies into the app bundle for %s builds",
(electronPlatformName) => {
const context = {
electronPlatformName,
appOutDir: "/out",
packager: { appInfo: { productFilename: "Termix" } },
};
expect(resourcesDirFor(context)).toBe(
path.join("/out", "Termix.app", "Contents", "Resources"),
);
},
);
it.each(["win32", "linux"])(
"copies into resources for %s builds",
(electronPlatformName) => {
const context = {
electronPlatformName,
appOutDir: "/out",
packager: { appInfo: { productFilename: "Termix" } },
};
expect(resourcesDirFor(context)).toBe(path.join("/out", "resources"));
},
);
});
@@ -487,6 +487,16 @@ describe("resolveHostById", () => {
expect(host.sshOptions).toEqual({ keepaliveCountMax: 3 });
});
it("turns the stored force_keyboard_interactive text into a boolean", async () => {
state.host = baseHost({ forceKeyboardInteractive: "false" });
const off = (await resolveHostById(42, "owner")) as Record<string, unknown>;
expect(off.forceKeyboardInteractive).toBe(false);
state.host = baseHost({ forceKeyboardInteractive: "true" });
const on = (await resolveHostById(42, "owner")) as Record<string, unknown>;
expect(on.forceKeyboardInteractive).toBe(true);
});
it("resolves an admin bypass like the owner, keeping owner-only secrets", async () => {
state.isAdminBypass = true;
state.host = baseHost({
@@ -13,10 +13,13 @@ describe("HostSessionStatus", () => {
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenLastCalledWith(7, true);
expect(status.hasActiveSession(7)).toBe(true);
closeFirst();
expect(status.hasActiveSession(7)).toBe(true);
expect(listener).toHaveBeenCalledTimes(1);
closeSecond();
expect(status.hasActiveSession(7)).toBe(false);
expect(listener).toHaveBeenLastCalledWith(7, false);
expect(listener).toHaveBeenCalledTimes(2);
});
@@ -52,6 +52,7 @@ function setup(
loadSharedHostIds: async (userId) => shared[userId] ?? [],
ping,
pingThroughJumpHosts,
hasActiveSession: (id) => hostSessionStatus.hasActiveSession(id),
globalInterval: () => 60,
emit: (payload) => emitted.push(payload),
});
@@ -74,6 +75,34 @@ afterEach(() => {
});
describe("HostStatusService", () => {
it.each([{ jumpHosts: [] }, { jumpHosts: [{ hostId: 9 }] }])(
"skips probes while a session is open, including jump hosts: %j",
async ({ jumpHosts }) => {
const { service, ping, pingThroughJumpHosts } = setup([
target(7, { jumpHosts }),
]);
active = service;
const close = hostSessionStatus.register(7);
try {
await service.statusesFor("owner", null);
await flush();
await vi.advanceTimersByTimeAsync(60_000);
await flush();
expect(service.get(7)?.status).toBe("online");
expect(ping).not.toHaveBeenCalled();
expect(pingThroughJumpHosts).not.toHaveBeenCalled();
close();
await vi.advanceTimersByTimeAsync(60_000);
await flush();
expect(
jumpHosts.length ? pingThroughJumpHosts : ping,
).toHaveBeenCalledOnce();
} finally {
close();
}
},
);
it("starts a user's own hosts and reports them online", async () => {
const { service, emitted, ping } = setup([target(1), target(2)]);
active = service;
@@ -0,0 +1,104 @@
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
import { JumpProbePool } from "../../../hosts/status/jump-probe-pool.js";
function fakeClient(reachable = true) {
const client = Object.assign(new EventEmitter(), {
end: vi.fn(),
forwardOut: vi.fn(
(
_srcIp: string,
_srcPort: number,
_host: string,
_port: number,
callback: (error: Error | null, stream?: { destroy(): void }) => void,
) => {
callback(
reachable ? null : new Error("refused"),
reachable ? { destroy: () => {} } : undefined,
);
},
),
});
return client;
}
afterEach(() => {
vi.useRealTimers();
});
describe("JumpProbePool", () => {
it("shares one chain across hosts behind the same hops", async () => {
const client = fakeClient();
const open = vi.fn(async () => client as never);
const pool = new JumpProbePool(open);
const hops = [{ hostId: 1 }, { hostId: 2 }];
const results = await Promise.all([
pool.ping(hops, "u1", "10.0.0.3", 22),
pool.ping(hops, "u1", "10.0.0.4", 22),
pool.ping(hops, "u1", "10.0.0.5", 22),
]);
expect(results).toEqual([true, true, true]);
expect(open).toHaveBeenCalledTimes(1);
expect(client.forwardOut).toHaveBeenCalledTimes(3);
expect(client.end).not.toHaveBeenCalled();
pool.closeAll();
});
it("keeps chains apart per user and hop list", async () => {
const open = vi.fn(async () => fakeClient() as never);
const pool = new JumpProbePool(open);
await pool.ping([{ hostId: 1 }], "u1", "a", 22);
await pool.ping([{ hostId: 1 }], "u2", "a", 22);
await pool.ping([{ hostId: 2 }], "u1", "a", 22);
expect(open).toHaveBeenCalledTimes(3);
pool.closeAll();
});
it("reports an unreachable target without dropping the chain", async () => {
const client = fakeClient(false);
const open = vi.fn(async () => client as never);
const pool = new JumpProbePool(open);
expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(false);
expect(await pool.ping([{ hostId: 1 }], "u1", "b", 22)).toBe(false);
expect(open).toHaveBeenCalledTimes(1);
pool.closeAll();
});
it("retries a chain that failed to open", async () => {
const open = vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(fakeClient());
const pool = new JumpProbePool(open);
expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(false);
expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(true);
pool.closeAll();
});
it("opens a new chain after the old one closed or sat idle", async () => {
vi.useFakeTimers();
const first = fakeClient();
const second = fakeClient();
const third = fakeClient();
const open = vi
.fn()
.mockResolvedValueOnce(first)
.mockResolvedValueOnce(second)
.mockResolvedValueOnce(third);
const pool = new JumpProbePool(open, 1000);
await pool.ping([{ hostId: 1 }], "u1", "a", 22);
first.emit("close");
await pool.ping([{ hostId: 1 }], "u1", "a", 22);
expect(open).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(1500);
expect(second.end).toHaveBeenCalled();
await pool.ping([{ hostId: 1 }], "u1", "a", 22);
expect(open).toHaveBeenCalledTimes(3);
pool.closeAll();
});
});
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from "vitest";
const resolve = vi.fn(async (_userId: string, ref: string) => `real:${ref}`);
vi.mock("../../hosts/external-secrets.js", async (importOriginal) => ({
...(await importOriginal<object>()),
resolveSecretReference: (userId: string, ref: string) => resolve(userId, ref),
}));
const { parseKeyForStorage, resolveKeyReferences } =
await import("../../hosts/stored-ssh-key.js");
describe("parseKeyForStorage", () => {
it("accepts a secret reference without parsing it", () => {
expect(parseKeyForStorage(" op://v/i/private_key ")).toEqual({
success: true,
privateKey: "op://v/i/private_key",
publicKey: "",
keyType: "",
reference: true,
});
});
it("does not try to open a real key with a referenced passphrase", () => {
const result = parseKeyForStorage("-----BEGIN...", "op://v/i/pass");
expect(result).toMatchObject({ success: true, reference: true });
});
it("parses anything else as a key", () => {
const result = parseKeyForStorage("not a key");
expect(result.success).toBe(false);
expect(result.reference).toBe(false);
});
});
describe("resolveKeyReferences", () => {
it("resolves only the values that are references", async () => {
expect(await resolveKeyReferences("u1", "op://v/i/key", "plain")).toEqual({
key: "real:op://v/i/key",
passphrase: "plain",
});
expect(resolve).toHaveBeenCalledTimes(1);
});
});
@@ -91,6 +91,18 @@ vi.mock("../../hosts/connect/connect-host.js", () => ({
return { client, jumpClient: null, dispose: vi.fn(() => client.end()) };
},
}));
vi.mock("../../hosts/host-resolver.js", () => ({
resolveHostById: async (hostId: number, userId: string) => ({
id: hostId,
userId,
ip: "10.0.0.5",
port: 22,
username: "root",
authType: "password",
password: "stored-secret",
}),
resolveHostBySyncId: async () => null,
}));
vi.mock("../../hosts/status/host-status-service.js", () => ({
hostStatusService: {
reportLogin: (hostId: number, outcome: unknown) =>
@@ -253,6 +265,50 @@ describe("ctx.ssh", () => {
expect(h.connects[0].target).toMatchObject({ userId: "caller" });
});
it("connects a copied redacted host with its stored login", async () => {
h.granted = new Set(["ssh:connect", "credentials:use"]);
const ssh = createPluginSsh({
manifest: manifest(["ssh:connect", "credentials:use"]),
bag: new DisposableBag("fixture"),
audit: vi.fn(async () => {}),
});
const host = (await ssh.resolveHost(5))!;
expect((host as Record<string, unknown>).password).toBeUndefined();
expect(JSON.stringify(host)).not.toContain("stored-secret");
for (const symbol of Object.getOwnPropertySymbols(host)) {
expect(JSON.stringify((host as never)[symbol])).not.toContain(
"stored-secret",
);
}
await ssh.connect({ ...host, port: host.port || 22 });
expect(h.connects[0].target).toMatchObject({ password: "stored-secret" });
await ssh.connect({ ...host, password: "typed", authType: "password" });
expect(h.connects[1].target).toMatchObject({ password: "typed" });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(h.logins.map((login) => login.hostId)).toEqual([5, 5]);
});
it("does not honor another plugin's resolved host", async () => {
h.granted = new Set(["ssh:connect", "credentials:use"]);
const deps = {
manifest: manifest(["ssh:connect", "credentials:use"]),
audit: vi.fn(async () => {}),
};
const first = createPluginSsh({
...deps,
bag: new DisposableBag("fixture"),
});
const second = createPluginSsh({
...deps,
bag: new DisposableBag("fixture"),
});
const host = (await first.resolveHost(5))!;
await second.connect({ ...host });
expect(h.connects[0].target).not.toHaveProperty("password");
});
it("passes a given stream through to the pipeline, gated like any connect", async () => {
const stream = { throughSource: true };
const ssh = createPluginSsh({
@@ -117,6 +117,32 @@ describe("plugin user data", () => {
expect(again).toMatchObject({ imported: 0, skipped: 1 });
});
it("writes large tables in chunks and lets the event loop run between them", async () => {
const insert = sqlite.prepare(
"INSERT INTO p_fixture_bookmark (user_id, path) VALUES ('u1', ?)",
);
sqlite.transaction(() => {
for (let i = 0; i < 4999; i++) insert.run(`/p/${i}`);
})();
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')");
const transaction = vi.spyOn(file, "transaction");
let ticks = 0;
const ticker = setInterval(() => ticks++, 0);
const written = await writeUserPluginTables(file, "u1");
clearInterval(ticker);
expect(written).toBe(5000);
expect(transaction).toHaveBeenCalled();
expect(ticks).toBeGreaterThan(0);
expect(
file.prepare("SELECT COUNT(*) AS n FROM p_fixture_bookmark").get(),
).toEqual({ n: 5000 });
});
it("reads a pre-2.9 export by the legacy table name", async () => {
const file = new Database(":memory:");
file.exec(
+1
View File
@@ -26,6 +26,7 @@ vi.mock("../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: {
triggerSave: vi.fn(),
forceSave: vi.fn(async () => {}),
batched: <T>(work: T) => work,
},
}));
vi.mock("../../utils/system-crypto.js", () => ({
@@ -111,4 +111,20 @@ describe("runExternalIdentityMigration", () => {
expect(second).toEqual({ identities: 0, skipped: 1 });
expect(h.state.identities).toHaveLength(1);
});
it("does not recreate a legacy-oidc link that was moved to its provider", async () => {
user("oidc-user", { oidcIdentifier: "sub-1" });
h.state.identities.push({
id: 1,
userId: "oidc-user",
providerId: "1",
subject: "sub-1",
email: null,
} as never);
const result = await runExternalIdentityMigration();
expect(result).toEqual({ identities: 0, skipped: 1 });
expect(h.state.identities).toHaveLength(1);
});
});
@@ -0,0 +1,57 @@
import Database from "better-sqlite3";
import { readFileSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { repairSnippetsNoteColumn } from "../../upgrade/snippets-schema-migration.js";
let sqlite: Database.Database;
beforeEach(() => {
sqlite = new Database(":memory:");
});
afterEach(() => {
sqlite.close();
});
describe("snippet note column upgrade", () => {
it.each(["snippets", "p_snippets_snippets"])(
"repairs %s without losing rows",
(table) => {
sqlite.exec(
`CREATE TABLE "${table}" (id INTEGER PRIMARY KEY, user_id TEXT, content TEXT)`,
);
sqlite
.prepare(`INSERT INTO "${table}" VALUES (7, 'user', 'echo hello')`)
.run();
expect(() => sqlite.prepare(`SELECT "is_note" FROM "${table}"`)).toThrow(
/is_note/,
);
repairSnippetsNoteColumn(sqlite);
if (table === "snippets") {
sqlite.exec(
readFileSync(
"plugins/snippets/migrations/sqlite/0001_adopt_snippets_tables.sql",
"utf8",
),
);
}
expect(
sqlite
.prepare("SELECT id, content, is_note FROM p_snippets_snippets")
.get(),
).toEqual({ id: 7, content: "echo hello", is_note: 0 });
sqlite.exec("UPDATE p_snippets_snippets SET is_note = 1");
repairSnippetsNoteColumn(sqlite);
expect(
sqlite.prepare("SELECT is_note FROM p_snippets_snippets").get(),
).toEqual({ is_note: 1 });
},
);
it("does not create tables on a fresh install", () => {
repairSnippetsNoteColumn(sqlite);
expect(
sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
.all(),
).toEqual([]);
});
});
@@ -0,0 +1,200 @@
/**
* 2.8 OIDC accounts with no provider id were filed under "legacy-oidc", but
* the sso plugin signs in through the migrated provider row (#1381). They move
* to that row when it is the only one from before 2.9 and the env provider is
* not in use; anything ambiguous is left alone.
*/
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
db: null as unknown,
warn: null as unknown as ReturnType<typeof vi.fn>,
}));
vi.mock("../../database/db/index.js", () => ({ getDb: () => h.db }));
vi.mock("../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
h.warn = log.warn;
return { databaseLogger: log };
});
const { runSsoLegacyIdentityMigration } =
await import("../../upgrade/sso-legacy-identity-migration.js");
const ENV = [
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_ISSUER_URL",
"OIDC_AUTHORIZATION_URL",
"OIDC_TOKEN_URL",
"OIDC_ENV_OVERRIDE",
];
let sqlite: Database.Database;
function setup(providers: Array<[number, string, number, number]>) {
sqlite = new Database(":memory:");
sqlite.exec(`
CREATE TABLE users (id TEXT PRIMARY KEY, oidc_identifier TEXT);
CREATE TABLE p_sso_providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT 'x',
type TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
legacy_callback INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE user_external_identities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
subject TEXT NOT NULL,
email TEXT
);
`);
const insert = sqlite.prepare(
"INSERT INTO p_sso_providers (id, type, enabled, legacy_callback) VALUES (?, ?, ?, ?)",
);
for (const row of providers) insert.run(...row);
h.db = drizzle(sqlite);
}
function addUser(id: string, identifier: string, providerId: string) {
sqlite
.prepare("INSERT INTO users (id, oidc_identifier) VALUES (?, ?)")
.run(id, identifier);
const subject = identifier.replace(/^github:[^:]+:/, "");
sqlite
.prepare(
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES (?, ?, ?)",
)
.run(id, providerId, subject);
}
function links() {
return sqlite
.prepare(
"SELECT user_id, provider_id, subject FROM user_external_identities ORDER BY id",
)
.all();
}
beforeEach(() => {
for (const name of ENV) delete process.env[name];
});
afterEach(() => {
for (const name of ENV) delete process.env[name];
});
describe("runSsoLegacyIdentityMigration", () => {
it("moves legacy OIDC sign-ins to the one migrated provider", async () => {
setup([
[1, "oidc", 1, 1],
[2, "github", 1, 1],
]);
addUser("alice", "sub-a", "legacy-oidc");
addUser("gh", "github:null:42", "legacy-oidc");
const result = await runSsoLegacyIdentityMigration();
expect(result).toEqual({ moved: 1, conflicts: 0 });
expect(links()).toEqual([
{ user_id: "alice", provider_id: "1", subject: "sub-a" },
{ user_id: "gh", provider_id: "legacy-oidc", subject: "42" },
]);
});
it("ignores OIDC providers added after the upgrade", async () => {
setup([
[1, "oidc", 1, 1],
[5, "oidc", 1, 0],
]);
addUser("alice", "sub-a", "legacy-oidc");
await runSsoLegacyIdentityMigration();
expect(links()).toEqual([
{ user_id: "alice", provider_id: "1", subject: "sub-a" },
]);
});
it("leaves them when two providers are from before 2.9", async () => {
setup([
[1, "oidc", 1, 1],
[2, "oidc", 1, 1],
]);
addUser("alice", "sub-a", "legacy-oidc");
expect(await runSsoLegacyIdentityMigration()).toEqual({
moved: 0,
conflicts: 0,
});
expect(links()[0]).toMatchObject({ provider_id: "legacy-oidc" });
});
it("leaves them when the env provider signs in", async () => {
setup([[1, "oidc", 1, 1]]);
addUser("alice", "sub-a", "legacy-oidc");
for (const name of ENV.slice(0, 5)) process.env[name] = "x";
process.env.OIDC_ENV_OVERRIDE = "true";
await runSsoLegacyIdentityMigration();
expect(links()[0]).toMatchObject({ provider_id: "legacy-oidc" });
});
it("still moves them when env settings exist but the row is what signs in", async () => {
setup([[1, "oidc", 1, 1]]);
addUser("alice", "sub-a", "legacy-oidc");
for (const name of ENV.slice(0, 5)) process.env[name] = "x";
await runSsoLegacyIdentityMigration();
expect(links()[0]).toMatchObject({ provider_id: "1" });
});
it("skips a subject a 2.9.0 duplicate account already holds", async () => {
setup([[1, "oidc", 1, 1]]);
addUser("alice", "sub-a", "legacy-oidc");
sqlite
.prepare(
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES ('dup', '1', 'sub-a')",
)
.run();
const result = await runSsoLegacyIdentityMigration();
expect(result).toEqual({ moved: 0, conflicts: 1 });
expect(h.warn).toHaveBeenCalled();
expect(links()).toHaveLength(2);
});
it("drops the legacy link once the same account holds the real one", async () => {
setup([[1, "oidc", 1, 1]]);
addUser("alice", "sub-a", "legacy-oidc");
sqlite
.prepare(
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES ('alice', '1', 'sub-a')",
)
.run();
await runSsoLegacyIdentityMigration();
expect(links()).toEqual([
{ user_id: "alice", provider_id: "1", subject: "sub-a" },
]);
});
it("is idempotent", async () => {
setup([[1, "oidc", 1, 1]]);
addUser("alice", "sub-a", "legacy-oidc");
await runSsoLegacyIdentityMigration();
expect(await runSsoLegacyIdentityMigration()).toEqual({
moved: 0,
conflicts: 0,
});
});
});
@@ -7,6 +7,25 @@ describe("DatabaseSaveTrigger", () => {
DatabaseSaveTrigger.cleanup();
});
it("batches informational touches without scheduling per-session saves", async () => {
vi.useFakeTimers();
const save = vi.fn().mockResolvedValue(undefined);
DatabaseSaveTrigger.initialize(save);
for (let minute = 0; minute < 4; minute++) {
DatabaseSaveTrigger.markDirty();
await vi.advanceTimersByTimeAsync(60_000);
}
expect(save).not.toHaveBeenCalled();
expect(DatabaseSaveTrigger.isDirty).toBe(true);
expect(DatabaseSaveTrigger.getStatus().hasPendingTimeout).toBe(false);
const flushed = DatabaseSaveTrigger.forceSave("periodic_flush");
// A save yields one event-loop turn, which fake timers also control.
await vi.runOnlyPendingTimersAsync();
await flushed;
expect(save).toHaveBeenCalledOnce();
expect(DatabaseSaveTrigger.isDirty).toBe(false);
});
it("force saves through the initialized save function", async () => {
const save = vi.fn().mockResolvedValue(undefined);
DatabaseSaveTrigger.initialize(save);
@@ -21,6 +40,18 @@ describe("DatabaseSaveTrigger", () => {
});
});
it("lets the event loop turn before a force save resolves", async () => {
DatabaseSaveTrigger.initialize(vi.fn().mockResolvedValue(undefined));
let turned = false;
setImmediate(() => {
turned = true;
});
await DatabaseSaveTrigger.forceSave("bulk_write");
expect(turned).toBe(true);
});
it("debounces dirty saves and marks the database clean after saving", async () => {
vi.useFakeTimers();
const save = vi.fn().mockResolvedValue(undefined);
@@ -33,12 +64,24 @@ describe("DatabaseSaveTrigger", () => {
expect(DatabaseSaveTrigger.getStatus().hasPendingTimeout).toBe(true);
await vi.advanceTimersByTimeAsync(2000);
await vi.runOnlyPendingTimersAsync();
expect(save).toHaveBeenCalledTimes(1);
expect(DatabaseSaveTrigger.isDirty).toBe(false);
expect(DatabaseSaveTrigger.getStatus().pendingSave).toBe(false);
});
it("saves within the max wait even while writes keep coming", async () => {
vi.useFakeTimers();
const save = vi.fn().mockResolvedValue(undefined);
DatabaseSaveTrigger.initialize(save);
for (let second = 0; second < 40; second++) {
void DatabaseSaveTrigger.triggerSave("sample");
await vi.advanceTimersByTimeAsync(1000);
}
expect(save).toHaveBeenCalledTimes(1);
});
it("queues a force save behind an in-flight save", async () => {
let finishFirstSave: (() => void) | undefined;
const firstSave = new Promise<void>((resolve) => {
@@ -62,4 +105,77 @@ describe("DatabaseSaveTrigger", () => {
expect(save).toHaveBeenCalledTimes(2);
expect(DatabaseSaveTrigger.getStatus().pendingSave).toBe(false);
});
it("collapses force saves inside a batch into one save", async () => {
const save = vi.fn().mockResolvedValue(undefined);
DatabaseSaveTrigger.initialize(save);
const result = await DatabaseSaveTrigger.batched(async (rows: number) => {
for (let i = 0; i < rows; i++) {
await DatabaseSaveTrigger.forceSave("row_write");
}
expect(save).not.toHaveBeenCalled();
return rows;
})(50);
expect(result).toBe(50);
expect(save).toHaveBeenCalledTimes(1);
expect(DatabaseSaveTrigger.isDirty).toBe(false);
});
it("folds a nested batch into the outer one", async () => {
const save = vi.fn().mockResolvedValue(undefined);
DatabaseSaveTrigger.initialize(save);
const inner = DatabaseSaveTrigger.batched(() =>
DatabaseSaveTrigger.forceSave("inner_write"),
);
await DatabaseSaveTrigger.batched(async () => {
await inner();
await inner();
})();
expect(save).toHaveBeenCalledTimes(1);
});
it("skips the save when a batch wrote nothing", async () => {
const save = vi.fn().mockResolvedValue(undefined);
DatabaseSaveTrigger.initialize(save);
await DatabaseSaveTrigger.batched(async () => {})();
expect(save).not.toHaveBeenCalled();
});
it("still saves what a failed batch wrote", async () => {
const save = vi.fn().mockResolvedValue(undefined);
DatabaseSaveTrigger.initialize(save);
await expect(
DatabaseSaveTrigger.batched(async () => {
await DatabaseSaveTrigger.forceSave("row_write");
throw new Error("boom");
})(),
).rejects.toThrow("boom");
expect(save).toHaveBeenCalledTimes(1);
});
it("saves immediately for work that outlives its batch", async () => {
const save = vi.fn().mockResolvedValue(undefined);
DatabaseSaveTrigger.initialize(save);
let release: (() => void) | undefined;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
let detached: Promise<void> | undefined;
await DatabaseSaveTrigger.batched(async () => {
detached = gate.then(() => DatabaseSaveTrigger.forceSave("late_write"));
})();
release?.();
await detached;
expect(save).toHaveBeenCalledTimes(1);
});
});