diff --git a/docs/product/application-state.md b/docs/product/application-state.md index a539daa..c1ea5a5 100644 --- a/docs/product/application-state.md +++ b/docs/product/application-state.md @@ -58,6 +58,14 @@ Browser transport state lives beside, not inside, the domain snapshot. It record Server IDs are currently derived from the existing trimmed subscription tag. Stable IDs across cosmetic renames are deferred to TASK-008. +## Subscription import and refresh + +The browser validates only the shape and `http`/`https` protocol of a subscription URL. The provider is contacted once, after explicit submit. The backend fetches and parses the complete response before entering the serialized commit. + +Import and refresh share one commit path. It prepares the candidate server list and sing-box config first, then updates cache, config, runtime and canonical state. If provider fetch, parsing, config validation or runtime apply fails, the previous subscription cache, selected server, config and running process remain active. Refreshes for the saved URL share one in-flight Promise; a refresh that finishes after another import is rejected with `STATE_CONFLICT` instead of overwriting the newer subscription. + +The existing background refresh remains every 15 minutes. Provider requests time out after 15 seconds by default (`SUBSCRIPTION_TIMEOUT_MS` may override it). A failed background refresh logs a redacted warning and keeps the last successful subscription snapshot. + ## Compatibility and migration No path, volume or file is renamed. A legacy `state.json` without `revision`, `appliedTag` or `connectionDesired` is normalized on read: revision starts at `0`, the legacy `selectedTag` is treated as both desired and applied, and connection intent is inferred from the existing config. The next domain write stores the added fields. Existing unknown fields remain untouched. diff --git a/docs/product/frontend-operations.md b/docs/product/frontend-operations.md index 6158070..e429de2 100644 --- a/docs/product/frontend-operations.md +++ b/docs/product/frontend-operations.md @@ -11,6 +11,6 @@ Each entry is `{ status: "running", startedAt }`. A repeated operation key recei The registry only disables controls that can mutate the same domain state. Copy actions, instruction navigation and local tabs remain available during subscription refresh. Progress is announced with `role="status"`; the structured error from TASK-004 remains `role="alert"` after failure. -Subscription URL validation uses a latest-request runner. Starting a new validation aborts the previous signal and ignores its result even if the underlying request resolves late. +Subscription URL validation is local and accepts only well-formed `http` and `https` URLs. It does not contact the provider; the explicit import operation performs the single provider request and reports provider failures through the structured subscription error. The registry is local transport/UI state. It does not replace backend `snapshot.operation`, change revisions or persist data. Rollback is frontend-only. A `diagnostics` key is intentionally deferred until TASK-016 adds a diagnostics operation to run. diff --git a/src/server/config.js b/src/server/config.js index 3130e75..b35e21a 100644 --- a/src/server/config.js +++ b/src/server/config.js @@ -29,6 +29,7 @@ export const settings = { hostNetworkStatePath: process.env.HARBOR_HOST_NETWORK_STATE || "/run/harbor-host/network.json", gatewayPresencePort: parsePort(process.env.HARBOR_GATEWAY_CONTROL_PORT, 3456), + subscriptionTimeoutMs: parsePort(process.env.SUBSCRIPTION_TIMEOUT_MS, 15_000), hwidPath: path.join(dataDir, "hwid"), logLevel: process.env.LOG_LEVEL || "info", appName: "VPN Proxy Gateway", diff --git a/src/server/index.js b/src/server/index.js index b32c19b..fe62de4 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -431,55 +431,39 @@ async function applyRouteRules(routeRules) { } } -function refreshSavedSubscription() { - if (subscriptionRefreshPromise) return subscriptionRefreshPromise; - - subscriptionRefreshPromise = (async () => { - const initialState = stateStore.read(); - if (!initialState.subscriptionUrl) { - throw new HarborError('SUBSCRIPTION_INVALID'); +async function commitSubscription(subscriptionUrl, parsed, { resetSelection = false } = {}) { + return serializeControl(async () => { + const previousState = normalizeStoredState(stateStore.read()); + if (!resetSelection && previousState.subscriptionUrl !== subscriptionUrl) { + throw new HarborError('STATE_CONFLICT'); } - const subscriptionUrl = initialState.subscriptionUrl; - const parsed = await fetchSubscription(subscriptionUrl); - return serializeControl(async () => { - const currentState = stateStore.read(); - if (currentState.subscriptionUrl !== subscriptionUrl) { - throw new HarborError('STATE_CONFLICT'); - } + const selectedTag = resetSelection + ? '' + : selectRefreshedServer(previousState.selectedTag, parsed.servers); + const candidateConfig = selectedTag + ? buildActiveConfig(parsed.config, selectedTag, previousState.routeRules) + : null; + const previousCache = readSubscriptionCache(); + const previousConfig = fs.existsSync(settings.configPath) + ? fs.readFileSync(settings.configPath, 'utf8') + : null; + const previousGatewayAutoState = gatewayAutoState; + const wasRunning = Boolean((await singboxRuntime.refresh()).running); - const selectedTag = selectRefreshedServer(currentState.selectedTag, parsed.servers); - const previousCache = readSubscriptionCache(); - const previousConfig = fs.existsSync(settings.configPath) - ? fs.readFileSync(settings.configPath, 'utf8') - : null; - const activeConfigChanged = Boolean(currentState.selectedTag && selectedTag) && ( - !previousCache?.config || !isDeepStrictEqual( - buildActiveConfig(previousCache.config, currentState.selectedTag), - buildActiveConfig(parsed.config, selectedTag), - ) - ); + try { + if (resetSelection && wasRunning) await stopSingbox(); + if (candidateConfig) writeSingboxConfig(candidateConfig); + else removeSingboxConfig(); subscriptionCacheStore.write({ url: subscriptionUrl, ...parsed }); - - try { - if (singboxRuntime.running && activeConfigChanged) { - await applySelectedServer(selectedTag, { persist: false }); - } - else if (selectedTag) { - if (!singboxRuntime.running) writeSingboxConfig(buildActiveConfig(parsed.config, selectedTag)); - } else { - removeSingboxConfig(); - } - } catch (error) { - if (previousCache) subscriptionCacheStore.write(previousCache); - else subscriptionCacheStore.remove(); - if (previousConfig === null) removeSingboxConfig(); - else restoreSingboxConfig(previousConfig); - throw error; - } + if (!resetSelection && wasRunning && candidateConfig) await startSingbox(); updateStoredState((state) => ({ - ...state, + ...(resetSelection ? { + routeRules: state.routeRules, + gatewayAutoEnabled: state.gatewayAutoEnabled !== false, + connectionDesired: 'stopped', + } : state), subscriptionUrl, servers: parsed.servers, userInfo: parsed.userInfo, @@ -487,15 +471,48 @@ function refreshSavedSubscription() { selectedTag, appliedTag: selectedTag, })); + if (resetSelection) gatewayAutoState = createGatewayAutoState(); + } catch (error) { + gatewayAutoState = previousGatewayAutoState; + if (previousCache) subscriptionCacheStore.write(previousCache); + else subscriptionCacheStore.remove(); + if (previousConfig === null) removeSingboxConfig(); + else restoreSingboxConfig(previousConfig); + if (wasRunning) { + try { + await startSingbox(); + } catch (rollbackError) { + throw new HarborError('PROCESS_START_FAILED', { + cause: new AggregateError([error, rollbackError], 'Subscription rollback failed'), + }); + } + } + throw error; + } - return { - success: true, - servers: parsed.servers, - userInfo: parsed.userInfo, - fetchedAt: parsed.fetchedAt, - selectedTag, - }; - }); + return { + success: true, + servers: parsed.servers, + userInfo: parsed.userInfo, + fetchedAt: parsed.fetchedAt, + selectedTag, + }; + }); +} + +async function importSubscription(subscriptionUrl) { + const parsed = await fetchSubscription(subscriptionUrl); + return commitSubscription(subscriptionUrl, parsed, { resetSelection: true }); +} + +function refreshSavedSubscription() { + if (subscriptionRefreshPromise) return subscriptionRefreshPromise; + + subscriptionRefreshPromise = (async () => { + const subscriptionUrl = stateStore.read().subscriptionUrl; + if (!subscriptionUrl) throw new HarborError('SUBSCRIPTION_INVALID'); + const parsed = await fetchSubscription(subscriptionUrl); + return commitSubscription(subscriptionUrl, parsed); })().finally(() => { subscriptionRefreshPromise = null; }); @@ -553,25 +570,7 @@ async function handleApi(req, res) { const { url = '' } = await readBody(req); const normalizedUrl = String(url).trim(); const parsed = await withOperation('subscription-import', async () => { - const result = await fetchSubscription(normalizedUrl); - await serializeControl(async () => { - await stopSingbox(); - removeSingboxConfig(); - subscriptionCacheStore.write({ url: normalizedUrl, ...result }); - updateStoredState((state) => ({ - routeRules: state.routeRules, - subscriptionUrl: normalizedUrl, - gatewayAutoEnabled: state.gatewayAutoEnabled !== false, - servers: result.servers, - userInfo: result.userInfo, - fetchedAt: result.fetchedAt, - selectedTag: '', - appliedTag: '', - connectionDesired: 'stopped', - })); - gatewayAutoState = createGatewayAutoState(); - }); - return result; + return importSubscription(normalizedUrl); }); return sendState(res, parsed); } diff --git a/src/server/subscription.js b/src/server/subscription.js index ebf30f2..0b2b71b 100644 --- a/src/server/subscription.js +++ b/src/server/subscription.js @@ -143,7 +143,7 @@ export function parseSubscriptionBody(body) { return { config: parsedConfig, servers }; } -async function requestSubscription(url) { +async function requestSubscription(url, { fetchImpl = fetch, timeoutMs = settings.subscriptionTimeoutMs } = {}) { let parsedUrl; try { parsedUrl = new URL(url); @@ -157,9 +157,10 @@ async function requestSubscription(url) { let response; try { - response = await fetch(parsedUrl, { + response = await fetchImpl(parsedUrl, { headers: subscriptionHeaders(), redirect: 'follow', + signal: AbortSignal.timeout(timeoutMs), }); } catch (cause) { throw new HarborError('PROVIDER_UNAVAILABLE', { cause }); @@ -181,8 +182,8 @@ export function selectRefreshedServer(currentTag, servers) { || ''; } -export async function fetchSubscription(url) { - const response = await requestSubscription(url); +export async function fetchSubscription(url, options) { + const response = await requestSubscription(url, options); const body = await response.text(); const userInfo = parseUserInfo(response.headers.get('subscription-userinfo')); diff --git a/src/shared/versions.js b/src/shared/versions.js index 9221360..1131e0b 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.6.16', - gatewayClient: '0.6.15', - gatewayBackend: '0.6.0', + macClient: '0.6.17', + gatewayClient: '0.6.16', + gatewayBackend: '0.6.1', }); export function parseVersion(value) { diff --git a/src/web/api.js b/src/web/api.js index 0089e3d..a96d42a 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -16,10 +16,6 @@ export class HarborApiError extends Error { } } -export function validationStatusForError(error) { - return error?.code === 'SUBSCRIPTION_INVALID' ? 'invalid' : 'unavailable'; -} - export async function request(url, options = {}, fetchImpl = fetch) { let response; try { @@ -54,11 +50,6 @@ export const api = { state: () => request('/api/state'), version: () => request('/api/version'), subscription: { - validate: (url, signal) => request('/api/subscription/validate', { - method: 'POST', - body: JSON.stringify({ url }), - signal, - }), fetch: (url) => request('/api/subscription/fetch', { method: 'POST', body: JSON.stringify({ url }), diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index 5d67f57..ea0aa64 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -1,10 +1,11 @@ import React, { useEffect, useRef, useState } from 'react'; import { flushSync } from 'react-dom'; -import { api, validationStatusForError } from '../api.js'; +import { api } from '../api.js'; import { connectionAction, connectionDurationParts, copyText, + isSubscriptionUrlValid, localProxyUrls, subscriptionDomain, subscriptionDaysLeft, @@ -12,7 +13,7 @@ import { } from '../utils/clientControls.js'; import { formatBytes } from '../utils/format.js'; import { instructionBlocks } from '../instructions.js'; -import { createLatestRequest, operationBlocked } from '../state/operations.js'; +import { operationBlocked } from '../state/operations.js'; import { ConfirmationPopup } from './ConfirmationPopup.jsx'; import { canAppendRouteRule } from '../../shared/routingRules.js'; import { @@ -594,8 +595,6 @@ export function ClientOverviewPage({ } }); const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription); - const [subscriptionValidation, setSubscriptionValidation] = useState({ url: '', status: 'idle' }); - const [validationAttempt, setValidationAttempt] = useState(0); const [showIntro, setShowIntro] = useState(!hasSubscription); const [subscriptionContentReady, setSubscriptionContentReady] = useState(hasSubscription); const [pings, setPings] = useState({}); @@ -621,8 +620,6 @@ export function ClientOverviewPage({ const localRulesToggleRef = useRef(null); const localRulesBaselineRef = useRef('[]'); const previousHasSubscriptionRef = useRef(hasSubscription); - const validationRequests = useRef(null); - if (!validationRequests.current) validationRequests.current = createLatestRequest(); const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|'); const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1'; const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress); @@ -646,12 +643,9 @@ export function ClientOverviewPage({ ? [openInstruction, ...instructionGuides.filter((block) => block.id !== openInstructionId)] : instructionGuides; const normalizedSubscriptionUrl = subscriptionUrl.trim(); - const subscriptionValidationStatus = subscriptionValidation.url === normalizedSubscriptionUrl - ? subscriptionValidation.status - : normalizedSubscriptionUrl ? 'checking' : 'idle'; - const subscriptionError = subscriptionValidation.url === normalizedSubscriptionUrl - ? subscriptionValidation.error - : null; + const subscriptionValidationStatus = !normalizedSubscriptionUrl + ? 'idle' + : isSubscriptionUrlValid(normalizedSubscriptionUrl) ? 'valid' : 'invalid'; const subscriptionWaiting = hasSubscription && !subscriptionContentReady; const connectionBlocked = operationBlocked(operations, 'connection'); const serverApplyBlocked = operationBlocked(operations, 'serverApply'); @@ -729,54 +723,6 @@ export function ClientOverviewPage({ return () => clearTimeout(timer); }, [hasSubscription]); - useEffect(() => { - validationRequests.current.cancel(); - if (!normalizedSubscriptionUrl) { - setSubscriptionValidation({ url: '', status: 'idle' }); - return undefined; - } - - setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'checking' }); - const timer = setTimeout(() => { - validationRequests.current - .run((signal) => api.subscription.validate(normalizedSubscriptionUrl, signal)) - .then((result) => { - if (!result) return; - setSubscriptionValidation({ - url: normalizedSubscriptionUrl, - status: 'valid', - error: null, - }); - }) - .catch((validationError) => { - setSubscriptionValidation({ - url: normalizedSubscriptionUrl, - status: validationStatusForError(validationError), - error: { - context: 'subscription', - message: validationError.message, - correlationId: validationError.correlationId, - retry: validationError.retryable - ? () => { - setSubscriptionValidation({ - url: normalizedSubscriptionUrl, - status: 'checking', - error: null, - }); - setValidationAttempt((attempt) => attempt + 1); - } - : null, - }, - }); - }); - }, 350); - - return () => { - clearTimeout(timer); - validationRequests.current.cancel(); - }; - }, [normalizedSubscriptionUrl, validationAttempt]); - useEffect(() => { if (!hasSubscription) { setEditingSubscription(true); @@ -1317,22 +1263,16 @@ export function ClientOverviewPage({ type="submit" aria-label={subscriptionValidationStatus === 'valid' ? 'Сохранить подписку' - : subscriptionValidationStatus === 'invalid' - ? 'Ссылка подписки не распознана' - : subscriptionValidationStatus === 'unavailable' - ? 'Проверка подписки временно недоступна' - : 'Проверяем ссылку подписки'} + : 'Ссылка подписки не распознана'} disabled={subscriptionImportBlocked || subscriptionValidationStatus !== 'valid'} > {subscriptionValidationStatus === 'valid' ? '✓' - : subscriptionValidationStatus === 'invalid' - ? '×' - : subscriptionValidationStatus === 'unavailable' ? '!' : '…'} + : '×'} )} - + diff --git a/src/web/state/operations.js b/src/web/state/operations.js index 07470ac..5ad0726 100644 --- a/src/web/state/operations.js +++ b/src/web/state/operations.js @@ -40,27 +40,3 @@ export function createOperationRegistry(onChange = () => {}, now = () => new Dat return { run, getSnapshot: () => operations }; } - -export function createLatestRequest() { - let controller = null; - - return { - run(action) { - controller?.abort(); - controller = new AbortController(); - const current = controller; - return Promise.resolve() - .then(() => action(current.signal)) - .then( - (value) => current.signal.aborted ? undefined : value, - (error) => { - if (current.signal.aborted) return undefined; - throw error; - }, - ); - }, - cancel() { - controller?.abort(); - }, - }; -} diff --git a/src/web/styles.css b/src/web/styles.css index fcf28f9..1338884 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -2361,20 +2361,11 @@ p { cursor: default; } -.client-subscription-edit.is-checking .client-subscription-submit { - color: var(--client-muted); - animation: client-validation-pulse 900ms ease-in-out infinite alternate; -} - .client-subscription-edit.is-invalid .client-subscription-submit { color: oklch(0.68 0.15 28); opacity: 0.9; } -@keyframes client-validation-pulse { - to { opacity: 0.35; } -} - .client-subscription-heading { display: flex; align-items: center; diff --git a/src/web/utils/clientControls.js b/src/web/utils/clientControls.js index 8b83dff..b296be1 100644 --- a/src/web/utils/clientControls.js +++ b/src/web/utils/clientControls.js @@ -61,6 +61,14 @@ export function subscriptionDomain(subscriptionHost) { } } +export function isSubscriptionUrlValid(value) { + try { + return ['http:', 'https:'].includes(new URL(String(value).trim()).protocol); + } catch { + return false; + } +} + export function localProxyUrls(port = 8082, host = '127.0.0.1') { const urlHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host; return { diff --git a/test/server/state-contract.test.js b/test/server/state-contract.test.js index e5ebed8..692c6fd 100644 --- a/test/server/state-contract.test.js +++ b/test/server/state-contract.test.js @@ -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'); diff --git a/test/web/api-errors.test.js b/test/web/api-errors.test.js index 88cf134..a5091d2 100644 --- a/test/web/api-errors.test.js +++ b/test/web/api-errors.test.js @@ -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'); -}); diff --git a/test/web/client-controls.test.js b/test/web/client-controls.test.js index 64f412b..5b2f28b 100644 --- a/test/web/client-controls.test.js +++ b/test/web/client-controls.test.js @@ -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', diff --git a/test/web/operations.test.js b/test/web/operations.test.js index 49a3d5e..6177961 100644 --- a/test/web/operations.test.js +++ b/test/web/operations.test.js @@ -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); -});