mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: move opkssh and warpgate into plugins (C3)
This commit is contained in:
1 parent
925da4a661
commit
98d367d7e2
336 files changed
+22380
-4746
No files matched your search
@@ -1,105 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { OpksshTokenRepository } from "../../../database/repositories/opkssh-token-repository.js";
|
||||
|
||||
describe("OpksshTokenRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<OpksshTokenRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||
VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'two', '10.0.0.1', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password');
|
||||
INSERT INTO opkssh_tokens (
|
||||
user_id, host_id, ssh_cert, private_key, email, expires_at
|
||||
)
|
||||
VALUES
|
||||
('user-1', 1, 'cert-1', 'key-1', 'alice@example.com', '2099-01-01T00:00:00.000Z'),
|
||||
('user-1', 2, 'cert-2', 'key-2', 'alice2@example.com', '2099-01-01T00:00:00.000Z'),
|
||||
('user-2', 3, 'cert-3', 'key-3', 'bob@example.com', '2099-01-01T00:00:00.000Z');
|
||||
`);
|
||||
|
||||
return new OpksshTokenRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("finds and upserts a token by user and host", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const existing = await repo.findByUserAndHost("user-1", 1);
|
||||
expect(existing?.sshCert).toBe("cert-1");
|
||||
|
||||
await repo.upsert({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
sshCert: "new-cert",
|
||||
privateKey: "new-key",
|
||||
email: "new@example.com",
|
||||
sub: "sub",
|
||||
issuer: "issuer",
|
||||
audience: "aud",
|
||||
expiresAt: "2099-02-01T00:00:00.000Z",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const updated = await repo.findByUserAndHost("user-1", 1);
|
||||
expect(updated).toMatchObject({
|
||||
sshCert: "new-cert",
|
||||
privateKey: "new-key",
|
||||
email: "new@example.com",
|
||||
sub: "sub",
|
||||
issuer: "issuer",
|
||||
audience: "aud",
|
||||
expiresAt: "2099-02-01T00:00:00.000Z",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
expect(writeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("updates last-used and deletes one token", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
expect(
|
||||
await repo.updateLastUsed("user-1", 1, "2026-01-02T00:00:00.000Z"),
|
||||
).toBe(true);
|
||||
expect(await repo.updateLastUsed("missing", 1)).toBe(false);
|
||||
expect((await repo.findByUserAndHost("user-1", 1))?.lastUsed).toBe(
|
||||
"2026-01-02T00:00:00.000Z",
|
||||
);
|
||||
|
||||
expect(await repo.deleteByUserAndHost("user-1", 1)).toBe(true);
|
||||
expect(await repo.deleteByUserAndHost("user-1", 1)).toBe(false);
|
||||
expect(await repo.findByUserAndHost("user-1", 1)).toBeNull();
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
|
||||
it("deletes tokens by user id", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
expect(await repo.deleteByUserId("user-1")).toBe(2);
|
||||
expect(await repo.deleteByUserId("user-1")).toBe(0);
|
||||
expect(await repo.findByUserAndHost("user-1", 1)).toBeNull();
|
||||
expect(await repo.findByUserAndHost("user-2", 3)).not.toBeNull();
|
||||
expect(writeCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* The OPKSSH redirect URI identity providers were registered with before
|
||||
* 2.9 must keep reaching the plugin's callback, query intact.
|
||||
*/
|
||||
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import express from "express";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
const { registerHostAuthCompatRoutes } =
|
||||
await import("../../../database/routes/host-compat-routes.js");
|
||||
|
||||
let server: http.Server;
|
||||
let base: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const router = express.Router();
|
||||
registerHostAuthCompatRoutes(router);
|
||||
const app = express();
|
||||
app.use("/host", 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()));
|
||||
});
|
||||
|
||||
describe("the old OPKSSH callback", () => {
|
||||
it("forwards to the plugin's callback with the query", async () => {
|
||||
const response = await fetch(
|
||||
`${base}/host/opkssh-callback?code=abc&state=xyz`,
|
||||
{ redirect: "manual" },
|
||||
);
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.headers.get("location")).toBe(
|
||||
"/plugin-api/opkssh/callback?code=abc&state=xyz",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a subpath", async () => {
|
||||
const response = await fetch(`${base}/host/opkssh-callback/req-1/done`, {
|
||||
redirect: "manual",
|
||||
});
|
||||
expect(response.headers.get("location")).toBe(
|
||||
"/plugin-api/opkssh/callback/req-1/done",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -18,10 +18,9 @@ vi.mock("ssh2", async (importOriginal) => {
|
||||
const mocks = vi.hoisted(() => ({
|
||||
verifier: () => {},
|
||||
createHostVerifier: vi.fn(),
|
||||
getOPKSSHToken: vi.fn(),
|
||||
invalidateOPKSSHToken: vi.fn(),
|
||||
setupOPKSSHCertAuth: vi.fn(),
|
||||
setupCACertAuth: vi.fn(),
|
||||
getStepCaCert: vi.fn(),
|
||||
invalidateStepCaCert: vi.fn(),
|
||||
applyCertificateAuth: vi.fn(),
|
||||
getVaultCert: vi.fn(),
|
||||
deleteVaultCert: vi.fn(),
|
||||
applyAgentAuth: vi.fn(),
|
||||
@@ -41,13 +40,12 @@ vi.mock("../../../hosts/host-key-verifier.js", () => ({
|
||||
createHostVerifier: mocks.createHostVerifier,
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../hosts/opkssh-auth.js", () => ({
|
||||
getOPKSSHToken: mocks.getOPKSSHToken,
|
||||
invalidateOPKSSHToken: mocks.invalidateOPKSSHToken,
|
||||
vi.mock("../../../hosts/step-ca-auth.js", () => ({
|
||||
getStepCaCert: mocks.getStepCaCert,
|
||||
invalidateStepCaCert: mocks.invalidateStepCaCert,
|
||||
}));
|
||||
vi.mock("../../../hosts/opkssh-cert-auth.js", () => ({
|
||||
setupOPKSSHCertAuth: mocks.setupOPKSSHCertAuth,
|
||||
setupCACertAuth: mocks.setupCACertAuth,
|
||||
vi.mock("@termix/plugin-sdk/ssh-certs", () => ({
|
||||
applyCertificateAuth: mocks.applyCertificateAuth,
|
||||
}));
|
||||
vi.mock("../../../hosts/vault-signer-auth.js", () => ({
|
||||
getVaultCert: mocks.getVaultCert,
|
||||
@@ -135,12 +133,9 @@ beforeEach(() => {
|
||||
config.agent = "agent-object";
|
||||
return { socketPath: "/tmp/agent.sock" };
|
||||
});
|
||||
mocks.setupOPKSSHCertAuth.mockImplementation(async (config) => {
|
||||
mocks.applyCertificateAuth.mockImplementation(async (config) => {
|
||||
config.authHandler = "cert-handler";
|
||||
});
|
||||
mocks.setupCACertAuth.mockImplementation(async (config) => {
|
||||
config.authHandler = "ca-handler";
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildConnectConfig per auth type", () => {
|
||||
@@ -186,7 +181,7 @@ describe("buildConnectConfig per auth type", () => {
|
||||
expect(Buffer.isBuffer(config.privateKey)).toBe(true);
|
||||
expect(config.passphrase).toBe("secret");
|
||||
expect(config.password).toBe("fallback");
|
||||
expect(mocks.setupCACertAuth).not.toHaveBeenCalled();
|
||||
expect(mocks.applyCertificateAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("key with a CA certificate grafts the certificate", async () => {
|
||||
@@ -198,12 +193,15 @@ describe("buildConnectConfig per auth type", () => {
|
||||
certPublicKey: "ssh-ed25519-cert-v01@openssh.com AAAA",
|
||||
}),
|
||||
);
|
||||
expect(mocks.setupCACertAuth).toHaveBeenCalledOnce();
|
||||
expect(config.authHandler).toBe("ca-handler");
|
||||
expect(mocks.applyCertificateAuth).toHaveBeenCalledOnce();
|
||||
expect(mocks.applyCertificateAuth.mock.calls[0][2]).toMatchObject({
|
||||
certificate: "ssh-ed25519-cert-v01@openssh.com AAAA",
|
||||
});
|
||||
expect(config.authHandler).toBe("cert-handler");
|
||||
});
|
||||
|
||||
it("key with a failing CA certificate falls back to key only", async () => {
|
||||
mocks.setupCACertAuth.mockRejectedValueOnce(new Error("bad cert"));
|
||||
mocks.applyCertificateAuth.mockRejectedValueOnce(new Error("bad cert"));
|
||||
const { outcome } = await build(
|
||||
host({ authType: "key", key: plainKey, certPublicKey: "junk" }),
|
||||
);
|
||||
@@ -275,27 +273,28 @@ describe("buildConnectConfig per auth type", () => {
|
||||
expect(config.tryKeyboard).toBe(true);
|
||||
});
|
||||
|
||||
it("opkssh without a cached token asks for a sign-in", async () => {
|
||||
mocks.getOPKSSHToken.mockResolvedValueOnce(null);
|
||||
const { outcome } = await build(host({ authType: "opkssh" }));
|
||||
// OPKSSH is a plugin-registered SSH auth provider now: see
|
||||
// plugins/opkssh/tests/backend/provider.test.ts.
|
||||
|
||||
it("stepca without a cached certificate asks for a sign-in", async () => {
|
||||
mocks.getStepCaCert.mockReturnValueOnce(null);
|
||||
const { outcome } = await build(host({ authType: "stepca" }));
|
||||
expect(outcome).toMatchObject({
|
||||
status: "interaction-required",
|
||||
interaction: "opkssh",
|
||||
interaction: "stepca",
|
||||
});
|
||||
expect(mocks.getOPKSSHToken).toHaveBeenCalledWith("user-1", 7);
|
||||
expect(mocks.getStepCaCert).toHaveBeenCalledWith("user-1", 7);
|
||||
});
|
||||
|
||||
it("opkssh and stepca use the cached certificate", async () => {
|
||||
for (const authType of ["opkssh", "stepca"]) {
|
||||
mocks.getOPKSSHToken.mockResolvedValueOnce({
|
||||
privateKey: "k",
|
||||
sshCert: "c",
|
||||
});
|
||||
const { config, outcome } = await build(host({ authType }));
|
||||
expect(outcome.status).toBe("ready");
|
||||
expect(config.authHandler).toBe("cert-handler");
|
||||
}
|
||||
expect(mocks.setupOPKSSHCertAuth).toHaveBeenCalledTimes(2);
|
||||
it("stepca uses the cached certificate", async () => {
|
||||
mocks.getStepCaCert.mockReturnValueOnce({
|
||||
privateKey: "k",
|
||||
sshCert: "c",
|
||||
});
|
||||
const { config, outcome } = await build(host({ authType: "stepca" }));
|
||||
expect(outcome.status).toBe("ready");
|
||||
expect(config.authHandler).toBe("cert-handler");
|
||||
expect(mocks.applyCertificateAuth).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("vault needs a profile, then a cached certificate", async () => {
|
||||
@@ -466,18 +465,18 @@ describe("connectHost", () => {
|
||||
});
|
||||
|
||||
it("throws the auth outcome instead of connecting", async () => {
|
||||
mocks.getOPKSSHToken.mockResolvedValueOnce(null);
|
||||
const attempt = connectHost(host({ authType: "opkssh" }), {
|
||||
mocks.getStepCaCert.mockReturnValue(null);
|
||||
const attempt = connectHost(host({ authType: "stepca" }), {
|
||||
userId: "user-1",
|
||||
purpose: "tmux",
|
||||
});
|
||||
await expect(attempt).rejects.toBeInstanceOf(SshConnectError);
|
||||
await expect(
|
||||
connectHost(host({ authType: "opkssh" }), {
|
||||
connectHost(host({ authType: "stepca" }), {
|
||||
userId: "user-1",
|
||||
purpose: "tmux",
|
||||
}).catch((error) => error.code),
|
||||
).resolves.toBe("opkssh-required");
|
||||
).resolves.toBe("stepca-required");
|
||||
});
|
||||
|
||||
it("goes through the jump chain and forwards to the target", async () => {
|
||||
@@ -629,22 +628,18 @@ describe("connectHost", () => {
|
||||
|
||||
it("lets the provider react to an auth failure", async () => {
|
||||
FakeSshClient.nextBehaviour = ["auth-fail"];
|
||||
mocks.getOPKSSHToken.mockResolvedValueOnce({
|
||||
mocks.getStepCaCert.mockReturnValueOnce({
|
||||
privateKey: "k",
|
||||
sshCert: "c",
|
||||
});
|
||||
await expect(
|
||||
connectHost(host({ authType: "opkssh" }), {
|
||||
connectHost(host({ authType: "stepca" }), {
|
||||
userId: "user-1",
|
||||
purpose: "fleet",
|
||||
}),
|
||||
).rejects.toThrow(/All configured authentication methods failed/);
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.invalidateOPKSSHToken).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
7,
|
||||
"SSH auth failed",
|
||||
),
|
||||
expect(mocks.invalidateStepCaCert).toHaveBeenCalledWith("user-1", 7),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { WebSocket } from "ws";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
sent: [] as Array<Record<string, unknown>>,
|
||||
upsert: vi.fn(async () => undefined),
|
||||
parseInfo: {
|
||||
publicKeyLine: "ssh-ed25519 TESTKEY",
|
||||
principals: ["alice"],
|
||||
@@ -19,15 +18,6 @@ const state = vi.hoisted(() => ({
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
sshLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../utils/data-crypto.js", () => ({
|
||||
DataCrypto: { getUserDataKey: () => Buffer.alloc(32, 1) },
|
||||
}));
|
||||
vi.mock("../../utils/field-crypto.js", () => ({
|
||||
FieldCrypto: {
|
||||
encryptField: (value: string, _key: Buffer, _id: string, field: string) =>
|
||||
`${field}:${value}`,
|
||||
},
|
||||
}));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) =>
|
||||
@@ -37,7 +27,6 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
step_ca_provisioner: "oidc",
|
||||
})[key],
|
||||
}),
|
||||
createCurrentOpksshTokenRepository: () => ({ upsert: state.upsert }),
|
||||
}));
|
||||
vi.mock("../../utils/step-ca-egress.js", () => ({
|
||||
readStepCaPrivateAllowlist: async () => [],
|
||||
@@ -92,8 +81,13 @@ vi.mock("../../hosts/step-ca-runtime.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const { cancelStepCaAuth, completeStepCaAuth, startStepCaAuth } =
|
||||
await import("../../hosts/step-ca-auth.js");
|
||||
const {
|
||||
cancelStepCaAuth,
|
||||
completeStepCaAuth,
|
||||
getStepCaCert,
|
||||
invalidateStepCaCert,
|
||||
startStepCaAuth,
|
||||
} = await import("../../hosts/step-ca-auth.js");
|
||||
|
||||
function jwt(payload: object): string {
|
||||
return `x.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.y`;
|
||||
@@ -124,7 +118,7 @@ async function start(): Promise<{ requestId: string; nonce: string }> {
|
||||
|
||||
beforeEach(() => {
|
||||
state.sent.length = 0;
|
||||
state.upsert.mockClear();
|
||||
invalidateStepCaCert("user-1", 7);
|
||||
state.runtimeComplete.mockClear();
|
||||
state.command = null;
|
||||
state.remoteResult = null;
|
||||
@@ -147,9 +141,11 @@ describe("Step CA authentication", () => {
|
||||
ok: true,
|
||||
message: "Signed in. You can close this window.",
|
||||
});
|
||||
expect(state.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: "user-1", hostId: 7 }),
|
||||
);
|
||||
expect(getStepCaCert("user-1", 7)).toEqual({
|
||||
sshCert: "CERT",
|
||||
privateKey: "PRIVATE",
|
||||
});
|
||||
expect(state.sent.at(-1)).toMatchObject({ type: "stepca_completed" });
|
||||
});
|
||||
|
||||
it("rejects a token without the requested nonce", async () => {
|
||||
@@ -159,7 +155,7 @@ describe("Step CA authentication", () => {
|
||||
const result = await completeStepCaAuth({ state: requestId, code: "code" });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).toMatch(/does not match/);
|
||||
expect(state.upsert).not.toHaveBeenCalled();
|
||||
expect(getStepCaCert("user-1", 7)).toBeNull();
|
||||
});
|
||||
|
||||
it("routes a callback through Redis to the instance holding the WebSocket", async () => {
|
||||
@@ -170,7 +166,7 @@ describe("Step CA authentication", () => {
|
||||
await vi.waitFor(() => expect(state.runtimeComplete).toHaveBeenCalled(), {
|
||||
timeout: 2000,
|
||||
});
|
||||
expect(state.upsert).toHaveBeenCalled();
|
||||
expect(getStepCaCert("user-1", 7)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("returns a result produced by another instance", async () => {
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const h = vi.hoisted(() => ({ granted: new Set<string>() }));
|
||||
|
||||
vi.mock("../../plugins/permissions.js", async () => {
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
return {
|
||||
assertCapability: async (
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => {
|
||||
if (!declared.includes(capability) || !h.granted.has(capability)) {
|
||||
throw new PluginCapabilityError(pluginId, capability);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { createPluginProcess } = await import("../../plugins/ctx-process.js");
|
||||
const { DisposableBag } = await import("../../plugins/disposables.js");
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
|
||||
const DECLARED = ["process:spawn", "network:outbound"];
|
||||
|
||||
function manifest(capabilities: string[] = DECLARED) {
|
||||
return {
|
||||
id: "fixture",
|
||||
name: "Fixture",
|
||||
version: "1.0.0",
|
||||
capabilities,
|
||||
} as never;
|
||||
}
|
||||
|
||||
function sha256(data: string): string {
|
||||
return createHash("sha256").update(data).digest("hex");
|
||||
}
|
||||
|
||||
let dir: string;
|
||||
let audits: Array<{ action: string; success: boolean }>;
|
||||
|
||||
function create(
|
||||
options: { capabilities?: string[]; fetch?: typeof fetch } = {},
|
||||
) {
|
||||
const bag = new DisposableBag("fixture");
|
||||
const process = createPluginProcess({
|
||||
manifest: manifest(options.capabilities),
|
||||
bag,
|
||||
audit: async (action, _details, outcome) => {
|
||||
audits.push({ action, success: outcome.success });
|
||||
},
|
||||
binDir: () => path.join(dir, "bin"),
|
||||
fetch: options.fetch,
|
||||
});
|
||||
return { process, bag };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
h.granted = new Set(DECLARED);
|
||||
audits = [];
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), "ctx-process-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("ctx.process.run", () => {
|
||||
it("runs a program and streams its output", async () => {
|
||||
const { process: proc } = create();
|
||||
const child = await proc.run(globalThis.process.execPath, [
|
||||
"-e",
|
||||
"process.stdout.write('hello'); process.stderr.write('oops')",
|
||||
]);
|
||||
let out = "";
|
||||
let err = "";
|
||||
child.onStdout((chunk) => (out += chunk));
|
||||
child.onStderr((chunk) => (err += chunk));
|
||||
await expect(child.exited).resolves.toEqual({ code: 0, signal: null });
|
||||
expect(out).toBe("hello");
|
||||
expect(err).toBe("oops");
|
||||
expect(audits).toContainEqual({ action: "process_run", success: true });
|
||||
});
|
||||
|
||||
it("refuses without process:spawn, and audits the refusal", async () => {
|
||||
h.granted = new Set();
|
||||
const { process: proc } = create();
|
||||
await expect(
|
||||
proc.run(globalThis.process.execPath, ["-e", ""]),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
expect(audits).toContainEqual({ action: "process_run", success: false });
|
||||
});
|
||||
|
||||
it("kills what is still running on deactivate", async () => {
|
||||
const { process: proc, bag } = create();
|
||||
const child = await proc.run(globalThis.process.execPath, [
|
||||
"-e",
|
||||
"setInterval(() => {}, 1000)",
|
||||
]);
|
||||
await bag.disposeAll();
|
||||
const exit = await child.exited;
|
||||
expect(exit.code === null || exit.signal !== null).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.process.ensureBinary", () => {
|
||||
const contents = "#!/bin/sh\necho hi\n";
|
||||
|
||||
it("uses a prebuilt copy whose checksum matches, without downloading", async () => {
|
||||
const prebuilt = path.join(dir, "prebuilt-tool");
|
||||
await fs.writeFile(prebuilt, contents);
|
||||
const fetch = vi.fn();
|
||||
const { process: proc } = create({ fetch: fetch as never });
|
||||
await expect(
|
||||
proc.ensureBinary({
|
||||
name: "tool",
|
||||
version: "1",
|
||||
url: "https://example.test/tool",
|
||||
sha256: sha256(contents),
|
||||
prebuilt: [path.join(dir, "missing"), prebuilt],
|
||||
}),
|
||||
).resolves.toBe(prebuilt);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("downloads once, verifies it and reuses the copy", async () => {
|
||||
const fetch = vi.fn(async () => new Response(contents));
|
||||
const { process: proc } = create({ fetch: fetch as never });
|
||||
const spec = {
|
||||
name: "tool",
|
||||
version: "1",
|
||||
url: "https://example.test/tool",
|
||||
sha256: sha256(contents),
|
||||
};
|
||||
const first = await proc.ensureBinary(spec);
|
||||
expect(first).toBe(path.join(dir, "bin", "tool"));
|
||||
expect(await fs.readFile(first, "utf8")).toBe(contents);
|
||||
await proc.ensureBinary(spec);
|
||||
expect(fetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("refuses a download whose checksum does not match", async () => {
|
||||
const fetch = vi.fn(async () => new Response("tampered"));
|
||||
const { process: proc } = create({ fetch: fetch as never });
|
||||
await expect(
|
||||
proc.ensureBinary({
|
||||
name: "tool",
|
||||
version: "1",
|
||||
url: "https://example.test/tool",
|
||||
sha256: sha256(contents),
|
||||
prebuilt: [],
|
||||
}),
|
||||
).rejects.toThrow(/Checksum mismatch/);
|
||||
await expect(fs.access(path.join(dir, "bin", "tool"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("needs network:outbound only to download", async () => {
|
||||
h.granted = new Set(["process:spawn"]);
|
||||
const { process: proc } = create({
|
||||
fetch: vi.fn(async () => new Response(contents)) as never,
|
||||
});
|
||||
await expect(
|
||||
proc.ensureBinary({
|
||||
name: "tool",
|
||||
version: "1",
|
||||
url: "https://example.test/tool",
|
||||
sha256: sha256(contents),
|
||||
}),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
});
|
||||
|
||||
it("refuses plain http and odd file names", async () => {
|
||||
const { process: proc } = create({
|
||||
fetch: vi.fn(async () => new Response(contents)) as never,
|
||||
});
|
||||
await expect(
|
||||
proc.ensureBinary({
|
||||
name: "tool",
|
||||
version: "1",
|
||||
url: "http://example.test/tool",
|
||||
sha256: sha256(contents),
|
||||
}),
|
||||
).rejects.toThrow(/https/);
|
||||
await expect(
|
||||
proc.ensureBinary({
|
||||
name: "../tool",
|
||||
version: "1",
|
||||
url: "https://example.test/tool",
|
||||
sha256: sha256(contents),
|
||||
}),
|
||||
).rejects.toThrow(/Invalid binary name/);
|
||||
});
|
||||
});
|
||||
@@ -104,6 +104,8 @@ const { createPluginAuth, createPluginSsh } =
|
||||
const { DisposableBag } = await import("../../plugins/disposables.js");
|
||||
const { getSshAuthProvider } =
|
||||
await import("../../hosts/connect/auth-provider-registry.js");
|
||||
const { classifyKeyboardInteractive } =
|
||||
await import("../../hosts/connect/keyboard-interactive.js");
|
||||
const { getLoginMethod, getSecondFactor } =
|
||||
await import("../../auth/registry.js");
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
@@ -341,6 +343,101 @@ describe("ctx.auth", () => {
|
||||
expect(getSshAuthProvider("corp")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("registers a keyboard-interactive handler that sees only its own host settings", async () => {
|
||||
const bag = new DisposableBag("fixture");
|
||||
const auth = createPluginAuth({
|
||||
manifest: manifest(["auth:provide"], { keyboardInteractive: ["gate"] }),
|
||||
bag,
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const seen: Array<Record<string, unknown>> = [];
|
||||
auth.registerKeyboardInteractiveHandler({
|
||||
id: "gate",
|
||||
label: "Gate",
|
||||
detect: (round, _host, settings) => {
|
||||
seen.push(settings);
|
||||
return /gate/i.test(round.name)
|
||||
? {
|
||||
kind: "browser",
|
||||
url: "https://gate",
|
||||
code: "AB12",
|
||||
instructions: "",
|
||||
}
|
||||
: null;
|
||||
},
|
||||
autoAnswerPasswords: (_host, settings) => settings.on === true,
|
||||
});
|
||||
|
||||
const target = {
|
||||
id: 1,
|
||||
ip: "10.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
password: "pw",
|
||||
pluginSettings: { fixture: { on: true }, other: { secret: "x" } },
|
||||
};
|
||||
expect(
|
||||
classifyKeyboardInteractive(
|
||||
{ name: "Gate sign-in", instructions: "", prompts: [] },
|
||||
target,
|
||||
),
|
||||
).toEqual({
|
||||
kind: "browser",
|
||||
id: "gate",
|
||||
label: "Gate",
|
||||
url: "https://gate",
|
||||
code: "AB12",
|
||||
instructions: "",
|
||||
});
|
||||
expect(seen[0]).toEqual({ on: true });
|
||||
expect(
|
||||
classifyKeyboardInteractive(
|
||||
{
|
||||
name: "",
|
||||
instructions: "",
|
||||
prompts: [{ prompt: "Password:", echo: false }],
|
||||
},
|
||||
target,
|
||||
),
|
||||
).toEqual({ kind: "auto", responses: ["pw"] });
|
||||
|
||||
await bag.disposeAll();
|
||||
expect(
|
||||
classifyKeyboardInteractive(
|
||||
{ name: "Gate sign-in", instructions: "", prompts: [] },
|
||||
target,
|
||||
).kind,
|
||||
).not.toBe("browser");
|
||||
});
|
||||
|
||||
it("refuses a keyboard-interactive handler that is not declared", () => {
|
||||
const auth = createPluginAuth({
|
||||
manifest: manifest(["auth:provide"], { keyboardInteractive: ["gate"] }),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(() =>
|
||||
auth.registerKeyboardInteractiveHandler({
|
||||
id: "other",
|
||||
label: "Other",
|
||||
detect: () => null,
|
||||
}),
|
||||
).toThrow(/contributes\.auth\.keyboardInteractive/);
|
||||
|
||||
const undeclared = createPluginAuth({
|
||||
manifest: manifest([], { keyboardInteractive: ["gate"] }),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(() =>
|
||||
undeclared.registerKeyboardInteractiveHandler({
|
||||
id: "gate",
|
||||
label: "Gate",
|
||||
detect: () => null,
|
||||
}),
|
||||
).toThrow(PluginCapabilityError);
|
||||
});
|
||||
|
||||
it("registers login methods and second factors under the plugin, and records enrolment", async () => {
|
||||
h.granted = new Set(["auth:provide"]);
|
||||
const bag = new DisposableBag("fixture");
|
||||
|
||||
@@ -234,6 +234,24 @@ describe("ctx.http authentication", () => {
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("opens everything below a trailing /* and nothing beside it", async () => {
|
||||
const router = http.createPluginRouter({
|
||||
manifest: manifest(),
|
||||
options: { public: ["/chooser/:id/*"] },
|
||||
reportError: () => {},
|
||||
});
|
||||
router.use("/chooser", (_req, res) => res.json({ ok: true }));
|
||||
router.get("/chooserx/a", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
const base = await startServer();
|
||||
const root = `${base}/plugin-api/sample-plugin`;
|
||||
|
||||
expect((await fetch(`${root}/chooser/abc`)).status).toBe(200);
|
||||
expect((await fetch(`${root}/chooser/abc/select/x`)).status).toBe(200);
|
||||
expect((await fetch(`${root}/chooser`)).status).toBe(401);
|
||||
expect((await fetch(`${root}/chooserx/a`)).status).toBe(401);
|
||||
});
|
||||
|
||||
it("gives the plugin the user core authenticated, not one the caller named", async () => {
|
||||
const router = http.createPluginRouter({
|
||||
manifest: manifest(),
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
import { promises as fs } from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { OPKSSHBinaryManager } from "../../utils/opkssh-binary-manager.js";
|
||||
|
||||
// Mirrors the manager's own platform/arch mapping so the test's fake release
|
||||
// asset matches whatever OS and architecture actually run the suite.
|
||||
const OS_MAP: Record<string, string> = {
|
||||
win32: "windows",
|
||||
linux: "linux",
|
||||
darwin: "osx",
|
||||
};
|
||||
const ARCH_MAP: Record<string, string> = {
|
||||
x64: "amd64",
|
||||
arm64: "arm64",
|
||||
};
|
||||
const mappedOs = OS_MAP[process.platform] ?? process.platform;
|
||||
const mappedArch = ARCH_MAP[process.arch] ?? process.arch;
|
||||
const binaryName =
|
||||
process.platform === "win32"
|
||||
? `opkssh-${mappedOs}-${mappedArch}.exe`
|
||||
: `opkssh-${mappedOs}-${mappedArch}`;
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "termix-opkssh-"));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
process.env.OPKSSH_VERSION = "v-test";
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.OPKSSH_VERSION;
|
||||
delete process.env.OPKSSH_SHA256;
|
||||
await fs.rm(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function mockRelease(binary: Buffer): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
tag_name: "v-test",
|
||||
assets: [
|
||||
{ name: binaryName, browser_download_url: "https://asset.test" },
|
||||
],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(binary, { status: 200 })),
|
||||
);
|
||||
}
|
||||
|
||||
describe("OPKSSHBinaryManager download integrity", () => {
|
||||
it("installs a release only after its configured checksum matches", async () => {
|
||||
const binary = Buffer.from("verified-opkssh-binary");
|
||||
process.env.OPKSSH_SHA256 = crypto
|
||||
.createHash("sha256")
|
||||
.update(binary)
|
||||
.digest("hex");
|
||||
mockRelease(binary);
|
||||
|
||||
await OPKSSHBinaryManager.downloadBinary();
|
||||
|
||||
const installDir = path.join(dataDir, "opkssh");
|
||||
expect(await fs.readFile(path.join(installDir, binaryName))).toEqual(
|
||||
binary,
|
||||
);
|
||||
expect(
|
||||
await fs.readFile(path.join(installDir, "version.txt"), "utf8"),
|
||||
).toBe("v-test");
|
||||
expect(
|
||||
await fs.readFile(path.join(installDir, "checksum.txt"), "utf8"),
|
||||
).toBe(process.env.OPKSSH_SHA256);
|
||||
});
|
||||
|
||||
it("rejects a mismatched release without replacing the installed binary", async () => {
|
||||
const installDir = path.join(dataDir, "opkssh");
|
||||
const binaryPath = path.join(installDir, binaryName);
|
||||
await fs.mkdir(installDir, { recursive: true });
|
||||
await fs.writeFile(binaryPath, "known-good");
|
||||
process.env.OPKSSH_SHA256 = "0".repeat(64);
|
||||
mockRelease(Buffer.from("tampered"));
|
||||
|
||||
await expect(OPKSSHBinaryManager.downloadBinary()).rejects.toThrow(
|
||||
"checksum mismatch",
|
||||
);
|
||||
expect(await fs.readFile(binaryPath, "utf8")).toBe("known-good");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* The OPKSSH config move. A 2.8 install's config must reach the opkssh
|
||||
* plugin's folder, the old file must stay for a downgrade, and the install
|
||||
* must keep the redirect URI its identity providers know. Running it twice
|
||||
* must not overwrite anything.
|
||||
*/
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
settings: new Map<string, string | null>(),
|
||||
pluginInstalled: true,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
state.pluginInstalled && id === "opkssh" ? { id } : null,
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (_p: string, _s: string, _id: string | null, key: string) =>
|
||||
state.settings.has(key) ? { key, value: state.settings.get(key) } : null,
|
||||
set: async (
|
||||
_p: string,
|
||||
_s: string,
|
||||
_id: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
state.settings.set(key, value);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import { runOpksshConfigMigration } from "../../utils/crypto-migration/opkssh-config-migration.js";
|
||||
|
||||
let dataDir: string;
|
||||
const legacy = () => path.join(dataDir, ".opk", "config.yml");
|
||||
const target = () => path.join(dataDir, "plugins", "opkssh", "config.yml");
|
||||
|
||||
beforeEach(async () => {
|
||||
state.settings = new Map();
|
||||
state.pluginInstalled = true;
|
||||
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "opkssh-config-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("runOpksshConfigMigration", () => {
|
||||
it("copies the config, keeps the old one and the old redirect URI, once", async () => {
|
||||
await fs.mkdir(path.dirname(legacy()), { recursive: true });
|
||||
await fs.writeFile(legacy(), "providers:\n - alias: google\n");
|
||||
|
||||
expect(await runOpksshConfigMigration(dataDir)).toEqual({
|
||||
copied: true,
|
||||
legacyCallback: true,
|
||||
});
|
||||
expect(await fs.readFile(target(), "utf8")).toContain("alias: google");
|
||||
expect(await fs.readFile(legacy(), "utf8")).toContain("alias: google");
|
||||
expect(state.settings.get("legacyCallback")).toBe("true");
|
||||
|
||||
await fs.writeFile(target(), "providers:\n - alias: edited\n");
|
||||
state.settings.set("legacyCallback", "false");
|
||||
expect(await runOpksshConfigMigration(dataDir)).toEqual({
|
||||
copied: false,
|
||||
legacyCallback: false,
|
||||
});
|
||||
expect(await fs.readFile(target(), "utf8")).toContain("alias: edited");
|
||||
expect(state.settings.get("legacyCallback")).toBe("false");
|
||||
});
|
||||
|
||||
it("does nothing on an install that never set up OPKSSH", async () => {
|
||||
expect(await runOpksshConfigMigration(dataDir)).toEqual({
|
||||
copied: false,
|
||||
legacyCallback: false,
|
||||
});
|
||||
expect(state.settings.size).toBe(0);
|
||||
});
|
||||
|
||||
it("waits for the plugin row", async () => {
|
||||
state.pluginInstalled = false;
|
||||
await fs.mkdir(path.dirname(legacy()), { recursive: true });
|
||||
await fs.writeFile(legacy(), "providers: []\n");
|
||||
await runOpksshConfigMigration(dataDir);
|
||||
await expect(fs.access(target())).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* The Warpgate host settings migration.
|
||||
*
|
||||
* Every host with use_warpgate on, whatever shape the engine returned it in,
|
||||
* and every pre-2.7 host still on auth type "warpgate", must come through
|
||||
* into the warpgate plugin's useWarpgate host setting. Running it twice must
|
||||
* change nothing.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
pluginRows: [] as Array<{
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}>,
|
||||
hostRows: [] as Array<Record<string, unknown>>,
|
||||
statements: [] as string[],
|
||||
pluginInstalled: true,
|
||||
columnGone: false,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
state.pluginInstalled && id === "warpgate" ? { id } : null,
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) =>
|
||||
state.pluginRows.find(
|
||||
(row) =>
|
||||
row.scope === scope && row.scopeId === scopeId && row.key === key,
|
||||
) ?? null,
|
||||
set: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
state.pluginRows.push({ scope, scopeId, key, value });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
|
||||
selectRows: async (query: { queryChunks?: unknown[] }) => {
|
||||
const text = JSON.stringify(query);
|
||||
if (text.includes("use_warpgate") && state.columnGone) {
|
||||
throw new Error("no such column: use_warpgate");
|
||||
}
|
||||
return state.hostRows.filter(
|
||||
(row) => !text.includes("WHERE") || row.auth_type === "warpgate",
|
||||
);
|
||||
},
|
||||
runStatement: async (query: unknown) => {
|
||||
const text = JSON.stringify(query);
|
||||
state.statements.push(text);
|
||||
const id = (query as { queryChunks: unknown[] }).queryChunks.find(
|
||||
(chunk) => typeof chunk === "number",
|
||||
);
|
||||
const row = state.hostRows.find((candidate) => candidate.id === id);
|
||||
if (row) row.auth_type = "none";
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import {
|
||||
runWarpgateSettingsMigration,
|
||||
usesWarpgate,
|
||||
} from "../../utils/crypto-migration/warpgate-settings-migration.js";
|
||||
|
||||
const enabled = (hostId: number) =>
|
||||
state.pluginRows.some(
|
||||
(row) =>
|
||||
row.scopeId === String(hostId) &&
|
||||
row.key === "useWarpgate" &&
|
||||
row.value === "true",
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
state.pluginRows = [];
|
||||
state.hostRows = [];
|
||||
state.statements = [];
|
||||
state.pluginInstalled = true;
|
||||
state.columnGone = false;
|
||||
});
|
||||
|
||||
describe("usesWarpgate", () => {
|
||||
it("reads the flag in every shape the engines return", () => {
|
||||
for (const flag of [true, 1, "1", "true"]) {
|
||||
expect(
|
||||
usesWarpgate({ id: 1, use_warpgate: flag, auth_type: "none" }),
|
||||
).toBe(true);
|
||||
}
|
||||
for (const flag of [false, 0, "0", null]) {
|
||||
expect(
|
||||
usesWarpgate({ id: 1, use_warpgate: flag, auth_type: "none" }),
|
||||
).toBe(false);
|
||||
}
|
||||
expect(usesWarpgate({ id: 1, auth_type: "warpgate" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runWarpgateSettingsMigration", () => {
|
||||
it("moves every flagged host and fixes the old auth type, and a second run changes nothing", async () => {
|
||||
state.hostRows = [
|
||||
{ id: 1, use_warpgate: 1, auth_type: "password" },
|
||||
{ id: 2, use_warpgate: 0, auth_type: "key" },
|
||||
{ id: 3, use_warpgate: true, auth_type: "none" },
|
||||
{ id: 4, use_warpgate: 0, auth_type: "warpgate" },
|
||||
];
|
||||
|
||||
const first = await runWarpgateSettingsMigration();
|
||||
expect(first).toEqual({
|
||||
hostsMoved: 3,
|
||||
hostsSkipped: 0,
|
||||
authTypesFixed: 1,
|
||||
});
|
||||
expect(enabled(1)).toBe(true);
|
||||
expect(enabled(2)).toBe(false);
|
||||
expect(enabled(3)).toBe(true);
|
||||
expect(enabled(4)).toBe(true);
|
||||
expect(state.hostRows[3].auth_type).toBe("none");
|
||||
|
||||
const rows = state.pluginRows.length;
|
||||
const second = await runWarpgateSettingsMigration();
|
||||
// Host 4 is on auth type "none" now, so only the flagged two are seen.
|
||||
expect(second).toEqual({
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 2,
|
||||
authTypesFixed: 0,
|
||||
});
|
||||
expect(state.pluginRows).toHaveLength(rows);
|
||||
});
|
||||
|
||||
it("still fixes old auth types on an install without the column", async () => {
|
||||
state.columnGone = true;
|
||||
state.hostRows = [{ id: 5, auth_type: "warpgate" }];
|
||||
await runWarpgateSettingsMigration();
|
||||
expect(enabled(5)).toBe(true);
|
||||
expect(state.hostRows[0].auth_type).toBe("none");
|
||||
});
|
||||
|
||||
it("waits for the plugin row", async () => {
|
||||
state.pluginInstalled = false;
|
||||
state.hostRows = [{ id: 1, use_warpgate: 1, auth_type: "password" }];
|
||||
await runWarpgateSettingsMigration();
|
||||
expect(state.pluginRows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user