feat: store rdp, vnc and telnet logins in a generic per-protocol table

This commit is contained in:
LukeGus committed 2026-09-29 12:33:09 -05:00
1 parent e0c30a4e6f
commit bba06f7ff6
107 files changed
+18750 -1224

No files matched your search

+147 -34
View File
@@ -18,6 +18,8 @@ const state = vi.hoisted(() => ({
savedCredentials: [] as Array<Record<string, unknown>>,
userCredentials: new Map<string, Array<Record<string, unknown>>>(),
corePermissions: new Set<string>(["credentials.view", "credentials.create"]),
logins: new Map<string, Record<string, unknown>>(),
ownerDecrypts: 0,
}));
vi.mock("../../database/repositories/factory.js", () => ({
@@ -40,6 +42,30 @@ vi.mock("../../database/repositories/factory.js", () => ({
return row;
},
}),
createCurrentHostProtocolAuthRepository: () => ({
find: async (hostId: number, protocol: string) => {
state.ownerDecrypts++;
return state.logins.get(`${hostId}:${protocol}`) ?? null;
},
findRow: async (hostId: number, protocol: string) => {
const login = state.logins.get(`${hostId}:${protocol}`);
return login
? {
hostId,
protocol,
authType: login.authType,
credentialId: login.credentialId ?? null,
username: login.username ?? null,
password: "sealed",
fields: JSON.stringify(login.fields ?? {}),
secretFields: "sealed",
}
: null;
},
}),
createCurrentSharedHostAuthOverrideRepository: () => ({
listCredentialIds: async () => ({}),
}),
createCurrentHostResolutionRepository: () => ({
findHostOwnerId: async (hostId: number) =>
(state.hosts.get(hostId)?.userId as string) ?? null,
@@ -49,6 +75,17 @@ vi.mock("../../database/repositories/factory.js", () => ({
}),
}));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: {
validateUserAccess: () => Buffer.alloc(32),
getUserDataKey: () => Buffer.alloc(32),
},
}));
vi.mock("../../hosts/usable-credential.js", () => ({
findUsableCredential: async (id: number) => state.credentials.get(id) ?? null,
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
@@ -84,6 +121,13 @@ import {
} from "../../hosts/connect/secret-resolver-registry.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import ssh2 from "ssh2";
import { setHostProtocolSource } from "../../hosts/protocol-auth/registry.js";
const SPICE = {
id: "spice",
credentialFields: [{ key: "display" }, { key: "ticket", secret: true }],
hostLoginFallback: ["password" as const],
};
function contextFor(
capabilities: string[],
@@ -114,13 +158,13 @@ const baseHost = {
username: "sshuser",
password: "ssh-secret",
jumpHosts: JSON.stringify([{ hostId: 3 }]),
rdpUser: "admin",
rdpPassword: "rdp-secret",
rdpDomain: "CORP",
rdpAuthType: null,
rdpCredentialId: null,
};
/** The demo plugin declares SPICE; nothing in core knows it. */
function spiceContext(capabilities: string[]) {
return contextFor(capabilities, { protocols: [SPICE] });
}
beforeEach(() => {
grants.clear();
auditEntries.length = 0;
@@ -134,17 +178,32 @@ beforeEach(() => {
state.userCredentials.clear();
state.corePermissions = new Set(["credentials.view", "credentials.create"]);
state.hosts.set(7, { ...baseHost });
state.logins.clear();
state.ownerDecrypts = 0;
state.logins.set("7:spice", {
protocol: "spice",
authType: "direct",
credentialId: null,
username: "admin",
password: "spice-secret",
fields: { display: "2" },
secretFields: { ticket: "t-123" },
});
setHostProtocolSource(() => [
{ ...SPICE, pluginId: "demo", pluginName: "demo" },
{ id: "rival", pluginId: "other", pluginName: "other" },
]);
resetSecretResolverRegistryForTests();
});
describe("ctx.credentials.resolveHostProtocol", () => {
it("refuses without credentials:read and audits the refusal", async () => {
grants.set("demo", []);
const ctx = contextFor([]);
const ctx = spiceContext([]);
await expect(
runAsActor("owner", "request", () =>
ctx.credentials.resolveHostProtocol(7, "rdp"),
ctx.credentials.resolveHostProtocol(7, "spice"),
),
).rejects.toThrow(/credentials:read/);
expect(auditEntries.at(-1)).toMatchObject({
@@ -153,12 +212,31 @@ describe("ctx.credentials.resolveHostProtocol", () => {
});
});
it("refuses a protocol the plugin does not declare, or another plugin owns", async () => {
grants.set("demo", ["credentials:read"]);
const ctx = contextFor(["credentials:read"], {
protocols: [SPICE, { id: "rival" }],
});
await expect(
runAsActor("owner", "request", () =>
ctx.credentials.resolveHostProtocol(7, "vnc"),
),
).rejects.toThrow(/does not declare/);
await expect(
runAsActor("owner", "request", () =>
ctx.credentials.resolveHostProtocol(7, "rival"),
),
).rejects.toThrow(/does not declare/);
expect(state.ownerDecrypts).toBe(0);
});
it("returns the owner's stored login and audits it", async () => {
grants.set("demo", ["credentials:read"]);
const ctx = contextFor(["credentials:read"]);
const ctx = spiceContext(["credentials:read"]);
const target = await runAsActor("owner", "request", () =>
ctx.credentials.resolveHostProtocol(7, "rdp"),
ctx.credentials.resolveHostProtocol(7, "spice"),
);
expect(target).toEqual({
host: {
@@ -173,8 +251,8 @@ describe("ctx.credentials.resolveHostProtocol", () => {
auth: {
authType: "direct",
username: "admin",
password: "rdp-secret",
domain: "CORP",
password: "spice-secret",
fields: { display: "2", ticket: "t-123" },
},
});
expect(auditEntries.at(-1)).toMatchObject({
@@ -183,23 +261,52 @@ describe("ctx.credentials.resolveHostProtocol", () => {
});
});
it("uses the stored credential in credential mode", async () => {
it("falls back to the host's own password as declared", async () => {
grants.set("demo", ["credentials:read"]);
state.hosts.set(7, {
...baseHost,
vncCredentialId: 11,
vncAuthType: "credential",
state.logins.set("7:spice", {
protocol: "spice",
authType: "direct",
credentialId: null,
username: null,
password: null,
fields: {},
secretFields: {},
});
state.credentials.set(11, { username: "viewer", password: "vnc-pass" });
const ctx = contextFor(["credentials:read"]);
const ctx = spiceContext(["credentials:read"]);
const target = await runAsActor("owner", "request", () =>
ctx.credentials.resolveHostProtocol(7, "vnc"),
ctx.credentials.resolveHostProtocol(7, "spice"),
);
expect(target?.auth).toEqual({
authType: "direct",
username: "",
password: "ssh-secret",
fields: { display: "", ticket: "" },
});
});
it("uses the stored credential in credential mode", async () => {
grants.set("demo", ["credentials:read"]);
state.logins.set("7:spice", {
protocol: "spice",
authType: "credential",
credentialId: 11,
username: null,
password: null,
fields: { display: "1" },
secretFields: {},
});
state.credentials.set(11, { username: "viewer", password: "cred-pass" });
const ctx = spiceContext(["credentials:read"]);
const target = await runAsActor("owner", "request", () =>
ctx.credentials.resolveHostProtocol(7, "spice"),
);
expect(target?.auth).toMatchObject({
authType: "credential",
username: "viewer",
password: "vnc-pass",
password: "cred-pass",
fields: { display: "1" },
});
});
@@ -208,50 +315,56 @@ describe("ctx.credentials.resolveHostProtocol", () => {
state.resolution = {
source: "owner-shared",
authType: "direct",
secret: { username: "shared", password: "snap", domain: null },
secret: {
username: "shared",
password: "snap",
fields: { ticket: "snap-ticket" },
},
};
const ctx = contextFor(["credentials:read"]);
const ctx = spiceContext(["credentials:read"]);
const target = await runAsActor("guest", "request", () =>
ctx.credentials.resolveHostProtocol(7, "rdp"),
ctx.credentials.resolveHostProtocol(7, "spice"),
);
expect(target?.shared).toBe(true);
expect(target?.auth).toMatchObject({
expect(target?.auth).toEqual({
authType: "direct",
username: "shared",
password: "snap",
fields: { display: "2", ticket: "snap-ticket" },
});
expect(state.resolverCalls[0]).toMatchObject({
password: null,
rdpUser: null,
rdpPassword: null,
});
expect(state.resolverCalls[0]).toMatchObject({ password: null });
// The owner's login is never decrypted for a recipient.
expect(state.ownerDecrypts).toBe(0);
});
it("gives a recipient with nothing shared an empty login", async () => {
grants.set("demo", ["credentials:read"]);
state.resolution = { source: "required" };
const ctx = contextFor(["credentials:read"]);
const ctx = spiceContext(["credentials:read"]);
const target = await runAsActor("guest", "request", () =>
ctx.credentials.resolveHostProtocol(7, "telnet"),
ctx.credentials.resolveHostProtocol(7, "spice"),
);
expect(target?.auth.username).toBe("");
expect(target?.auth.password).toBe("");
expect(target?.auth.fields.ticket).toBe("");
expect(state.ownerDecrypts).toBe(0);
});
it("returns null without connect access or for a missing host", async () => {
grants.set("demo", ["credentials:read"]);
state.canConnect = false;
const ctx = contextFor(["credentials:read"]);
const ctx = spiceContext(["credentials:read"]);
await expect(
runAsActor("guest", "request", () =>
ctx.credentials.resolveHostProtocol(7, "rdp"),
ctx.credentials.resolveHostProtocol(7, "spice"),
),
).resolves.toBeNull();
await expect(
runAsActor("owner", "request", () =>
ctx.credentials.resolveHostProtocol(99, "rdp"),
ctx.credentials.resolveHostProtocol(99, "spice"),
),
).resolves.toBeNull();
expect(auditEntries.at(-1)).toMatchObject({ success: false });
@@ -759,3 +759,67 @@ describe("contributes.keybindingActions", () => {
expect(errors).toMatch(/d/);
});
});
describe("contributes.protocols", () => {
const withProtocols = (protocols: unknown) =>
validateManifest(base({ contributes: { protocols } }));
it("accepts protocols with credential fields", () => {
expect(
withProtocols([
{
id: "spice",
credentialFields: [
{ key: "display" },
{ key: "ticket", secret: true },
],
defaultPort: 5930,
hostLoginFallback: ["username", "password"],
},
{ id: "x2go" },
]),
).toEqual([]);
});
it("refuses core's protocol, duplicates and bad ids", () => {
const errors = withProtocols([
{ id: "ssh" },
{ id: "spice" },
{ id: "spice" },
{ id: "Bad_Id" },
]);
expect(errors.join("\n")).toMatch(/"ssh" belongs to core/);
expect(errors.join("\n")).toMatch(/duplicates "spice"/);
expect(errors.join("\n")).toMatch(/\[3\]\.id must be lowercase/);
});
it("refuses field keys every login already has, and duplicate keys", () => {
const errors = withProtocols([
{
id: "spice",
credentialFields: [
{ key: "password" },
{ key: "display" },
{ key: "display", secret: "yes" },
],
},
]);
expect(errors.join("\n")).toMatch(/"password" is part of every login/);
expect(errors.join("\n")).toMatch(/duplicates "display"/);
expect(errors.join("\n")).toMatch(/secret must be a boolean/);
});
it("refuses unknown keys, bad ports and bad fallbacks", () => {
const errors = withProtocols([
{
id: "spice",
defaultPort: 70000,
hostLoginFallback: ["key"],
extra: true,
},
]);
expect(errors.join("\n")).toMatch(/defaultPort must be a port number/);
expect(errors.join("\n")).toMatch(/hostLoginFallback/);
expect(errors.join("\n")).toMatch(/extra/);
});
});
@@ -0,0 +1,387 @@
/**
* A fixture plugin declares a protocol core has never heard of ("spice") and
* gets a per-host login stored, encrypted, shared with a recipient (and
* overridden by them) and resolved, with no core change: only the manifest
* declaration. Runs on a real database with real repositories, sharing
* manager and override service; only keys, permissions and audit are faked.
*/
import crypto from "node:crypto";
import { sql } from "drizzle-orm";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { TestSqliteDatabase } from "../database/repositories/test-support.js";
import type { DatabaseContext } from "../../database/repositories/database-context.js";
const state = vi.hoisted(() => ({
db: null as null | { drizzle: unknown },
keys: new Map<string, Buffer>(),
levels: new Map<string, string>(),
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => state.db!.drizzle,
getSqlite: () => null,
}));
vi.mock("../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: {
triggerSave: vi.fn(),
forceSave: vi.fn(async () => {}),
},
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: vi.fn(async () => {}),
getAuditUsername: async (id: string) => id,
getRequestMeta: () => ({}),
}));
vi.mock("../../utils/data-crypto.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../utils/data-crypto.js")>();
const DataCrypto = actual.DataCrypto as unknown as Record<string, unknown>;
DataCrypto.getUserDataKey = (userId: string) =>
state.keys.get(userId) ?? null;
DataCrypto.validateUserAccess = (userId: string) => {
const key = state.keys.get(userId);
if (!key) throw new Error(`User ${userId} has no data encryption key`);
return key;
};
return actual;
});
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
canAccessHost: async (userId: string, _hostId: number) => {
const level = state.levels.get(userId);
return level
? { hasAccess: true, isShared: true, permissionLevel: level }
: { hasAccess: false };
},
hasPermission: async () => true,
}),
},
}));
import { setHostProtocolSource } from "../../hosts/protocol-auth/registry.js";
import {
loadProtocolAuthSummaries,
ProtocolAuthWriteError,
readProtocolAuthPayload,
writeProtocolAuth,
} from "../../hosts/protocol-auth/protocol-auth.js";
import { SharedHostSecretsManager } from "../../utils/shared-host-secrets-manager.js";
import { SharedHostAuthOverrideService } from "../../utils/shared-host-auth-override-service.js";
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { runAsActor } from "../../plugins/actor.js";
import { sanitizeHostForRecipient } from "../../database/routes/host-normalizers.js";
import { FieldCrypto } from "../../utils/field-crypto.js";
const HOST_ID = 10;
const GRANT_ID = 1;
const manifest = {
id: "fixture-spice",
name: "Fixture Spice",
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: ["credentials:read"],
contributes: {
protocols: [
{
id: "spice",
credentialFields: [{ key: "display" }, { key: "ticket", secret: true }],
},
],
},
} as PluginManifest;
let database: TestSqliteDatabase | null = null;
let context: DatabaseContext;
async function exec(query: string): Promise<void> {
await context.drizzle.run(sql.raw(query));
}
beforeEach(async () => {
database = new TestSqliteDatabase("sqlite");
context = await database.connect();
state.db = context;
state.keys = new Map([
["owner", crypto.randomBytes(32)],
["recipient", crypto.randomBytes(32)],
]);
state.levels = new Map([["recipient", "connect"]]);
for (const statement of [
`INSERT INTO users (id, username, password_hash) VALUES
('owner', 'owner', 'x'), ('recipient', 'recipient', 'x')`,
`INSERT INTO ssh_credentials (id, user_id, name, username, password, auth_type)
VALUES (5, 'recipient', 'mine', 'own-user', 'own-pass', 'password'),
(6, 'owner', 'owners', 'cred-user', 'cred-pass', 'password')`,
`INSERT INTO ssh_data (id, user_id, name, ip, port, username, password, auth_type)
VALUES (${HOST_ID}, 'owner', 'desk', '10.0.0.10', 22, 'root', 'ssh-pass', 'password')`,
`INSERT INTO host_access (id, host_id, user_id, granted_by, permission_level)
VALUES (${GRANT_ID}, ${HOST_ID}, 'recipient', 'owner', 'connect')`,
`INSERT INTO plugins (id, name, version, state, manifest_json)
VALUES ('fixture-spice', 'Fixture Spice', '1.0.0', 'enabled', '{}')`,
`INSERT INTO plugin_permission_grants (plugin_id, capability, source)
VALUES ('fixture-spice', 'credentials:read', 'bundled')`,
]) {
await exec(statement);
}
invalidatePluginPermissionCache();
// What the loader does with every installed manifest.
setHostProtocolSource(() =>
manifest.contributes!.protocols!.map((protocol) => ({
...protocol,
pluginId: manifest.id,
pluginName: manifest.name,
})),
);
});
afterEach(async () => {
setHostProtocolSource(() => []);
await database?.close();
database = null;
});
function pluginCtx() {
return createPluginContext(
manifest,
createPluginHandle(manifest.id, { activate: () => {} }),
);
}
async function storeLogin(): Promise<void> {
const patch = readProtocolAuthPayload({
protocolAuth: {
spice: {
authType: "direct",
username: "viewer",
password: "spice-pass",
fields: { display: "2", ticket: "t-secret" },
},
unknown: { username: "ignored" },
},
});
await writeProtocolAuth("owner", HOST_ID, patch!, { isOwner: true });
}
async function storedRow(): Promise<Record<string, unknown>> {
const rows = (await context.drizzle.all(
sql.raw(`SELECT * FROM host_protocol_auth WHERE host_id = ${HOST_ID}`),
)) as Record<string, unknown>[];
expect(rows).toHaveLength(1);
return rows[0];
}
describe("a plugin protocol core does not know", () => {
it("stores the login encrypted with the owner's key, secrets apart", async () => {
await storeLogin();
const row = await storedRow();
expect(row.protocol).toBe("spice");
expect(row.username).toBe("viewer");
expect(JSON.parse(row.fields as string)).toEqual({ display: "2" });
expect(String(row.password)).not.toContain("spice-pass");
expect(String(row.secret_fields)).not.toContain("t-secret");
expect(
FieldCrypto.decryptField(
row.password as string,
state.keys.get("owner")!,
"",
"password",
),
).toBe("spice-pass");
expect(() =>
FieldCrypto.decryptField(
row.password as string,
state.keys.get("recipient")!,
"",
"password",
),
).toThrow();
const summaries = await loadProtocolAuthSummaries([{ id: HOST_ID }]);
expect(summaries.get(HOST_ID)).toEqual({
spice: {
authType: "direct",
credentialId: null,
username: "viewer",
fields: { display: "2" },
hasPassword: true,
secretFieldKeys: ["ticket"],
},
});
});
it("resolves the owner's login for the declaring plugin", async () => {
await storeLogin();
const target = await runAsActor("owner", "request", () =>
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
);
expect(target?.shared).toBe(false);
expect(target?.auth).toEqual({
authType: "direct",
username: "viewer",
password: "spice-pass",
fields: { display: "2", ticket: "t-secret" },
});
});
it("treats a tampered password as missing, never as a value", async () => {
await storeLogin();
const row = await storedRow();
const envelope = JSON.parse(row.password as string);
envelope.tag = "0".repeat(envelope.tag.length);
await exec(
`UPDATE host_protocol_auth SET password = '${JSON.stringify(envelope)}' WHERE host_id = ${HOST_ID}`,
);
const target = await runAsActor("owner", "request", () =>
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
);
expect(target?.auth.password).toBe("");
expect(target?.auth.fields.ticket).toBe("t-secret");
});
it("gives a recipient the shared snapshot, not the owner's secret", async () => {
await storeLogin();
await SharedHostSecretsManager.getInstance().snapshotForUser(
GRANT_ID,
HOST_ID,
"recipient",
"owner",
);
// The owner's row loses its password without a resync: the recipient
// keeps reading their own copy, never the owner's row.
await exec(
`UPDATE host_protocol_auth SET password = NULL WHERE host_id = ${HOST_ID}`,
);
const target = await runAsActor("recipient", "request", () =>
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
);
expect(target?.shared).toBe(true);
expect(target?.auth).toEqual({
authType: "direct",
username: "viewer",
password: "spice-pass",
fields: { display: "2", ticket: "t-secret" },
});
});
it("uses the recipient's own override over the shared snapshot", async () => {
await storeLogin();
await SharedHostSecretsManager.getInstance().snapshotForUser(
GRANT_ID,
HOST_ID,
"recipient",
"owner",
);
await SharedHostAuthOverrideService.getInstance().setCredentialId(
HOST_ID,
"recipient",
"spice",
5,
);
const target = await runAsActor("recipient", "request", () =>
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
);
expect(target?.auth).toEqual({
authType: "credential",
username: "own-user",
password: "own-pass",
// The owner's plain fields, never their secret ones.
fields: { display: "2", ticket: "" },
});
});
it("refuses an override for a protocol nobody declares", async () => {
await expect(
SharedHostAuthOverrideService.getInstance().setCredentialId(
HOST_ID,
"recipient",
"made-up",
5,
),
).rejects.toThrow(/No plugin declares/);
});
it("follows a credential the owner points the login at", async () => {
const patch = readProtocolAuthPayload({
protocolAuth: { spice: { authType: "credential", credentialId: 6 } },
});
await writeProtocolAuth("owner", HOST_ID, patch!, { isOwner: true });
const target = await runAsActor("owner", "request", () =>
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
);
expect(target?.auth).toMatchObject({
authType: "credential",
username: "cred-user",
password: "cred-pass",
});
});
it("lets a shared editor change the login but not its credential", async () => {
await storeLogin();
const rename = readProtocolAuthPayload({
protocolAuth: { spice: { username: "renamed", password: "" } },
});
await writeProtocolAuth("owner", HOST_ID, rename!, { isOwner: false });
expect((await storedRow()).username).toBe("renamed");
// An empty password from an editor who cannot see it keeps it.
const target = await runAsActor("owner", "request", () =>
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
);
expect(target?.auth.password).toBe("spice-pass");
const repoint = readProtocolAuthPayload({
protocolAuth: { spice: { authType: "credential", credentialId: 6 } },
});
await expect(
writeProtocolAuth("owner", HOST_ID, repoint!, { isOwner: false }),
).rejects.toBeInstanceOf(ProtocolAuthWriteError);
const remove = readProtocolAuthPayload({ protocolAuth: { spice: null } });
await writeProtocolAuth("owner", HOST_ID, remove!, { isOwner: false });
expect(
(await context.drizzle.all(
sql.raw(`SELECT id FROM host_protocol_auth`),
)) as unknown[],
).toHaveLength(0);
});
it("shows each share level only what it may see", async () => {
await storeLogin();
const summaries = await loadProtocolAuthSummaries([{ id: HOST_ID }]);
const host = {
id: HOST_ID,
name: "desk",
ip: "10.0.0.10",
protocolAuth: summaries.get(HOST_ID),
};
expect(
sanitizeHostForRecipient({ ...host }, "connect").protocolAuth,
).toEqual({ spice: { authType: "direct" } });
for (const level of ["view", "edit", "manage"]) {
expect(
sanitizeHostForRecipient({ ...host }, level).protocolAuth,
level,
).toEqual({
spice: {
authType: "direct",
credentialId: null,
username: "viewer",
fields: { display: "2" },
},
});
}
});
});
@@ -31,9 +31,6 @@ function secretHost(id: number): Record<string, unknown> {
keyPassword: "s3cret-value",
sudoPassword: "s3cret-value",
socks5Password: "s3cret-value",
rdpPassword: "s3cret-value",
vncPassword: "s3cret-value",
telnetPassword: "s3cret-value",
autostartPassword: "s3cret-value",
autostartKey: "s3cret-value",
autostartKeyPassword: "s3cret-value",
@@ -420,11 +420,45 @@ function upgradeChecks(current: () => Booted, bootIndex: number) {
environmentVariables: [{ key: "D4", value: "yes" }],
});
// Every 2.8 RDP, VNC and Telnet login reads back through
// host_protocol_auth, the only place core reads them from now.
const ctx = await pluginCtx("remote-desktop");
const target = await asUser(USERS.admin, () =>
ctx.credentials.resolveHostProtocol(HOSTS.rdpOnly, "rdp"),
const resolve = (hostId: number, protocol: string) =>
asUser(USERS.admin, () =>
ctx.credentials.resolveHostProtocol(hostId, protocol),
);
expect((await resolve(HOSTS.rdpOnly, "rdp"))?.auth).toMatchObject({
authType: "direct",
username: "d4-rdp-user",
password: "d4-rdp-password",
});
expect((await resolve(HOSTS.desktop, "rdp"))?.auth).toEqual({
authType: "direct",
username: "d4-rdp-user",
password: "d4-rdp-password",
fields: { domain: "D4-DOMAIN" },
});
expect((await resolve(HOSTS.desktop, "vnc"))?.auth).toMatchObject({
authType: "direct",
username: "d4-vnc-user",
password: "d4-vnc-password",
});
expect((await resolve(HOSTS.desktop, "telnet"))?.auth).toMatchObject({
authType: "credential",
username: "deploy",
});
const logins = await selectRows<{ host_id: number; protocol: string }>(
sql`SELECT host_id, protocol FROM host_protocol_auth ORDER BY host_id, protocol`,
);
expect(target?.auth.password).toBe("d4-rdp-password");
expect(
logins.map((row) => `${Number(row.host_id)}:${row.protocol}`),
).toEqual([
`${HOSTS.rdpOnly}:rdp`,
`${HOSTS.desktop}:rdp`,
`${HOSTS.desktop}:telnet`,
`${HOSTS.desktop}:vnc`,
]);
});
it("registers an SSH auth provider for every 2.8 auth type", async () => {