Refine subscription import and refresh flow
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-07-12 11:18:33 +03:00
parent ba15a25c89
commit 005c7a101b
15 changed files with 190 additions and 218 deletions

View File

@@ -114,11 +114,32 @@ setInterval(() => {}, 60_000);
fs.writeFileSync(singboxPath, workingSingbox);
fs.chmodSync(singboxPath, 0o755);
const subscriptionServer = http.createServer((req, res) => {
let providerFetchCount = 0;
let delayedPath = '';
let invalidNextPath = '';
let delayedRequestStarted = null;
let releaseDelayedRequest = null;
const subscriptionServer = http.createServer(async (req, res) => {
providerFetchCount += 1;
if (req.url === '/timeout') return;
if (req.url === '/unavailable') {
res.writeHead(503);
return res.end('unavailable');
}
if (req.url === '/invalid') {
res.writeHead(200, { 'content-type': 'text/plain' });
return res.end('not a subscription');
}
if (req.url === invalidNextPath) {
invalidNextPath = '';
res.writeHead(200, { 'content-type': 'text/plain' });
return res.end('not a subscription');
}
if (req.url === delayedPath) {
delayedRequestStarted?.();
await new Promise((resolve) => { releaseDelayedRequest = resolve; });
delayedPath = '';
}
res.writeHead(200, {
'content-type': 'application/json',
'subscription-userinfo': 'upload=10; download=20; total=100',
@@ -147,6 +168,7 @@ setInterval(() => {}, 60_000);
PORT: String(port),
PATH: `${binDir}:${process.env.PATH}`,
HARBOR_HOST_NETWORK_STATE: path.join(dir, 'missing-network.json'),
SUBSCRIPTION_TIMEOUT_MS: '50',
},
stdio: ['ignore', 'ignore', 'pipe'],
});
@@ -206,6 +228,36 @@ setInterval(() => {}, 60_000);
assert.equal(providerUnavailable.payload.error.code, 'PROVIDER_UNAVAILABLE');
assert.equal(providerUnavailable.payload.error.retryable, true);
const preservedSubscription = {
state: JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')),
cache: fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'),
config: fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'),
};
for (const [pathname, expectedCode] of [
['/timeout', 'PROVIDER_UNAVAILABLE'],
['/invalid', 'SUBSCRIPTION_INVALID'],
]) {
const failedImport = await rawRequest(
port,
'/api/subscription/fetch',
'POST',
{ url: `http://127.0.0.1:${subscriptionPort}${pathname}` },
);
assert.equal(failedImport.payload.error.code, expectedCode);
const storedAfterFailure = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
assert.equal(storedAfterFailure.subscriptionUrl, preservedSubscription.state.subscriptionUrl);
assert.equal(storedAfterFailure.selectedTag, preservedSubscription.state.selectedTag);
assert.deepEqual(storedAfterFailure.servers, preservedSubscription.state.servers);
assert.equal(
fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'),
preservedSubscription.cache,
);
assert.equal(
fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'),
preservedSubscription.config,
);
}
const missingServer = await rawRequest(
port,
'/api/apply',
@@ -230,14 +282,25 @@ setInterval(() => {}, 60_000);
return result;
}
await stateResponse('/api/subscription/validate', 'POST', { url: subscriptionUrl });
const fetchesBeforeImport = providerFetchCount;
await mutation('/api/subscription/fetch', 'POST', { url: subscriptionUrl });
assert.equal(providerFetchCount, fetchesBeforeImport + 1);
const applied = await mutation('/api/apply', 'POST', { selectedTag: 'test-vpn' });
assert.deepEqual(applied.state.selection, {
desiredServerId: 'test-vpn',
appliedServerId: 'test-vpn',
});
assert.equal(applied.state.connection.process, 'running');
const cacheBeforeFailedRefresh = fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8');
const configBeforeFailedRefresh = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
invalidNextPath = '/subscription/test';
const failedRefresh = await rawRequest(port, '/api/subscription/refresh', 'POST');
assert.equal(failedRefresh.response.status, 400);
assert.equal(failedRefresh.payload.error.code, 'SUBSCRIPTION_INVALID');
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')).selectedTag, 'test-vpn');
assert.equal(fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'), cacheBeforeFailedRefresh);
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), configBeforeFailedRefresh);
revision = (await request(port, '/api/state')).revision;
assert.equal((await mutation('/api/singbox/stop')).state.connection.desired, 'stopped');
assert.equal((await mutation('/api/singbox/restart')).state.connection.desired, 'running');
@@ -341,7 +404,19 @@ if (process.argv[2] === 'check') {
fs.writeFileSync(singboxPath, workingSingbox);
fs.chmodSync(singboxPath, 0o755);
await mutation('/api/subscription/refresh');
const delayedRequest = new Promise((resolve) => { delayedRequestStarted = resolve; });
delayedPath = '/subscription/test';
const staleRefresh = rawRequest(port, '/api/subscription/refresh', 'POST');
await delayedRequest;
const replacementUrl = `http://127.0.0.1:${subscriptionPort}/subscription/replacement`;
await mutation('/api/subscription/fetch', 'POST', { url: replacementUrl });
releaseDelayedRequest();
const staleRefreshResult = await staleRefresh;
assert.equal(staleRefreshResult.response.status, 409);
assert.equal(staleRefreshResult.payload.error.code, 'STATE_CONFLICT');
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')).subscriptionUrl, replacementUrl);
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8')).url, replacementUrl);
revision = (await request(port, '/api/state')).revision;
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');

View File

@@ -4,7 +4,6 @@ import test from 'node:test';
import {
HarborApiError,
request,
validationStatusForError,
} from '../../src/web/api.js';
const response = (status, error) => ({
@@ -54,8 +53,3 @@ test('local unknown errors get a safe message and diagnostic reference', () => {
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');
});

View File

@@ -6,6 +6,7 @@ import {
copyText,
formatConnectionDuration,
formatConnectionDurationWords,
isSubscriptionUrlValid,
localProxyUrls,
subscriptionDomain,
subscriptionDaysLeft,
@@ -44,6 +45,13 @@ test('saved subscription is reduced to its public domain', () => {
assert.equal(subscriptionDomain(''), '');
});
test('subscription URL is validated locally by shape', () => {
assert.equal(isSubscriptionUrlValid(' https://sub.example/token '), true);
assert.equal(isSubscriptionUrlValid('http://127.0.0.1/subscription'), true);
assert.equal(isSubscriptionUrlValid('ftp://sub.example/token'), false);
assert.equal(isSubscriptionUrlValid('not-a-url'), false);
});
test('local proxy exposes both supported URLs', () => {
assert.deepEqual(localProxyUrls(18080), {
socks5: 'socks5://127.0.0.1:18080',

View File

@@ -3,7 +3,6 @@ import http from 'node:http';
import test from 'node:test';
import {
createLatestRequest,
createOperationRegistry,
OPERATION_CONFLICTS,
operationBlocked,
@@ -72,22 +71,3 @@ test('a conflicting operation is rejected before its action starts', async () =>
connection.resolve(true);
await running;
});
test('latest request cancels and ignores a stale validation result', async () => {
const latest = createLatestRequest();
const oldResult = deferred();
const newResult = deferred();
let oldSignal;
const oldRequest = latest.run((signal) => {
oldSignal = signal;
return oldResult.promise;
});
const newRequest = latest.run(() => newResult.promise);
oldResult.resolve('old');
newResult.resolve('new');
assert.equal(await oldRequest, undefined);
assert.equal(await newRequest, 'new');
assert.equal(oldSignal.aborted, true);
});