Handle rejected subscriptions and add local client compose
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 6s

This commit is contained in:
2026-07-13 20:23:37 +03:00
parent 8c19f2cba9
commit 56f5e408e3
10 changed files with 306 additions and 443 deletions

View File

@@ -13,6 +13,7 @@ import {
readHostNetworkState,
verifyGatewayPresence,
} from '../../src/server/gatewayPresence.js';
import { createStateSnapshot } from '../../src/shared/contracts/state.js';
const subscriptionUrl = 'https://subscription.example/0123456789abcdef0123456789abcdef';
const nonce = '0123456789abcdef0123456789abcdef';
@@ -34,16 +35,19 @@ test('Gateway presence is authenticated by the shared subscription secret', asyn
const result = await probeGatewayPresence({
gateway: '192.168.50.111',
subscriptionUrl,
port: 4567,
nonce,
fetchImpl: async (url) => {
assert.equal(
url,
`http://192.168.50.111:3456/api/gateway-presence?nonce=${nonce}`,
`http://192.168.50.111:4567/api/gateway-presence?nonce=${nonce}`,
);
return { ok: true, json: async () => payload };
},
});
assert.deepEqual(result, { gatewayId: 'gateway-1' });
assert.equal(result.gatewayId, 'gateway-1');
assert.equal(result.uiOrigin, 'http://192.168.50.111:4567');
assert.equal(Number.isFinite(Date.parse(result.verifiedAt)), true);
assert.equal(buildGatewayPresence({
appMode: 'gateway',
@@ -88,9 +92,15 @@ test('host route freshness and Gateway failures drive a safe automatic fallback'
let state = nextGatewayAutoState(createGatewayAutoState(), {
network,
verifiedGateway: { gatewayId: 'gateway-1' },
verifiedGateway: {
gatewayId: 'gateway-1',
uiOrigin: 'http://192.168.50.111:4567',
verifiedAt: new Date(now).toISOString(),
},
});
assert.equal(state.mode, 'gateway-direct');
assert.equal(state.uiOrigin, 'http://192.168.50.111:4567');
assert.equal(state.lastVerifiedAt, new Date(now).toISOString());
state = nextGatewayAutoState(state, { network });
state = nextGatewayAutoState(state, { network });
@@ -116,6 +126,44 @@ test('host route freshness and Gateway failures drive a safe automatic fallback'
assert.equal(readHostNetworkState(statePath, { now }), null);
});
test('canonical route distinguishes fresh, stale, lost, disabled and local states', () => {
const storedState = {
revision: 1,
subscriptionUrl,
gatewayAutoEnabled: true,
};
const snapshot = (gatewayAuto, stored = storedState) => createStateSnapshot({
storedState: stored,
runtime: { running: true },
gatewayAuto,
appMode: 'client',
configExists: true,
subscriptionHost: 'subscription.example/…',
now: new Date('2026-07-13T12:00:00.000Z'),
}).route;
const fresh = {
...createGatewayAutoState(),
mode: 'gateway-direct',
gateway: { gateway: '192.168.50.111' },
gatewayId: 'gateway-1',
uiOrigin: 'http://192.168.50.111:4567',
lastVerifiedAt: '2026-07-13T11:59:59.000Z',
};
const found = snapshot(fresh);
assert.equal(found.mode, 'gateway-direct');
assert.equal(found.reason, 'gateway-found');
assert.equal(found.gatewayAddress, '192.168.50.111');
assert.equal(found.gatewayUiOrigin, 'http://192.168.50.111:4567');
assert.equal(found.lastVerifiedAt, '2026-07-13T11:59:59.000Z');
assert.equal(found.autoEnabled, true);
assert.equal(found.fallbackPreference, 'local-vpn');
assert.equal(snapshot({ ...fresh, failures: 1 }).reason, 'gateway-stale');
assert.equal(snapshot({ ...fresh, mode: 'local-vpn', gatewayId: '', lastError: 'lost' }).reason, 'gateway-lost');
assert.equal(snapshot(fresh, { ...storedState, gatewayAutoEnabled: false }).reason, 'disabled');
assert.equal(snapshot(createGatewayAutoState()).reason, 'local');
});
test('client can ignore and restore a verified Gateway without losing discovery', () => {
const detected = {
...createGatewayAutoState(),

View File

@@ -9,6 +9,7 @@ import {
formatConnectionDurationWords,
isSubscriptionUrlValid,
localProxyUrls,
routePresentation,
subscriptionDomain,
subscriptionDaysLeft,
subscriptionUsage,
@@ -74,6 +75,14 @@ test('gateway proxy URLs use its network address', () => {
});
});
test('route copy distinguishes found, stale, lost, disabled and local states', () => {
assert.equal(routePresentation({ mode: 'gateway-direct', reason: 'gateway-found' }).title, 'Через Harbor Gateway');
assert.equal(routePresentation({ mode: 'gateway-direct', reason: 'gateway-stale' }).tone, 'stale');
assert.match(routePresentation({ mode: 'local-vpn', reason: 'gateway-lost' }).title, /Gateway недоступен/);
assert.match(routePresentation({ mode: 'local-vpn', reason: 'disabled' }).detail, /выключен/);
assert.equal(routePresentation({ mode: 'local-vpn', reason: 'local' }).title, 'Локальный VPN');
});
test('copy uses the synchronous native path available on gateway HTTP', async () => {
const textarea = {
style: {},

View File

@@ -94,7 +94,7 @@ test('tooltips stay opaque, above adjacent content, and do not stick after point
assert.match(styles, /\.client-tooltip-anchor:has\(> :focus-visible\) > \.client-tooltip/);
assert.doesNotMatch(styles, /\.client-tooltip-anchor:focus-within > \.client-tooltip/);
assert.match(rule('.harbor-version-tooltip'), /background:\s*oklch\(0\.14 0\.012 145\)/);
assert.match(rule('.harbor-mode-tooltip'), /background:\s*oklch\(0\.14 0\.012 145\)/);
assert.doesNotMatch(component, /harbor-mode-tooltip|harbor-brand-control/);
});
test('subscription validation waits for the provider and keeps diagnostics below errors', () => {

View File

@@ -69,3 +69,13 @@ test('copy feedback, drawers and Gateway tabs expose complete keyboard semantics
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-copy-button \{[\s\S]*min-height: 44px/);
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-local-rule-enabled,[\s\S]*\.client-local-rule-delete \{[\s\S]*width: 44px;[\s\S]*height: 44px/);
});
test('Connect route uses a labelled native Auto control instead of the logo', () => {
assert.match(component, /aria-label="Маршрут Connect"/);
assert.match(component, /type="checkbox"[\s\S]*checked={route\?\.autoEnabled !== false}/);
assert.match(component, /onChange={\(event\) => onSetGatewayAuto\(event\.target\.checked\)}/);
assert.match(component, /Использовать домашний Gateway автоматически/);
assert.doesNotMatch(component, /harbor-brand-control|:3456/);
assert.match(styles, /\.client-route-auto \{[\s\S]*min-height: 44px/);
assert.match(styles, /\.client-route-auto:has\(input:focus-visible\)/);
});