Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ Before submitting code, run typecheck, build, and focused tests proportionate to

- [Website](https://maka.apache.org/en/)
- [Documentation index and authority map](./docs/README.md)
- [Bot platform guide](./docs/bot-platforms.md) ([中文](./docs/bot-platforms.zh-CN.md))
- [Backend architecture](./ARCHITECTURE.md)
- [Product design](./DESIGN.md)
- [Contributing guide](./CONTRIBUTING.md)
Expand Down
212 changes: 212 additions & 0 deletions apps/desktop/src/main/__tests__/client-network-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from "node:assert/strict";
import { describe, test } from "node:test";
import type { ProxySettings } from "@maka/core/settings/network-settings";
import type { NetworkProxyResolveResult } from "@maka/runtime-host/protocol";
import { createClientNetworkProxyApplier } from "../client-network-proxy.js";

const RESOLVED: NetworkProxyResolveResult = {
kind: "ready",
proxy: {
enabled: true,
type: "http",
host: "127.0.0.1",
port: 7897,
bypassList: ["localhost"],
},
};

function harness(
profileKind: "local" | "environment" | "remote",
resolve: () => Promise<NetworkProxyResolveResult>,
options: { active?: () => boolean } = {},
) {
const applied: (ProxySettings | null)[] = [];
let blocked = 0;
const errors: unknown[] = [];
const scheduled: { run: () => void; delayMs: number }[] = [];
const applier = createClientNetworkProxyApplier({
profileKind,
resolve,
isAuthoritativeTarget: options.active,
apply: (proxy) => applied.push(proxy),
applyBlocked: () => { blocked += 1; },
onError: (error) => errors.push(error),
schedule: (run, delayMs) => {
scheduled.push({ run, delayMs });
},
});
return { applier, applied, blocked: () => blocked, errors, scheduled };
}

const flush = () => new Promise<void>((resolve) => setImmediate(resolve));

describe("createClientNetworkProxyApplier", () => {
test("applies the resolved proxy for a local Host", async () => {
const { applier, applied } = harness("local", async () => RESOLVED);
await applier.refresh();
assert.deepStrictEqual(applied, [RESOLVED.proxy]);
});

test("applies direct when the policy disables the proxy", async () => {
const { applier, applied } = harness("local", async () => ({ kind: "ready" }));
await applier.refresh();
assert.deepStrictEqual(applied, [null]);
});

test("blocks client-owned requests when the proxy credential is missing", async () => {
const { applier, applied, blocked } = harness("local", async () => ({
kind: "credential_not_configured",
}));
await applier.refresh();
assert.deepStrictEqual(applied, []);
assert.equal(blocked(), 1);
});

test("never adopts a non-local Host's proxy policy", async () => {
for (const kind of ["remote", "environment"] as const) {
let resolved = false;
const { applier, applied } = harness(kind, async () => {
resolved = true;
return RESOLVED;
});
await applier.refresh();
// The bot bridges dial out from this machine, so a Host describing a
// different network must not be asked, let alone applied.
assert.strictEqual(resolved, false, `${kind} resolved the proxy`);
assert.deepStrictEqual(applied, [null]);
}
});

test("keeps the last applied proxy when resolution fails", async () => {
let fail = false;
const { applier, applied, errors } = harness("local", async () => {
if (fail) throw new Error("Host unreachable");
return RESOLVED;
});
await applier.refresh();
fail = true;
await applier.refresh();
// A brief Host outage, or an older Host without the operation, is not
// evidence that the user wants direct connections.
assert.deepStrictEqual(applied, [RESOLVED.proxy]);
assert.strictEqual(errors.length, 1);
});

test("reports a repeated resolution failure once", async () => {
const { applier, errors } = harness("local", async () => {
throw new Error("Host unreachable");
});
await applier.refresh();
await applier.refresh();
assert.strictEqual(errors.length, 1);
});

test("retries a failed first resolution instead of settling on direct", async () => {
// The first refresh runs while the Host connection is still settling, so
// losing that race must not leave the bot bridges direct until the user
// next edits the proxy.
let fail = true;
const { applier, applied, scheduled } = harness("local", async () => {
if (fail) throw new Error("host_not_ready");
return RESOLVED;
});
await applier.refresh();
assert.deepStrictEqual(applied, []);
assert.strictEqual(scheduled.length, 1);

fail = false;
scheduled[0]?.run();
await flush();
assert.deepStrictEqual(applied, [RESOLVED.proxy]);
});

test("bounds the retry budget instead of reconnecting forever", async () => {
const { applier, scheduled } = harness("local", async () => {
throw new Error("host_not_ready");
});
await applier.refresh();
for (let index = 0; index < 6; index += 1) {
const pending = scheduled[index];
if (!pending) break;
pending.run();
await flush();
}
assert.deepStrictEqual(
scheduled.map((entry) => entry.delayMs),
[1_000, 5_000, 15_000],
);
});

test("serializes concurrent refreshes so the last resolution wins", async () => {
const gates: (() => void)[] = [];
const order: string[] = [];
let call = 0;
const { applier, applied } = harness("local", async () => {
const index = call++;
order.push(`start:${index}`);
await new Promise<void>((resolve) => gates.push(resolve));
order.push(`end:${index}`);
return index === 0 ? RESOLVED : { kind: "ready" };
});

const first = applier.refresh();
const second = applier.refresh();
await flush();
// The second resolution must not have begun while the first is in flight.
assert.deepStrictEqual(order, ["start:0"]);
gates[0]?.();
await first;
await flush();
gates[1]?.();
await second;
assert.deepStrictEqual(order, ["start:0", "end:0", "start:1", "end:1"]);
assert.deepStrictEqual(applied, [RESOLVED.proxy, null]);
});

test("fences a late resolution after the target stops being authoritative", async () => {
let active = true;
let resolveRequest!: (result: NetworkProxyResolveResult) => void;
const { applier, applied } = harness(
"local",
() => new Promise((resolve) => { resolveRequest = resolve; }),
{ active: () => active },
);
const refresh = applier.refresh();
await flush();
active = false;
resolveRequest(RESOLVED);
await refresh;
assert.deepStrictEqual(applied, []);
});

test("disposes pending retries and ignores a late retry callback", async () => {
const { applier, scheduled, applied } = harness("local", async () => {
throw new Error("host_not_ready");
});
await applier.refresh();
assert.equal(scheduled.length, 1);
applier.dispose();
scheduled[0]?.run();
await flush();
assert.deepStrictEqual(applied, []);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ function createModuleFixture(options: {
},
};

let networkProxyChanges = 0;
const module = createRuntimeHostSettingsModule({
client: client as never,
settingsStore: {
Expand All @@ -261,16 +262,32 @@ function createModuleFixture(options: {
},
} as never,
async applyClientSettings() {},
onNetworkProxyChanged: () => {
networkProxyChanges += 1;
},
});

return {
module,
events,
policy: () => policy,
secret: () => secret,
networkProxyChanges: () => networkProxyChanges,
};
}

test("a proxy patch notifies Client-owned traffic so it re-resolves", async () => {
const fixture = createModuleFixture();

// Without this the bot bridges keep the proxy they were started with, which
// in practice means none at all (apache/maka#5091).
await fixture.module.update({ network: { proxy: { host: "127.0.0.1" } } });
assert.equal(fixture.networkProxyChanges(), 1);

await fixture.module.update({ personalization: { displayName: "Operator" } });
assert.equal(fixture.networkProxyChanges(), 1);
});

test("runtime settings project credential status without a password value", async () => {
const fixture = createModuleFixture({ configured: true });

Expand Down
Loading