feat(server,web,desktop): admin "use system HTTP proxy" switch (#225)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e8499befd4
commit
91f3292ab5
29 files changed
+710
-19
No files matched your search
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Admin server-settings route tests: permission boundary (non-admin 403), the
|
||||
* "use system HTTP proxy" default (absent row reads as on), PUT persistence and
|
||||
* validation, and merge semantics (an omitted field keeps its current value).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { ServerSettingsResponse } from "../src/api/types.js";
|
||||
import { apiClient, createTestApp, loginAdmin, provisionUser } from "./helpers.js";
|
||||
import type { TestApp } from "./helpers.js";
|
||||
|
||||
describe("admin server settings", () => {
|
||||
let t: TestApp;
|
||||
let admin: ReturnType<typeof apiClient>;
|
||||
|
||||
beforeEach(async () => {
|
||||
t = await createTestApp();
|
||||
admin = apiClient(t.app, (await loginAdmin(t.app)).cookie);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await t.cleanup();
|
||||
});
|
||||
|
||||
const getSettings = async (api: ReturnType<typeof apiClient> = admin) => {
|
||||
const res = await api.get("/api/admin/settings");
|
||||
expect(res.status).toBe(200);
|
||||
return (await res.json()) as ServerSettingsResponse;
|
||||
};
|
||||
|
||||
it("non-admin access is always 403", async () => {
|
||||
const { cookie } = await provisionUser(t.app, "norm");
|
||||
const api = apiClient(t.app, cookie);
|
||||
expect((await api.get("/api/admin/settings")).status).toBe(403);
|
||||
expect((await api.put("/api/admin/settings", { useSystemProxy: false })).status).toBe(403);
|
||||
// The failed PUT changed nothing.
|
||||
expect((await getSettings()).settings.useSystemProxy).toBe(true);
|
||||
});
|
||||
|
||||
it("useSystemProxy defaults to on while no row exists", async () => {
|
||||
expect(t.deps.db.prepare("SELECT COUNT(*) AS n FROM server_settings").get()).toMatchObject({
|
||||
n: 0,
|
||||
});
|
||||
expect((await getSettings()).settings.useSystemProxy).toBe(true);
|
||||
});
|
||||
|
||||
it("PUT persists the toggle and echoes the full settings", async () => {
|
||||
const off = await admin.put("/api/admin/settings", { useSystemProxy: false });
|
||||
expect(off.status).toBe(200);
|
||||
expect(((await off.json()) as ServerSettingsResponse).settings.useSystemProxy).toBe(false);
|
||||
// Round-trips through the repo (the DB row, not process state, is what GET serves).
|
||||
expect((await getSettings()).settings.useSystemProxy).toBe(false);
|
||||
expect(t.deps.serverSettingsRepo.getUseSystemProxy()).toBe(false);
|
||||
|
||||
const on = await admin.put("/api/admin/settings", { useSystemProxy: true });
|
||||
expect(on.status).toBe(200);
|
||||
expect((await getSettings()).settings.useSystemProxy).toBe(true);
|
||||
});
|
||||
|
||||
it("an omitted field keeps its current value; a non-boolean is 400", async () => {
|
||||
await admin.put("/api/admin/settings", { useSystemProxy: false });
|
||||
// Empty PUT: no-op, still returns the current settings.
|
||||
const noop = await admin.put("/api/admin/settings", {});
|
||||
expect(noop.status).toBe(200);
|
||||
expect(((await noop.json()) as ServerSettingsResponse).settings.useSystemProxy).toBe(false);
|
||||
// Type check: only booleans are accepted, and a rejected write changes nothing.
|
||||
expect((await admin.put("/api/admin/settings", { useSystemProxy: "on" })).status).toBe(400);
|
||||
expect((await getSettings()).settings.useSystemProxy).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Unit tests for the outbound-proxy module's pure parts: the NO_PROXY loopback merge
|
||||
* and the dispatcher choice per switch state. No real network and no global install —
|
||||
* installGlobalProxyDispatcher (fetch replacement) runs only in the production entry.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Agent, EnvHttpProxyAgent } from "undici";
|
||||
import { buildProxyDispatcher, mergedNoProxy } from "../src/net/proxy.js";
|
||||
|
||||
describe("mergedNoProxy", () => {
|
||||
it("yields exactly the loopback names when the environment has no NO_PROXY", () => {
|
||||
expect(mergedNoProxy({})).toBe("localhost,127.0.0.1,::1");
|
||||
});
|
||||
|
||||
it("appends the loopback names after the environment's entries", () => {
|
||||
expect(mergedNoProxy({ NO_PROXY: "example.com,.corp.internal" })).toBe(
|
||||
"example.com,.corp.internal,localhost,127.0.0.1,::1",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers the lowercase spelling, like undici itself", () => {
|
||||
expect(mergedNoProxy({ no_proxy: "a.example", NO_PROXY: "b.example" })).toBe(
|
||||
"a.example,localhost,127.0.0.1,::1",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate loopback names already present (case-insensitively)", () => {
|
||||
expect(mergedNoProxy({ NO_PROXY: "LOCALHOST,::1" })).toBe("LOCALHOST,::1,127.0.0.1");
|
||||
});
|
||||
|
||||
it("survives messy separators (comma/whitespace mix, empty segments)", () => {
|
||||
expect(mergedNoProxy({ NO_PROXY: " example.com ,, other.example " })).toBe(
|
||||
"example.com,other.example,localhost,127.0.0.1,::1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildProxyDispatcher", () => {
|
||||
it("on → EnvHttpProxyAgent (proxy env honored), off → plain Agent (direct)", async () => {
|
||||
const on = buildProxyDispatcher(true, {});
|
||||
const off = buildProxyDispatcher(false, {});
|
||||
try {
|
||||
expect(on).toBeInstanceOf(EnvHttpProxyAgent);
|
||||
expect(off).toBeInstanceOf(Agent);
|
||||
expect(off).not.toBeInstanceOf(EnvHttpProxyAgent);
|
||||
} finally {
|
||||
await on.close();
|
||||
await off.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user