Unify Harbor error handling across server and client
This commit is contained in:
28
test/server/errors.test.js
Normal file
28
test/server/errors.test.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
ERROR_DEFINITIONS,
|
||||
HarborError,
|
||||
normalizeHarborError,
|
||||
} from '../../src/shared/errors.js';
|
||||
|
||||
test('every Harbor error code has stable Russian copy and retry policy', () => {
|
||||
for (const [code, definition] of Object.entries(ERROR_DEFINITIONS)) {
|
||||
const error = new HarborError(code);
|
||||
assert.equal(error.code, code);
|
||||
assert.equal(error.message, definition.message);
|
||||
assert.equal(error.retryable, definition.retryable);
|
||||
assert.match(error.message, /[А-Яа-яЁё]/);
|
||||
assert.equal(typeof error.status, 'number');
|
||||
}
|
||||
});
|
||||
|
||||
test('unknown failures use the safe non-retryable fallback', () => {
|
||||
const error = normalizeHarborError(new Error('secret internal failure'));
|
||||
|
||||
assert.equal(error.code, 'UNKNOWN');
|
||||
assert.equal(error.message, ERROR_DEFINITIONS.UNKNOWN.message);
|
||||
assert.equal(error.retryable, false);
|
||||
assert.equal(error.message.includes('secret'), false);
|
||||
});
|
||||
@@ -29,13 +29,18 @@ async function freePort() {
|
||||
return port;
|
||||
}
|
||||
|
||||
async function request(port, pathname, method = 'GET', body) {
|
||||
async function rawRequest(port, pathname, method = 'GET', body) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}${pathname}`, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const payload = await response.json();
|
||||
return { response, payload };
|
||||
}
|
||||
|
||||
async function request(port, pathname, method = 'GET', body) {
|
||||
const { response, payload } = await rawRequest(port, pathname, method, body);
|
||||
assert.equal(response.ok, true, JSON.stringify(payload));
|
||||
return payload;
|
||||
}
|
||||
@@ -54,7 +59,7 @@ async function waitForState(port, child, stderr) {
|
||||
|
||||
test('state v1 normalizes legacy storage and validates the canonical snapshot', () => {
|
||||
const stored = normalizeStoredState({
|
||||
subscriptionUrl: 'https://provider.example/subscription/full-secret-token',
|
||||
subscriptionUrl: 'https://provider.example/subscription/test',
|
||||
selectedTag: ' legacy ',
|
||||
servers: [{ tag: ' legacy ', type: 'vless', server: 'vpn.example', server_port: 443 }],
|
||||
});
|
||||
@@ -95,14 +100,20 @@ test('GET and domain mutations return one state shape with monotonic revisions',
|
||||
}],
|
||||
};
|
||||
fs.mkdirSync(binDir);
|
||||
fs.writeFileSync(path.join(binDir, 'sing-box'), `#!/usr/bin/env node
|
||||
const singboxPath = path.join(binDir, 'sing-box');
|
||||
const workingSingbox = `#!/usr/bin/env node
|
||||
if (process.argv[2] === 'check') process.exit(0);
|
||||
process.on('SIGTERM', () => process.exit(0));
|
||||
setInterval(() => {}, 60_000);
|
||||
`);
|
||||
fs.chmodSync(path.join(binDir, 'sing-box'), 0o755);
|
||||
`;
|
||||
fs.writeFileSync(singboxPath, workingSingbox);
|
||||
fs.chmodSync(singboxPath, 0o755);
|
||||
|
||||
const subscriptionServer = http.createServer((req, res) => {
|
||||
if (req.url === '/unavailable') {
|
||||
res.writeHead(503);
|
||||
return res.end('unavailable');
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'content-type': 'application/json',
|
||||
'subscription-userinfo': 'upload=10; download=20; total=100',
|
||||
@@ -110,7 +121,7 @@ setInterval(() => {}, 60_000);
|
||||
res.end(JSON.stringify(config));
|
||||
});
|
||||
const subscriptionPort = await listen(subscriptionServer);
|
||||
const subscriptionUrl = `http://127.0.0.1:${subscriptionPort}/subscription/full-secret-token`;
|
||||
const subscriptionUrl = `http://127.0.0.1:${subscriptionPort}/subscription/test`;
|
||||
fs.writeFileSync(path.join(dir, 'state.json'), JSON.stringify({
|
||||
subscriptionUrl,
|
||||
selectedTag: 'test-vpn',
|
||||
@@ -150,6 +161,42 @@ setInterval(() => {}, 60_000);
|
||||
const stateKeys = Object.keys(initial).sort();
|
||||
let revision = initial.revision;
|
||||
|
||||
const invalidSubscription = await rawRequest(
|
||||
port,
|
||||
'/api/subscription/validate',
|
||||
'POST',
|
||||
{ url: 'not-a-url' },
|
||||
);
|
||||
assert.equal(invalidSubscription.response.status, 400);
|
||||
assert.deepEqual(
|
||||
{
|
||||
code: invalidSubscription.payload.error.code,
|
||||
retryable: invalidSubscription.payload.error.retryable,
|
||||
},
|
||||
{ code: 'SUBSCRIPTION_INVALID', retryable: false },
|
||||
);
|
||||
assert.equal(typeof invalidSubscription.payload.error.correlationId, 'string');
|
||||
|
||||
const providerUnavailable = await rawRequest(
|
||||
port,
|
||||
'/api/subscription/validate',
|
||||
'POST',
|
||||
{ url: `http://127.0.0.1:${subscriptionPort}/unavailable` },
|
||||
);
|
||||
assert.equal(providerUnavailable.response.status, 502);
|
||||
assert.equal(providerUnavailable.payload.error.code, 'PROVIDER_UNAVAILABLE');
|
||||
assert.equal(providerUnavailable.payload.error.retryable, true);
|
||||
|
||||
const missingServer = await rawRequest(
|
||||
port,
|
||||
'/api/apply',
|
||||
'POST',
|
||||
{ selectedTag: 'missing-server' },
|
||||
);
|
||||
assert.equal(missingServer.response.status, 404);
|
||||
assert.equal(missingServer.payload.error.code, 'SERVER_NOT_FOUND');
|
||||
assert.equal((await request(port, '/api/state')).selection.desiredServerId, 'test-vpn');
|
||||
|
||||
async function stateResponse(pathname, method = 'POST', body) {
|
||||
const result = await request(port, pathname, method, body);
|
||||
assert.deepEqual(Object.keys(result.state).sort(), stateKeys);
|
||||
@@ -174,10 +221,30 @@ setInterval(() => {}, 60_000);
|
||||
assert.equal(applied.state.connection.process, 'running');
|
||||
assert.equal((await mutation('/api/singbox/stop')).state.connection.desired, 'stopped');
|
||||
assert.equal((await mutation('/api/singbox/restart')).state.connection.desired, 'running');
|
||||
|
||||
fs.writeFileSync(singboxPath, `#!/usr/bin/env node
|
||||
if (process.argv[2] === 'check') {
|
||||
require('node:fs').unlinkSync(process.argv[1]);
|
||||
process.exit(0);
|
||||
}
|
||||
`);
|
||||
fs.chmodSync(singboxPath, 0o755);
|
||||
const processFailure = await rawRequest(port, '/api/singbox/restart', 'POST');
|
||||
assert.equal(processFailure.response.status, 503);
|
||||
assert.equal(processFailure.payload.error.code, 'PROCESS_START_FAILED');
|
||||
assert.equal(processFailure.payload.error.retryable, true);
|
||||
fs.writeFileSync(singboxPath, workingSingbox);
|
||||
fs.chmodSync(singboxPath, 0o755);
|
||||
|
||||
await mutation('/api/subscription/refresh');
|
||||
assert.equal((await mutation('/api/gateway-auto', 'POST', { enabled: false })).state.gatewayAuto.enabled, false);
|
||||
const forgotten = await mutation('/api/subscription', 'DELETE');
|
||||
assert.equal(forgotten.state.subscription.status, 'missing');
|
||||
assert.equal(forgotten.state.servers.length, 0);
|
||||
assert.deepEqual((await stateResponse('/api/servers/ping-all')).results, []);
|
||||
|
||||
const missingConfig = await rawRequest(port, '/api/singbox/restart', 'POST');
|
||||
assert.equal(missingConfig.response.status, 422);
|
||||
assert.equal(missingConfig.payload.error.code, 'CONFIG_INVALID');
|
||||
assert.equal(missingConfig.payload.error.retryable, false);
|
||||
});
|
||||
|
||||
61
test/web/api-errors.test.js
Normal file
61
test/web/api-errors.test.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
HarborApiError,
|
||||
request,
|
||||
validationStatusForError,
|
||||
} from '../../src/web/api.js';
|
||||
|
||||
const response = (status, error) => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => ({ success: false, error }),
|
||||
});
|
||||
|
||||
test('frontend exposes retry only for retryable structured errors', async () => {
|
||||
await assert.rejects(
|
||||
request('/api/test', {}, async () => response(502, {
|
||||
code: 'PROVIDER_UNAVAILABLE',
|
||||
message: 'untrusted server copy',
|
||||
retryable: false,
|
||||
correlationId: 'provider-reference',
|
||||
})),
|
||||
(error) => {
|
||||
assert.ok(error instanceof HarborApiError);
|
||||
assert.equal(error.message, 'Провайдер подписки временно недоступен.');
|
||||
assert.equal(error.retryable, true);
|
||||
assert.equal(error.correlationId, 'provider-reference');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
request('/api/test', {}, async () => response(400, {
|
||||
code: 'SUBSCRIPTION_INVALID',
|
||||
retryable: true,
|
||||
})),
|
||||
(error) => error.retryable === false,
|
||||
);
|
||||
});
|
||||
|
||||
test('network failures become retryable control errors', async () => {
|
||||
await assert.rejects(
|
||||
request('/api/state', {}, async () => { throw new TypeError('fetch failed'); }),
|
||||
(error) => error.code === 'CONTROL_UNREACHABLE' && error.retryable === true,
|
||||
);
|
||||
});
|
||||
|
||||
test('local unknown errors get a safe message and diagnostic reference', () => {
|
||||
const error = new HarborApiError({ code: 'NOT_A_REAL_CODE' });
|
||||
|
||||
assert.equal(error.code, 'UNKNOWN');
|
||||
assert.equal(error.message, 'Не удалось выполнить действие.');
|
||||
assert.equal(typeof error.correlationId, 'string');
|
||||
assert.ok(error.correlationId.length >= 8);
|
||||
});
|
||||
|
||||
test('subscription validation distinguishes bad input from provider outage', () => {
|
||||
assert.equal(validationStatusForError({ code: 'SUBSCRIPTION_INVALID' }), 'invalid');
|
||||
assert.equal(validationStatusForError({ code: 'PROVIDER_UNAVAILABLE' }), 'unavailable');
|
||||
});
|
||||
Reference in New Issue
Block a user