portal-mojo 0.2.19 → 0.2.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/README.md +7 -1
- package/docs/admin-credentials.md +27 -0
- package/docs/admin-deployments.md +201 -0
- package/docs/admin-dns-certificates.md +160 -0
- package/docs/admin-dns-domains.md +92 -0
- package/docs/admin-dns.md +18 -0
- package/docs/admin-edge.md +200 -196
- package/docs/admin-fleet-configuration.md +98 -2
- package/docs/admin-geolocation.md +100 -0
- package/docs/admin-identity-directories.md +12 -0
- package/docs/admin-maintenance.md +133 -0
- package/docs/admin-modals.md +45 -2
- package/docs/admin-operational-fleet.md +206 -0
- package/docs/admin-presentation.md +51 -0
- package/docs/admin-signin.md +12 -2
- package/docs/auth-pages.md +18 -6
- package/docs/auth.md +16 -2
- package/docs/client.md +7 -0
- package/docs/collection-select.md +14 -0
- package/docs/combobox.md +18 -0
- package/docs/popover.md +5 -4
- package/package.json +1 -1
- package/src/admin/core/OperationsHelp.tsx +10 -0
- package/src/admin/core/index.ts +6 -4
- package/src/admin/core/route-context.ts +6 -0
- package/src/admin/credentials/AcmeConsumerKeyDialog.tsx +88 -0
- package/src/admin/credentials/acme-consumer-key.ts +34 -0
- package/src/admin/credentials/group-api-keys.tsx +5 -3
- package/src/admin/deployments/ApiDeploymentDetail.tsx +58 -0
- package/src/admin/deployments/DeploymentsPage.tsx +73 -0
- package/src/admin/deployments/WebAppDeploySetup.tsx +49 -0
- package/src/admin/deployments/WebAppDeploymentDetail.tsx +59 -0
- package/src/admin/deployments/WebAppDetail.tsx +104 -0
- package/src/admin/deployments/WebAppEditDialog.tsx +70 -0
- package/src/admin/deployments/WebAppKeyDialog.tsx +25 -0
- package/src/admin/deployments/WebAppOnboardingDialog.tsx +217 -0
- package/src/admin/deployments/WebAppRollbackDialog.tsx +82 -0
- package/src/admin/deployments/WebAppUploadPanel.tsx +132 -0
- package/src/admin/deployments/api.ts +158 -0
- package/src/admin/deployments/authority-state.ts +9 -0
- package/src/admin/deployments/deployment-state.ts +13 -0
- package/src/admin/deployments/index.ts +4 -0
- package/src/admin/deployments/onboarding-state.ts +11 -0
- package/src/admin/deployments/permissions.ts +37 -0
- package/src/admin/deployments/receipts.ts +78 -0
- package/src/admin/deployments/sanitize.ts +73 -0
- package/src/admin/deployments/shared.tsx +39 -0
- package/src/admin/deployments/types.ts +238 -0
- package/src/admin/deployments/upload.ts +53 -0
- package/src/admin/deployments/useDeploymentObservation.ts +104 -0
- package/src/admin/dns/CertificateDetail.tsx +8 -3
- package/src/admin/dns/CertificateRequestDialog.tsx +45 -7
- package/src/admin/dns/DomainDetail.tsx +14 -4
- package/src/admin/dns/DomainOnboardingContent.tsx +44 -0
- package/src/admin/dns/DomainOnboardingDialog.tsx +5 -24
- package/src/admin/dns/DomainPurchaseWizard.tsx +1 -1
- package/src/admin/dns/DomainRegisterExisting.tsx +63 -0
- package/src/admin/dns/DomainRegistrarSettings.tsx +52 -0
- package/src/admin/dns/DomainsPage.tsx +26 -13
- package/src/admin/dns/SharedValidationDialog.tsx +298 -0
- package/src/admin/dns/SharedValidationPanel.tsx +76 -0
- package/src/admin/dns/api.ts +4 -2
- package/src/admin/dns/domain-navigation.ts +16 -0
- package/src/admin/dns/registrar-api.ts +13 -0
- package/src/admin/dns/shared-validation-api.ts +90 -0
- package/src/admin/dns/shared-validation-data.ts +106 -0
- package/src/admin/domains/assistant.ts +5 -4
- package/src/admin/domains/communications.ts +10 -9
- package/src/admin/domains/identity.ts +8 -7
- package/src/admin/domains/infrastructure.ts +19 -13
- package/src/admin/domains/lazy-page.ts +6 -0
- package/src/admin/domains/observability.ts +6 -5
- package/src/admin/domains/operations.ts +27 -10
- package/src/admin/domains/security.ts +16 -15
- package/src/admin/edge/EdgeHelp.tsx +10 -0
- package/src/admin/edge/ServingReadiness.tsx +54 -0
- package/src/admin/edge/VhostDetail.tsx +145 -139
- package/src/admin/edge/VhostWizard.tsx +204 -115
- package/src/admin/edge/VhostsPage.tsx +27 -28
- package/src/admin/edge/api.ts +24 -10
- package/src/admin/edge/models.ts +4 -4
- package/src/admin/edge/serving-data.ts +78 -0
- package/src/admin/fleet/CapacityEditor.tsx +81 -0
- package/src/admin/fleet/CapacityInspector.tsx +19 -0
- package/src/admin/fleet/CapacityProgress.tsx +9 -0
- package/src/admin/fleet/FleetHelp.tsx +7 -0
- package/src/admin/fleet/FleetOverview.tsx +150 -0
- package/src/admin/fleet/FleetPage.tsx +11 -0
- package/src/admin/fleet/FleetWorkspace.tsx +82 -0
- package/src/admin/fleet/ScaleFleetDialog.tsx +169 -0
- package/src/admin/fleet/access.ts +22 -0
- package/src/admin/fleet/api.ts +77 -0
- package/src/admin/fleet/data.ts +148 -0
- package/src/admin/fleet/index.ts +8 -0
- package/src/admin/fleet/operations.ts +179 -0
- package/src/admin/fleet/standalone-data.ts +87 -0
- package/src/admin/fleet/types.ts +208 -0
- package/src/admin/fleet-configuration/FleetConfigurationPage.tsx +14 -8
- package/src/admin/fleet-configuration/SharedValidationConnectionPanel.tsx +166 -0
- package/src/admin/fleet-configuration/data.ts +4 -0
- package/src/admin/fleet-configuration/operator-destinations.ts +20 -0
- package/src/admin/fleet-configuration/shared-validation-data.ts +143 -0
- package/src/admin/geolocation/GeoLocationPage.tsx +171 -0
- package/src/admin/geolocation/data.ts +138 -0
- package/src/admin/geolocation/index.ts +3 -0
- package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +73 -2
- package/src/admin/identity/groups/models.ts +2 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +2 -8
- package/src/admin/index.ts +8 -0
- package/src/admin/maintenance/MaintenanceDialogs.tsx +161 -0
- package/src/admin/maintenance/MaintenancePage.tsx +442 -0
- package/src/admin/maintenance/data.ts +486 -0
- package/src/admin/maintenance/index.ts +3 -0
- package/src/admin/maintenance/operations.ts +208 -0
- package/src/admin/public/infrastructure.ts +1 -0
- package/src/admin/public/operations.ts +3 -0
- package/src/admin/registry.ts +6 -2
- package/src/admin/settings/SettingDetail.tsx +7 -2
- package/src/admin/settings/SettingEditor.tsx +2 -1
- package/src/admin/settings/SettingsPage.tsx +3 -1
- package/src/admin/settings/model.ts +1 -0
- package/src/admin/signin/SigninPage.tsx +17 -3
- package/src/admin/signin/api.ts +2 -0
- package/src/charts/MetricsChartView.tsx +4 -11
- package/src/charts/MetricsMiniWidget.tsx +3 -15
- package/src/client/auth.ts +77 -25
- package/src/client/client.ts +6 -0
- package/src/client/deployments-mock.ts +375 -0
- package/src/client/domain-operations-mock.ts +37 -0
- package/src/client/fleet-mock.ts +73 -1
- package/src/client/geo-provider-mock.ts +82 -0
- package/src/client/index.ts +2 -2
- package/src/client/maintenance-mock.ts +285 -0
- package/src/client/mock.ts +280 -11
- package/src/client/operational-fleet-mock.ts +279 -0
- package/src/client/operations-mock.ts +85 -0
- package/src/client/serving-mock.ts +22 -0
- package/src/styles/components/admin-deployments.css +35 -0
- package/src/styles/components/admin-dns-certificates.css +33 -0
- package/src/styles/components/admin-domains.css +12 -0
- package/src/styles/components/admin-edge.css +125 -1
- package/src/styles/components/admin-geolocation.css +40 -0
- package/src/styles/components/admin-maintenance.css +127 -0
- package/src/styles/components/admin-operational-fleet.css +38 -0
- package/src/styles/components/admin-operations.css +9 -0
- package/src/styles/components/admin-signin.css +2 -0
- package/src/styles/components/combobox.css +12 -2
- package/src/styles/components/fleet-page.css +9 -0
- package/src/styles/index.css +7 -0
- package/src/ui/CollectionSelect.tsx +30 -8
- package/src/ui/ComboBox.tsx +39 -8
- package/src/ui/FilterBar.tsx +3 -5
- package/src/ui/ModelTable.tsx +2 -8
- package/src/ui/Popover.tsx +15 -7
- package/src/ui/date/TimePicker.tsx +4 -14
- package/src/ui/image-editor/ImageEditor.tsx +4 -5
- package/src/ui/schema-form-core.tsx +3 -5
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Fixture-only GeoLocation provider setup. No submitted credential is retained or observed. */
|
|
2
|
+
import type { GeoDraft, GeoLocationState, GeoVerdict, StoredGeoVerification } from '../admin/geolocation/data';
|
|
3
|
+
export const mockGeoProviderScenarios = ['ready', 'no-key', 'unknown-provider', 'empty-registry', 'unavailable', 'remote-error', 'rejected-key', 'missing-sync-permission', 'changed-origin', 'stale-revision', 'pending-restart', 'published-origin-pending', 'missing-delegation', 'old-api', 'malformed', 'fresh-auth', 'save-uncertain'] as const;
|
|
4
|
+
export type MockGeoProviderScenario = typeof mockGeoProviderScenarios[number];
|
|
5
|
+
const endpoint = '/api/account/admin/settings';
|
|
6
|
+
const keys = ['GEOIP_PRIMARY_PROVIDER', 'GEOIP_FALLBACK_PROVIDER', 'GEOIP_ADDITIONAL_PROVIDERS', 'GEOIP_MOJO_PROVIDER_URL', 'GEOIP_MOJO_SYNC_ENABLED'];
|
|
7
|
+
const defaults: GeoDraft = { GEOIP_PRIMARY_PROVIDER: 'mojo', GEOIP_FALLBACK_PROVIDER: 'ipinfo', GEOIP_ADDITIONAL_PROVIDERS: ['ip-api', 'maxmind'], GEOIP_MOJO_PROVIDER_URL: 'https://api.mojoverify.com', GEOIP_MOJO_SYNC_ENABLED: false };
|
|
8
|
+
const metadata = [
|
|
9
|
+
['Primary GeoIP provider', 'Provider queried first for IP intelligence.', 'string', 'Provider identifier'],
|
|
10
|
+
['Fallback GeoIP provider', 'Provider used when the primary cannot answer.', 'string', 'Provider identifier'],
|
|
11
|
+
['Additional GeoIP providers', 'Extra providers available after primary and fallback.', 'list', 'Unique provider identifiers'],
|
|
12
|
+
['Mojo GeoIP URL', 'Canonical HTTPS origin of the upstream django-mojo provider.', 'origin', 'One HTTPS origin'],
|
|
13
|
+
['Mojo GeoIP sync', 'Push observed abuse signals back to the upstream provider.', 'boolean', ''],
|
|
14
|
+
];
|
|
15
|
+
let scenario: MockGeoProviderScenario = 'ready', sequence = 1, publishedVersion = 1, usedFault = false;
|
|
16
|
+
let configurationRevision = '1'.repeat(32), publishedRevision = '1'.repeat(32), loadedRevision = '1'.repeat(32);
|
|
17
|
+
let values: GeoDraft = structuredClone(defaults), loadedOrigin = defaults.GEOIP_MOJO_PROVIDER_URL, loadedSync = false;
|
|
18
|
+
let keyConfigured = true, keyHint = '9f2c', stored: StoredGeoVerification | undefined;
|
|
19
|
+
const observations: { action: string; key_action: 'keep' | 'replace' | 'clear'; stored_test: boolean; expected_revision: string | null }[] = [];
|
|
20
|
+
export function setMockGeoProviderScenario(value: MockGeoProviderScenario): void {
|
|
21
|
+
if (!mockGeoProviderScenarios.includes(value)) throw new Error('Unknown GeoLocation fixture');
|
|
22
|
+
scenario = value; usedFault = false; sequence = 1; publishedVersion = 1; configurationRevision = publishedRevision = loadedRevision = '1'.repeat(32);
|
|
23
|
+
values = structuredClone(defaults); loadedOrigin = defaults.GEOIP_MOJO_PROVIDER_URL; loadedSync = false; keyConfigured = true; keyHint = '9f2c'; observations.length = 0;
|
|
24
|
+
stored = { ok: true, code: null, message: 'Connection verified', at: '2026-10-21T12:00:00Z' };
|
|
25
|
+
if (value === 'no-key') { keyConfigured = false; keyHint = ''; stored = undefined; }
|
|
26
|
+
if (value === 'unknown-provider') { values.GEOIP_PRIMARY_PROVIDER = 'custom_geo'; values.GEOIP_ADDITIONAL_PROVIDERS = ['custom_extra', 'maxmind']; }
|
|
27
|
+
if (['pending-restart', 'published-origin-pending', 'changed-origin'].includes(value)) { publishedRevision = configurationRevision = '2'.repeat(32); }
|
|
28
|
+
if (['published-origin-pending', 'changed-origin'].includes(value)) loadedOrigin = 'https://legacy-geo.example.invalid';
|
|
29
|
+
}
|
|
30
|
+
export const getMockGeoProviderScenario = (): MockGeoProviderScenario => scenario;
|
|
31
|
+
export const getMockGeoProviderObservations = () => observations.map(row => ({ ...row }));
|
|
32
|
+
export const clearMockGeoProviderObservations = () => { observations.length = 0; };
|
|
33
|
+
export function geoProviderSetupMock(): GeoLocationState {
|
|
34
|
+
return { available: scenario !== 'unavailable', bucket_configured: scenario !== 'unavailable', restart_configured: true, object_key: 'fixture/config/django.override.json',
|
|
35
|
+
delegated_keys: keys.filter(key => scenario !== 'missing-delegation' || key !== keys[2]), fleet_fields: keys.map((key, i) => ({ key, label: metadata[i]![0]!, description: metadata[i]![1]!, value_type: metadata[i]![2]!, constraints: metadata[i]![3]! })),
|
|
36
|
+
loaded_revision: loadedRevision, published_revision: publishedRevision, configuration_revision: configurationRevision, published_version: `fixture-version-${publishedVersion}`, pending_restart: publishedRevision !== loadedRevision,
|
|
37
|
+
remote_error: scenario === 'remote-error' ? 'FixtureRemoteReadError' : null, geoip: { ...structuredClone(values), GEOIP_API_KEY_MOJO_CONFIGURED: keyConfigured, GEOIP_API_KEY_MOJO_HINT: keyHint },
|
|
38
|
+
geoip_providers: scenario === 'empty-registry' ? [] : ['maxmind', 'ipinfo', 'ipstack', 'ip-api', 'mojo'], verify_state: stored ? { geoip: { ...stored } } : {} };
|
|
39
|
+
}
|
|
40
|
+
const fail = (error: string, code = 400) => ({ status: false, code, error_code: code, error });
|
|
41
|
+
const provider = (v: unknown): v is string => typeof v === 'string' && /^[a-z][a-z0-9_-]{0,31}$/.test(v);
|
|
42
|
+
function origin(v: unknown): v is string {
|
|
43
|
+
if (typeof v !== 'string' || v.length > 512) return false;
|
|
44
|
+
try { const u = new URL(v); return u.protocol === 'https:' && !!u.hostname && !u.username && !u.password && !u.search && !u.hash && ['', '/'].includes(u.pathname) && (!u.port || u.port === '443'); } catch { return false; }
|
|
45
|
+
}
|
|
46
|
+
const record = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
47
|
+
/** Undefined means another topic/route owns the request. The central mock supplies session gates. */
|
|
48
|
+
export function geoProviderMock(path: string, method: string, body: Record<string, unknown> | undefined, superuser: boolean): unknown {
|
|
49
|
+
if (path !== endpoint) return undefined;
|
|
50
|
+
if (method !== 'GET' && body?.topic !== 'geoip') return undefined;
|
|
51
|
+
if (!superuser) return fail('Superuser required', 403);
|
|
52
|
+
if (scenario === 'old-api') return fail('Not found', 404);
|
|
53
|
+
if (method === 'GET') return { status: true, data: { schema_version: scenario === 'malformed' ? 999 : 1, provider_setup: geoProviderSetupMock() } };
|
|
54
|
+
if (method !== 'POST' || !body || Object.keys(body).sort().join(',') !== 'action,providers,topic' || !['test_providers', 'configure_providers'].includes(String(body.action))) return fail('Provider setup accepts only action, topic, and providers');
|
|
55
|
+
const p = body.providers;
|
|
56
|
+
if (!record(p) || Object.keys(p).some(k => !['expected_revision', 'geoip'].includes(k)) || !record(p.geoip)) return fail('Provider setup accepts only geoip and expected_revision');
|
|
57
|
+
const g = p.geoip;
|
|
58
|
+
if (Object.keys(g).some(k => ![...keys, 'GEOIP_API_KEY_MOJO', 'clear_api_key'].includes(k))) return fail('GeoIP setup contains unsupported fields');
|
|
59
|
+
const candidate = { ...defaults, GEOIP_ADDITIONAL_PROVIDERS: [], GEOIP_MOJO_PROVIDER_URL: 'https://api.mojoverify.com', ...g };
|
|
60
|
+
if (!provider(candidate.GEOIP_PRIMARY_PROVIDER) || !provider(candidate.GEOIP_FALLBACK_PROVIDER) || !Array.isArray(candidate.GEOIP_ADDITIONAL_PROVIDERS) || candidate.GEOIP_ADDITIONAL_PROVIDERS.length > 16 || !candidate.GEOIP_ADDITIONAL_PROVIDERS.every(provider) || new Set(candidate.GEOIP_ADDITIONAL_PROVIDERS).size !== candidate.GEOIP_ADDITIONAL_PROVIDERS.length || !origin(candidate.GEOIP_MOJO_PROVIDER_URL) || typeof candidate.GEOIP_MOJO_SYNC_ENABLED !== 'boolean') return fail('Invalid GeoIP provider settings');
|
|
61
|
+
if (p.expected_revision != null && (typeof p.expected_revision !== 'string' || !/^[a-f0-9]{32,64}$/.test(p.expected_revision))) return fail('Provider setup revision is invalid');
|
|
62
|
+
if (g.GEOIP_API_KEY_MOJO != null && (typeof g.GEOIP_API_KEY_MOJO !== 'string' || g.GEOIP_API_KEY_MOJO.length > 4096)) return fail('GeoIP API key is invalid');
|
|
63
|
+
if (scenario === 'missing-delegation' || body.action === 'configure_providers' && ['unavailable', 'remote-error'].includes(scenario)) return fail('Fleet publishing requires its exact object, all provider delegations, a KMS key, and config-sync restart');
|
|
64
|
+
if (scenario === 'fresh-auth' && !usedFault) { usedFault = true; return fail('Fresh authentication required', 440); }
|
|
65
|
+
const next: GeoDraft = { GEOIP_PRIMARY_PROVIDER: candidate.GEOIP_PRIMARY_PROVIDER, GEOIP_FALLBACK_PROVIDER: candidate.GEOIP_FALLBACK_PROVIDER, GEOIP_ADDITIONAL_PROVIDERS: [...candidate.GEOIP_ADDITIONAL_PROVIDERS], GEOIP_MOJO_PROVIDER_URL: candidate.GEOIP_MOJO_PROVIDER_URL.replace(/\/$/, ''), GEOIP_MOJO_SYNC_ENABLED: candidate.GEOIP_MOJO_SYNC_ENABLED };
|
|
66
|
+
const clear = g.clear_api_key === true, typed = typeof g.GEOIP_API_KEY_MOJO === 'string' && g.GEOIP_API_KEY_MOJO !== '';
|
|
67
|
+
// The real predicate ignores primary/fallback/additional differences and compares NODE-LOADED connection facts.
|
|
68
|
+
const testedStored = !clear && !typed && next.GEOIP_MOJO_PROVIDER_URL === loadedOrigin && next.GEOIP_MOJO_SYNC_ENABLED === loadedSync;
|
|
69
|
+
observations.push({ action: String(body.action), key_action: clear ? 'clear' : typed ? 'replace' : 'keep', stored_test: testedStored, expected_revision: p.expected_revision == null ? null : String(p.expected_revision) });
|
|
70
|
+
const accepted = scenario !== 'rejected-key' && (typed ? !String(g.GEOIP_API_KEY_MOJO).startsWith('rejected') : keyConfigured && next.GEOIP_MOJO_PROVIDER_URL === loadedOrigin);
|
|
71
|
+
const result: GeoVerdict = clear ? { success: true, code: 'clear_requested', message: 'GeoIP credential will be cleared' } : accepted && next.GEOIP_MOJO_SYNC_ENABLED && scenario === 'missing-sync-permission' ? { success: false, code: 'insufficient_permission', message: `${new URL(next.GEOIP_MOJO_PROVIDER_URL).hostname} accepted the key, but it is missing geoip_sync` } : accepted ? { success: true, code: null, message: 'Connection verified' } : { success: false, code: 'http_401', message: `${new URL(next.GEOIP_MOJO_PROVIDER_URL).hostname} rejected the API key` };
|
|
72
|
+
const remember = () => { stored = { ok: result.success, code: result.code, message: result.message, at: new Date().toISOString() }; };
|
|
73
|
+
if (body.action === 'test_providers') { if (testedStored) remember(); return { status: true, tested: true, topic: 'geoip', results: { geoip: result }, success: result.success }; }
|
|
74
|
+
if (!result.success) return fail('Provider verification failed for geoip');
|
|
75
|
+
if (p.expected_revision !== configurationRevision || scenario === 'stale-revision' && !usedFault) { usedFault = true; configurationRevision = '3'.repeat(32); return fail('Provider configuration changed; reload before publishing'); }
|
|
76
|
+
const changed = JSON.stringify(next) !== JSON.stringify(values);
|
|
77
|
+
if (clear) { keyConfigured = false; keyHint = ''; } else if (typed) { keyConfigured = true; keyHint = String(g.GEOIP_API_KEY_MOJO).length >= 8 ? String(g.GEOIP_API_KEY_MOJO).slice(-4) : ''; }
|
|
78
|
+
values = next; configurationRevision = (++sequence).toString(16).padStart(32, '0'); if (changed) { publishedRevision = configurationRevision; publishedVersion++; } remember();
|
|
79
|
+
if (scenario === 'save-uncertain' && !usedFault) { usedFault = true; return fail('Connection unavailable after persistence', 503); }
|
|
80
|
+
return { status: true, published: changed, unchanged: !changed, topic: 'geoip', revision: configurationRevision, published_revision: publishedRevision, version_id: `fixture-version-${publishedVersion}`, pending_restart: publishedRevision !== loadedRevision, results: { geoip: result } };
|
|
81
|
+
}
|
|
82
|
+
setMockGeoProviderScenario('ready');
|
package/src/client/index.ts
CHANGED
|
@@ -32,8 +32,8 @@ export * from './realtime-mock';
|
|
|
32
32
|
export * from './types';
|
|
33
33
|
// Dev affordance: per-endpoint call counts from the mock (single-flight proof).
|
|
34
34
|
export {
|
|
35
|
-
armMockReauth, clearMockRequestHistory, getMockCallCounts, setMockAssistantSetupScenario,
|
|
36
|
-
getMockRequestHistory, setMockDnsConfigMalformed, setMockDnsAcmeMode, armMockDnsWriteFault,
|
|
35
|
+
armMockReauth, clearMockRequestHistory, getMockCallCounts, setMockAssistantSetupScenario, setMockConnectionScenario, type MockConnectionScenario,
|
|
36
|
+
getMockRequestHistory, setMockSharedValidationScenario, armMockSharedValidationFault, type MockSharedValidationScenario, setMockDnsConfigMalformed, setMockDnsAcmeMode, armMockDnsWriteFault,
|
|
37
37
|
getMockPhoneConfigCredentialState, getMockEffectivePhoneConfigId,
|
|
38
38
|
deleteMockPhoneGroupForTest,
|
|
39
39
|
setMockDnsRegistrarMode, armMockRegistrarPurchaseFault,
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/** Mock-owned Maintenance provider simulation. Never performs a provider request. */
|
|
2
|
+
import { backendGlobalCan, type BackendGlobalUser } from '../admin/fleet/access';
|
|
3
|
+
import type { Finding, Framework, Report } from '../admin/maintenance/data';
|
|
4
|
+
export const MAINTENANCE_SCENARIOS = [
|
|
5
|
+
'findings',
|
|
6
|
+
'support-overdue', 'support-soon', 'later-target', 'status-missing', 'status-error',
|
|
7
|
+
'framework-cached', 'policy-lost-response', 'policy-refused', 'framework-converged', 'framework-converged-version-missing', 'framework-failed', 'framework-missing-deployment',
|
|
8
|
+
'empty',
|
|
9
|
+
'denied-inventory',
|
|
10
|
+
'unavailable',
|
|
11
|
+
'scanning-off',
|
|
12
|
+
'external',
|
|
13
|
+
'bootstrap-denied',
|
|
14
|
+
'bootstrap-malformed',
|
|
15
|
+
'next-window',
|
|
16
|
+
'target-before-settled',
|
|
17
|
+
'settled-old',
|
|
18
|
+
'mixed-cache',
|
|
19
|
+
'all-members-upgraded',
|
|
20
|
+
'provider-unknown',
|
|
21
|
+
'duplicate',
|
|
22
|
+
'cache-refusal',
|
|
23
|
+
'stale-target',
|
|
24
|
+
'reauth',
|
|
25
|
+
'framework-held',
|
|
26
|
+
'framework-pinned',
|
|
27
|
+
'framework-current',
|
|
28
|
+
'framework-unknown',
|
|
29
|
+
'framework-no-converged',
|
|
30
|
+
'framework-requires-superuser',
|
|
31
|
+
'framework-pin-cleared-503',
|
|
32
|
+
'framework-lost-response',
|
|
33
|
+
] as const;
|
|
34
|
+
export type MaintenanceScenario = (typeof MAINTENANCE_SCENARIOS)[number];
|
|
35
|
+
let scenario: MaintenanceScenario = 'findings';
|
|
36
|
+
const pending = new Map<string, { target: string; immediate: boolean; reads: number }>();
|
|
37
|
+
let frameworkRequested = false;
|
|
38
|
+
let policyValue: string | undefined;
|
|
39
|
+
let deploymentReads = 0;
|
|
40
|
+
const deploymentId = '92450000-0000-4000-8000-000000000001';
|
|
41
|
+
export function setMockMaintenanceScenario(value: MaintenanceScenario) {
|
|
42
|
+
scenario = value;
|
|
43
|
+
pending.clear();
|
|
44
|
+
frameworkRequested = false; policyValue = undefined; deploymentReads = 0;
|
|
45
|
+
}
|
|
46
|
+
export function readMaintenanceBootstrapFacts(): {
|
|
47
|
+
mode: 'managed' | 'external';
|
|
48
|
+
infrastructureManaged: boolean;
|
|
49
|
+
bootstrap: 'ok' | 'denied' | 'malformed';
|
|
50
|
+
} {
|
|
51
|
+
return {
|
|
52
|
+
mode: scenario === 'external' ? 'external' : 'managed',
|
|
53
|
+
infrastructureManaged: scenario !== 'external',
|
|
54
|
+
bootstrap:
|
|
55
|
+
scenario === 'bootstrap-denied'
|
|
56
|
+
? 'denied'
|
|
57
|
+
: scenario === 'bootstrap-malformed'
|
|
58
|
+
? 'malformed'
|
|
59
|
+
: 'ok',
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const date = () => new Date().toISOString();
|
|
63
|
+
const finding = (
|
|
64
|
+
kind: Finding['kind'],
|
|
65
|
+
resource_id: string,
|
|
66
|
+
engine: string,
|
|
67
|
+
current_version: string,
|
|
68
|
+
available_major: string,
|
|
69
|
+
): Finding => ({
|
|
70
|
+
kind,
|
|
71
|
+
resource_id,
|
|
72
|
+
engine,
|
|
73
|
+
current_version,
|
|
74
|
+
available_major,
|
|
75
|
+
deadline: kind === 'elasticache' ? null : '2027-02-28T00:00:00Z',
|
|
76
|
+
extended_deadline: kind === 'elasticache' ? null : '2030-02-28T00:00:00Z',
|
|
77
|
+
days_remaining: kind === 'elasticache' ? null : scenario === 'support-overdue' ? -14 : scenario === 'support-soon' ? 30 : 130,
|
|
78
|
+
note:
|
|
79
|
+
kind === 'elasticache'
|
|
80
|
+
? 'A replication group can contain mixed member versions.'
|
|
81
|
+
: 'Review engine compatibility and snapshot recovery before applying.',
|
|
82
|
+
release_notes_url: 'https://docs.aws.amazon.com/',
|
|
83
|
+
status: 'available',
|
|
84
|
+
pending_version: null,
|
|
85
|
+
settled: true,
|
|
86
|
+
...(kind === 'elasticache' ? { members: 2, engine_versions: ['6.2', '6.0'] } : {}),
|
|
87
|
+
});
|
|
88
|
+
export function maintenanceMockReport(): Report {
|
|
89
|
+
return {
|
|
90
|
+
schema_version: 1,
|
|
91
|
+
generated_at: date(),
|
|
92
|
+
region: 'us-east-1',
|
|
93
|
+
status: scenario === 'unavailable' ? 'unavailable' : 'ok',
|
|
94
|
+
level: 5,
|
|
95
|
+
scheduled: scenario !== 'scanning-off',
|
|
96
|
+
findings: ['empty', 'unavailable'].includes(scenario)
|
|
97
|
+
? []
|
|
98
|
+
: [
|
|
99
|
+
{ ...finding('rds-instance', 'orders-db', 'postgres', scenario === 'later-target' && (pending.get('rds-instance:orders-db')?.reads ?? 0) >= 2 ? pending.get('rds-instance:orders-db')!.target : '14.12', scenario === 'later-target' && (pending.get('rds-instance:orders-db')?.reads ?? 0) >= 2 ? '17.2' : '16.4') },
|
|
100
|
+
finding('rds-cluster', 'analytics-cluster', 'aurora-postgresql', '13.12', '15.8'),
|
|
101
|
+
finding('elasticache', 'session-cache', 'redis', '6.2', '7.1'),
|
|
102
|
+
finding('elasticache', 'standalone-cache', 'redis', '6.0', '7.1'),
|
|
103
|
+
],
|
|
104
|
+
warnings:
|
|
105
|
+
scenario === 'denied-inventory'
|
|
106
|
+
? [
|
|
107
|
+
{ iam_action: 'rds:DescribeDBClusters', aws_code: 'AccessDenied' },
|
|
108
|
+
{ iam_action: 'elasticache:DescribeReplicationGroups', aws_code: 'AccessDenied' },
|
|
109
|
+
]
|
|
110
|
+
: [],
|
|
111
|
+
reason: scenario === 'unavailable' ? 'AWS credentials unavailable' : null,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
export function maintenanceMockFramework(user: BackendGlobalUser): Framework {
|
|
115
|
+
const defaultPin = ['framework-held','framework-pin-cleared-503'].includes(scenario) ? 'hold' : scenario === 'framework-pinned' ? '4.0.0' : '';
|
|
116
|
+
const pin = policyValue ?? (frameworkRequested ? '' : defaultPin);
|
|
117
|
+
const mode = pin === '' ? 'latest' : pin === 'hold' ? 'hold' : 'pinned';
|
|
118
|
+
const unknown = scenario === 'framework-unknown';
|
|
119
|
+
const latest = unknown ? null : scenario === 'framework-current' ? '4.0.0' : '4.1.0';
|
|
120
|
+
const installed = '4.0.0';
|
|
121
|
+
const update_available = !!latest && latest !== installed && mode === 'latest';
|
|
122
|
+
const blocked_reason: Framework['blocked_reason'] = scenario === 'external' ? 'infrastructure_external' : !latest || !update_available && mode === 'latest' ? 'update_unavailable' : mode !== 'latest' && user.is_superuser !== true ? 'requires_superuser' : scenario === 'framework-no-converged' ? 'no_converged_deployment' : scenario === 'framework-requires-superuser' ? 'requires_superuser' : null;
|
|
123
|
+
return {schema_version:1, installed, latest, checked_at:date(), source:unknown?'unavailable':scenario==='framework-cached'?'cache':'pypi',update_available,pin:{mode,value:pin||null},can_update:blocked_reason===null,blocked_reason};
|
|
124
|
+
}
|
|
125
|
+
const ok = (data: unknown) => ({ status: true, data });
|
|
126
|
+
const fail = (code: string, status: number, data?: unknown) => ({
|
|
127
|
+
status: false,
|
|
128
|
+
error: code.replaceAll('_', ' '),
|
|
129
|
+
error_code: code,
|
|
130
|
+
code: status,
|
|
131
|
+
...(data ? { data } : {}),
|
|
132
|
+
});
|
|
133
|
+
export interface MaintenanceMockContext {
|
|
134
|
+
user?: (BackendGlobalUser & { is_active?: boolean; id?: number | string }) | null;
|
|
135
|
+
keyBacked?: boolean;
|
|
136
|
+
fresh?: boolean;
|
|
137
|
+
}
|
|
138
|
+
export function maintenanceMock(
|
|
139
|
+
path: string,
|
|
140
|
+
method: string,
|
|
141
|
+
body: Record<string, unknown> | undefined,
|
|
142
|
+
params: Record<string, unknown> = {},
|
|
143
|
+
context: MaintenanceMockContext = {},
|
|
144
|
+
): unknown {
|
|
145
|
+
const aws = [
|
|
146
|
+
'/api/aws/maintenance/versions',
|
|
147
|
+
'/api/aws/maintenance/status',
|
|
148
|
+
'/api/aws/maintenance/apply',
|
|
149
|
+
].includes(path),
|
|
150
|
+
framework =
|
|
151
|
+
path === '/api/account/admin/platform/framework' ||
|
|
152
|
+
path === '/api/account/admin/platform/framework/update';
|
|
153
|
+
const policy = path === '/api/account/admin/advanced/settings' && Object.hasOwn(body ?? {}, 'framework_pin');
|
|
154
|
+
const platform = path === '/api/account/admin/platform' && params.sections === 'deployments';
|
|
155
|
+
if (!aws && !framework && !policy && !platform) return undefined;
|
|
156
|
+
const user = context.user;
|
|
157
|
+
if (!user || user.is_active === false) return fail('permission_denied', 403);
|
|
158
|
+
if (!backendGlobalCan(user, aws ? 'manage_aws' : ['view_platform', 'manage_platform', 'admin']))
|
|
159
|
+
return fail('permission_denied', 403);
|
|
160
|
+
if (platform && method === 'GET') {
|
|
161
|
+
deploymentReads++;
|
|
162
|
+
const status = scenario === 'framework-failed' ? 'failed' : deploymentReads >= 2 ? 'converged' : 'fleet';
|
|
163
|
+
return ok({ schema_version: 1, sections: { deployments: { status: 'ok', data: { items: !frameworkRequested || scenario === 'framework-missing-deployment' ? [] : [{ id: deploymentId, sha: 'a'.repeat(40), status, framework_version: '4.1.0', frozen_roster: ['runner-maint-1'], node_evidence: [{runner: 'runner-maint-1', state: status === 'converged' ? 'proven' : 'dispatched', observed_at: date(), stale_after: new Date(Date.now()+60000).toISOString(), proof: {platform_deployment: deploymentId, platform_sha: 'a'.repeat(40), django_mojo_version: scenario === 'framework-converged-version-missing' ? null : '4.1.0'}}] }] } } } });
|
|
164
|
+
}
|
|
165
|
+
if (method === 'GET' && path === '/api/aws/maintenance/versions') return ok(maintenanceMockReport());
|
|
166
|
+
if (method === 'GET' && path === '/api/account/admin/platform/framework')
|
|
167
|
+
return ok(maintenanceMockFramework(user));
|
|
168
|
+
if (method === 'GET' && path === '/api/aws/maintenance/status') {
|
|
169
|
+
if (scenario === 'status-error') return fail('provider_unavailable',503);
|
|
170
|
+
if (scenario === 'status-missing') return ok({schema_version:1,kind:params.kind,resource:params.resource,target_version:params.target_version,found:false,status:null,engine_version:null,engine_versions:[],pending_version:null,settled:false,upgraded:false});
|
|
171
|
+
const f = maintenanceMockReport().findings.find(
|
|
172
|
+
(f) => f.kind === params.kind && f.resource_id === params.resource,
|
|
173
|
+
);
|
|
174
|
+
if (!f || typeof params.target_version !== 'string') return fail('invalid_request', 400);
|
|
175
|
+
const attempt = pending.get(`${f.kind}:${f.resource_id}`);
|
|
176
|
+
if (attempt) attempt.reads++;
|
|
177
|
+
const waiting =
|
|
178
|
+
['next-window', 'settled-old', 'provider-unknown'].includes(scenario) ||
|
|
179
|
+
(!!attempt && !attempt.immediate);
|
|
180
|
+
const targetObserved = !!attempt && !waiting && attempt.reads >= 2;
|
|
181
|
+
const settled =
|
|
182
|
+
targetObserved && (scenario !== 'target-before-settled' || (attempt?.reads ?? 0) >= 4);
|
|
183
|
+
const mixed = scenario === 'mixed-cache' && f.kind === 'elasticache';
|
|
184
|
+
const versions = targetObserved
|
|
185
|
+
? mixed
|
|
186
|
+
? [params.target_version, f.current_version]
|
|
187
|
+
: [params.target_version, params.target_version]
|
|
188
|
+
: [f.current_version];
|
|
189
|
+
return ok({
|
|
190
|
+
schema_version: 1,
|
|
191
|
+
kind: f.kind,
|
|
192
|
+
resource: f.resource_id,
|
|
193
|
+
target_version: params.target_version,
|
|
194
|
+
found: true,
|
|
195
|
+
status: waiting || settled ? 'available' : 'modifying',
|
|
196
|
+
engine_version: versions[0],
|
|
197
|
+
engine_versions: versions,
|
|
198
|
+
pending_version: attempt && !settled ? attempt.target : null,
|
|
199
|
+
settled: waiting || settled,
|
|
200
|
+
upgraded: targetObserved && !mixed,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
if (
|
|
204
|
+
method !== 'POST' ||
|
|
205
|
+
!['/api/aws/maintenance/apply', '/api/account/admin/platform/framework/update', '/api/account/admin/advanced/settings'].includes(path)
|
|
206
|
+
)
|
|
207
|
+
return fail('method_not_allowed', 405);
|
|
208
|
+
if (context.keyBacked) return fail('permission_denied', 403);
|
|
209
|
+
if (!context.fresh || scenario === 'reauth') return fail('fresh_auth_required', 440);
|
|
210
|
+
if (!backendGlobalCan(user, ['manage_platform', 'admin'])) return fail('permission_denied', 403);
|
|
211
|
+
if (policy) {
|
|
212
|
+
if (user.is_superuser !== true) return fail('permission_denied',403);
|
|
213
|
+
const value = body?.framework_pin;
|
|
214
|
+
if (typeof value !== 'string' || !(value === '' || value === 'hold' || /^[0-9][a-z0-9._!+-]{0,63}$/.test(value))) return fail('invalid_request',400);
|
|
215
|
+
if (scenario === 'policy-refused') return fail('invalid_request',400);
|
|
216
|
+
policyValue = value;
|
|
217
|
+
if (scenario === 'policy-lost-response') return fail('service_unavailable',503);
|
|
218
|
+
return ok({schema_version:1,saved:true,value});
|
|
219
|
+
}
|
|
220
|
+
if (scenario === 'external') return fail('infrastructure_external', 403);
|
|
221
|
+
if (!body) return fail('invalid_request', 400);
|
|
222
|
+
if (aws) {
|
|
223
|
+
const f = maintenanceMockReport().findings.find(
|
|
224
|
+
(f) => f.kind === body.kind && f.resource_id === body.resource,
|
|
225
|
+
);
|
|
226
|
+
if (!f || body.confirm_resource !== body.resource || typeof body.apply_immediately !== 'boolean')
|
|
227
|
+
return fail('invalid_request', 400);
|
|
228
|
+
if (body.target_version !== f.available_major || scenario === 'stale-target')
|
|
229
|
+
return fail('upgrade_not_offered', 409);
|
|
230
|
+
const key = `${f.kind}:${f.resource_id}`;
|
|
231
|
+
if (pending.has(key) && !(scenario === 'later-target' && pending.get(key)!.reads >= 2) || scenario === 'duplicate') return fail('upgrade_in_progress', 409);
|
|
232
|
+
if (scenario === 'cache-refusal') return fail('cache_unavailable', 503);
|
|
233
|
+
pending.set(key, { target: f.available_major, immediate: body.apply_immediately, reads: 0 });
|
|
234
|
+
if (scenario === 'provider-unknown')
|
|
235
|
+
return fail('provider_unavailable', 503, {
|
|
236
|
+
failure: {
|
|
237
|
+
operation: 'rds.modify_db_instance',
|
|
238
|
+
provider_code: 'RequestTimeout',
|
|
239
|
+
retryable: true,
|
|
240
|
+
mutation_state: 'unknown',
|
|
241
|
+
iam_action: 'rds:ModifyDBInstance',
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
return ok({
|
|
245
|
+
schema_version: 1,
|
|
246
|
+
requested: true,
|
|
247
|
+
resource: f.resource_id,
|
|
248
|
+
kind: f.kind,
|
|
249
|
+
engine: f.engine,
|
|
250
|
+
from_version: f.current_version,
|
|
251
|
+
target_version: f.available_major,
|
|
252
|
+
apply_immediately: body.apply_immediately,
|
|
253
|
+
release_notes_url: f.release_notes_url,
|
|
254
|
+
operation:
|
|
255
|
+
f.kind === 'elasticache'
|
|
256
|
+
? f.resource_id === 'standalone-cache'
|
|
257
|
+
? 'elasticache.modify_cache_cluster'
|
|
258
|
+
: 'elasticache.modify_replication_group'
|
|
259
|
+
: f.kind === 'rds-cluster'
|
|
260
|
+
? 'rds.modify_db_cluster'
|
|
261
|
+
: 'rds.modify_db_instance',
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
const fw = maintenanceMockFramework(user);
|
|
265
|
+
if (!fw.can_update || body.version !== fw.latest || body.confirm_version !== body.version)
|
|
266
|
+
return fail('update_unavailable', 409);
|
|
267
|
+
frameworkRequested = true; policyValue = ''; deploymentReads = 0;
|
|
268
|
+
if (['framework-pin-cleared-503', 'framework-lost-response'].includes(scenario))
|
|
269
|
+
return fail('deployment_coordination_unavailable', 503);
|
|
270
|
+
return ok({
|
|
271
|
+
schema_version: 1,
|
|
272
|
+
requested: true,
|
|
273
|
+
version: fw.latest,
|
|
274
|
+
cleared_pin: fw.pin.value,
|
|
275
|
+
deployment: { id: deploymentId, sha: 'a'.repeat(40), status: 'pending' },
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export const getMockMaintenanceScenario = () => scenario;
|
|
280
|
+
/** Demo-only provider passage: retain receipts and server operations; never replay a write. */
|
|
281
|
+
export function advanceMockMaintenance() {
|
|
282
|
+
scenario = 'all-members-upgraded';
|
|
283
|
+
for (const operation of pending.values()) { operation.immediate = true; operation.reads = Math.max(2, operation.reads); }
|
|
284
|
+
deploymentReads = Math.max(2, deploymentReads);
|
|
285
|
+
}
|