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');