mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
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:
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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user