mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: convert secret-sources into a plugin (B20)
This commit is contained in:
1 parent
2ac7779eda
commit
f4f26a9b8b
72 files changed
+18843
-933
No files matched your search
@@ -0,0 +1,103 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express, { type Router } from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type MockPluginContext,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
|
||||
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
|
||||
export const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
export const ALL_PERMISSIONS = [
|
||||
"credentials.view",
|
||||
"credentials.create",
|
||||
"credentials.edit",
|
||||
"credentials.delete",
|
||||
];
|
||||
|
||||
export interface TestServer {
|
||||
db: TestDb;
|
||||
mock: MockPluginContext;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { user?: string; body?: unknown },
|
||||
// Route bodies vary per test; asserting on them is the point.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<{ status: number; body: any }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin activated against a real in-memory database, its router mounted
|
||||
* on an express app the way core mounts it: JSON parsed, the acting user set
|
||||
* from the request, everything else left to the plugin.
|
||||
*/
|
||||
export async function startServer(
|
||||
options: {
|
||||
permissions?: string[];
|
||||
users?: string[];
|
||||
fetch?: (url: string, init?: unknown) => Promise<Response>;
|
||||
} = {},
|
||||
): Promise<TestServer> {
|
||||
const db = await createTestDb(pluginDir);
|
||||
for (const user of options.users ?? ["user-1", "user-2"]) {
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
|
||||
.run(user, user);
|
||||
}
|
||||
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
db: db.database,
|
||||
router: () => (router = express.Router()),
|
||||
permissions: options.permissions ?? ALL_PERMISSIONS,
|
||||
fetch: options.fetch as never,
|
||||
});
|
||||
|
||||
const { activate } = await import("../../src/backend/index.js");
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") ?? undefined);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
db,
|
||||
mock,
|
||||
async request(method, path, { user = "user-1", body: given } = {}) {
|
||||
const body = method === "GET" ? undefined : given;
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"x-test-user": user,
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
return { status: response.status, body: text ? JSON.parse(text) : null };
|
||||
},
|
||||
async close() {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
parseSecretReference,
|
||||
resolveConnectReference,
|
||||
testConnectSource,
|
||||
} from "../../src/backend/onepassword-connect.js";
|
||||
import type { PluginFetch } from "@termix/plugin-sdk/backend";
|
||||
|
||||
function jsonResponse(body: unknown, ok = true, status = 200): Response {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe("1Password secret references", () => {
|
||||
it("parses op://vault/item/field, ignoring a query suffix", () => {
|
||||
expect(parseSecretReference("op://Infra/prod-db/password")).toEqual({
|
||||
vault: "Infra",
|
||||
item: "prod-db",
|
||||
field: "password",
|
||||
});
|
||||
expect(
|
||||
parseSecretReference(
|
||||
"op://Infra/deploy key/private key?ssh-format=openssh",
|
||||
),
|
||||
).toEqual({ vault: "Infra", item: "deploy key", field: "private key" });
|
||||
expect(parseSecretReference("op://Infra/only-two")).toBeNull();
|
||||
expect(parseSecretReference("https://x")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("testConnectSource", () => {
|
||||
it("returns the vault count when the token is valid", async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse([
|
||||
{ id: "v1", name: "Infra" },
|
||||
{ id: "v2", name: "Dev" },
|
||||
]),
|
||||
) as unknown as PluginFetch;
|
||||
|
||||
const count = await testConnectSource(fetch, {
|
||||
baseUrl: "https://connect.internal",
|
||||
token: "tok",
|
||||
allowedPrivateHosts: ["connect.internal"],
|
||||
});
|
||||
expect(count).toBe(2);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"https://connect.internal/v1/vaults",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
allowPrivateHosts: ["connect.internal"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when the server answers with a non-2xx status", async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse([], false, 401),
|
||||
) as unknown as PluginFetch;
|
||||
|
||||
await expect(
|
||||
testConnectSource(fetch, {
|
||||
baseUrl: "https://connect.internal",
|
||||
token: "bad",
|
||||
allowedPrivateHosts: [],
|
||||
}),
|
||||
).rejects.toThrow(/HTTP 401/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveConnectReference", () => {
|
||||
it("resolves a field by label across vault, item and field lookups", async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse([{ id: "vid", name: "Infra" }]))
|
||||
.mockResolvedValueOnce(jsonResponse([{ id: "iid", title: "prod-db" }]))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
fields: [{ id: "f1", label: "password", value: "s3cret" }],
|
||||
}),
|
||||
) as unknown as PluginFetch;
|
||||
|
||||
const value = await resolveConnectReference(
|
||||
fetch,
|
||||
{
|
||||
baseUrl: "https://connect.internal",
|
||||
token: "tok",
|
||||
allowedPrivateHosts: [],
|
||||
},
|
||||
{ vault: "Infra", item: "prod-db", field: "password" },
|
||||
);
|
||||
expect(value).toBe("s3cret");
|
||||
});
|
||||
|
||||
it("fails clearly when the vault is not found", async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse([]))
|
||||
.mockResolvedValueOnce(jsonResponse([])) as unknown as PluginFetch;
|
||||
|
||||
await expect(
|
||||
resolveConnectReference(
|
||||
fetch,
|
||||
{
|
||||
baseUrl: "https://connect.internal",
|
||||
token: "tok",
|
||||
allowedPrivateHosts: [],
|
||||
},
|
||||
{ vault: "Missing", item: "x", field: "y" },
|
||||
),
|
||||
).rejects.toThrow(/vault "Missing" not found/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createMockCtx, createTestDb } from "@termix/plugin-sdk/testing";
|
||||
import { createSecretSourceRepository } from "../../src/backend/repository.js";
|
||||
import { createTokenStore } from "../../src/backend/token-store.js";
|
||||
import { registerOnePasswordResolver } from "../../src/backend/resolver.js";
|
||||
import { sources } from "../../src/backend/tables.js";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => body,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
async function setUp(fetchMock: ReturnType<typeof vi.fn>) {
|
||||
const db = await createTestDb(pluginDir);
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
|
||||
.run("owner-1", "owner-1");
|
||||
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
db: db.database,
|
||||
actor: "owner-1",
|
||||
fetch: fetchMock as never,
|
||||
});
|
||||
|
||||
const table = await mock.ctx.db.define(sources);
|
||||
const repository = createSecretSourceRepository(mock.ctx.db, table as never);
|
||||
const tokenStore = createTokenStore(mock.ctx);
|
||||
registerOnePasswordResolver(mock.ctx, repository, tokenStore);
|
||||
|
||||
const resolve = mock.auth.secretResolvers.get("op");
|
||||
if (!resolve) throw new Error("resolver was not registered");
|
||||
|
||||
return { db, repository, tokenStore, resolve };
|
||||
}
|
||||
|
||||
describe("op:// secret resolver", () => {
|
||||
it("registers under the declared scheme", async () => {
|
||||
const { resolve } = await setUp(vi.fn());
|
||||
expect(typeof resolve).toBe("function");
|
||||
});
|
||||
|
||||
it("resolves through the owner's source", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse([{ id: "vid", name: "Infra" }]))
|
||||
.mockResolvedValueOnce(jsonResponse([{ id: "iid", title: "prod-db" }]))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
fields: [{ id: "f1", label: "password", value: "s3cret" }],
|
||||
}),
|
||||
);
|
||||
const { repository, tokenStore, resolve } = await setUp(fetchMock);
|
||||
|
||||
const source = await repository.create({
|
||||
id: "src-1",
|
||||
userId: "owner-1",
|
||||
name: "n",
|
||||
kind: "onepassword-connect",
|
||||
baseUrl: "https://connect.internal",
|
||||
shared: false,
|
||||
});
|
||||
await tokenStore.set(source.id, "tok");
|
||||
|
||||
const value = await resolve("owner-1", "op://Infra/prod-db/password");
|
||||
expect(value).toBe("s3cret");
|
||||
});
|
||||
|
||||
it("fails clearly with no configured source", async () => {
|
||||
const { resolve } = await setUp(vi.fn());
|
||||
await expect(
|
||||
resolve("nobody", "op://Infra/prod-db/password"),
|
||||
).rejects.toThrow(/no secret source is configured/);
|
||||
});
|
||||
|
||||
it("rejects a malformed reference", async () => {
|
||||
const { resolve } = await setUp(vi.fn());
|
||||
await expect(resolve("owner-1", "op://only-two")).rejects.toThrow(
|
||||
/Invalid secret reference/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { startServer, type TestServer } from "./helpers.js";
|
||||
|
||||
function jsonResponse(body: unknown, ok = true, status = 200): Response {
|
||||
return { ok, status, json: async () => body } as unknown as Response;
|
||||
}
|
||||
|
||||
let server: TestServer;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
describe("secret-sources routes", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startServer();
|
||||
});
|
||||
|
||||
it("refuses a request without the matching core permission", async () => {
|
||||
await server.close();
|
||||
server = await startServer({ permissions: [] });
|
||||
const res = await server.request("GET", "/");
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("creates, lists and hides the token from the owner's own list", async () => {
|
||||
const create = await server.request("POST", "/", {
|
||||
body: {
|
||||
name: "Team 1Password",
|
||||
baseUrl: "https://connect.internal",
|
||||
token: "s3cret-token",
|
||||
},
|
||||
});
|
||||
expect(create.status).toBe(200);
|
||||
expect(create.body.source).toMatchObject({
|
||||
name: "Team 1Password",
|
||||
baseUrl: "https://connect.internal",
|
||||
owned: true,
|
||||
shared: false,
|
||||
});
|
||||
expect(create.body.source.token).toBeUndefined();
|
||||
|
||||
const list = await server.request("GET", "/");
|
||||
expect(list.body.sources).toHaveLength(1);
|
||||
expect(list.body.sources[0].owned).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a create missing a required field", async () => {
|
||||
const res = await server.request("POST", "/", {
|
||||
body: { name: "", baseUrl: "https://x", token: "t" },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects an http(s)-only baseUrl", async () => {
|
||||
const res = await server.request("POST", "/", {
|
||||
body: { name: "n", baseUrl: "ftp://x", token: "t" },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("refuses shared: true for a non-admin", async () => {
|
||||
const res = await server.request("POST", "/", {
|
||||
body: {
|
||||
name: "n",
|
||||
baseUrl: "https://connect.internal",
|
||||
token: "t",
|
||||
shared: true,
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("allows shared: true for an admin", async () => {
|
||||
await server.close();
|
||||
server = await startServer({
|
||||
permissions: [
|
||||
"credentials.view",
|
||||
"credentials.create",
|
||||
"credentials.edit",
|
||||
"credentials.delete",
|
||||
"admin.plugins.manage",
|
||||
],
|
||||
});
|
||||
const res = await server.request("POST", "/", {
|
||||
body: {
|
||||
name: "n",
|
||||
baseUrl: "https://connect.internal",
|
||||
token: "t",
|
||||
shared: true,
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.source.shared).toBe(true);
|
||||
});
|
||||
|
||||
it("shows a shared source to another user, unowned", async () => {
|
||||
await server.close();
|
||||
server = await startServer({
|
||||
permissions: [
|
||||
"credentials.view",
|
||||
"credentials.create",
|
||||
"credentials.edit",
|
||||
"credentials.delete",
|
||||
"admin.plugins.manage",
|
||||
],
|
||||
});
|
||||
await server.request("POST", "/", {
|
||||
body: { name: "n", baseUrl: "https://x", token: "t", shared: true },
|
||||
});
|
||||
const list = await server.request("GET", "/", { user: "user-2" });
|
||||
expect(list.body.sources).toHaveLength(1);
|
||||
expect(list.body.sources[0].owned).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses an update or delete from a non-owner", async () => {
|
||||
const created = await server.request("POST", "/", {
|
||||
body: { name: "n", baseUrl: "https://x", token: "t" },
|
||||
});
|
||||
const id = created.body.source.id;
|
||||
|
||||
const update = await server.request("PUT", `/${id}`, {
|
||||
user: "user-2",
|
||||
body: { name: "hijacked" },
|
||||
});
|
||||
expect(update.status).toBe(403);
|
||||
|
||||
const del = await server.request("DELETE", `/${id}`, { user: "user-2" });
|
||||
expect(del.status).toBe(403);
|
||||
});
|
||||
|
||||
it("updates without a token keeping the existing one, and deletes cleanly", async () => {
|
||||
const created = await server.request("POST", "/", {
|
||||
body: { name: "n", baseUrl: "https://x", token: "orig-token" },
|
||||
});
|
||||
const id = created.body.source.id;
|
||||
|
||||
const update = await server.request("PUT", `/${id}`, {
|
||||
body: { name: "renamed" },
|
||||
});
|
||||
expect(update.status).toBe(200);
|
||||
|
||||
const test = await server.request("POST", `/${id}/test`);
|
||||
// Reaches out through ctx.fetch, unstubbed here, so it fails softly.
|
||||
expect(test.status).toBe(200);
|
||||
expect(test.body.ok).toBe(false);
|
||||
|
||||
const del = await server.request("DELETE", `/${id}`);
|
||||
expect(del.status).toBe(200);
|
||||
|
||||
const list = await server.request("GET", "/");
|
||||
expect(list.body.sources).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("tests a source through ctx.fetch and reports the vault count", async () => {
|
||||
await server.close();
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse([{ id: "v1", name: "Infra" }]));
|
||||
server = await startServer({ fetch: fetchMock });
|
||||
|
||||
const created = await server.request("POST", "/", {
|
||||
body: { name: "n", baseUrl: "https://connect.internal", token: "tok" },
|
||||
});
|
||||
const id = created.body.source.id;
|
||||
|
||||
const test = await server.request("POST", `/${id}/test`);
|
||||
expect(test.status).toBe(200);
|
||||
expect(test.body).toEqual({ ok: true, vaults: 1 });
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://connect.internal/v1/vaults",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createMockCtx } from "@termix/plugin-sdk/testing";
|
||||
import { createTokenStore } from "../../src/backend/token-store.js";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
function mockCtx(actor?: string) {
|
||||
return createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
actor,
|
||||
});
|
||||
}
|
||||
|
||||
describe("token store", () => {
|
||||
it("stores and reads back a token for the acting user", async () => {
|
||||
const { ctx } = mockCtx("owner-1");
|
||||
const store = createTokenStore(ctx);
|
||||
|
||||
await store.set("src-1", "s3cret");
|
||||
await expect(store.getForOwner("src-1", "owner-1")).resolves.toBe("s3cret");
|
||||
});
|
||||
|
||||
it("reads the owner's token for a caller currently acting as someone else", async () => {
|
||||
const { ctx, setActor } = mockCtx("owner-1");
|
||||
const store = createTokenStore(ctx);
|
||||
await store.set("src-1", "s3cret");
|
||||
|
||||
// A shared source's test/resolve runs as the recipient viewing it.
|
||||
setActor("recipient-1");
|
||||
await expect(store.getForOwner("src-1", "owner-1")).resolves.toBe("s3cret");
|
||||
// ctx.asUser restores the previous actor once it returns.
|
||||
expect(ctx.currentActor()).toBe("recipient-1");
|
||||
});
|
||||
|
||||
it("clears a token", async () => {
|
||||
const { ctx } = mockCtx("owner-1");
|
||||
const store = createTokenStore(ctx);
|
||||
await store.set("src-1", "s3cret");
|
||||
await store.clear("src-1");
|
||||
await expect(store.getForOwner("src-1", "owner-1")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import { PluginComponent } from "@termix/plugin-sdk/ui";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import * as plugin from "../../src/frontend/index";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import locales from "../../locales/en.json";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
let rendered: RenderedPluginApp | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await rendered?.deactivate();
|
||||
rendered = null;
|
||||
});
|
||||
|
||||
describe(`${manifest.id} activate`, () => {
|
||||
it("registers the hint and manager components", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
render(
|
||||
<PluginComponent
|
||||
id="secret-sources.hint"
|
||||
onManage={vi.fn()}
|
||||
fallback={<span>fallback</span>}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByText(locales.hint)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the manager and can be closed", async () => {
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: {
|
||||
get: vi.fn(async () => ({ data: { sources: [] } })),
|
||||
} as never,
|
||||
});
|
||||
const onClose = vi.fn();
|
||||
render(<PluginComponent id="secret-sources.manager" onClose={onClose} />);
|
||||
expect(await screen.findByText(locales.title)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("removes both components on deactivate", async () => {
|
||||
const app = await renderWithApp(plugin, { manifest, locales });
|
||||
await app.deactivate();
|
||||
render(
|
||||
<PluginComponent id="secret-sources.hint" fallback={<span>gone</span>} />,
|
||||
);
|
||||
expect(await screen.findByText("gone")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user