Files
Termix/src/backend/tests/hosts/protocol-auth/protocol-auth.test.ts
T

206 lines
5.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../database/db/index.js", () => ({
getDb: () => null,
getSqlite: () => null,
}));
vi.mock("../../../hosts/usable-credential.js", () => ({
findUsableCredential: async (id: number, userId: string) =>
id === 5 && userId === "importer" ? { id, username: "u" } : null,
}));
import {
fromPortableLogin,
keepUsableProtocolCredentials,
mergeProtocolLogin,
readProtocolAuthPayload,
toPortableLogins,
} from "../../../hosts/protocol-auth/protocol-auth.js";
import {
setHostProtocolSource,
type DeclaredHostProtocol,
} from "../../../hosts/protocol-auth/registry.js";
import type { HostProtocolLogin } from "../../../database/repositories/host-protocol-auth-repository.js";
const SPICE: DeclaredHostProtocol = {
id: "spice",
credentialFields: [{ key: "display" }, { key: "ticket", secret: true }],
pluginId: "spice-plugin",
pluginName: "Spice",
};
const REMOTE_X: DeclaredHostProtocol = {
id: "remote-x",
credentialFields: [{ key: "domain" }],
pluginId: "x-plugin",
pluginName: "X",
};
const stored: HostProtocolLogin = {
protocol: "spice",
authType: "direct",
credentialId: null,
username: "viewer",
password: "saved",
fields: { display: "2" },
secretFields: { ticket: "t-saved" },
};
beforeEach(() => setHostProtocolSource(() => [SPICE, REMOTE_X]));
afterEach(() => setHostProtocolSource(() => []));
describe("readProtocolAuthPayload", () => {
it("reads protocolAuth for declared protocols only", () => {
const patch = readProtocolAuthPayload({
protocolAuth: {
spice: { username: "a" },
"remote-x": null,
stranger: { username: "b" },
},
});
expect(patch && Object.fromEntries(patch)).toEqual({
spice: { username: "a" },
"remote-x": null,
});
});
it("reads the flat fields a 2.8 client sends, named after the protocol", () => {
const patch = readProtocolAuthPayload({
spiceUser: "viewer",
spicePassword: "pw",
spiceDisplay: "3",
spiceAuthType: "direct",
remoteXUser: "x-user",
remoteXDomain: "CORP",
remoteXCredentialId: 9,
});
expect(patch && Object.fromEntries(patch)).toEqual({
spice: {
authType: "direct",
username: "viewer",
password: "pw",
fields: { display: "3" },
},
"remote-x": {
credentialId: 9,
username: "x-user",
fields: { domain: "CORP" },
},
});
});
it("treats a 2.8 editor's null password as leaving it alone", () => {
const patch = readProtocolAuthPayload({
spiceAuthType: "direct",
spiceUser: "viewer",
spicePassword: null,
});
expect(patch?.get("spice")).toEqual({
authType: "direct",
username: "viewer",
});
});
it("reads a 2.8 editor's all-null protocol as removed", () => {
const patch = readProtocolAuthPayload({
spiceAuthType: null,
spiceUser: null,
spicePassword: null,
spiceCredentialId: null,
});
expect(patch?.get("spice")).toBeNull();
});
it("answers null when a write carries no logins", () => {
expect(readProtocolAuthPayload({ name: "host" })).toBeNull();
});
});
describe("mergeProtocolLogin", () => {
it("keeps what the input leaves out, including secrets", () => {
expect(mergeProtocolLogin("spice", SPICE, stored, {})).toEqual(stored);
});
it("keeps a saved password and secret for an empty value", () => {
const next = mergeProtocolLogin("spice", SPICE, stored, {
password: "",
fields: { ticket: "", display: "" },
});
expect(next.password).toBe("saved");
expect(next.secretFields).toEqual({ ticket: "t-saved" });
// A plain field can be cleared.
expect(next.fields).toEqual({});
});
it("drops the direct login and undeclared fields for a credential", () => {
const next = mergeProtocolLogin("spice", SPICE, stored, {
authType: "credential",
credentialId: "12",
fields: { unknown: "x" },
});
expect(next).toMatchObject({
authType: "credential",
credentialId: 12,
username: null,
password: null,
fields: { display: "2" },
});
});
it("starts a new login from the input", () => {
expect(
mergeProtocolLogin("spice", SPICE, null, {
username: "new",
password: "pw",
fields: { ticket: "t" },
}),
).toEqual({
protocol: "spice",
authType: "direct",
credentialId: null,
username: "new",
password: "pw",
fields: {},
secretFields: { ticket: "t" },
});
});
it("falls back to direct for an auth type it does not know", () => {
expect(
mergeProtocolLogin("spice", SPICE, null, { authType: "magic" }).authType,
).toBe("direct");
});
});
describe("portable logins", () => {
it("round-trips through an export, splitting fields by the declaration", () => {
const portable = toPortableLogins([stored]);
expect(portable.spice.fields).toEqual({ display: "2", ticket: "t-saved" });
expect(fromPortableLogin("spice", portable.spice, null)).toEqual(stored);
});
it("keeps every field of an undeclared protocol secret", () => {
expect(
fromPortableLogin("gone", { fields: { display: "1" } }, null)
?.secretFields,
).toEqual({ display: "1" });
});
it("drops a credential link a user cannot use", async () => {
const patch = await keepUsableProtocolCredentials(
new Map([
["spice", { authType: "credential", credentialId: 5 }],
["remote-x", { authType: "credential", credentialId: 6 }],
]),
"importer",
);
expect(patch.get("spice")).toEqual({
authType: "credential",
credentialId: 5,
});
expect(patch.get("remote-x")).toEqual({
authType: "direct",
credentialId: null,
});
});
});