feat: convert secret-sources into a plugin (B20)

This commit is contained in:
LukeGus committed 2026-09-24 16:20:20 -05:00
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();
});
});