SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,226 @@
|
||||
// Hot-reload tests for HttpServerAdapter — pins the contract that
|
||||
// `reload({ accessToken: <new> })` must take effect on the very next
|
||||
// request without the listener being torn down. Earlier the adapter
|
||||
// captured `accessToken` into a `startServer()` closure and relied on
|
||||
// `bindingSignature` to trigger a `close()` + `open()` round-trip on
|
||||
// token change; that race-prone path is gone and `handleRequest` now
|
||||
// reads `this.config.accessToken` per request.
|
||||
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import http from 'http';
|
||||
import type { AddressInfo } from 'net';
|
||||
import { HttpServerAdapter } from '../src/network/http-server-adapter';
|
||||
import { NetworkReloadType, type NetworkAdapterContext } from '../src/network/adapter';
|
||||
import type { HttpServerNetwork } from '../src/types';
|
||||
|
||||
function fakeCtx(): NetworkAdapterContext {
|
||||
return {
|
||||
uin: '10001',
|
||||
api: {
|
||||
handle: vi.fn(async () => ({ status: 'ok', retcode: 0, data: { online: true } })),
|
||||
} as any,
|
||||
buildLifecycleEvent: vi.fn(() => ({})),
|
||||
buildHeartbeatEvent: vi.fn(() => ({})),
|
||||
};
|
||||
}
|
||||
|
||||
function get(
|
||||
port: number,
|
||||
path: string,
|
||||
headers: Record<string, string> = {},
|
||||
): Promise<{ status: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{ hostname: '127.0.0.1', port, path, method: 'GET', headers },
|
||||
(res) => {
|
||||
res.on('data', () => {
|
||||
/* drain */
|
||||
});
|
||||
res.on('end', () => resolve({ status: res.statusCode! }));
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Wait until the underlying http.Server has bound its port; tests that
|
||||
// hit the socket too early get ECONNREFUSED on slow CI hosts.
|
||||
async function waitForListen(adapter: HttpServerAdapter): Promise<number> {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const server = (adapter as any).server as http.Server | null;
|
||||
const addr = server?.address() as AddressInfo | null | undefined;
|
||||
|
||||
if (addr && typeof addr === 'object') {
|
||||
return addr.port;
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
|
||||
throw new Error('http server never started listening');
|
||||
}
|
||||
|
||||
describe('HttpServerAdapter — accessToken hot reload', () => {
|
||||
let adapter: HttpServerAdapter | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await adapter?.close();
|
||||
adapter = null;
|
||||
});
|
||||
|
||||
it('a swapped accessToken authorizes the new token on the next request — no listener re-bind', async () => {
|
||||
const ctx = fakeCtx();
|
||||
// port=0 → OS-assigned; the swap proves no re-bind happens because
|
||||
// a re-bind would land on a different port and `waitForListen`
|
||||
// would diverge from the original.
|
||||
const config: HttpServerNetwork = {
|
||||
name: 'hot',
|
||||
port: 0,
|
||||
enabled: true,
|
||||
accessToken: 'tok-1',
|
||||
messageFormat: 'array',
|
||||
reportSelfMessage: false,
|
||||
};
|
||||
adapter = new HttpServerAdapter('hot', config, ctx);
|
||||
await adapter.open();
|
||||
const portBefore = await waitForListen(adapter);
|
||||
|
||||
// Sanity: old token authorizes, anything else 401s.
|
||||
expect((await get(portBefore, '/', { authorization: 'Bearer tok-1' })).status).toBe(200);
|
||||
expect((await get(portBefore, '/', { authorization: 'Bearer tok-2' })).status).toBe(401);
|
||||
|
||||
// Hot-swap the token.
|
||||
await adapter.reload({ ...config, accessToken: 'tok-2' });
|
||||
|
||||
// Same port (no re-bind) — and the new token must take effect on
|
||||
// the very next request.
|
||||
const portAfter = ((adapter as any).server as http.Server).address() as AddressInfo;
|
||||
expect(portAfter.port).toBe(portBefore);
|
||||
|
||||
expect((await get(portBefore, '/', { authorization: 'Bearer tok-1' })).status).toBe(401);
|
||||
expect((await get(portBefore, '/', { authorization: 'Bearer tok-2' })).status).toBe(200);
|
||||
});
|
||||
|
||||
it('clearing accessToken to empty disables auth on the next request', async () => {
|
||||
const ctx = fakeCtx();
|
||||
const config: HttpServerNetwork = {
|
||||
name: 'hot-clear',
|
||||
port: 0,
|
||||
enabled: true,
|
||||
accessToken: 'tok-1',
|
||||
messageFormat: 'array',
|
||||
reportSelfMessage: false,
|
||||
};
|
||||
adapter = new HttpServerAdapter('hot-clear', config, ctx);
|
||||
await adapter.open();
|
||||
const port = await waitForListen(adapter);
|
||||
|
||||
expect((await get(port, '/')).status).toBe(401); // no token header → 401
|
||||
|
||||
await adapter.reload({ ...config, accessToken: '' });
|
||||
|
||||
expect((await get(port, '/')).status).toBe(200); // auth disabled
|
||||
});
|
||||
|
||||
it('setting accessToken from empty enforces auth on the next request', async () => {
|
||||
const ctx = fakeCtx();
|
||||
const config: HttpServerNetwork = {
|
||||
name: 'hot-add',
|
||||
port: 0,
|
||||
enabled: true,
|
||||
messageFormat: 'array',
|
||||
reportSelfMessage: false,
|
||||
};
|
||||
adapter = new HttpServerAdapter('hot-add', config, ctx);
|
||||
await adapter.open();
|
||||
const port = await waitForListen(adapter);
|
||||
|
||||
expect((await get(port, '/')).status).toBe(200); // no auth configured
|
||||
|
||||
await adapter.reload({ ...config, accessToken: 'tok-new' });
|
||||
|
||||
expect((await get(port, '/')).status).toBe(401); // auth now required
|
||||
expect((await get(port, '/', { authorization: 'Bearer tok-new' })).status).toBe(200);
|
||||
});
|
||||
|
||||
it('changing only accessToken returns NetworkReloadType.Normal — no Reopened/Closed', async () => {
|
||||
const ctx = fakeCtx();
|
||||
const config: HttpServerNetwork = {
|
||||
name: 'hot-type',
|
||||
port: 0,
|
||||
enabled: true,
|
||||
accessToken: 'a',
|
||||
messageFormat: 'array',
|
||||
reportSelfMessage: false,
|
||||
};
|
||||
adapter = new HttpServerAdapter('hot-type', config, ctx);
|
||||
await adapter.open();
|
||||
await waitForListen(adapter);
|
||||
|
||||
const reloadType = await adapter.reload({ ...config, accessToken: 'b' });
|
||||
// Without re-binding, the adapter reports `Normal` — anything but
|
||||
// `Reopened` / `Closed` is acceptable; we mostly care that nothing
|
||||
// touched the listener.
|
||||
expect(reloadType).toBe(NetworkReloadType.Normal);
|
||||
});
|
||||
|
||||
it('rejects open only after an occupied-port bind actually fails', async () => {
|
||||
const blocker = http.createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
blocker.once('error', reject);
|
||||
blocker.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
const address = blocker.address() as AddressInfo;
|
||||
const config: HttpServerNetwork = {
|
||||
name: 'occupied',
|
||||
host: '127.0.0.1',
|
||||
port: address.port,
|
||||
enabled: true,
|
||||
messageFormat: 'array',
|
||||
reportSelfMessage: false,
|
||||
};
|
||||
adapter = new HttpServerAdapter('occupied', config, fakeCtx());
|
||||
|
||||
try {
|
||||
await expect(adapter.open()).rejects.toMatchObject({ code: 'EADDRINUSE' });
|
||||
expect(adapter.describeManagedStatus()).toMatchObject({
|
||||
status: 'degraded',
|
||||
lastError: expect.stringContaining('EADDRINUSE'),
|
||||
});
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
blocker.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('close resolves only after the port can be rebound', async () => {
|
||||
const config: HttpServerNetwork = {
|
||||
name: 'release',
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
enabled: true,
|
||||
messageFormat: 'array',
|
||||
reportSelfMessage: false,
|
||||
};
|
||||
adapter = new HttpServerAdapter('release', config, fakeCtx());
|
||||
await adapter.open();
|
||||
const port = ((adapter as any).server as http.Server).address() as AddressInfo;
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
|
||||
const rebound = http.createServer();
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
rebound.once('error', reject);
|
||||
rebound.listen(port.port, '127.0.0.1', resolve);
|
||||
});
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
rebound.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user