mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: harden plugin api for separate repos, signed updates and generic external login
This commit is contained in:
1 parent
fb9c168b6b
commit
80944301dd
310 files changed
+6577
-4927
No files matched your search
@@ -43,7 +43,7 @@ export function fakeFactory(state: AuthState) {
|
||||
findById: async (id: string) => state.users.get(id) ?? null,
|
||||
findByUsername: async (username: string) =>
|
||||
[...state.users.values()].find((u) => u.username === username) ?? null,
|
||||
findByOidcIdentifier: async (identifier: string) =>
|
||||
findByExternalIdentifier: async (identifier: string) =>
|
||||
[...state.users.values()].find(
|
||||
(u) => u.oidcIdentifier === identifier,
|
||||
) ?? null,
|
||||
@@ -206,10 +206,10 @@ export function fakeAuthManager(state: AuthState) {
|
||||
return userId ? { userId, pendingTOTP: true } : null;
|
||||
}),
|
||||
authenticateUser: vi.fn(async () => true),
|
||||
authenticateOIDCUser: vi.fn(async () => true),
|
||||
revokeSessionsByOidc: vi.fn(async () => 1),
|
||||
authenticateExternalUser: vi.fn(async () => true),
|
||||
revokeSessionsByExternalSession: vi.fn(async () => 1),
|
||||
unlockWithSystemKey: vi.fn(async () => true),
|
||||
registerOIDCUser: vi.fn(async () => {}),
|
||||
registerExternalUser: vi.fn(async () => {}),
|
||||
isTrustedDevice: vi.fn(async (userId: string, fingerprint: string) =>
|
||||
state.trusted.has(`${userId}:${fingerprint}`),
|
||||
),
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { PluginInstallCountRepository } from "../../../database/repositories/plugin-install-count-repository.js";
|
||||
|
||||
describe("PluginInstallCountRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<PluginInstallCountRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
return new PluginInstallCountRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("inserts a count on first upsert", async () => {
|
||||
let writes = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
const created = await repo.upsert({
|
||||
pluginId: "plugin-a",
|
||||
registryId: "registry-official",
|
||||
count: 42,
|
||||
source: "github-releases",
|
||||
});
|
||||
|
||||
expect(created).toMatchObject({
|
||||
pluginId: "plugin-a",
|
||||
registryId: "registry-official",
|
||||
count: 42,
|
||||
source: "github-releases",
|
||||
});
|
||||
expect(writes).toBe(1);
|
||||
});
|
||||
|
||||
it("updates the existing row on a repeat upsert for the same plugin/registry pair", async () => {
|
||||
let writes = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
const first = await repo.upsert({
|
||||
pluginId: "plugin-a",
|
||||
registryId: "registry-official",
|
||||
count: 10,
|
||||
});
|
||||
|
||||
const second = await repo.upsert({
|
||||
pluginId: "plugin-a",
|
||||
registryId: "registry-official",
|
||||
count: 25,
|
||||
});
|
||||
|
||||
expect(second.id).toBe(first.id);
|
||||
expect(second.count).toBe(25);
|
||||
expect(writes).toBe(2);
|
||||
|
||||
const rows = await repo.listByPlugin("plugin-a");
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps counts from different registries for the same plugin separate", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.upsert({
|
||||
pluginId: "plugin-a",
|
||||
registryId: "registry-official",
|
||||
count: 10,
|
||||
});
|
||||
await repo.upsert({
|
||||
pluginId: "plugin-a",
|
||||
registryId: "registry-community",
|
||||
count: 5,
|
||||
});
|
||||
|
||||
const rows = await repo.listByPlugin("plugin-a");
|
||||
expect(rows.map((r) => r.registryId).sort()).toEqual([
|
||||
"registry-community",
|
||||
"registry-official",
|
||||
]);
|
||||
|
||||
expect(
|
||||
await repo.findByPluginAndRegistry("plugin-a", "registry-official"),
|
||||
).toMatchObject({ count: 10 });
|
||||
expect(
|
||||
await repo.findByPluginAndRegistry("plugin-a", "missing-registry"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes all counts for a plugin", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.upsert({
|
||||
pluginId: "plugin-a",
|
||||
registryId: "registry-official",
|
||||
count: 10,
|
||||
});
|
||||
await repo.upsert({
|
||||
pluginId: "plugin-a",
|
||||
registryId: "registry-community",
|
||||
count: 5,
|
||||
});
|
||||
|
||||
expect(await repo.deleteByPlugin("plugin-a")).toBe(2);
|
||||
expect(await repo.listByPlugin("plugin-a")).toEqual([]);
|
||||
expect(await repo.deleteByPlugin("plugin-a")).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { PluginRegistryRepository } from "../../../database/repositories/plugin-registry-repository.js";
|
||||
|
||||
describe("PluginRegistryRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<PluginRegistryRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
return new PluginRegistryRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("creates, finds, and lists registries", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.create({
|
||||
id: "registry-official",
|
||||
name: "Official Registry",
|
||||
url: "https://plugins.termix.site/index.json",
|
||||
kind: "official",
|
||||
});
|
||||
await repo.create({
|
||||
id: "registry-custom",
|
||||
name: "Custom Registry",
|
||||
url: "https://example.com/index.json",
|
||||
});
|
||||
|
||||
const found = await repo.findById("registry-official");
|
||||
expect(found).toMatchObject({
|
||||
id: "registry-official",
|
||||
name: "Official Registry",
|
||||
kind: "official",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const custom = await repo.findById("registry-custom");
|
||||
expect(custom).toMatchObject({ kind: "community", enabled: true });
|
||||
|
||||
const all = await repo.listAll();
|
||||
expect(all.map((r) => r.id).sort()).toEqual([
|
||||
"registry-custom",
|
||||
"registry-official",
|
||||
]);
|
||||
expect(await repo.findById("missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("updates only the provided fields", async () => {
|
||||
let writes = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
await repo.create({
|
||||
id: "registry-custom",
|
||||
name: "Custom Registry",
|
||||
url: "https://example.com/index.json",
|
||||
});
|
||||
expect(writes).toBe(1);
|
||||
|
||||
const updated = await repo.update("registry-custom", {
|
||||
enabled: false,
|
||||
lastCheckedAt: "2026-01-01T00:00:00.000Z",
|
||||
lastIndexHash: "abc123",
|
||||
});
|
||||
|
||||
expect(updated).toMatchObject({
|
||||
id: "registry-custom",
|
||||
name: "Custom Registry",
|
||||
enabled: false,
|
||||
lastCheckedAt: "2026-01-01T00:00:00.000Z",
|
||||
lastIndexHash: "abc123",
|
||||
});
|
||||
expect(writes).toBe(2);
|
||||
|
||||
expect(await repo.update("missing", { enabled: false })).toBeNull();
|
||||
expect(writes).toBe(2);
|
||||
});
|
||||
|
||||
it("deletes a registry and reports whether a row was removed", async () => {
|
||||
let writes = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
await repo.create({
|
||||
id: "registry-custom",
|
||||
name: "Custom Registry",
|
||||
url: "https://example.com/index.json",
|
||||
});
|
||||
expect(writes).toBe(1);
|
||||
|
||||
expect(await repo.delete("missing")).toBe(false);
|
||||
expect(writes).toBe(1);
|
||||
|
||||
expect(await repo.delete("registry-custom")).toBe(true);
|
||||
expect(writes).toBe(2);
|
||||
expect(await repo.findById("registry-custom")).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes all registries and returns the count removed", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.create({
|
||||
id: "registry-a",
|
||||
name: "A",
|
||||
url: "https://a.example.com/index.json",
|
||||
});
|
||||
await repo.create({
|
||||
id: "registry-b",
|
||||
name: "B",
|
||||
url: "https://b.example.com/index.json",
|
||||
});
|
||||
|
||||
expect(await repo.deleteAll()).toBe(2);
|
||||
expect(await repo.listAll()).toEqual([]);
|
||||
expect(await repo.deleteAll()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -67,7 +67,7 @@ describe("UserRepository and SessionRepository", () => {
|
||||
oidcIdentifier: "oidc:admin",
|
||||
});
|
||||
expect(updated?.oidcIdentifier).toBe("oidc:admin");
|
||||
expect((await repo.users.findByOidcIdentifier("oidc:admin"))?.id).toBe(
|
||||
expect((await repo.users.findByExternalIdentifier("oidc:admin"))?.id).toBe(
|
||||
"user-1",
|
||||
);
|
||||
|
||||
|
||||
+35
-3
@@ -40,6 +40,8 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
},
|
||||
}),
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
listIdentitiesForUser: async (userId: string) =>
|
||||
h.identities.filter((row) => row.userId === userId),
|
||||
moveIdentities: async (from: string, to: string) => {
|
||||
for (const row of h.identities) if (row.userId === from) row.userId = to;
|
||||
},
|
||||
@@ -68,8 +70,8 @@ vi.mock("../../../utils/logger.js", () => {
|
||||
return { authLogger: log };
|
||||
});
|
||||
|
||||
const { registerUserOidcAccountRoutes } =
|
||||
await import("../../../database/routes/user-oidc-account-routes.js");
|
||||
const { registerUserExternalAccountRoutes } =
|
||||
await import("../../../database/routes/user-external-account-routes.js");
|
||||
|
||||
let server: http.Server;
|
||||
let base: string;
|
||||
@@ -114,7 +116,7 @@ beforeEach(async () => {
|
||||
|
||||
const router = express.Router();
|
||||
router.use(express.json());
|
||||
registerUserOidcAccountRoutes(router, {
|
||||
registerUserExternalAccountRoutes(router, {
|
||||
authenticateJWT: (req, _res, next) => {
|
||||
(req as unknown as { userId: string }).userId =
|
||||
req.header("x-user") ?? "";
|
||||
@@ -164,6 +166,24 @@ describe("linking an external account to a password account", () => {
|
||||
expect(h.deleted).toEqual(["sso"]);
|
||||
});
|
||||
|
||||
it("takes the new path and field name", async () => {
|
||||
const response = await post("/link-external-to-password", {
|
||||
externalUserId: "sso",
|
||||
targetUsername: "alice",
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(h.identities[0].userId).toBe("local");
|
||||
});
|
||||
|
||||
it("refuses a target that already signs in externally", async () => {
|
||||
h.identities.push({ userId: "local", providerId: "ldap:4", subject: "x" });
|
||||
const response = await post("/link-external-to-password", {
|
||||
externalUserId: "sso",
|
||||
targetUsername: "alice",
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("is for admins only", async () => {
|
||||
const response = await post(
|
||||
"/link-oidc-to-password",
|
||||
@@ -200,6 +220,18 @@ describe("unlinking", () => {
|
||||
expect(h.identities).toEqual([]);
|
||||
});
|
||||
|
||||
it("takes the new path", async () => {
|
||||
await post("/link-external-to-password", {
|
||||
externalUserId: "sso",
|
||||
targetUsername: "alice",
|
||||
});
|
||||
const response = await post("/unlink-external-from-password", {
|
||||
userId: "local",
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(h.identities).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses to leave an account with no way in", async () => {
|
||||
const response = await post("/unlink-oidc-from-password", {
|
||||
userId: "sso",
|
||||
@@ -1,21 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateDefaultsJson } from "../../../database/routes/user-preferences.js";
|
||||
|
||||
describe("user connection defaults validation", () => {
|
||||
it("accepts JSON objects", () => {
|
||||
expect(validateDefaultsJson('{"fontSize":16}')).toBe(true);
|
||||
expect(validateDefaultsJson("{}")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects malformed JSON and non-object values", () => {
|
||||
expect(validateDefaultsJson("{")).toBe(false);
|
||||
expect(validateDefaultsJson("null")).toBe(false);
|
||||
expect(validateDefaultsJson("[]")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects payloads larger than 32 KiB", () => {
|
||||
expect(
|
||||
validateDefaultsJson(JSON.stringify({ value: "x".repeat(32_768) })),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -165,6 +165,7 @@ function startBackend(
|
||||
describe("local-files transfer target resolution", () => {
|
||||
const resolve = localFiles.createTargetResolver({
|
||||
localBaseUrl: "http://127.0.0.1:30001/plugin-api/file-manager",
|
||||
apiPath: "/plugin-api/file-manager",
|
||||
getLinkedServer: () => ({
|
||||
serverUrl: "https://termix.example.com/",
|
||||
token: "remote-jwt",
|
||||
@@ -1149,3 +1150,17 @@ describe("local-files upload boundary", () => {
|
||||
expect(result.error).toMatch(/Unknown transfer origin/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("local-files transfer API path", () => {
|
||||
it("accepts only a /plugin-api/<id> path from the plugin", () => {
|
||||
const set = (
|
||||
localFiles as unknown as {
|
||||
setTransferApiPath: (path: unknown) => void;
|
||||
}
|
||||
).setTransferApiPath;
|
||||
expect(() => set("/plugin-api/file-manager")).not.toThrow();
|
||||
for (const bad of ["/users", "/plugin-api/../users", "https://x", 3]) {
|
||||
expect(() => set(bad)).toThrow(/Invalid transfer API path/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
pickResolvedUsername,
|
||||
pickResolvedPassword,
|
||||
expandOidcUsername,
|
||||
expandExternalUsername,
|
||||
} from "../../hosts/credential-username.js";
|
||||
|
||||
describe("pickResolvedUsername", () => {
|
||||
@@ -52,14 +52,14 @@ describe("pickResolvedPassword", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandOidcUsername", () => {
|
||||
describe("expandExternalUsername", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("returns the username unchanged when it has no placeholder", async () => {
|
||||
expect(await expandOidcUsername("alice", "user-1")).toBe("alice");
|
||||
expect(await expandOidcUsername(undefined, "user-1")).toBeUndefined();
|
||||
expect(await expandExternalUsername("alice", "user-1")).toBe("alice");
|
||||
expect(await expandExternalUsername(undefined, "user-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("expands the placeholder with the user's OIDC identifier", async () => {
|
||||
@@ -69,9 +69,13 @@ describe("expandOidcUsername", () => {
|
||||
}),
|
||||
}));
|
||||
|
||||
const { expandOidcUsername: expand } =
|
||||
const { expandExternalUsername: expand } =
|
||||
await import("../../hosts/credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe("jdoe");
|
||||
expect(await expand("$external.username", "user-1")).toBe("jdoe");
|
||||
expect(
|
||||
await expand("$external.username-$oidc.preferred_username", "user-1"),
|
||||
).toBe("jdoe-jdoe");
|
||||
});
|
||||
|
||||
it("leaves the placeholder as-is when the user has no OIDC identifier", async () => {
|
||||
@@ -81,7 +85,7 @@ describe("expandOidcUsername", () => {
|
||||
}),
|
||||
}));
|
||||
|
||||
const { expandOidcUsername: expand } =
|
||||
const { expandExternalUsername: expand } =
|
||||
await import("../../hosts/credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
|
||||
"$oidc.preferred_username",
|
||||
@@ -95,7 +99,7 @@ describe("expandOidcUsername", () => {
|
||||
},
|
||||
}));
|
||||
|
||||
const { expandOidcUsername: expand } =
|
||||
const { expandExternalUsername: expand } =
|
||||
await import("../../hosts/credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
|
||||
"$oidc.preferred_username",
|
||||
@@ -117,7 +121,7 @@ describe("expandOidcUsername", () => {
|
||||
}),
|
||||
}));
|
||||
|
||||
const { expandOidcUsername: expand } =
|
||||
const { expandExternalUsername: expand } =
|
||||
await import("../../hosts/credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe("jdoe");
|
||||
});
|
||||
@@ -137,7 +141,7 @@ describe("expandOidcUsername", () => {
|
||||
}),
|
||||
}));
|
||||
|
||||
const { expandOidcUsername: expand } =
|
||||
const { expandExternalUsername: expand } =
|
||||
await import("../../hosts/credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
|
||||
"ldap:1:admin",
|
||||
@@ -159,7 +163,7 @@ describe("expandOidcUsername", () => {
|
||||
}),
|
||||
}));
|
||||
|
||||
const { expandOidcUsername: expand } =
|
||||
const { expandExternalUsername: expand } =
|
||||
await import("../../hosts/credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
|
||||
"ldap:1:admin",
|
||||
|
||||
@@ -149,7 +149,7 @@ describe("capability gate", () => {
|
||||
describe("ctx.asUser", () => {
|
||||
it("makes the named user the actor and audits the switch", async () => {
|
||||
grants.set("sample-plugin", ["kv:own"]);
|
||||
const ctx = context(["kv:own"]);
|
||||
const ctx = context(["kv:own", "users:impersonate"]);
|
||||
|
||||
let seen: string | undefined;
|
||||
await ctx.asUser("user-7", async () => {
|
||||
@@ -169,7 +169,7 @@ describe("ctx.asUser", () => {
|
||||
});
|
||||
|
||||
it("restores the previous actor afterwards", async () => {
|
||||
const ctx = context([]);
|
||||
const ctx = context(["users:impersonate"]);
|
||||
|
||||
await runAsActor("outer-user", "request", async () => {
|
||||
await ctx.asUser("inner-user", async () => {
|
||||
@@ -186,6 +186,46 @@ describe("ctx.asUser", () => {
|
||||
/without a user id/,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses without users:impersonate", async () => {
|
||||
const ctx = context(["kv:own"]);
|
||||
|
||||
await expect(ctx.asUser("user-7", async () => {})).rejects.toThrow(
|
||||
/users:impersonate/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.registry namespacing", () => {
|
||||
it("lets a plugin provide and revoke its own keys", () => {
|
||||
const ctx = context([]);
|
||||
const value = { ok: true };
|
||||
|
||||
ctx.registry.provide("sample-plugin.thing", value);
|
||||
expect(ctx.registry.consume("sample-plugin.thing")).toBe(value);
|
||||
expect(ctx.registry.revoke("sample-plugin.thing", value)).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a key outside the plugin's namespace", () => {
|
||||
const ctx = context([]);
|
||||
|
||||
expect(() => ctx.registry.provide("other.thing", {})).toThrow(
|
||||
/registry keys under "sample-plugin\."/,
|
||||
);
|
||||
expect(() => ctx.registry.revoke("other.thing")).toThrow(
|
||||
/registry keys under/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.services.get", () => {
|
||||
it("refuses a service the manifest does not require", () => {
|
||||
const ctx = context([]);
|
||||
|
||||
expect(() => ctx.services.get("someone.else")).toThrow(
|
||||
/not declared in the manifest's requires/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.events namespacing", () => {
|
||||
|
||||
@@ -140,7 +140,7 @@ function setup() {
|
||||
kind: "redirect",
|
||||
external: true,
|
||||
describe: async () => [
|
||||
{ id: "3", label: "Corp", enabled: true, type: "oidc" },
|
||||
{ id: "3", label: "Corp", enabled: true, type: "oidc", autoStart: true },
|
||||
],
|
||||
start: async () => ({ redirectUrl: "https://idp.example/auth" }),
|
||||
callback: async () => ({
|
||||
@@ -161,7 +161,9 @@ function setup() {
|
||||
labelKey: "signIn",
|
||||
kind: "form",
|
||||
external: true,
|
||||
describe: async () => [{ id: "4", label: "Directory", enabled: true }],
|
||||
describe: async () => [
|
||||
{ id: "4", label: "Directory", enabled: true, autoStart: true },
|
||||
],
|
||||
verify: async (request) => {
|
||||
if (request.body.password !== "hunter2") {
|
||||
await auth.loginRateLimit.recordFailure(
|
||||
@@ -303,7 +305,7 @@ describe("the other ctx.auth helpers", () => {
|
||||
it("revokes sessions by logout claims and audits it", async () => {
|
||||
const { auth } = setup();
|
||||
expect(await auth.revokeSessions({ providerId: 3, sid: "sid-1" })).toBe(1);
|
||||
expect(h.manager.revokeSessionsByOidc).toHaveBeenCalledWith({
|
||||
expect(h.manager.revokeSessionsByExternalSession).toHaveBeenCalledWith({
|
||||
ssoProviderId: 3,
|
||||
sub: null,
|
||||
sid: "sid-1",
|
||||
@@ -352,6 +354,17 @@ describe("a disabled plugin", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes autoStart through for redirect methods only", async () => {
|
||||
setup();
|
||||
const methods = await listPublicLoginMethods();
|
||||
expect(methods.find((m) => m.id === "corp-sso")?.instances).toEqual([
|
||||
{ id: "3", label: "Corp", autoStart: true },
|
||||
]);
|
||||
expect(methods.find((m) => m.id === "corp-dir")?.instances).toEqual([
|
||||
{ id: "4", label: "Directory" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides its buttons and leaves no route that signs anyone in", async () => {
|
||||
const { bag } = setup();
|
||||
await bag.disposeAll();
|
||||
|
||||
@@ -190,10 +190,17 @@ describe("ctx.db capability", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("hands back read-only refs to the core tables a plugin may join", async () => {
|
||||
it("refuses core refs with only db:own", async () => {
|
||||
grants.set("demo", ["db:own"]);
|
||||
const { ctx } = contextFor("demo", ["db:own"]);
|
||||
|
||||
await expect(ctx.db.refs()).rejects.toThrow(/db:core-refs/);
|
||||
});
|
||||
|
||||
it("hands back refs to the core tables a plugin may join", async () => {
|
||||
grants.set("demo", ["db:core-refs"]);
|
||||
const { ctx } = contextFor("demo", ["db:core-refs"]);
|
||||
|
||||
const refs = (await ctx.db.refs()) as Record<string, unknown>;
|
||||
|
||||
expect(Object.keys(refs).sort()).toEqual([
|
||||
|
||||
@@ -122,19 +122,24 @@ describe("ctx.desktop.openIsolatedWindow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.desktop.launchNativeRdp", () => {
|
||||
const request = { host: "win.example.test", port: 3389, username: "bob" };
|
||||
describe("ctx.desktop.launchExternalClient", () => {
|
||||
const request = {
|
||||
protocol: "rdp",
|
||||
host: "win.example.test",
|
||||
port: 3389,
|
||||
username: "bob",
|
||||
};
|
||||
|
||||
it("refuses without desktop:window", async () => {
|
||||
grants.set("demo", []);
|
||||
const { ctx } = contextFor("demo", []);
|
||||
|
||||
await expect(ctx.desktop.launchNativeRdp(request)).rejects.toThrow(
|
||||
await expect(ctx.desktop.launchExternalClient(request)).rejects.toThrow(
|
||||
/desktop:window/,
|
||||
);
|
||||
expect(bridge.requestFromElectronMain).not.toHaveBeenCalled();
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_desktop_launch_native_rdp",
|
||||
action: "plugin_desktop_launch_external_client",
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
@@ -144,7 +149,7 @@ describe("ctx.desktop.launchNativeRdp", () => {
|
||||
const { ctx } = contextFor("demo", ["desktop:window"]);
|
||||
|
||||
expect(ctx.desktop.available()).toBe(false);
|
||||
await expect(ctx.desktop.launchNativeRdp(request)).rejects.toThrow(
|
||||
await expect(ctx.desktop.launchExternalClient(request)).rejects.toThrow(
|
||||
/desktop app/,
|
||||
);
|
||||
});
|
||||
@@ -155,15 +160,15 @@ describe("ctx.desktop.launchNativeRdp", () => {
|
||||
const { ctx } = contextFor("demo", ["desktop:window"]);
|
||||
|
||||
expect(ctx.desktop.available()).toBe(true);
|
||||
await expect(ctx.desktop.launchNativeRdp(request)).resolves.toEqual({
|
||||
await expect(ctx.desktop.launchExternalClient(request)).resolves.toEqual({
|
||||
success: true,
|
||||
});
|
||||
expect(bridge.requestFromElectronMain).toHaveBeenCalledWith(
|
||||
"launch-native-rdp",
|
||||
"launch-external-client",
|
||||
request,
|
||||
);
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_desktop_launch_native_rdp",
|
||||
action: "plugin_desktop_launch_external_client",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -401,3 +401,28 @@ describe("ctx.http streaming", () => {
|
||||
expect(rest).toContain("data: done");
|
||||
});
|
||||
});
|
||||
|
||||
describe("public route rate limit", () => {
|
||||
afterEach(() => {
|
||||
http.resetPublicRateLimits();
|
||||
delete process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT;
|
||||
});
|
||||
|
||||
it("allows up to the limit per address and plugin, then refuses", () => {
|
||||
process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "3";
|
||||
const now = 1_000;
|
||||
const hits = [1, 2, 3, 4].map(() =>
|
||||
http.overPublicRateLimit("demo", "1.2.3.4", now),
|
||||
);
|
||||
expect(hits).toEqual([false, false, false, true]);
|
||||
expect(http.overPublicRateLimit("demo", "5.6.7.8", now)).toBe(false);
|
||||
expect(http.overPublicRateLimit("other", "1.2.3.4", now)).toBe(false);
|
||||
});
|
||||
|
||||
it("starts a new window after a minute", () => {
|
||||
process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "1";
|
||||
expect(http.overPublicRateLimit("demo", "ip", 0)).toBe(false);
|
||||
expect(http.overPublicRateLimit("demo", "ip", 10)).toBe(true);
|
||||
expect(http.overPublicRateLimit("demo", "ip", 60_001)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -472,19 +472,6 @@ describe("PluginLoader error budget", () => {
|
||||
expect(loader.get("sample-plugin")?.state).toBe("active");
|
||||
});
|
||||
|
||||
it("wrap() counts a throwing callback instead of letting it escape", async () => {
|
||||
const loader = await failing(2);
|
||||
|
||||
const wrapped = loader.wrap("sample-plugin", () => {
|
||||
throw new Error("callback failed");
|
||||
});
|
||||
|
||||
await expect(wrapped()).resolves.toBeUndefined();
|
||||
await wrapped();
|
||||
|
||||
expect(loader.get("sample-plugin")?.state).toBe("failed");
|
||||
});
|
||||
|
||||
it("retry clears the budget and starts the plugin again", async () => {
|
||||
const loader = await failing(1);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
parseManifest,
|
||||
validateManifest,
|
||||
SUPPORTED_PLUGIN_API_VERSION,
|
||||
isTermixCompatible,
|
||||
} from "../../plugins/manifest.js";
|
||||
|
||||
function base(overrides: Record<string, unknown> = {}) {
|
||||
@@ -91,7 +92,35 @@ describe("manifest v2 validation", () => {
|
||||
);
|
||||
|
||||
expect(manifest).toBeUndefined();
|
||||
expect(errors.join()).toMatch(/SDK API version/);
|
||||
expect(errors.join()).toMatch(/needs plugin API 2/);
|
||||
});
|
||||
|
||||
it("accepts an api range this build satisfies", () => {
|
||||
for (const api of ["1", "^1.0.0", ">=1.0.0 <2", "1.x"]) {
|
||||
expect(
|
||||
parseManifest(base({ engine: { termix: ">=2.9.0", api } })).errors,
|
||||
).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses an api range needing a newer minor", () => {
|
||||
const { errors } = parseManifest(
|
||||
base({ engine: { termix: ">=2.9.0", api: "^1.99" } }),
|
||||
);
|
||||
expect(errors.join()).toMatch(/needs plugin API \^1\.99/);
|
||||
});
|
||||
|
||||
it("refuses an engine that is not a semver range", () => {
|
||||
expect(
|
||||
validateManifest(base({ engine: { termix: "soon", api: "one" } })).join(),
|
||||
).toMatch(/engine.termix.*semver[\s\S]*engine.api.*semver/);
|
||||
});
|
||||
|
||||
it("checks engine.termix against the core version", () => {
|
||||
expect(isTermixCompatible(">=2.9.0", "2.9.0")).toBe(true);
|
||||
expect(isTermixCompatible(">=2.9.0", "2.9.0-beta.3")).toBe(true);
|
||||
expect(isTermixCompatible(">=3.0.0", "2.9.1")).toBe(false);
|
||||
expect(isTermixCompatible(">=3.0.0", null)).toBe(true);
|
||||
});
|
||||
|
||||
it("fills in the entry point defaults", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* group was declared twice.
|
||||
*/
|
||||
|
||||
import type { PluginLoader } from "../../plugins/loader.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
@@ -113,7 +114,6 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const { PluginLoader } = await import("../../plugins/loader.js");
|
||||
const { createFixturePlugin } = await import("./fixture-plugin.js");
|
||||
const {
|
||||
getPermissionCatalog,
|
||||
@@ -433,7 +433,7 @@ describe("shipped manifests keep their permission ids", () => {
|
||||
|
||||
describe("role defaults apply exactly once", () => {
|
||||
let fixture: Fixture;
|
||||
let loader: InstanceType<typeof PluginLoader>;
|
||||
let loader: PluginLoader;
|
||||
|
||||
beforeEach(() => {
|
||||
state.roles.set("user", { id: 2, permissions: ["hosts.*"] });
|
||||
|
||||
@@ -128,7 +128,7 @@ function consumerFixture(
|
||||
): Fixture {
|
||||
return createFixturePlugin({
|
||||
id: CONSUMER_ID,
|
||||
capabilities: ["kv:own"],
|
||||
capabilities: ["kv:own", "users:impersonate"],
|
||||
backendSource: CONSUMER_SOURCE,
|
||||
manifestOverrides: { category: "Infrastructure", requiresSecret },
|
||||
});
|
||||
|
||||
@@ -81,7 +81,13 @@ function contextFor(pluginId: string, extra: Record<string, unknown> = {}) {
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities: ["hosts:read", "ssh:connect", "credentials:use"],
|
||||
capabilities: [
|
||||
"hosts:read",
|
||||
"ssh:connect",
|
||||
"credentials:use",
|
||||
"users:impersonate",
|
||||
],
|
||||
requires: [{ service: "sample.echo", versionRange: "^1.0.0" }],
|
||||
...extra,
|
||||
} as never,
|
||||
handle,
|
||||
|
||||
@@ -11,7 +11,10 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { findUnownedTableWrites } from "@termix/plugin-sdk/ddl";
|
||||
import {
|
||||
collectOwnedIndexes,
|
||||
findUnownedTableWrites,
|
||||
} from "@termix/plugin-sdk/ddl";
|
||||
import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
@@ -74,6 +77,40 @@ describe("the migration checker", () => {
|
||||
["a truncate", "TRUNCATE TABLE users"],
|
||||
["an unlogged table", "CREATE UNLOGGED TABLE users (id int)"],
|
||||
["a CTE write", "WITH x AS (SELECT 1) DELETE FROM users"],
|
||||
[
|
||||
"a Postgres dollar-quoted string hiding a statement",
|
||||
"SELECT $$'$$; UPDATE users SET is_admin = true; SELECT $$'$$",
|
||||
],
|
||||
[
|
||||
"a MySQL backslash escape hiding a statement",
|
||||
"SELECT 'a\\''; UPDATE users SET is_admin = 1; SELECT ''",
|
||||
],
|
||||
["dropping a core index by name", "DROP INDEX idx_sessions_user_id"],
|
||||
["altering a core index by name", "ALTER INDEX users_pkey RENAME TO x"],
|
||||
["dropping a core sequence", "DROP SEQUENCE users_id_seq"],
|
||||
["a cascade", "DROP TABLE p_foo_a CASCADE"],
|
||||
[
|
||||
"a MySQL multi-table update",
|
||||
"UPDATE p_foo_a JOIN users ON 1 = 1 SET users.is_admin = 1",
|
||||
],
|
||||
["a MySQL comma update", "UPDATE p_foo_a, users SET users.is_admin = 1"],
|
||||
[
|
||||
"a MySQL multi-table delete",
|
||||
"DELETE p_foo_a, users FROM p_foo_a JOIN users",
|
||||
],
|
||||
[
|
||||
"a delete with USING",
|
||||
"DELETE FROM p_foo_a, users USING p_foo_a JOIN users",
|
||||
],
|
||||
["SELECT INTO", "SELECT * INTO users_copy FROM users"],
|
||||
["writing a file", "SELECT 1 INTO OUTFILE '/tmp/x'"],
|
||||
["reading a server file", "SELECT pg_read_file('/etc/passwd')"],
|
||||
["changing a server setting", "SELECT set_config('x', 'y', false)"],
|
||||
[
|
||||
"running a string as SQL",
|
||||
"SELECT query_to_xml('DELETE FROM users', true, true, '')",
|
||||
],
|
||||
["setval on a core sequence", "SELECT setval('users_id_seq', 1)"],
|
||||
])("refuses %s", (_label, sql) => {
|
||||
expect(findUnownedTableWrites("foo", sql)).not.toEqual([]);
|
||||
});
|
||||
@@ -90,10 +127,34 @@ describe("the migration checker", () => {
|
||||
"a quoted name in a string",
|
||||
"INSERT INTO p_foo_a (x) VALUES ('DROP TABLE users')",
|
||||
],
|
||||
[
|
||||
"dropping an index it created in the same file",
|
||||
"CREATE INDEX idx_foo_a ON p_foo_a (id); DROP INDEX idx_foo_a",
|
||||
],
|
||||
["dropping a prefixed index", "DROP INDEX IF EXISTS p_foo_a_idx"],
|
||||
[
|
||||
"setval on its own sequence",
|
||||
"SELECT setval(pg_get_serial_sequence('p_foo_a', 'id'), 1)",
|
||||
],
|
||||
["its own sequence", "CREATE SEQUENCE IF NOT EXISTS p_foo_seq"],
|
||||
])("allows %s", (_label, sql) => {
|
||||
expect(findUnownedTableWrites("foo", sql)).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows dropping an index an earlier migration created", () => {
|
||||
const owned = collectOwnedIndexes("foo", [
|
||||
"CREATE INDEX idx_foo_a ON p_foo_a (id)",
|
||||
"CREATE INDEX idx_core ON users (id)",
|
||||
]);
|
||||
expect([...owned]).toEqual(["idx_foo_a"]);
|
||||
expect(
|
||||
findUnownedTableWrites("foo", "DROP INDEX idx_foo_a", new Set(), owned),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
findUnownedTableWrites("foo", "DROP INDEX idx_core", new Set(), owned),
|
||||
).not.toEqual([]);
|
||||
});
|
||||
|
||||
it("passes every migration a bundled plugin ships", () => {
|
||||
const plugins = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
|
||||
@@ -121,7 +121,7 @@ function consumerFixture(
|
||||
): Fixture {
|
||||
return createFixturePlugin({
|
||||
id: "docker",
|
||||
capabilities: ["kv:own"],
|
||||
capabilities: ["kv:own", "users:impersonate"],
|
||||
backendSource: CONSUMER_SOURCE,
|
||||
manifestOverrides: { category: "Infrastructure", requires },
|
||||
});
|
||||
@@ -219,6 +219,7 @@ describe("plugin service contracts", () => {
|
||||
|
||||
it("activates anyway when an unsatisfied requirement is optional", async () => {
|
||||
consumer = consumerFixture([
|
||||
{ service: SERVICE, versionRange: "^1.2.0", optional: true },
|
||||
{ service: "testplugin.absent", versionRange: "^1.0.0", optional: true },
|
||||
]);
|
||||
|
||||
|
||||
@@ -69,10 +69,11 @@ async function writeArtifact(
|
||||
dir: string,
|
||||
id: string,
|
||||
sig: "valid" | "wrong-key" | "none",
|
||||
version = "1.0.0",
|
||||
): Promise<string> {
|
||||
const build = tempRoot("termix-build-");
|
||||
createFixturePlugin({ id, root: build });
|
||||
const file = path.join(dir, `${id}-1.0.0.tmxplug`);
|
||||
createFixturePlugin({ id, root: build, manifestOverrides: { version } });
|
||||
const file = path.join(dir, `${id}-${version}.tmxplug`);
|
||||
await tar.c({ gzip: true, file, cwd: path.join(build, id) }, [
|
||||
"manifest.json",
|
||||
"backend",
|
||||
@@ -146,13 +147,57 @@ describe("signed plugins gate on", () => {
|
||||
expect(await loadedIds()).toEqual(["bundled-one", "signed-one"]);
|
||||
});
|
||||
|
||||
it("refuses a signed archive that takes a bundled plugin's id", async () => {
|
||||
it("refuses a signed archive of a bundled id that is not newer", async () => {
|
||||
const { userDir } = setup();
|
||||
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true";
|
||||
await writeArtifact(userDir, "bundled-one", "valid");
|
||||
const loader = new PluginLoader();
|
||||
const loaded = await loader.loadAll();
|
||||
expect(loaded.map((plugin) => plugin.id)).toEqual(["bundled-one"]);
|
||||
expect(loader.get("bundled-one")?.source).toBe("bundled");
|
||||
expect(loader.get("bundled-one")?.artifact).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updating a bundled plugin", () => {
|
||||
it("loads a signed, newer archive in place of the bundled copy", async () => {
|
||||
const { userDir } = setup();
|
||||
const file = await writeArtifact(userDir, "bundled-one", "valid", "1.0.1");
|
||||
const loader = new PluginLoader();
|
||||
const loaded = await loader.loadAll();
|
||||
|
||||
expect(loaded.map((plugin) => plugin.id)).toEqual(["bundled-one"]);
|
||||
const plugin = loader.get("bundled-one");
|
||||
expect(plugin?.manifest.version).toBe("1.0.1");
|
||||
expect(plugin?.source).toBe("bundled");
|
||||
expect(plugin?.artifact).toBe(file);
|
||||
expect(plugin?.signedBy).toBe("test");
|
||||
});
|
||||
|
||||
it("keeps the bundled copy for an unsigned newer archive", async () => {
|
||||
const { userDir } = setup();
|
||||
await writeArtifact(userDir, "bundled-one", "none", "2.0.0");
|
||||
const loader = new PluginLoader();
|
||||
await loader.loadAll();
|
||||
expect(loader.get("bundled-one")?.manifest.version).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("keeps the bundled copy for a signed older archive", async () => {
|
||||
const { userDir } = setup();
|
||||
await writeArtifact(userDir, "bundled-one", "valid", "0.9.0");
|
||||
const loader = new PluginLoader();
|
||||
await loader.loadAll();
|
||||
expect(loader.get("bundled-one")?.manifest.version).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("refuses a downgrade after the bundled copy was unloaded", async () => {
|
||||
const { userDir } = setup();
|
||||
const loader = new PluginLoader();
|
||||
await loader.loadAll();
|
||||
loader.forget("bundled-one");
|
||||
const file = await writeArtifact(userDir, "bundled-one", "valid", "0.5.0");
|
||||
|
||||
await expect(
|
||||
loader.loadArtifact(file, loader.bundledIds()),
|
||||
).rejects.toThrow(/not newer than the bundled 1\.0\.0/);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
defineTable,
|
||||
encryptedText,
|
||||
id,
|
||||
refHost,
|
||||
refUser,
|
||||
text,
|
||||
varchar,
|
||||
@@ -134,4 +135,37 @@ describe("plugin user data", () => {
|
||||
.get(),
|
||||
).toEqual({ user_id: "u1", note: "x" });
|
||||
});
|
||||
|
||||
it("skips imported rows that point at another user or host", async () => {
|
||||
sqlite.exec("DROP TABLE ssh_data");
|
||||
sqlite.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY, user_id TEXT)");
|
||||
sqlite.exec(
|
||||
"INSERT INTO ssh_data (id, user_id) VALUES (1, 'u2'), (2, 'u1')",
|
||||
);
|
||||
const grants = defineTable("grant", {
|
||||
id: id(),
|
||||
ownerId: refUser(),
|
||||
granteeId: refUser(),
|
||||
hostId: refHost(),
|
||||
});
|
||||
for (const statement of createTableSql("sqlite", "fixture", grants)) {
|
||||
sqlite.exec(statement);
|
||||
}
|
||||
registerTable("fixture", grants);
|
||||
|
||||
const file = new Database(":memory:");
|
||||
file.exec(
|
||||
"CREATE TABLE p_fixture_grant (id INTEGER PRIMARY KEY, owner_id TEXT, grantee_id TEXT, host_id INTEGER)",
|
||||
);
|
||||
file.exec(`INSERT INTO p_fixture_grant (owner_id, grantee_id, host_id) VALUES
|
||||
('old', 'old', 1), ('old', 'u1', 1), ('old', 'old', 2)`);
|
||||
|
||||
const result = await importUserPluginRows(file, "u2");
|
||||
expect(result).toMatchObject({ imported: 1, skipped: 2 });
|
||||
expect(
|
||||
sqlite
|
||||
.prepare("SELECT owner_id, grantee_id, host_id FROM p_fixture_grant")
|
||||
.all(),
|
||||
).toEqual([{ owner_id: "u2", grantee_id: "u2", host_id: 1 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* The silent sign-in default moving from core settings into the sso plugin's
|
||||
* admin settings.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
core: new Map<string, string>(),
|
||||
plugin: new Map<string, string | null>(),
|
||||
pluginInstalled: true,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
state.pluginInstalled && id === "sso" ? { id } : null,
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.core.get(key) ?? null,
|
||||
delete: async (key: string) => {
|
||||
state.core.delete(key);
|
||||
},
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
_pluginId: string,
|
||||
_scope: string,
|
||||
_scopeId: string | null,
|
||||
key: string,
|
||||
) => (state.plugin.has(key) ? { value: state.plugin.get(key) } : null),
|
||||
set: async (
|
||||
_pluginId: string,
|
||||
_scope: string,
|
||||
_scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
state.plugin.set(key, value);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import { runSsoSettingsMigration } from "../../upgrade/sso-settings-migration.js";
|
||||
|
||||
beforeEach(() => {
|
||||
state.core = new Map();
|
||||
state.plugin = new Map();
|
||||
state.pluginInstalled = true;
|
||||
});
|
||||
|
||||
describe("runSsoSettingsMigration", () => {
|
||||
it("moves the core value into the sso admin setting", async () => {
|
||||
state.core.set("oidc_silent_login_default", "true");
|
||||
expect(await runSsoSettingsMigration()).toEqual({ moved: true });
|
||||
expect(state.plugin.get("silentLoginDefault")).toBe("true");
|
||||
expect(state.core.has("oidc_silent_login_default")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a value already in plugin settings", async () => {
|
||||
state.core.set("oidc_silent_login_default", "true");
|
||||
state.plugin.set("silentLoginDefault", "false");
|
||||
await runSsoSettingsMigration();
|
||||
expect(state.plugin.get("silentLoginDefault")).toBe("false");
|
||||
expect(state.core.has("oidc_silent_login_default")).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing twice or without the plugin", async () => {
|
||||
expect(await runSsoSettingsMigration()).toEqual({ moved: false });
|
||||
state.core.set("oidc_silent_login_default", "true");
|
||||
state.pluginInstalled = false;
|
||||
expect(await runSsoSettingsMigration()).toEqual({ moved: false });
|
||||
expect(state.core.get("oidc_silent_login_default")).toBe("true");
|
||||
});
|
||||
});
|
||||
@@ -99,7 +99,7 @@ function sessionIds(): string[] {
|
||||
.map((row) => String(row.id));
|
||||
}
|
||||
|
||||
describe("AuthManager.revokeSessionsByOidc", () => {
|
||||
describe("AuthManager.revokeSessionsByExternalSession", () => {
|
||||
beforeAll(() => {
|
||||
mocks.sqlite!.exec(`
|
||||
CREATE TABLE sessions (
|
||||
@@ -147,7 +147,7 @@ describe("AuthManager.revokeSessionsByOidc", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
authManager.revokeSessionsByOidc({
|
||||
authManager.revokeSessionsByExternalSession({
|
||||
ssoProviderId: 7,
|
||||
sub: "subject-1",
|
||||
sid: "session-1",
|
||||
@@ -181,7 +181,7 @@ describe("AuthManager.revokeSessionsByOidc", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
authManager.revokeSessionsByOidc({
|
||||
authManager.revokeSessionsByExternalSession({
|
||||
ssoProviderId: 7,
|
||||
sub: "subject-1",
|
||||
}),
|
||||
@@ -192,7 +192,7 @@ describe("AuthManager.revokeSessionsByOidc", () => {
|
||||
|
||||
it("does not persist when no session matches", async () => {
|
||||
await expect(
|
||||
authManager.revokeSessionsByOidc({
|
||||
authManager.revokeSessionsByExternalSession({
|
||||
ssoProviderId: 7,
|
||||
sid: "missing",
|
||||
}),
|
||||
@@ -212,7 +212,7 @@ describe("AuthManager.revokeSessionsByOidc", () => {
|
||||
mocks.saveDatabase.mockRejectedValueOnce(new Error("disk full"));
|
||||
|
||||
await expect(
|
||||
authManager.revokeSessionsByOidc({
|
||||
authManager.revokeSessionsByExternalSession({
|
||||
ssoProviderId: 7,
|
||||
sid: "session-1",
|
||||
}),
|
||||
|
||||
+4
-4
@@ -1,13 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isOidcTokenCallback } from "../../utils/oidc-desktop-callback.js";
|
||||
import { isExternalTokenCallback } from "../../utils/external-login-callback.js";
|
||||
|
||||
describe("isOidcTokenCallback", () => {
|
||||
describe("isExternalTokenCallback", () => {
|
||||
it.each([
|
||||
"http://localhost:17850/oidc-callback",
|
||||
"http://127.0.0.1:17850/oidc-callback",
|
||||
"termix-mobile://oidc-callback",
|
||||
])("recognizes app callback %s", (url) => {
|
||||
expect(isOidcTokenCallback(url)).toBe(true);
|
||||
expect(isExternalTokenCallback(url)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -15,6 +15,6 @@ describe("isOidcTokenCallback", () => {
|
||||
"http://example.com:17850/oidc-callback",
|
||||
"http://localhost:17850/other",
|
||||
])("rejects non-app callback %s", (url) => {
|
||||
expect(isOidcTokenCallback(url)).toBe(false);
|
||||
expect(isExternalTokenCallback(url)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,146 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import express from "express";
|
||||
import http from "http";
|
||||
import type { AddressInfo } from "net";
|
||||
import { listenOnServicePort } from "../../utils/service-listen.js";
|
||||
import { Logger } from "../../utils/logger.js";
|
||||
|
||||
function testLogger() {
|
||||
const logger = new Logger("TEST", "🧪", "#000000");
|
||||
return {
|
||||
logger,
|
||||
error: vi.spyOn(logger, "error").mockImplementation(() => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function occupyPort(): Promise<{ port: number; release: () => void }> {
|
||||
return new Promise((resolve) => {
|
||||
const blocker = http.createServer();
|
||||
blocker.listen(0, "127.0.0.1", () => {
|
||||
resolve({
|
||||
port: (blocker.address() as AddressInfo).port,
|
||||
release: () => blocker.close(),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const servers: http.Server[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (servers.length) servers.pop()?.close();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("listenOnServicePort", () => {
|
||||
it("starts the service and reports listening on a free port", async () => {
|
||||
const { logger, error } = testLogger();
|
||||
const onListening = vi.fn();
|
||||
|
||||
const server = listenOnServicePort({
|
||||
app: express(),
|
||||
port: 0,
|
||||
logger,
|
||||
serviceName: "test",
|
||||
onListening,
|
||||
});
|
||||
servers.push(server);
|
||||
|
||||
await vi.waitFor(() => expect(onListening).toHaveBeenCalledOnce());
|
||||
expect(server.listening).toBe(true);
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The bug this helper exists for: express's app.listen(port, host, cb)
|
||||
// registers cb as the server's error handler too, so a port conflict
|
||||
// invoked the "service started" callback and emitted nothing at all.
|
||||
it("does not report the service as started when the port is taken", async () => {
|
||||
const { port, release } = await occupyPort();
|
||||
const { logger } = testLogger();
|
||||
const onListening = vi.fn();
|
||||
const exit = vi
|
||||
.spyOn(process, "exit")
|
||||
.mockImplementation(() => undefined as never);
|
||||
|
||||
const server = listenOnServicePort({
|
||||
app: express(),
|
||||
port,
|
||||
logger,
|
||||
serviceName: "test",
|
||||
onListening,
|
||||
});
|
||||
servers.push(server);
|
||||
|
||||
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
|
||||
expect(onListening).not.toHaveBeenCalled();
|
||||
release();
|
||||
});
|
||||
|
||||
it("names the conflicting port so the desktop can surface it", async () => {
|
||||
const { port, release } = await occupyPort();
|
||||
const { logger, error } = testLogger();
|
||||
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
||||
|
||||
servers.push(
|
||||
listenOnServicePort({
|
||||
app: express(),
|
||||
port,
|
||||
logger,
|
||||
serviceName: "metrics",
|
||||
}),
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(error).toHaveBeenCalled());
|
||||
const [message, , context] = error.mock.calls[0];
|
||||
expect(message).toContain(String(port));
|
||||
expect(context).toMatchObject({
|
||||
operation: "service_port_conflict",
|
||||
port,
|
||||
service: "metrics",
|
||||
});
|
||||
release();
|
||||
});
|
||||
|
||||
it("leaves any other listen error to the process-level handlers", async () => {
|
||||
const { logger } = testLogger();
|
||||
const exit = vi
|
||||
.spyOn(process, "exit")
|
||||
.mockImplementation(() => undefined as never);
|
||||
const server = listenOnServicePort({
|
||||
app: express(),
|
||||
port: 0,
|
||||
logger,
|
||||
serviceName: "test",
|
||||
});
|
||||
servers.push(server);
|
||||
|
||||
const notPortConflict = Object.assign(new Error("boom"), { code: "EPERM" });
|
||||
expect(() => server.emit("error", notPortConflict)).toThrow("boom");
|
||||
expect(exit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// express's app.listen() swallows a bind failure by design (see
|
||||
// service-listen.ts), so no service may reach for it again.
|
||||
describe("backend services", () => {
|
||||
function sourceFiles(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((entry) => {
|
||||
const full = path.join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
return entry === "tests" ? [] : sourceFiles(full);
|
||||
}
|
||||
return full.endsWith(".ts") ? [full] : [];
|
||||
});
|
||||
}
|
||||
|
||||
it("never bind a port with express's app.listen()", () => {
|
||||
const offenders = sourceFiles(path.resolve("src/backend"))
|
||||
// service-listen.ts names the call only to document why it is banned.
|
||||
.filter((file) => !file.endsWith(path.join("utils", "service-listen.ts")))
|
||||
.filter((file) => /\bapp\.listen\(/.test(readFileSync(file, "utf8")));
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { IncomingMessage } from "http";
|
||||
import { extractWebSocketToken } from "../../utils/ws-auth.js";
|
||||
import {
|
||||
extractWebSocketToken,
|
||||
isCookieOriginAllowed,
|
||||
} from "../../utils/ws-auth.js";
|
||||
|
||||
function request(headers: Record<string, string>): IncomingMessage {
|
||||
return { headers } as IncomingMessage;
|
||||
@@ -26,3 +29,58 @@ describe("extractWebSocketToken", () => {
|
||||
).toBe("cookie-token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the jwt cookie on a socket", () => {
|
||||
it("counts when the page is this server", () => {
|
||||
expect(
|
||||
extractWebSocketToken(
|
||||
request({
|
||||
cookie: "jwt=cookie-token",
|
||||
host: "termix.example.com",
|
||||
origin: "https://termix.example.com",
|
||||
}),
|
||||
),
|
||||
).toBe("cookie-token");
|
||||
});
|
||||
|
||||
it("is ignored when another site opened the socket", () => {
|
||||
expect(
|
||||
extractWebSocketToken(
|
||||
request({
|
||||
cookie: "jwt=cookie-token",
|
||||
host: "termix.example.com",
|
||||
origin: "https://evil.example.com",
|
||||
}),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still takes an explicit token from another origin", () => {
|
||||
expect(
|
||||
extractWebSocketToken(
|
||||
request({
|
||||
cookie: "jwt=cookie-token",
|
||||
host: "termix.example.com",
|
||||
origin: "https://evil.example.com",
|
||||
"sec-websocket-protocol": "termix.jwt.protocol-token",
|
||||
}),
|
||||
),
|
||||
).toBe("protocol-token");
|
||||
});
|
||||
|
||||
it("allows the dev proxy, which rewrites Host to loopback", () => {
|
||||
expect(
|
||||
isCookieOriginAllowed(
|
||||
request({ host: "127.0.0.1:30001", origin: "http://localhost:5173" }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows the desktop app, which is not a web origin", () => {
|
||||
expect(
|
||||
isCookieOriginAllowed(
|
||||
request({ host: "termix.example.com", origin: "file://" }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user