Merge branch 'dev-2.9.1' into dev-26.10.0

# Conflicts:
#	package-lock.json
#	package.json
#	plugins/ai/locales/translated/af_ZA.json
#	plugins/ai/locales/translated/ar_SA.json
#	plugins/ai/locales/translated/bg_BG.json
#	plugins/ai/locales/translated/bn_BD.json
#	plugins/ai/locales/translated/ca_ES.json
#	plugins/ai/locales/translated/cs_CZ.json
#	plugins/ai/locales/translated/da_DK.json
#	plugins/ai/locales/translated/de_DE.json
#	plugins/ai/locales/translated/el_GR.json
#	plugins/ai/locales/translated/es_ES.json
#	plugins/ai/locales/translated/fi_FI.json
#	plugins/ai/locales/translated/fr_FR.json
#	plugins/ai/locales/translated/he_IL.json
#	plugins/ai/locales/translated/hi_IN.json
#	plugins/ai/locales/translated/hu_HU.json
#	plugins/ai/locales/translated/id_ID.json
#	plugins/ai/locales/translated/it_IT.json
#	plugins/ai/locales/translated/ja_JP.json
#	plugins/ai/locales/translated/ko_KR.json
#	plugins/ai/locales/translated/nl_NL.json
#	plugins/ai/locales/translated/no_NO.json
#	plugins/ai/locales/translated/pl_PL.json
#	plugins/ai/locales/translated/pt_BR.json
#	plugins/ai/locales/translated/pt_PT.json
#	plugins/ai/locales/translated/ro_RO.json
#	plugins/ai/locales/translated/ru_RU.json
#	plugins/ai/locales/translated/sr_SP.json
#	plugins/ai/locales/translated/sv_SE.json
#	plugins/ai/locales/translated/th_TH.json
#	plugins/ai/locales/translated/tr_TR.json
#	plugins/ai/locales/translated/uk_UA.json
#	plugins/ai/locales/translated/vi_VN.json
#	plugins/ai/locales/translated/zh_TW.json
This commit is contained in:
LukeGus committed 2026-10-04 17:34:09 -05:00
commit 6b91c3a460
996 files changed
+56245 -47648

No files matched your search

@@ -77,6 +77,34 @@ describe("AuditLogRepository", () => {
]);
});
it("stores entries with no acting user, and refuses ids that are not users", async () => {
const repo = await createRepository();
await repo.create({
userId: null,
username: "plugin:example",
action: "plugin_http_public_routes",
resourceType: "plugin",
success: true,
});
await expect(
repo.create({
userId: "system",
username: "system",
action: "cleanup",
resourceType: "plugin",
success: true,
}),
).rejects.toThrow();
const page = await repo.listPage({ filters: {}, limit: 10, offset: 0 });
expect(page.logs).toHaveLength(1);
expect(page.logs[0]).toMatchObject({
userId: null,
username: "plugin:example",
});
});
it("deletes logs by user id and only runs write hook for deleted rows", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
@@ -88,7 +88,7 @@ beforeEach(() => {
state.statuses = new Map([
[1, { status: "online", lastChecked: "t" }],
[2, { status: "offline", lastChecked: "t" }],
[3, { status: "reachable", lastChecked: "t" }],
[3, { status: "offline", lastChecked: "t" }],
]);
service.statusesFor.mockImplementation(async () => state.statuses);
service.get.mockImplementation(
@@ -55,6 +55,7 @@ function setup(
hasActiveSession: (id) => hostSessionStatus.hasActiveSession(id),
globalInterval: () => 60,
emit: (payload) => emitted.push(payload),
retryDelayMs: 0,
});
return { service, emitted, ping, pingThroughJumpHosts, loadTargets };
}
@@ -192,86 +193,44 @@ describe("HostStatusService", () => {
expect(ping).toHaveBeenCalledWith("10.0.0.1", 3389);
});
it("warns about a failed login until a login works again", async () => {
it("ignores a failed login and counts a working one as online", async () => {
const { service, emitted } = setup([target(1)]);
active = service;
await service.statusesFor("owner", null);
await flush();
const before = emitted.length;
service.reportLogin(1, { ok: false });
expect(service.get(1)?.status).toBe("reachable");
expect(emitted.at(-1)).toMatchObject({
status: "reachable",
previous: "online",
});
vi.advanceTimersByTime(75_000);
await flush();
expect(service.get(1)?.status).toBe("reachable");
service.reportLogin(1, { ok: true });
expect(service.get(1)?.status).toBe("online");
vi.advanceTimersByTime(75_000);
await flush();
expect(service.get(1)?.status).toBe("online");
expect(emitted).toHaveLength(before);
});
it("marks a changed host key and clears it once the new key is accepted", async () => {
const { service } = setup([target(1)]);
it("marks a host online when a terminal session opens", async () => {
const { service } = setup([target(1)], false);
active = service;
service.start();
await service.statusesFor("owner", null);
await flush();
service.reportLogin(1, { ok: false, hostKeyChanged: true });
expect(service.get(1)).toMatchObject({
status: "reachable",
reason: "host_key_changed",
});
pluginEvents.emit(TOPICS.hostKeyUpdated, { hostId: 1 });
expect(service.get(1)?.reason).toBeUndefined();
expect(service.get(1)?.status).toBe("online");
});
it("clears a failed login when the host is edited", async () => {
const { service } = setup([target(1)]);
active = service;
service.start();
await service.statusesFor("owner", null);
await flush();
service.reportLogin(1, { ok: false });
pluginEvents.emit(TOPICS.hostUpdated, { hostId: 1 });
await flush();
await flush();
expect(service.get(1)?.status).toBe("online");
});
it("clears a failed login when a terminal session opens", async () => {
const { service } = setup([target(1)]);
active = service;
service.start();
await service.statusesFor("owner", null);
await flush();
service.reportLogin(1, { ok: false });
expect(service.get(1)?.status).toBe("offline");
const release = hostSessionStatus.register(1);
expect(service.get(1)?.status).toBe("online");
release();
});
it("tries a failed ping again before reporting offline", async () => {
const { service, ping } = setup([target(1)]);
active = service;
ping.mockResolvedValueOnce(false);
await service.statusesFor("owner", null);
expect(ping).toHaveBeenCalledTimes(2);
expect(service.get(1)?.status).toBe("online");
});
it("shows an offline host as offline whatever the login said", async () => {
const { service } = setup([target(1)], false);
it("answers the first request with the first checks done", async () => {
const { service } = setup([target(1), target(2)]);
active = service;
await service.statusesFor("owner", null);
await flush();
service.reportLogin(1, { ok: false });
expect(service.get(1)?.status).toBe("offline");
service.reportLogin(1, { ok: true });
vi.advanceTimersByTime(75_000);
await flush();
expect(service.get(1)?.status).toBe("offline");
const statuses = await service.statusesFor("owner", null);
expect(statuses.get(1)?.status).toBe("online");
expect(statuses.get(2)?.status).toBe("online");
});
it("only checks the requested hosts on the desktop app", async () => {
@@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest";
import {
isHostKeyVerificationError,
statusAfterAuthentication,
statusAfterReachabilityCheck,
} from "../../../hosts/status/host-status.js";
import { isHostKeyVerificationError } from "../../../hosts/status/host-status.js";
describe("isHostKeyVerificationError", () => {
it.each([
@@ -19,24 +15,3 @@ describe("isHostKeyVerificationError", () => {
);
});
});
describe("host availability status", () => {
it("calls a host whose port answers online", () => {
expect(statusAfterReachabilityCheck(true, false)).toBe("online");
expect(statusAfterReachabilityCheck(false, false)).toBe("offline");
});
it("warns about a reachable host whose last login failed", () => {
expect(statusAfterReachabilityCheck(true, true)).toBe("reachable");
expect(statusAfterReachabilityCheck(false, true)).toBe("offline");
});
it("marks successful authentication online", () => {
expect(statusAfterAuthentication(true, "reachable")).toBe("online");
});
it("downgrades failed authentication without hiding reachability", () => {
expect(statusAfterAuthentication(false, "online")).toBe("reachable");
expect(statusAfterAuthentication(false, "offline")).toBe("offline");
});
});
@@ -67,6 +67,29 @@ describe("JumpProbePool", () => {
pool.closeAll();
});
it("drops a chain whose hop stops answering", async () => {
vi.useFakeTimers();
const stale = Object.assign(new EventEmitter(), {
end: vi.fn(),
forwardOut: vi.fn(),
});
const fresh = fakeClient();
const open = vi
.fn()
.mockResolvedValueOnce(stale)
.mockResolvedValueOnce(fresh);
const pool = new JumpProbePool(open);
const first = pool.ping([{ hostId: 1 }], "u1", "a", 22, 1000);
await vi.advanceTimersByTimeAsync(1000);
expect(await first).toBe(false);
expect(stale.end).toHaveBeenCalled();
expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(true);
expect(open).toHaveBeenCalledTimes(2);
pool.closeAll();
});
it("retries a chain that failed to open", async () => {
const open = vi
.fn()
+2 -1
View File
@@ -73,7 +73,8 @@ describe("ctx.audit.record", () => {
const ctx = contextFor("audit-fixture");
await ctx.audit.record({ action: "cleanup", success: false });
expect(auditEntries[0]).toMatchObject({
userId: "system",
userId: null,
username: "system",
resourceType: "plugin",
resourceId: "audit-fixture",
resourceName: "Audit Fixture",
+5 -7
View File
@@ -129,7 +129,7 @@ vi.mock("../../hosts/host-session-status.js", () => ({
vi.mock("../../hosts/status/host-status-service.js", () => ({
hostStatusService: {
get: (hostId: number) =>
hostId === 4 ? { status: "reachable", lastChecked: "t" } : null,
hostId === 4 ? { status: "online", lastChecked: "t" } : null,
check: async (hostId: number) => ({
status: "offline",
lastChecked: String(hostId),
@@ -496,7 +496,7 @@ describe("ctx.hosts", () => {
}
expect(record.sshOptions).toEqual({ keepaliveInterval: 5 });
expect(record.pluginSettings).toEqual({ mine: "own-4" });
expect(record.status).toEqual({ status: "reachable", lastChecked: "t" });
expect(record.status).toEqual({ status: "online", lastChecked: "t" });
expect(record.enableSsh).toBe(true);
});
@@ -620,7 +620,7 @@ describe("ctx.hosts.status", () => {
audit: vi.fn(async () => {}),
});
await expect(hosts.status.get(4)).resolves.toEqual({
status: "reachable",
status: "online",
lastChecked: "t",
});
await expect(hosts.status.get(5)).resolves.toBeNull();
@@ -656,11 +656,9 @@ describe("ctx.hosts.status", () => {
manifest: manifest(["hosts:read"]),
audit: vi.fn(async () => {}),
});
hosts.status.reportLogin(4, { ok: false, hostKeyChanged: true });
hosts.status.reportLogin(4, { ok: true });
await settle();
expect(h.statusReports).toEqual([
{ hostId: 4, ok: false, hostKeyChanged: true },
]);
expect(h.statusReports).toEqual([{ hostId: 4, ok: true }]);
});
it("removes a registered port when the plugin is disposed", async () => {
@@ -220,24 +220,19 @@ describe("ctx.ssh", () => {
expect(h.logins).toEqual([{ hostId: 7, outcome: { ok: true } }]);
});
it("reports an auth or host key failure, but not a timeout", async () => {
it("never reports a failed login to the status", 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 () => {}),
});
h.connectError = new Error("Timed out while waiting for handshake");
await expect(ssh.connect(7)).rejects.toThrow();
h.connectError = new Error("All configured authentication methods failed");
await expect(ssh.connect(7)).rejects.toThrow();
h.connectError = new Error("Host key changed");
await expect(ssh.connect(8)).rejects.toThrow();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(h.logins).toEqual([
{ hostId: 7, outcome: { ok: false, hostKeyChanged: false } },
{ hostId: 8, outcome: { ok: false, hostKeyChanged: true } },
]);
expect(h.logins).toEqual([]);
});
it("never believes a host object's own userId", async () => {
@@ -11,10 +11,19 @@ const h = vi.hoisted(() => ({
writes: [] as Array<[string, number, string, unknown]>,
hooks: new Map<string, unknown>(),
manifests: [] as PluginManifest[],
visible: null as Record<string, Record<string, unknown>> | null,
viewerCalls: [] as Array<[Record<string, unknown>, string | undefined]>,
}));
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
hostSettingsPlugins: () => h.manifests,
withHostPluginSettings: async (
host: Record<string, unknown>,
viewerId?: string,
) => {
h.viewerCalls.push([host, viewerId]);
return h.visible ? { ...host, pluginSettings: h.visible } : host;
},
}));
vi.mock("../../plugins/registry.js", () => ({
consume: (key: string) => h.hooks.get(key),
@@ -65,11 +74,27 @@ const VAULT = {
},
} as unknown as PluginManifest;
const TUNNELS = {
id: "tunnels",
contributes: {
settings: {
host: {
fields: [
{ key: "enableTunnel", type: "boolean", labelKey: "k" },
{ key: "tunnelConnections", type: "custom", labelKey: "k" },
],
},
},
},
} as unknown as PluginManifest;
beforeEach(() => {
h.stored.clear();
h.writes.length = 0;
h.hooks.clear();
h.manifests = [VAULT];
h.visible = null;
h.viewerCalls.length = 0;
h.hooks.set("vault.hostSettingsSync", {
exportValue: (key: string, value: unknown) =>
key === "profileId" && value === 4 ? "profile-sync-4" : value,
@@ -87,6 +112,25 @@ describe("host plugin settings over sync", () => {
});
});
it("exports only what a shared host's viewer sees", async () => {
h.manifests = [VAULT, TUNNELS];
h.visible = {
vault: { token: { set: true } },
tunnels: { enableTunnel: true, tunnelConnections: [{ sourcePort: 22 }] },
};
expect(
await exportHostPluginSettings(1, {
userId: "viewer",
permissionLevel: "connect",
}),
).toEqual({
tunnels: { enableTunnel: true, tunnelConnections: [{ sourcePort: 22 }] },
});
expect(h.viewerCalls).toEqual([
[{ id: 1, isShared: true, permissionLevel: "connect" }, "viewer"],
]);
});
it("imports them back to local ids", async () => {
await importHostPluginSettings(9, {
vault: { profileId: "profile-sync-4", token: "ignored" },
@@ -8,6 +8,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
logins: [] as Array<Record<string, unknown>>,
replaced: null as null | { ownerId: string; hostId: number; logins: unknown },
rows: [] as Array<{ protocol: string }>,
recipient: new Map<string, Record<string, unknown> | Error>(),
}));
vi.mock(
@@ -20,6 +22,15 @@ vi.mock(
return {
...actual,
listProtocolLogins: async () => state.logins,
resolveRecipientProtocolLogin: async (
_host: unknown,
_userId: string,
declared: { id: string },
) => {
const login = state.recipient.get(declared.id);
if (login instanceof Error) throw login;
return login;
},
replaceProtocolLogins: async (
ownerId: string,
hostId: number,
@@ -30,6 +41,11 @@ vi.mock(
};
},
);
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentHostProtocolAuthRepository: () => ({
listRowsForHost: async () => state.rows,
}),
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => null,
getSqlite: () => null,
@@ -37,12 +53,15 @@ vi.mock("../../database/db/index.js", () => ({
import {
exportProtocolLogins,
exportSharedProtocolLogins,
importProtocolLogins,
} from "../../sync/host-protocol-auth.js";
import { setHostProtocolSource } from "../../hosts/protocol-auth/registry.js";
beforeEach(() => {
state.replaced = null;
state.rows = [];
state.recipient.clear();
state.logins = [
{
protocol: "spice",
@@ -129,3 +148,52 @@ describe("host protocol logins over sync", () => {
expect(state.replaced).toBeNull();
});
});
describe("shared host protocol logins over sync", () => {
it("sends what the recipient may use as direct logins", async () => {
state.rows = [{ protocol: "spice" }, { protocol: "unknown" }];
state.recipient.set("spice", {
authType: "credential",
username: "alice",
password: "pw",
fields: { display: "2", ticket: "" },
});
expect(
await exportSharedProtocolLogins({ id: 1, userId: "owner" }, "viewer"),
).toEqual({
spice: {
authType: "direct",
username: "alice",
password: "pw",
fields: { display: "2" },
credentialSyncId: null,
},
});
});
it("keeps a none login and skips one that fails", async () => {
setHostProtocolSource(() => [
{ id: "spice", credentialFields: [], pluginId: "p", pluginName: "P" },
{ id: "vnc", credentialFields: [], pluginId: "p", pluginName: "P" },
]);
state.rows = [{ protocol: "spice" }, { protocol: "vnc" }];
state.recipient.set("spice", {
authType: "none",
username: "",
password: "",
fields: {},
});
state.recipient.set("vnc", new Error("locked"));
expect(
await exportSharedProtocolLogins({ id: 1, userId: "owner" }, "viewer"),
).toEqual({
spice: {
authType: "none",
username: null,
password: null,
fields: {},
credentialSyncId: null,
},
});
});
});