diff --git a/README.md b/README.md index badb32a..aad61e2 100644 --- a/README.md +++ b/README.md @@ -293,8 +293,11 @@ Control and VotingPlugin both enforce fixed quick-setup preset/option schemas; u than becoming arbitrary YAML writes. The WebUI settings catalog is a static versioned reference over these typed paths, not a generic setting API. -Opening Settings automatically reads current configuration; errors expose inline Retry. The Phase 2 General Settings visual -editor reads **each** selected workspace backend and previews only explicitly changed fields independently per target. +Opening Settings automatically reads current configuration; errors expose inline Retry. The General Settings visual +editor reads **each** selected workspace backend plus reporting managed proxies, using `Config.yml` and +`bungeeconfig.yml` respectively. It previews only explicitly changed allowlisted boolean, enum, and bounded-string +fields independently per target. Network-aware controls keep per-node values visible, and proxy-managed +`AllowUnjoined` repairs backend prerequisites without changing the proxy's effective preference. The Phase 3 [Vote Sites visual editor](docs/webui-vote-sites.md) likewise reads each selected backend's main `VoteSites.yml`, distinguishes mixed/partial/error states, and uses source-preserving per-target add/edit/remove previews. Successful applies invalidate cached values and read the confirmed state again. Legacy guided forms and raw YAML retain diff --git a/docs/webui-general-settings.md b/docs/webui-general-settings.md index f6eb94c..172b0bc 100644 --- a/docs/webui-general-settings.md +++ b/docs/webui-general-settings.md @@ -6,24 +6,37 @@ Full YAML, Compare, snapshots and network workflows retain their existing bounda ## Source-verified curated settings -All entries are top-level booleans in VotingPlugin `Config.yml`. Source verification used VotingPlugin `origin/master` -at `11f20c39f551c40c55923c6a686b1b6c2a260f83`, its default Config.yml, configuration reader, backend connector and +Entries are bounded top-level booleans, enums, or strings in VotingPlugin `Config.yml` and `bungeeconfig.yml`. +Source verification used VotingPlugin `origin/master` at `d22a611202aec5a7df99979390ecfbaa1d8f1e0c`, its default files, +configuration readers, backend/proxy connectors, and `VotingPluginMain.reloadFromControl`. This is evidence, not a pinned development dependency. -| YAML path | Documented default | Effect | Runtime action | +| YAML path / control | Documented default | Effect | Runtime action | | --- | --- | --- | --- | -| `ProcessRewards` | true | Process vote rewards (offline processing still follows VotingPlugin behavior) | Configuration reload | +| `DebugLevel` / proxy `Debug` | NONE | Backend diagnostic level; proxy offers Off/Info (Extra maps to Info) | Configuration reload | +| `OnlineMode` | true | Keep online identity handling aligned across applicable backend and proxy targets | Network configuration reload | | `AutoCreateVoteSites` | true | Create sites for newly received services | Configuration reload | -| `ExtraAllSitesCheck` | false | Additional duplicate all-sites reward check | Configuration reload | | `CountFakeVotes` | true | Include fake votes in points/totals | Configuration reload | -| `DisableNoServiceSiteMessage` | false | Suppress missing service-site and related site warnings | Configuration reload | -| `DisableUpdateChecking` | false | Disable update checks | Configuration reload plus backend restart to reconcile scheduler lifecycle | +| `AllowUnjoined` / proxy `AllowUnJoined` | false | Effective proxy value governs proxy-managed networks; backend requests true in that mode | Configuration reload | | `UseVoteGUIMainCommand` | false | Open the vote GUI from the main vote command | Configuration reload | +| `GiveDefaultPermission` | true | Grant default permissions during startup | Configuration reload plus backend restart | +| `LoadCommandAliases` | true | Load command aliases during startup | Configuration reload plus backend restart | +| `CaseInsensitiveYMLFiles` | true | Use case-insensitive YAML file lookups | Configuration reload plus backend restart | +| `BedrockPlayerPrefix` | `.` | Share Bedrock player name handling across applicable backend and proxy targets | Network configuration reload | +| `PerSiteCoolDownEvents` | false | Emit cooldown events per site | Configuration reload plus backend restart | +| `CheckForUpdates` (inverted `DisableUpdateChecking`) | true | Enable update checks | Configuration reload plus backend restart to reconcile scheduler lifecycle | | `CloseInventoryOnVote` | true | Close the vote inventory when voting | Configuration reload | -| `ExtraVoteShopCheck` | true | Additional vote shop purchase check | Configuration reload | + +The visual group deliberately omits older primary controls such as `ProcessRewards`, `ExtraAllSitesCheck`, +`DisableNoServiceSiteMessage`, and `ExtraVoteShopCheck`; their existing typed or guided workflows remain available where +supported. Backend `Config.yml` and proxy `bungeeconfig.yml` are read in grouped batches. A proxy is included when it +reports one of the selected backend targets, and its effective network values are shown alongside backend values. +Missing or unsupported values remain visible and are never replaced by documented defaults. Control recommends disabling +auto-create after intended vote sites are configured, then reviewing detected service sites before creating new entries. The update-check timer is started by `CheckUpdate.startUp` at plugin startup, not by the configuration reload path. -The editor does not restart a backend. A confirmed READ proves persisted configuration, not completion of a restart. +The editor does not restart a backend or proxy. Proxy `bungeeconfig.yml` writes always remain restart-required; a +confirmed READ proves persisted configuration, not completion of a restart. Vote Logging and Vote Party remain accessible through their existing typed/capability-gated tools; they are deliberately not added to this first visual group. Missing values are never synthesized from defaults or inserted automatically. @@ -33,17 +46,17 @@ not added to this first visual group. Missing values are never synthesized from MISSING, UNSUPPORTED and ERROR, with supported-subset values and each excluded target visible separately. Mixed controls have a placeholder, not an implicit true/false choice. Focusing a control is not an edit. A reset removes its override. -The required connector contract is the existing `config.files.v1` on an online Bukkit node. Actual parsed file presence -and boolean type gate each field, instead of guessing support from a version string. Missing keys, wrong types and +The connector contracts remain `config.files.v1` for Bukkit and `config.proxy-files.v1` for proxies. Actual parsed file +presence and expected scalar type gate each field, instead of guessing support from a version string. Missing keys, wrong types and anchored/aliased edited values are not writable. Malformed/ambiguous YAML fails closed with sanitized errors. Offline, unsupported and failed targets remain visible. Applying only a supported subset requires explicit acknowledgement. ## Additive Control API (no connector/protocol change) -1. Existing `/api/v1/configuration/read` reads Config.yml from every eligible selected target in a bounded batch. -2. POST `/api/v1/configuration/general-settings/state` with `readOperationId` and `nodeId` returns nine typed fields, - that target's session and revision. It requires a retained successful READ of that node's Config.yml. -3. POST `/api/v1/configuration/general-settings/preview` adds `overrides` (one to nine allowlisted JSON booleans). +1. Existing `/api/v1/configuration/read` reads `Config.yml` and `bungeeconfig.yml` in separate bounded target batches. +2. POST `/api/v1/configuration/general-settings/state` with `readOperationId` and `nodeId` returns the profile's typed + fields, target session, revision, and backend/proxy profile. It requires a retained successful read of the matching file. +3. POST `/api/v1/configuration/general-settings/preview` adds one or more allowlisted typed overrides. Control patches only changed scalar source spans in that target's retained redacted document and creates an ordinary FILE PREVIEW bound to the READ revision. Comments, unknown keys, unrelated values and redacted placeholders are not regenerated. Each target has its own proposal and approval token; no common document is distributed. @@ -60,7 +73,9 @@ Target IDs/sessions, every relevant READ revision/state and explicit overrides f scope changes and changed revisions invalidate approval. Before APPLY a fresh scope READ checks that signature again; the connector revision guard closes the remaining race. Navigation only reads and never previews, applies or reloads. -At most eight changed targets can be previewed in one visual batch, within the existing bounded FILE-operation retention. +The changed-target preview limit is calculated from the actual number of bounded 100-target backend/proxy read batches. +The coordinator retains at most sixteen FILE operations, so the editor reserves one slot per required fresh read batch +before APPLY instead of allowing an approval set that cannot be revalidated. All workspace targets are still read/displayed. Larger changed sets must be reduced explicitly, not silently truncated. Successful revision-bound pending previews are protected from ordinary FILE-retention eviction for fifteen minutes, or until consumed/discarded. `/api/v1/configuration/general-settings/discard-preview` releases the exact abandoned @@ -92,3 +107,14 @@ single-flight and stale-result guards, mixed/partial reads, dirty fields and con smoke evidence uses actual static assets against controlled API fixtures. A disposable Paper 26.2 server with the current VotingPlugin connector also exercised the real HTTP connector, Config.yml READ/PREVIEW/APPLY/reload/confirmed READ, and stale revision rejection. That local gate never points at production configuration. + +### Configuration health outside the editor + +Dashboard refresh (including the network workspace) and Network Doctor read the same allowlisted, +revision-bound managed configuration directly. Visiting General Settings first is not required. +Health reads use a separate read-only state so refreshing health never discards an editor draft or +approval. Reads are coalesced and cached for 30 seconds; configuration changes invalidate the cache. +Offline, unsupported, missing, or incomplete-topology observations are reported as UNKNOWN rather +than passing. Identity comparisons apply within reported proxy/backend groups, not between unrelated +standalone servers. Network Doctor exports these observations as `configurationChecks`; no settings +are written by a health check. diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index 5d3907a..7d0af56 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -149,12 +149,13 @@ public synchronized OperationView createApply(UUID previewId, String approvalTok return view(apply); } - /** Typed view of a retained, successful read of this particular backend's Config.yml. */ + /** Typed view of a retained successful backend Config.yml or proxy bungeeconfig.yml read. */ public synchronized SettingsState generalSettingsState(UUID readId, String nodeId) { - ConfigurationTaskResult snapshot = settingsSnapshot(readId, nodeId); + SettingsSnapshot source = settingsSnapshot(readId, nodeId); + ConfigurationTaskResult snapshot = source.result(); try { return new SettingsState(readId, nodeId, snapshot.sessionId(), snapshot.revision(), - GeneralSettingsDocument.fields(snapshot.configuration().content())); + source.profile(), GeneralSettingsDocument.fields(snapshot.configuration().content(), source.profile())); } catch (IllegalArgumentException malformed) { throw new ValidationException("INVALID_CONFIGURATION", "General Settings requires an unambiguous YAML mapping", List.of(nodeId)); } @@ -162,21 +163,20 @@ public synchronized SettingsState generalSettingsState(UUID readId, String nodeI public synchronized OperationView createGeneralSettingsPreview(UUID readId, String nodeId, Map overrides) { - ConfigurationTaskResult snapshot = settingsSnapshot(readId, nodeId); - if (overrides == null || overrides.isEmpty() || overrides.size() > 9 - || overrides.values().stream().anyMatch(value -> !(value instanceof Boolean))) { - throw invalid("General Settings requires one to nine boolean edits"); + SettingsSnapshot source = settingsSnapshot(readId, nodeId); + ConfigurationTaskResult snapshot = source.result(); + if (overrides == null || overrides.isEmpty() + || overrides.size() > GeneralSettingsDocument.maxOverrides(source.profile())) { + throw invalid("General Settings requires one or more bounded typed edits"); } - Map edits = new LinkedHashMap<>(); - overrides.forEach((key, value) -> edits.put(key, (Boolean) value)); String proposal; try { - proposal = GeneralSettingsDocument.patch(snapshot.configuration().content(), edits); + proposal = GeneralSettingsDocument.patch(snapshot.configuration().content(), source.profile(), overrides); } catch (IllegalArgumentException unsupported) { - throw invalid("Only present, supported General Settings booleans can be edited"); + throw invalid("Only present, supported General Settings values can be edited"); } - ManagedConfiguration configuration = ManagedConfiguration.file("Config.yml", proposal); - ValidatedTargets targets = validateTargets(List.of(nodeId), FILE_CAPABILITY); + ManagedConfiguration configuration = ManagedConfiguration.file(GeneralSettingsDocument.fileName(source.profile()), proposal); + ValidatedTargets targets = validateTargets(List.of(nodeId), configuration.capability()); validateConfigurationTargets(targets, configuration); byte[] token = new byte[32]; random.nextBytes(token); @@ -184,8 +184,27 @@ public synchronized OperationView createGeneralSettingsPreview(UUID readId, Stri Base64.getUrlEncoder().withoutPadding().encodeToString(token), Map.of(nodeId, snapshot.revision())); } - private ConfigurationTaskResult settingsSnapshot(UUID readId, String nodeId) { - return fileSnapshot(readId, nodeId, "Config.yml"); + private SettingsSnapshot settingsSnapshot(UUID readId, String nodeId) { + prune(); + StoredOperation read = operations.get(readId); + if (read == null || !"READ".equals(read.type) + || !ManagedConfiguration.FILE.equals(read.configuration.domain())) { + throw new ValidationException("READ_REQUIRED", "A retained successful General Settings read is required", + List.of(nodeId)); + } + GeneralSettingsDocument.Profile profile; + try { + profile = GeneralSettingsDocument.profileForFile(read.configuration.fileName()); + } catch (IllegalArgumentException invalidFile) { + throw new ValidationException("READ_REQUIRED", "A retained successful General Settings read is required", + List.of(nodeId)); + } + NodeStatus node = registry.find(nodeId); + boolean proxy = node != null && !"BUKKIT".equalsIgnoreCase(node.platform()); + if (node == null || proxy != (profile == GeneralSettingsDocument.Profile.PROXY)) { + throw new ValidationException("TARGET_ROLE_CHANGED", "The target role changed; read it again", List.of(nodeId)); + } + return new SettingsSnapshot(fileSnapshot(readId, nodeId, GeneralSettingsDocument.fileName(profile)), profile); } private ConfigurationTaskResult fileSnapshot(UUID readId, String nodeId, String fileName) { @@ -203,14 +222,17 @@ private ConfigurationTaskResult fileSnapshot(UUID readId, String nodeId, String List.of(nodeId)); } if (!Objects.equals(result.sessionId(), targets.sessions().get(nodeId))) { - throw new ValidationException("TARGET_CHANGED", "The backend reconnected; read it again", List.of(nodeId)); + throw new ValidationException("TARGET_CHANGED", "The node reconnected; read it again", List.of(nodeId)); } return result; } public record SettingsState(UUID readOperationId, String nodeId, UUID sessionId, String revision, + GeneralSettingsDocument.Profile profile, Map fields) { } + private record SettingsSnapshot(ConfigurationTaskResult result, GeneralSettingsDocument.Profile profile) { } + /** Typed, secret-bounded inventory from one retained successful VoteSites.yml read. */ public synchronized VoteSitesState voteSitesState(UUID readId, String nodeId) { ConfigurationTaskResult snapshot = fileSnapshot(readId, nodeId, "VoteSites.yml"); diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/GeneralSettingsDocument.java b/src/main/java/com/bencodez/votingplugin/control/domain/GeneralSettingsDocument.java index 1e1a096..7f00578 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/GeneralSettingsDocument.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/GeneralSettingsDocument.java @@ -3,11 +3,14 @@ import java.io.StringReader; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; +import org.yaml.snakeyaml.DumperOptions; import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.constructor.SafeConstructor; @@ -18,51 +21,76 @@ import org.yaml.snakeyaml.nodes.Tag; /** - * Bounded, lossless access to the small allow-list of top-level Config.yml - * general settings. This deliberately edits scalar source spans rather than - * dumping YAML, so comments, redactions, ordering, and unknown settings stay - * byte-for-byte (as Java characters) unchanged. + * Bounded, lossless access to the curated backend and proxy General Settings. + * Scalar source spans are replaced directly so unrelated YAML, comments, + * ordering, redactions, and unknown settings remain unchanged. */ public final class GeneralSettingsDocument { private static final int MAX_DOCUMENT_CODE_POINTS = 512 * 1024; private static final int MAX_NESTING_DEPTH = 40; private static final int MAX_COLLECTION_ALIASES = 16; + private static final int MAX_PREFIX_CODE_POINTS = 32; - private static final List KNOWN_KEYS = List.of( - "ProcessRewards", "AutoCreateVoteSites", "ExtraAllSitesCheck", "CountFakeVotes", - "DisableNoServiceSiteMessage", "DisableUpdateChecking", "UseVoteGUIMainCommand", - "CloseInventoryOnVote", "ExtraVoteShopCheck"); - private static final Set KNOWN_KEY_SET = Set.copyOf(KNOWN_KEYS); + private static final List BACKEND_SPECS = List.of( + enumSpec("Debug", "DebugLevel", Set.of("NONE", "INFO", "EXTRA")), + booleanSpec("OnlineMode", "OnlineMode"), + booleanSpec("AutoCreateVoteSites", "AutoCreateVoteSites"), + booleanSpec("UseVoteGUIMainCommand", "UseVoteGUIMainCommand"), + booleanSpec("CountFakeVotes", "CountFakeVotes"), + booleanSpec("AllowUnjoined", "AllowUnjoined"), + booleanSpec("GiveDefaultPermission", "GiveDefaultPermission"), + booleanSpec("LoadCommandAliases", "LoadCommandAliases"), + booleanSpec("CaseInsensitiveYMLFiles", "CaseInsensitiveYMLFiles"), + stringSpec("BedrockPlayerPrefix", "BedrockPlayerPrefix"), + booleanSpec("PerSiteCoolDownEvents", "PerSiteCoolDownEvents"), + invertedBooleanSpec("CheckForUpdates", "DisableUpdateChecking"), + booleanSpec("CloseInventoryOnVote", "CloseInventoryOnVote")); + private static final List PROXY_SPECS = List.of( + proxyDebugSpec(), + booleanSpec("OnlineMode", "OnlineMode"), + booleanSpec("AllowUnjoined", "AllowUnJoined"), + stringSpec("BedrockPlayerPrefix", "BedrockPlayerPrefix")); private GeneralSettingsDocument() { } - /** Returns the curated fixed settings only; it never exposes document content. */ + /** Existing callers default to the Bukkit Config.yml profile. */ public static Map fields(String redactedContent) { - Parsed parsed = parse(redactedContent); + return fields(redactedContent, Profile.BACKEND); + } + + public static Map fields(String redactedContent, Profile profile) { + List specs = specs(profile); + Parsed parsed = parse(redactedContent, specs); LinkedHashMap result = new LinkedHashMap<>(); - for (String key : KNOWN_KEYS) { - Location location = parsed.locations().get(key); - result.put(key, location == null ? new Field(Status.MISSING, null) + for (Spec spec : specs) { + Location location = parsed.locations().get(spec.key()); + result.put(spec.key(), location == null ? new Field(Status.MISSING, null) : new Field(location.status(), location.value())); } return Collections.unmodifiableMap(result); } - /** - * Applies explicitly chosen curated settings. A missing, non-boolean, anchored, - * or aliased setting is never invented or rewritten. - */ - public static String patch(String content, Map overrides) { - validateOverrides(overrides); - Parsed parsed = parse(content); + /** Existing callers default to the Bukkit Config.yml profile. */ + public static String patch(String content, Map overrides) { + return patch(content, Profile.BACKEND, overrides); + } + + public static String patch(String content, Profile profile, Map overrides) { + List specs = specs(profile); + Map byKey = byKey(specs); + validateOverrides(overrides, byKey); + Parsed parsed = parse(content, specs); List replacements = new ArrayList<>(); - for (Map.Entry override : overrides.entrySet()) { + Set rawKeys = new HashSet<>(); + for (Map.Entry override : overrides.entrySet()) { + Spec spec = byKey.get(override.getKey()); + if (!rawKeys.add(spec.rawKey())) throw invalidOverrides(); Location location = parsed.locations().get(override.getKey()); if (location == null || location.status() != Status.AVAILABLE) throw invalidOverrides(); - if (location.value().booleanValue() != override.getValue().booleanValue()) { - replacements.add(new Replacement(location.startOffset(), location.endOffset(), - Boolean.toString(override.getValue()))); + Object requested = spec.normalize(override.getValue()); + if (!requested.equals(location.value())) { + replacements.add(new Replacement(location.startOffset(), location.endOffset(), spec.encode(requested))); } } if (replacements.isEmpty()) return content; @@ -79,7 +107,24 @@ public static String patch(String content, Map overrides) { return patched.toString(); } - private static Parsed parse(String content) { + public static Profile profileForFile(String fileName) { + if ("Config.yml".equals(fileName)) return Profile.BACKEND; + if ("bungeeconfig.yml".equals(fileName)) return Profile.PROXY; + throw invalidDocument(); + } + + public static String fileName(Profile profile) { + return switch (requireProfile(profile)) { + case BACKEND -> "Config.yml"; + case PROXY -> "bungeeconfig.yml"; + }; + } + + public static int maxOverrides(Profile profile) { + return specs(profile).size(); + } + + private static Parsed parse(String content, List specs) { if (content == null || content.indexOf('\0') >= 0 || content.codePointCount(0, content.length()) > MAX_DOCUMENT_CODE_POINTS) { throw invalidDocument(); @@ -96,60 +141,113 @@ private static Parsed parse(String content) { List documents = new ArrayList<>(); for (Node node : yaml.composeAll(new StringReader(content))) documents.add(node); if (documents.size() != 1 || !(documents.get(0) instanceof MappingNode root)) throw invalidDocument(); - return new Parsed(locations(content, root)); + return new Parsed(locations(content, root, specs)); } catch (RuntimeException failure) { throw invalidDocument(); } } - private static Map locations(String content, MappingNode root) { + private static Map locations(String content, MappingNode root, List specs) { LinkedHashMap result = new LinkedHashMap<>(); - Set rootKeys = new java.util.HashSet<>(); + Map> byRawKey = new LinkedHashMap<>(); + for (Spec spec : specs) byRawKey.computeIfAbsent(spec.rawKey(), ignored -> new ArrayList<>()).add(spec); + Set rootKeys = new HashSet<>(); + Set foldedRootKeys = new HashSet<>(); for (NodeTuple tuple : root.getValue()) { if (!(tuple.getKeyNode() instanceof ScalarNode key)) throw invalidDocument(); String name = key.getValue(); - if (!rootKeys.add(name)) throw invalidDocument(); - if (!KNOWN_KEY_SET.contains(name)) continue; - result.put(name, location(content, tuple.getValueNode())); + if (!rootKeys.add(name) || !foldedRootKeys.add(name.toLowerCase(Locale.ROOT))) throw invalidDocument(); + List matching = byRawKey.get(name); + if (matching == null) continue; + for (Spec spec : matching) result.put(spec.key(), location(content, tuple.getValueNode(), spec)); } return result; } - private static Location location(String content, Node value) { - if (!(value instanceof ScalarNode scalar) || scalar.getAnchor() != null || !Tag.BOOL.equals(scalar.getTag())) { - return new Location(Status.UNSUPPORTED, null, -1, -1); - } - Boolean booleanValue = yamlBoolean(scalar.getValue()); - if (booleanValue == null) return new Location(Status.UNSUPPORTED, null, -1, -1); + private static Location location(String content, Node value, Spec spec) { + if (!(value instanceof ScalarNode scalar) || scalar.getAnchor() != null) return unsupported(); + if (scalar.getScalarStyle() == DumperOptions.ScalarStyle.LITERAL + || scalar.getScalarStyle() == DumperOptions.ScalarStyle.FOLDED) return unsupported(); + Object decoded = spec.decode(scalar); + if (decoded == null) return unsupported(); int startCodePoint = scalar.getStartMark().getIndex(); int endCodePoint = scalar.getEndMark().getIndex(); int totalCodePoints = content.codePointCount(0, content.length()); if (startCodePoint < 0 || endCodePoint < startCodePoint || endCodePoint > totalCodePoints) { throw invalidDocument(); } - int startOffset = content.offsetByCodePoints(0, startCodePoint); - int endOffset = content.offsetByCodePoints(0, endCodePoint); - return new Location(Status.AVAILABLE, booleanValue, startOffset, endOffset); + return new Location(Status.AVAILABLE, decoded, + content.offsetByCodePoints(0, startCodePoint), content.offsetByCodePoints(0, endCodePoint)); + } + + private static Location unsupported() { + return new Location(Status.UNSUPPORTED, null, -1, -1); } - private static Boolean yamlBoolean(String value) { - return switch (value.toLowerCase(java.util.Locale.ROOT)) { + private static Boolean yamlBoolean(ScalarNode scalar) { + if (!Tag.BOOL.equals(scalar.getTag())) return null; + return switch (scalar.getValue().toLowerCase(Locale.ROOT)) { case "true", "yes", "on" -> Boolean.TRUE; case "false", "no", "off" -> Boolean.FALSE; default -> null; }; } - private static void validateOverrides(Map overrides) { - if (overrides == null || overrides.isEmpty() || overrides.size() > KNOWN_KEYS.size()) { - throw invalidOverrides(); - } - for (Map.Entry override : overrides.entrySet()) { - if (override.getKey() == null || override.getValue() == null - || !KNOWN_KEY_SET.contains(override.getKey())) throw invalidOverrides(); + private static String yamlString(ScalarNode scalar) { + if (!Tag.STR.equals(scalar.getTag())) return null; + String value = scalar.getValue(); + if (value.codePointCount(0, value.length()) > MAX_PREFIX_CODE_POINTS + || value.chars().anyMatch(character -> Character.isISOControl(character))) return null; + return value; + } + + private static void validateOverrides(Map overrides, Map byKey) { + if (overrides == null || overrides.isEmpty() || overrides.size() > byKey.size()) throw invalidOverrides(); + for (Map.Entry override : overrides.entrySet()) { + Spec spec = byKey.get(override.getKey()); + if (spec == null || override.getValue() == null) throw invalidOverrides(); + spec.normalize(override.getValue()); } } + private static List specs(Profile profile) { + return switch (requireProfile(profile)) { + case BACKEND -> BACKEND_SPECS; + case PROXY -> PROXY_SPECS; + }; + } + + private static Profile requireProfile(Profile profile) { + if (profile == null) throw invalidDocument(); + return profile; + } + + private static Map byKey(List specs) { + LinkedHashMap result = new LinkedHashMap<>(); + for (Spec spec : specs) result.put(spec.key(), spec); + return result; + } + + private static Spec booleanSpec(String key, String rawKey) { + return new Spec(key, rawKey, Kind.BOOLEAN, Set.of(), false); + } + + private static Spec invertedBooleanSpec(String key, String rawKey) { + return new Spec(key, rawKey, Kind.BOOLEAN, Set.of(), true); + } + + private static Spec enumSpec(String key, String rawKey, Set values) { + return new Spec(key, rawKey, Kind.ENUM, values, false); + } + + private static Spec stringSpec(String key, String rawKey) { + return new Spec(key, rawKey, Kind.STRING, Set.of(), false); + } + + private static Spec proxyDebugSpec() { + return new Spec("Debug", "Debug", Kind.PROXY_DEBUG, Set.of("NONE", "INFO", "EXTRA"), false); + } + private static IllegalArgumentException invalidDocument() { return new IllegalArgumentException("general settings document is invalid"); } @@ -158,24 +256,74 @@ private static IllegalArgumentException invalidOverrides() { return new IllegalArgumentException("general settings overrides are invalid"); } - public enum Status { - AVAILABLE, MISSING, UNSUPPORTED - } + public enum Profile { BACKEND, PROXY } + public enum Status { AVAILABLE, MISSING, UNSUPPORTED } - public record Field(Status status, Boolean value) { + public record Field(Status status, Object value) { public Field { - if (status == null || (status == Status.AVAILABLE) != (value != null)) { + if (status == null || (status == Status.AVAILABLE) != (value != null) + || value != null && !(value instanceof Boolean) && !(value instanceof String)) { throw new IllegalArgumentException("field is invalid"); } } } - private record Parsed(Map locations) { - } + private enum Kind { BOOLEAN, ENUM, STRING, PROXY_DEBUG } - private record Location(Status status, Boolean value, int startOffset, int endOffset) { - } + private record Spec(String key, String rawKey, Kind kind, Set values, boolean invert) { + Object decode(ScalarNode scalar) { + return switch (kind) { + case BOOLEAN -> { + Boolean value = yamlBoolean(scalar); + yield value == null ? null : invert ? !value : value; + } + case ENUM -> { + if (!Tag.STR.equals(scalar.getTag())) yield null; + String value = scalar.getValue().toUpperCase(Locale.ROOT); + yield values.contains(value) ? value : null; + } + case STRING -> yamlString(scalar); + case PROXY_DEBUG -> { + Boolean value = yamlBoolean(scalar); + yield value == null ? null : value ? "INFO" : "NONE"; + } + }; + } + + Object normalize(Object value) { + return switch (kind) { + case BOOLEAN -> { + if (!(value instanceof Boolean booleanValue)) throw invalidOverrides(); + yield booleanValue; + } + case ENUM, PROXY_DEBUG -> { + if (!(value instanceof String stringValue)) throw invalidOverrides(); + String normalized = stringValue.toUpperCase(Locale.ROOT); + if (!values.contains(normalized)) throw invalidOverrides(); + yield kind == Kind.PROXY_DEBUG && "EXTRA".equals(normalized) ? "INFO" : normalized; + } + case STRING -> { + if (!(value instanceof String stringValue) + || stringValue.codePointCount(0, stringValue.length()) > MAX_PREFIX_CODE_POINTS + || stringValue.chars().anyMatch(character -> Character.isISOControl(character))) { + throw invalidOverrides(); + } + yield stringValue; + } + }; + } - private record Replacement(int startOffset, int endOffset, String value) { + String encode(Object normalized) { + return switch (kind) { + case BOOLEAN -> Boolean.toString(invert ? !(Boolean) normalized : (Boolean) normalized); + case ENUM -> (String) normalized; + case STRING -> "'" + ((String) normalized).replace("'", "''") + "'"; + case PROXY_DEBUG -> Boolean.toString(!"NONE".equals(normalized)); + }; + } } + + private record Parsed(Map locations) { } + private record Location(Status status, Object value, int startOffset, int endOffset) { } + private record Replacement(int startOffset, int endOffset, String value) { } } diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 7ba0d13..8a022cf 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -17,6 +17,8 @@ const welcome = document.querySelector('#welcome'); const appShell = document.querySelector('#app-shell'); const workspace = new ControlWorkspace.Workspace(); let settingsEditor = null; +let settingsHealthReader = null; +let settingsHealthGeneration = 0; let voteSitesEditor = null; let rewardsEditor = null; const scopeBar = document.querySelector('#scope-bar'); @@ -1348,6 +1350,7 @@ async function loadSetupState() { function applyAuthenticatedSession(body) { settingsEditor?.clear(); + settingsHealthReader?.clear(); voteSitesEditor?.clear(); rewardsEditor?.clear(); workspace.login(); @@ -1794,7 +1797,15 @@ function renderScopeOverview() { const node = nodeIndex.get(id); if (node) grid.append(nodeCard(node)); }); - grid.prepend(text(document.createElement('p'), 'Registry connectivity and capabilities only. Individual health requires a server inspection; unknown is not healthy.')); + for (const check of configurationHealthChecks()) { + if (check.status === 'PASS') continue; + const warning = document.createElement('article'); + warning.className = 'attention-item warning'; + warning.append(text(document.createElement('strong'), `${check.status}: ${check.title}`), + text(document.createElement('p'), check.message)); + grid.append(warning); + } + grid.prepend(text(document.createElement('p'), 'Registry connectivity and managed configuration checks. Runtime health requires individual inspection; unknown is not healthy.')); } text(document.querySelector('#metric-presence'), source ? nodePresence(source) : 'Unknown'); text(document.querySelector('#metric-presence-detail'), 'Known only when a connected proxy reports it'); @@ -2013,7 +2024,7 @@ async function autoLoadTab(tab) { return; } if (tab === 'rewards') { await rewardsEditor?.read(false); return; } - if (tab === 'overview' && isWorkspaceOverview()) return; + if (tab === 'overview' && isWorkspaceOverview()) { await refreshConfigurationHealth(); return; } if (autoLoadInFlight.has(tab)) { autoLoadPending.add(tab); return; @@ -2675,6 +2686,11 @@ function dashboardIssues() { 'A retained vote event used a service identifier with no configured match.', 'Open Vote Sites', 'data', 'site-health-card')); }); } + for (const check of configurationHealthChecks()) { + if (check.status === 'PASS') continue; + issues.push(issue('warning', check.title, + check.message, 'Open General Settings', 'general-settings')); + } operationHistoryItems.filter(operation => ['FAILED', 'COMPLETED_WITH_ERRORS'].includes(operation.state)) .forEach(operation => issues.push(issue('warning', `${operationLabel(operation)} needs review`, operationPhase(operation), 'View operation', 'activity'))); @@ -3629,6 +3645,7 @@ async function waitForDeployment(operation, generation) { function discardAuthenticationState(reason) { settingsEditor?.clear(); + settingsHealthReader?.clear(); voteSitesEditor?.clear(); rewardsEditor?.clear(); workspace.logout(); @@ -3885,6 +3902,9 @@ function operationContextCurrent(context) { function invalidateConfigurationReads() { settingsEditor?.invalidateReads(); + settingsHealthReader?.invalidateReads(); + settingsHealthContext = ''; + settingsHealthGeneration++; voteSitesEditor?.invalidateReads(); if (authenticated && tabFromHash() === 'general-settings' && !settingsEditor?.state.busy) { window.setTimeout(() => void settingsEditor?.read(false), 0); @@ -5191,6 +5211,7 @@ async function refreshOverview(target = dataOverview) { async function refreshDashboard() { if (isWorkspaceOverview()) { await Promise.all([loadNodes(), loadOperationHistory()]); + await refreshConfigurationHealth(); renderScopeOverview(); renderOverviewActivity(); return; @@ -5209,7 +5230,7 @@ async function refreshDashboard() { } finally { suppressNodeAutoLoad--; } - await Promise.all([loadEnrollments(), loadOperationHistory()]); + await Promise.all([loadEnrollments(), loadOperationHistory(), refreshConfigurationHealth()]); if (!inspectionCapableNode()) { dashboardLoading = false; inspectionInFlight = false; @@ -5319,7 +5340,8 @@ runNetworkDoctor.addEventListener('click', async () => { downloadNetworkDiagnostics.disabled = true; lastDiagnostics = null; try { - const diagnostics = await runInspection('diagnostics', {}, networkDoctorResults); + const [diagnostics] = await Promise.all([runInspection('diagnostics', {}, networkDoctorResults), + refreshConfigurationHealth()]); lastOverview = diagnostics.result; invalidateDashboardInspection(); const node = nodeIndex.get(selectedServerId); @@ -5341,7 +5363,7 @@ runNetworkDoctor.addEventListener('click', async () => { }; lastDiagnostics = { schemaVersion: 1, generatedAt: new Date().toISOString(), selectedNodeId: selectedServerId, - checks, voteLog, node: diagnostics.result, + checks, configurationChecks: configurationHealthChecks(), voteLog, node: diagnostics.result, control: {application: 'VotingPlugin Control', registeredNodes: allNodeItems.length, nodes: allNodeItems.slice(0, 100).map(item => ({nodeId: item.nodeId, displayName: item.displayName, role: roleLabel(item), online: item.online, pluginVersion: item.pluginVersion}))} @@ -6443,24 +6465,120 @@ async function refreshVoteSiteObservations() { voteSiteHealthFlight = flight; return flight.promise; } const GENERAL_SETTING_FIELDS = [ - {path: 'ProcessRewards', label: 'Process rewards', category: 'Voting & rewards', defaultValue: true, description: 'Allow VotingPlugin to process vote rewards.'}, - {path: 'ExtraAllSitesCheck', label: 'Extra all-sites check', category: 'Voting & rewards', defaultValue: false, description: 'Enable the additional duplicate all-sites reward check.'}, - {path: 'CountFakeVotes', label: 'Count fake votes', category: 'Voting & rewards', defaultValue: true, description: 'Include fake votes in vote totals.'}, - {path: 'AutoCreateVoteSites', label: 'Auto-create vote sites', category: 'Vote sites', defaultValue: true, description: 'Automatically create a site when an unknown service sends a vote.'}, - {path: 'DisableNoServiceSiteMessage', label: 'Hide unknown service-site warnings', category: 'Vote sites', defaultValue: false, description: 'Suppress the missing service-site warning.'}, - {path: 'ExtraVoteShopCheck', label: 'Extra vote shop check', category: 'Vote shop', defaultValue: true, description: 'Apply the additional vote shop purchase check.'}, - {path: 'UseVoteGUIMainCommand', label: 'Open vote GUI from main command', category: 'Interface', defaultValue: false, description: 'Use the vote GUI for the main vote command.'}, - {path: 'CloseInventoryOnVote', label: 'Close inventory when voting', category: 'Interface', defaultValue: true, description: 'Close the open vote inventory after a vote.'}, - {path: 'DisableUpdateChecking', label: 'Disable update checking', category: 'Maintenance', defaultValue: false, restart: true, description: 'Disable update checks. A backend restart is required to reconcile the update-check scheduler lifecycle.'} + {path: 'Debug', label: 'Debug', category: 'Diagnostics', type: 'enum', options: ['NONE', 'INFO', 'EXTRA'], defaultValue: 'NONE', description: 'Off, Info, or Extra diagnostic logging. A proxy supports only Off or Info, so Extra maps to Info there.'}, + {path: 'OnlineMode', label: 'Online Mode', category: 'Network & Identity', type: 'boolean', defaultValue: true, sync: true, description: 'Use the same UUID and identity mode across the applicable proxy and backends.'}, + {path: 'BedrockPlayerPrefix', label: 'Bedrock Player Prefix', category: 'Network & Identity', type: 'string', defaultValue: '.', sync: true, description: 'Prefix used for Geyser/Bedrock player names across the applicable proxy and backends.'}, + {path: 'AllowUnjoined', label: "Allow Players Who Haven't Joined", category: 'Network & Identity', type: 'boolean', defaultValue: false, description: 'On a managed proxy network, the proxy value is effective and every applicable backend is kept true so the proxy can decide.'}, + {path: 'AutoCreateVoteSites', label: 'Auto-create vote sites', category: 'Basic', type: 'boolean', defaultValue: true, recommendation: 'Recommended to disable after vote sites are configured so unexpected service names do not create entries.', description: 'Automatically create a site when an unknown service sends a vote.'}, + {path: 'CountFakeVotes', label: 'Count Fake Votes', category: 'Basic', type: 'boolean', defaultValue: true, description: 'Let fake, administrator, and test votes count toward applicable totals and points.'}, + {path: 'UseVoteGUIMainCommand', label: 'Open Vote GUI with /vote', category: 'Basic', type: 'boolean', defaultValue: false, description: 'Open the Vote GUI from the main vote command.'}, + {path: 'CloseInventoryOnVote', label: 'Close Vote GUI After Voting', category: 'Basic', type: 'boolean', defaultValue: true, description: 'Close the open vote inventory after a vote.'}, + {path: 'GiveDefaultPermission', label: 'Give VotingPlugin.Player by Default', category: 'Plugin Behaviour', type: 'boolean', defaultValue: true, restart: true, description: 'Grant the default player permission during startup. Restart required.'}, + {path: 'LoadCommandAliases', label: 'Enable Command Aliases', category: 'Plugin Behaviour', type: 'boolean', defaultValue: true, restart: true, description: 'Load command aliases such as /avgui. Restart required.'}, + {path: 'CaseInsensitiveYMLFiles', label: 'Case-insensitive YAML Files', category: 'Plugin Behaviour', type: 'boolean', defaultValue: true, restart: true, description: 'Treat YAML keys with different letter case as equivalent. Restart required.'}, + {path: 'PerSiteCoolDownEvents', label: 'Per-site Cooldown Events', category: 'Plugin Behaviour', type: 'boolean', defaultValue: false, restart: true, description: 'Enable per-site cooldown completion events and rewards. Restart required.'}, + {path: 'CheckForUpdates', label: 'Check for Updates', category: 'Plugin Behaviour', type: 'boolean', defaultValue: true, restart: true, description: 'Run update checks. A backend restart reconciles the update-check scheduler.'} ]; function generalSettingsContext() { return JSON.stringify([authenticated, authenticationGeneration, workspace.managementScope, - [...workspace.selectedTargetIds].map(id => [id, nodeIndex.get(id)?.sessionId || '', - nodeIndex.get(id)?.online === true, nodeIndex.get(id)?.acceptedCapabilities?.includes('config.files.v1') === true])]); + generalSettingsTargets().map(target => [target.id, target.sessionId, target.fileName, target.online, + target.supported, target.managedByProxy, target.reportingProxyIds, target.networkIncomplete, target.networkOnly])]); } -function settingValueLabel(value) { return value === true ? 'On' : value === false ? 'Off' : 'Unavailable'; } +function generalSettingsTargets(health = false) { + if ((!health && workspace.managementScope === 'GLOBAL') || !authenticated) return []; + const ids = new Set(health && workspace.managementScope === 'GLOBAL' + ? allNodeItems.map(node => node.nodeId) : [...workspace.selectedTargetIds]); + const directlyManagedIds = new Set(ids); + const backendReporters = new Map(); + const proxies = allNodeItems.filter(isProxy); + proxies.forEach(proxy => { + const reports = Array.isArray(proxy.backends) ? proxy.backends : []; + reports.forEach(report => { + if (!backendReporters.has(report.backendId)) backendReporters.set(report.backendId, new Set()); + backendReporters.get(report.backendId).add(proxy.nodeId); + }); + if (reports.some(report => directlyManagedIds.has(report.backendId))) directlyManagedIds.add(proxy.nodeId); + }); + // Network-aware fields must include the complete known proxy/backend group. + // Keep expanding because a backend may legitimately be reported by more than + // one managed proxy. Missing/offline siblings stay in the result so preview + // can expose and acknowledge exclusions instead of silently omitting them. + let changed = true; + while (changed) { + changed = false; + proxies.forEach(proxy => { + const reports = Array.isArray(proxy.backends) ? proxy.backends : []; + if (!ids.has(proxy.nodeId) && !reports.some(report => ids.has(report.backendId))) return; + if (!ids.has(proxy.nodeId)) { ids.add(proxy.nodeId); changed = true; } + reports.forEach(report => { + if (!ids.has(report.backendId)) { ids.add(report.backendId); changed = true; } + }); + }); + } + return [...ids].map(id => { + const node = nodeIndex.get(id); + const proxy = node ? isProxy(node) : false; + return {id, sessionId: node?.sessionId || '', online: node?.online === true, + fileName: proxy ? 'bungeeconfig.yml' : 'Config.yml', platform: node?.platform || '', + role: proxy ? 'PROXY' : 'BACKEND', + // Retain the known proxy ownership while that proxy is offline. The + // topology is incomplete, but its backend prerequisite must not become + // editable as if this were a standalone server. + managedByProxy: !proxy && backendReporters.has(id), + reportingProxyIds: proxy ? [] : [...(backendReporters.get(id) || [])].sort(), + // A truncated proxy may have omitted this backend relationship entirely, + // so network synchronization must fail closed across the workspace. + networkIncomplete: backendTopologyTruncatedNodeIds.size > 0 || !proxy + && [...(backendReporters.get(id) || [])].some(proxyId => { + const reporter = nodeIndex.get(proxyId); + return reporter?.online !== true + || reporter?.acceptedCapabilities?.includes('config.proxy-files.v1') !== true; + }), + networkOnly: !directlyManagedIds.has(id), + supported: proxy ? node?.acceptedCapabilities?.includes('config.proxy-files.v1') === true + : isBackend(node) && node?.acceptedCapabilities?.includes('config.files.v1') === true}; + }); +} + +function configurationHealthContext() { + return JSON.stringify([authenticated, authenticationGeneration, settingsHealthGeneration, workspace.managementScope, + generalSettingsTargets(true)]); +} + +function configurationHealthChecks() { + if (!authenticated) return []; + if (!settingsHealthReader || settingsHealthContext !== configurationHealthContext()) return [{ + path: 'configuration', title: 'Configuration health is not verified', status: 'UNKNOWN', + nodeIds: [], unknownNodeIds: generalSettingsTargets(true).map(target => target.id), + message: 'Refresh Dashboard or run Network Doctor to read the current managed configuration.' + }]; + return ControlGeneralSettings.healthChecks(settingsHealthReader.model); +} + +let settingsHealthContext = ''; +async function refreshConfigurationHealth() { + if (!authenticated || !settingsHealthReader) return; + const captured = configurationHealthContext(); + await settingsHealthReader.read(false); + if (captured !== configurationHealthContext()) return; + settingsHealthContext = captured; + renderMetrics(); +} + +function settingValueLabel(value) { + if (value === true) return 'On'; + if (value === false) return 'Off'; + if (value === undefined || value === null) return 'Unavailable'; + if (value === '') return 'Empty'; + return String(value); +} + +function generalSettingDisplayValue(field, value) { + if (field.type === 'boolean') return settingValueLabel(value); + return settingValueLabel(value); +} function renderGeneralSettings() { if (!settingsEditor) return; @@ -6479,17 +6597,26 @@ function renderGeneralSettings() { const row = document.createElement('div'); row.className = 'general-setting-row'; const label = text(document.createElement('label'), field.label); label.htmlFor = `general-setting-${field.path}`; - label.append(text(document.createElement('small'), `${field.description} Config.yml → ${field.path}. Documented default: ${settingValueLabel(field.defaultValue)} (not substituted for missing values).`)); - const select = document.createElement('select'); select.id = label.htmlFor; - ['', 'true', 'false'].forEach(value => { - const option = document.createElement('option'); option.value = value; - option.textContent = value ? value === 'true' ? 'On' : 'Off' : 'Read required'; - option.disabled = !value; select.append(option); - }); + label.append(text(document.createElement('small'), `${field.description} ${field.fileName === 'bungeeconfig.yml' ? 'bungeeconfig.yml' : 'Config.yml'} → ${field.path}. Documented default: ${generalSettingDisplayValue(field, field.defaultValue)} (not substituted for missing values).`)); + let select; + if (field.type === 'string') { + select = document.createElement('input'); select.type = 'text'; select.maxLength = 32; + select.placeholder = 'Read required'; + } else { + select = document.createElement('select'); + const values = field.type === 'enum' ? field.options : [true, false]; + const empty = document.createElement('option'); empty.value = ''; empty.textContent = 'Read required'; empty.disabled = true; select.append(empty); + values.forEach(value => { + const option = document.createElement('option'); option.value = String(value); + option.textContent = field.type === 'boolean' ? settingValueLabel(value) : String(value); + select.append(option); + }); + } + select.id = label.htmlFor; select.addEventListener('change', () => { - if (select.value === 'true' || select.value === 'false') { + if (field.type === 'string' || select.value !== '') { document.querySelector('#general-settings-ack').checked = false; - settingsEditor.edit(field.path, select.value === 'true'); + settingsEditor.edit(field.path, field.type === 'boolean' ? select.value === 'true' : select.value); } }); const reset = text(document.createElement('button'), 'Reset'); reset.type = 'button'; reset.className = 'secondary compact'; @@ -6507,15 +6634,28 @@ function renderGeneralSettings() { const aggregate = model.aggregate(field.path); const dirty = model.dirty.has(field.path); const select = document.querySelector(`#general-setting-${field.path}`); - select.options[0].textContent = aggregate.state === 'SAME' ? 'Current value' - : aggregate.state === 'MIXED' ? '— Mixed values —' : `${aggregate.state} · ${aggregate.supportedState === 'SAME' ? `supported targets ${settingValueLabel(aggregate.value)}` : aggregate.supportedState.toLowerCase()}`; + if (field.type === 'string') select.placeholder = aggregate.state === 'SAME' ? `Current: ${settingValueLabel(aggregate.value)}` : aggregate.state === 'MIXED' ? 'Mixed values' : 'Read required'; + else select.options[0].textContent = aggregate.state === 'SAME' ? `Current: ${generalSettingDisplayValue(field, aggregate.value)}` + : aggregate.state === 'MIXED' ? '— Mixed values —' : `${aggregate.state} · ${aggregate.supportedState === 'SAME' ? `supported targets ${generalSettingDisplayValue(field, aggregate.value)}` : aggregate.supportedState.toLowerCase()}`; select.value = dirty ? String(model.dirty.get(field.path)) : aggregate.state === 'SAME' ? String(aggregate.value) : ''; select.disabled = state.busy || !aggregate.targets.some(target => target.status === 'AVAILABLE'); select.setAttribute('aria-describedby', `general-setting-values-${field.path}`); const reset = document.querySelector(`#general-setting-reset-${field.path}`); reset.disabled = state.busy || !dirty; const values = document.querySelector(`#general-setting-values-${field.path}`); - values.replaceChildren(...aggregate.targets.map(target => text(document.createElement('p'), - `${nodeIndex.get(target.id)?.displayName || target.id}: ${target.status === 'AVAILABLE' ? settingValueLabel(target.value) : target.status}${dirty ? ` · requested ${settingValueLabel(model.dirty.get(field.path))}` : ''}`))); + values.replaceChildren(...aggregate.targets.map(target => { + const requested = dirty ? model.requestedValue(model.targets.get(target.id), field.path, model.dirty.get(field.path)) : undefined; + const shown = field.path === 'AllowUnjoined' && target.role === 'BACKEND' ? target.rawValue : target.value; + return text(document.createElement('p'), + `${nodeIndex.get(target.id)?.displayName || target.id}: ${target.status === 'AVAILABLE' ? generalSettingDisplayValue(field, shown) : target.status}${dirty ? ` · requested ${generalSettingDisplayValue(field, requested)}` : ''}`); + })); + if (field.path === 'AllowUnjoined' && !dirty && aggregate.value !== undefined + && aggregate.targets.some(target => target.role === 'BACKEND' && target.rawValue === false + && model.targets.get(target.id)?.managedByProxy)) { + const repair = text(document.createElement('button'), 'Repair backend prerequisites'); + repair.type = 'button'; repair.className = 'secondary compact'; repair.disabled = state.busy; + repair.addEventListener('click', () => settingsEditor.edit(field.path, aggregate.value)); + values.append(repair); + } }); text(document.querySelector('#general-settings-status'), state.error || state.message || (state.busy ? 'Loading / processing…' : 'Only explicit changes will be proposed.')); document.querySelector('#general-settings-panel').setAttribute('aria-busy', String(Boolean(state.busy))); @@ -6533,17 +6673,21 @@ function renderGeneralSettings() { const summary = document.querySelector('#general-settings-summary'); summary.replaceChildren(text(document.createElement('p'), `Target servers: ${model.targets.size}`), text(document.createElement('p'), `Explicit dirty settings: ${model.dirty.size}`), - text(document.createElement('p'), `Affected files: ${model.dirty.size ? 'Config.yml' : 'none'}`), + text(document.createElement('p'), `Affected files: ${model.dirty.size ? [...new Set([...model.targets.values()].map(target => target.fileName || 'Config.yml'))].join(', ') : 'none'}`), text(document.createElement('p'), `Preview: ${preview ? 'Ready' : state.previewState || 'Not previewed'}`)); model.dirty.forEach((value, path) => { const field = GENERAL_SETTING_FIELDS.find(item => item.path === path); const aggregate = model.aggregate(path); - summary.append(text(document.createElement('p'), `${field?.label || path}: ${aggregate.state === 'SAME' ? settingValueLabel(aggregate.value) : aggregate.state} → ${settingValueLabel(value)}`)); + summary.append(text(document.createElement('p'), `${field?.label || path}: ${aggregate.state === 'SAME' ? generalSettingDisplayValue(field || {type: 'boolean'}, aggregate.value) : aggregate.state} → ${field ? generalSettingDisplayValue(field, value) : settingValueLabel(value)}`)); + if (field?.recommendation && path === 'AutoCreateVoteSites' && value === true) summary.append(text(document.createElement('p'), `Recommendation: ${field.recommendation}`)); }); plans.forEach(plan => { const keys = Object.keys(plan.overrides); - const impact = keys.some(path => GENERAL_SETTING_FIELDS.find(field => field.path === path)?.restart) - ? 'Config reload + full backend restart required (restart is not performed by this editor)' : keys.length ? 'VotingPlugin configuration reload required' : 'No runtime action required'; + const proxyRestart = keys.length && model.targets.get(plan.id)?.role === 'PROXY'; + const impact = proxyRestart ? 'Saved proxy configuration; full proxy restart required (not performed by Control)' + : keys.some(path => GENERAL_SETTING_FIELDS.find(field => field.path === path)?.restart) + ? 'Config reload + full backend restart required (restart is not performed by this editor)' : keys.some(path => GENERAL_SETTING_FIELDS.find(field => field.path === path)?.sync) + ? 'Configuration reload required across the applicable backend/proxy network targets' : keys.length ? 'VotingPlugin configuration reload required' : 'No runtime action required'; summary.append(text(document.createElement('p'), `${plan.id}: ${impact}${plan.skipped.length ? ` · excluded: ${plan.skipped.map(item => `${item.field} (${item.status})`).join(', ')}` : ''}`)); }); const exclusions = plans.some(plan => plan.skipped.length); @@ -6563,11 +6707,11 @@ function renderGeneralSettings() { section.append(text(document.createElement('h4'), item.id)); const operation = item.operation; const result = operation?.results?.[item.id]; - section.append(text(document.createElement('p'), result ? `${result.success ? 'Config.yml · preview ready' : `${result.code} · ${result.message}`}${operation?.operationId ? ` · operation ${operation.operationId}` : ''}` : `${item.status || 'Unchanged / excluded'}${item.message ? ` · ${item.message}` : ''}`)); + section.append(text(document.createElement('p'), result ? `${result.success ? `${model.targets.get(item.id)?.fileName || 'Config.yml'} · preview ready` : `${result.code} · ${result.message}`}${operation?.operationId ? ` · operation ${operation.operationId}` : ''}` : `${item.status || 'Unchanged / excluded'}${item.message ? ` · ${item.message}` : ''}`)); (item.skipped || []).forEach(skipped => section.append(text(document.createElement('p'), `${skipped.field}: excluded (${skipped.status})`))); const plan = plans.find(plan => plan.id === item.id); if (plan) Object.entries(plan.overrides).forEach(([path, value]) => section.append(text(document.createElement('p'), - `Config.yml → ${path}: ${settingValueLabel(model.targets.get(item.id)?.fields[path]?.value)} → ${settingValueLabel(value)}`))); + `${model.targets.get(item.id)?.fileName || 'Config.yml'} → ${path}: ${generalSettingDisplayValue(GENERAL_SETTING_FIELDS.find(field => field.path === path) || {type: 'boolean'}, model.targets.get(item.id)?.fields[path]?.value)} → ${generalSettingDisplayValue(GENERAL_SETTING_FIELDS.find(field => field.path === path) || {type: 'boolean'}, value)}`))); (result?.changes || []).forEach(change => section.append(text(document.createElement('p'), change))); previewResults.append(section); }); @@ -6582,7 +6726,7 @@ function renderGeneralSettings() { results.append(section); return; } section.append(text(document.createElement('h4'), id), text(document.createElement('p'), - `${result.success ? '✓ written' : `✗ ${result.code || data.status || 'not applied'}: ${result.message || data.message || ''}`} · ${result.reloaded ? '✓ reloaded' : 'reload not confirmed'}${result.rolledBack ? ' · ✓ automatic local rollback' : ''}${data.operation?.operationId ? ` · operation ${data.operation.operationId}` : ''}`), + `${result.success ? '✓ written' : `✗ ${result.code || data.status || 'not applied'}: ${result.message || data.message || ''}`} · ${current?.role === 'PROXY' && result.success ? 'proxy restart required; active runtime not changed' : result.reloaded ? '✓ reloaded' : 'reload not confirmed'}${result.rolledBack ? ' · ✓ automatic local rollback' : ''}${data.operation?.operationId ? ` · operation ${data.operation.operationId}` : ''}`), text(document.createElement('p'), data.confirmed === true ? '✓ Confirmed requested persisted configuration after apply (not proof of a backend restart)' : current?.status === 'AVAILABLE' ? 'Current configuration re-read; requested values are not confirmed as successfully applied.' : '✗ Confirmed read unavailable; do not assume requested values are active.')); results.append(section); @@ -6590,17 +6734,26 @@ function renderGeneralSettings() { } settingsEditor = ControlGeneralSettings.create({ - targets: () => workspace.managementScope === 'GLOBAL' || !authenticated ? [] : [...workspace.selectedTargetIds].map(id => { - const node = nodeIndex.get(id); - return {id, sessionId: node?.sessionId || '', online: node?.online === true, - supported: isBackend(node) && node?.acceptedCapabilities?.includes('config.files.v1') === true}; - }), + targets: generalSettingsTargets, context: generalSettingsContext, active: () => authenticated && tabFromHash() === 'general-settings', operation: (path, body) => startConfigurationOperation(path, body, document.querySelector('#general-settings-status')), request: (path, body) => authorized(path, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)}), changed: renderGeneralSettings }); +// Independent read-only state: health refreshes must not invalidate an editor draft/approval. +settingsHealthReader = ControlGeneralSettings.create({ + targets: () => generalSettingsTargets(true), + context: configurationHealthContext, + operation: (path, body) => { + if (path !== '/api/v1/configuration/read') throw new Error('Health checks are read-only'); + return startConfigurationOperation(path, body, document.createElement('span')); + }, + request: (path, body) => { + if (path !== '/api/v1/configuration/general-settings/state') throw new Error('Health checks are read-only'); + return authorized(path, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)}); + } +}); document.querySelector('#general-settings-read').addEventListener('click', () => void settingsEditor.read(true)); document.querySelector('#general-settings-retry').addEventListener('click', () => void settingsEditor.read(true, true)); document.querySelector('#general-settings-preview').addEventListener('click', () => { document.querySelector('#general-settings-ack').checked = false; void settingsEditor.preview(); }); diff --git a/src/main/resources/web/configuration-state.js b/src/main/resources/web/configuration-state.js index 2e343ea..19b66c3 100644 --- a/src/main/resources/web/configuration-state.js +++ b/src/main/resources/web/configuration-state.js @@ -7,6 +7,7 @@ const READ_STATUSES = new Set(['AVAILABLE', 'ERROR', 'UNSUPPORTED']); const FIELD_STATUSES = new Set(['AVAILABLE', 'MISSING', 'UNSUPPORTED']); + const NETWORK_FIELDS = new Set(['OnlineMode', 'BedrockPlayerPrefix', 'AllowUnjoined']); function string(value) { return typeof value === 'string' ? value : ''; } @@ -29,9 +30,11 @@ if (!fields || typeof fields !== 'object') return result; Object.keys(fields).forEach(function (path) { const source = fields[path] || {}; + const value = typeof source.value === 'boolean' || typeof source.value === 'string' ? source.value : undefined; + const status = FIELD_STATUSES.has(source.status) ? source.status : 'MISSING'; result[path] = { - status: FIELD_STATUSES.has(source.status) ? source.status : 'MISSING', - value: source.value === true + status: status === 'AVAILABLE' && value === undefined ? 'UNSUPPORTED' : status, + value }; }); return result; @@ -50,6 +53,14 @@ return { id: target.id, sessionId: string(target.sessionId), + fileName: string(target.fileName) || 'Config.yml', + platform: string(target.platform), + role: string(target.role), + managedByProxy: target.managedByProxy === true, + reportingProxyIds: Array.isArray(target.reportingProxyIds) + ? target.reportingProxyIds.filter(function (id) { return typeof id === 'string' && id; }).slice().sort() : [], + networkIncomplete: target.networkIncomplete === true, + networkOnly: target.networkOnly === true, status: 'MISSING', revision: '', fields: {}, @@ -75,7 +86,20 @@ if (!target || typeof target.id !== 'string' || !target.id) return; const sessionId = string(target.sessionId); const current = this.targets.get(target.id); - next.set(target.id, current && current.sessionId === sessionId ? current : targetRecord({id: target.id, sessionId})); + if (current && current.sessionId === sessionId + && current.fileName === (string(target.fileName) || 'Config.yml')) { + current.fileName = string(target.fileName) || current.fileName || 'Config.yml'; + current.platform = string(target.platform); + current.role = string(target.role); + current.managedByProxy = target.managedByProxy === true; + current.reportingProxyIds = Array.isArray(target.reportingProxyIds) + ? target.reportingProxyIds.filter(function (id) { return typeof id === 'string' && id; }).slice().sort() : []; + current.networkIncomplete = target.networkIncomplete === true; + current.networkOnly = target.networkOnly === true; + next.set(target.id, current); + } else { + next.set(target.id, targetRecord(target)); + } }, this); } let changed = next.size !== this.targets.size; @@ -114,7 +138,8 @@ } edit(field, value) { - if (typeof field !== 'string' || !field || typeof value !== 'boolean') return this; + if (typeof field !== 'string' || !field + || (typeof value !== 'boolean' && typeof value !== 'string')) return this; this.dirty.set(field, value); this.invalidatePreview(); return this; @@ -135,34 +160,61 @@ aggregate(field) { const entries = []; this.targets.forEach(function (target) { + if (target.networkOnly && !NETWORK_FIELDS.has(field)) return; let status = target.status === 'ERROR' ? 'ERROR' : target.status === 'UNSUPPORTED' ? 'UNSUPPORTED' : 'MISSING'; let value; const snapshot = target.fields[field]; if (target.status === 'AVAILABLE' && snapshot) { status = snapshot.status; - if (status === 'AVAILABLE') value = snapshot.value === true; + if (status === 'AVAILABLE') value = this.logicalValue(target, field, snapshot.value); } - entries.push({id: target.id, status, value, revision: target.revision, sessionId: target.sessionId, - code: target.code, message: target.message}); - }); + const entry = {id: target.id, status, value, revision: target.revision, sessionId: target.sessionId, + code: target.code, message: target.message}; + if (field === 'AllowUnjoined') { + entry.rawValue = snapshot && snapshot.value; + entry.role = target.role; + } + entries.push(entry); + }, this); return aggregateSnapshots(entries, function (left, right) { return left === right; }); } + logicalValue(target, field, value) { + if (field !== 'AllowUnjoined' || target.role !== 'BACKEND' || !target.managedByProxy) return value; + const proxy = Array.from(this.targets.values()).find(function (candidate) { + const snapshot = candidate.fields.AllowUnjoined; + return target.reportingProxyIds.includes(candidate.id) && candidate.role === 'PROXY' + && candidate.status === 'AVAILABLE' + && snapshot && snapshot.status === 'AVAILABLE'; + }); + return proxy ? proxy.fields.AllowUnjoined.value : value; + } + + requestedValue(target, field, requested) { + if (field === 'Debug' && target.role === 'PROXY' && requested === 'EXTRA') return 'INFO'; + return field === 'AllowUnjoined' && target.role === 'BACKEND' && target.managedByProxy ? true : requested; + } + plans() { const plans = []; this.targets.forEach(function (target) { const overrides = {}; const skipped = []; this.dirty.forEach(function (requested, field) { + if (target.networkOnly && !NETWORK_FIELDS.has(field)) return; const snapshot = target.status === 'AVAILABLE' ? target.fields[field] : null; - if (snapshot && snapshot.status === 'AVAILABLE') { - if (snapshot.value !== requested) overrides[field] = requested; + if (target.networkIncomplete + && (field === 'OnlineMode' || field === 'BedrockPlayerPrefix' || field === 'AllowUnjoined')) { + skipped.push({field, status: 'TOPOLOGY_INCOMPLETE'}); + } else if (snapshot && snapshot.status === 'AVAILABLE') { + const targetRequest = this.requestedValue(target, field, requested); + if (snapshot.value !== targetRequest) overrides[field] = targetRequest; } else { skipped.push({field, status: target.status === 'ERROR' ? 'ERROR' : target.status === 'UNSUPPORTED' ? 'UNSUPPORTED' : snapshot ? snapshot.status : 'MISSING'}); } - }); + }, this); plans.push({id: target.id, revision: target.revision, sessionId: target.sessionId, overrides, skipped, status: target.status}); }, this); @@ -210,10 +262,14 @@ clearConfirmedDirty() { if (!this.targets.size) return this; this.dirty.forEach(function (requested, field) { - const confirmed = Array.from(this.targets.values()).every(function (target) { - const snapshot = target.status === 'AVAILABLE' && target.fields[field]; - return snapshot && snapshot.status === 'AVAILABLE' && snapshot.value === requested; + const relevantTargets = Array.from(this.targets.values()).filter(function (target) { + return !target.networkOnly || NETWORK_FIELDS.has(field); }); + const confirmed = relevantTargets.length > 0 && relevantTargets.every(function (target) { + const snapshot = target.status === 'AVAILABLE' && target.fields[field]; + return snapshot && snapshot.status === 'AVAILABLE' + && snapshot.value === this.requestedValue(target, field, requested); + }, this); if (confirmed) this.dirty.delete(field); }, this); return this; diff --git a/src/main/resources/web/general-settings.js b/src/main/resources/web/general-settings.js index 4ad8552..c230d71 100644 --- a/src/main/resources/web/general-settings.js +++ b/src/main/resources/web/general-settings.js @@ -7,7 +7,8 @@ if (!configuration || !configuration.MultiTargetState) throw new Error('ControlConfiguration.MultiTargetState is required'); const CACHE_MILLIS = 30000; - const MAX_CHANGED_TARGETS = 8; + const MAX_READ_TARGETS = 100; + const MAX_FILE_OPERATIONS = 16; function now() { return Date.now(); } function resultFor(operation, id) { @@ -41,7 +42,22 @@ function setState(values) { state = Object.assign({}, state, values); notify(); } function current(captured, capturedGeneration) { return context() === captured && generation === capturedGeneration; } function selected() { return targetList(adapter); } - function syncTargets() { model.setTargets(selected().map(function (target) { return {id: target.id, sessionId: target.sessionId}; })); } + function syncTargets() { model.setTargets(selected().map(function (target) { + return {id: target.id, sessionId: target.sessionId, fileName: target.fileName || 'Config.yml', + platform: target.platform, role: target.role, managedByProxy: target.managedByProxy, + reportingProxyIds: target.reportingProxyIds, + networkIncomplete: target.networkIncomplete, networkOnly: target.networkOnly}; + })); } + function readBatchCount() { + const byFile = new Map(); + selected().filter(function (target) { return target.online && target.supported; }).forEach(function (target) { + const fileName = target.fileName || 'Config.yml'; + byFile.set(fileName, (byFile.get(fileName) || 0) + 1); + }); + let count = 0; + byFile.forEach(function (targets) { count += Math.ceil(targets / MAX_READ_TARGETS); }); + return count; + } function releasePreviews(items) { return Promise.all((items || []).map(function (item) { if (!item.operationId || !item.approvalToken) return Promise.resolve(); @@ -78,7 +94,8 @@ } function cacheCurrent(target) { const cached = reads.get(target.id); - return cached && cached.sessionId === target.sessionId && now() - cached.loadedAt < CACHE_MILLIS; + return cached && cached.sessionId === target.sessionId && cached.fileName === (target.fileName || 'Config.yml') + && now() - cached.loadedAt < CACHE_MILLIS; } async function loadStates(operation, targets, captured, capturedGeneration) { @@ -97,6 +114,7 @@ readError(target, 'Read identity changed; read the target again', 'TARGET_CHANGED'); return; } reads.set(target.id, {readOperationId: operation.operationId, sessionId: target.sessionId, + fileName: target.fileName || 'Config.yml', revision: typed.revision || '', loadedAt: now()}); model.setRead(target.id, {status: 'AVAILABLE', sessionId: typed.sessionId || target.sessionId, revision: typed.revision, fields: typed.fields || {}}); @@ -135,13 +153,37 @@ const currentFlight = {key: key, promise: null}; currentFlight.promise = (async function () { try { - const operation = await adapter.operation('/api/v1/configuration/read', { - nodeIds: wanted.map(function (target) { return target.id; }), configuration: {domain: 'file', fileName: 'Config.yml'} - }); if (!current(captured, capturedGeneration)) return model; - await loadStates(operation, wanted, captured, capturedGeneration); + const groups = new Map(); + wanted.forEach(function (target) { + const fileName = target.fileName || 'Config.yml'; + if (!groups.has(fileName)) groups.set(fileName, []); + groups.get(fileName).push(target); + }); + const batches = []; + groups.forEach(function (targets, fileName) { + for (let offset = 0; offset < targets.length; offset += MAX_READ_TARGETS) { + batches.push({fileName: fileName, targets: targets.slice(offset, offset + MAX_READ_TARGETS)}); + } + }); + let groupFailed = false; + await Promise.all(batches.map(async function (batch) { + try { + const operation = await adapter.operation('/api/v1/configuration/read', { + nodeIds: batch.targets.map(function (target) { return target.id; }), + configuration: {domain: 'file', fileName: batch.fileName} + }); + await loadStates(operation, batch.targets, captured, capturedGeneration); + } catch (error) { + groupFailed = true; + if (current(captured, capturedGeneration)) batch.targets.forEach(function (target) { + readError(target, errorText(error), error && error.code); + }); + } + })); if (current(captured, capturedGeneration) && approval && !model.previewCurrent() && !applying) clearApproval('Stale'); - if (current(captured, capturedGeneration)) setState({message: 'General Settings loaded'}); + if (current(captured, capturedGeneration)) setState({message: groupFailed + ? 'General Settings loaded with unavailable target groups' : 'General Settings loaded'}); } catch (error) { if (current(captured, capturedGeneration)) { wanted.forEach(function (target) { readError(target, errorText(error), error && error.code); }); @@ -170,9 +212,11 @@ const signature = model.beginPreview(); const plans = model.plans(); const changed = plans.filter(function (plan) { return Object.keys(plan.overrides).length > 0; }); - if (changed.length > MAX_CHANGED_TARGETS) { + const maxChangedTargets = Math.max(0, MAX_FILE_OPERATIONS - readBatchCount()); + if (changed.length > maxChangedTargets) { setBusy(-1); - setState({error: 'At most ' + MAX_CHANGED_TARGETS + ' changed targets can be previewed at once', previewState: 'Stale', previewItems: []}); + setState({error: 'At most ' + maxChangedTargets + + ' changed targets can be previewed at once for this workspace size', previewState: 'Stale', previewItems: []}); return false; } setState({error: '', message: 'Previewing General Settings…', previewState: 'Not previewed', ackRequired: false, previewItems: []}); @@ -292,5 +336,53 @@ }; return editor; } - return {create}; + function healthChecks(model) { + const targets = Array.from(model.targets.values()); + const checks = []; + function add(path, title, relevant, warning, message) { + const known = relevant.filter(target => target.status === 'AVAILABLE' + && target.fields[path] && target.fields[path].status === 'AVAILABLE'); + const affected = warning(known); + const unknown = relevant.filter(target => !known.includes(target) || target.networkIncomplete); + const status = affected.length ? 'WARNING' : unknown.length || !relevant.length ? 'UNKNOWN' : 'PASS'; + const label = {AutoCreateVoteSites: 'Auto-create Vote Sites', OnlineMode: 'Online Mode', + BedrockPlayerPrefix: 'Bedrock Player Prefix', AllowUnjoined: 'Backend AllowUnjoined prerequisite'}[path]; + checks.push({path, title: status === 'WARNING' ? title + : label + (status === 'UNKNOWN' ? ' is not verified' : ' is verified'), status, + nodeIds: affected.map(target => target.id), unknownNodeIds: unknown.map(target => target.id), + message: affected.length ? message + ' Affected nodes: ' + affected.map(target => target.id).join(', ') + : unknown.length || !relevant.length ? 'Configuration could not be verified for all applicable nodes.' + : 'Verified from current managed configuration.'}); + } + const backends = targets.filter(target => target.role === 'BACKEND'); + add('AutoCreateVoteSites', 'Auto-create Vote Sites is enabled', backends, + known => known.filter(target => target.fields.AutoCreateVoteSites.value === true), + 'Recommended: disable after configuring vote sites to avoid unexpected service entries.'); + for (const pair of [['OnlineMode', 'Online Mode'], ['BedrockPlayerPrefix', 'Bedrock Player Prefix']]) { + add(pair[0], pair[1] + ' differs across the managed network', targets, + known => known.filter(target => { + const connected = new Set([target.id]); + let changed = true; + while (changed) { + changed = false; + targets.forEach(candidate => { + const links = [candidate.id].concat(candidate.reportingProxyIds || []); + if (!links.some(id => connected.has(id))) return; + links.forEach(id => { if (!connected.has(id)) { connected.add(id); changed = true; } }); + }); + } + return new Set(known.filter(candidate => connected.has(candidate.id)) + .map(candidate => candidate.fields[pair[0]].value)).size > 1; + }), + 'Review per-node values and use a revision-bound preview to synchronize applicable nodes.'); + } + add('AllowUnjoined', 'Backend AllowUnjoined prerequisite is disabled', + backends.filter(target => target.managedByProxy), + known => known.filter(target => target.fields.AllowUnjoined.value !== true), + 'Enable the backend prerequisite so the proxy controls acceptance; preserve the proxy preference.'); + // Standalone servers have no proxy prerequisite to check. + if (!backends.some(target => target.managedByProxy) && !targets.some(target => target.role === 'PROXY')) checks.pop(); + return checks; + } + return {create, healthChecks}; })); diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/GeneralSettingsDocumentTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/GeneralSettingsDocumentTest.java index 504fea7..66777be 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/GeneralSettingsDocumentTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/GeneralSettingsDocumentTest.java @@ -6,125 +6,99 @@ import java.util.HashMap; import java.util.Map; - import org.junit.jupiter.api.Test; class GeneralSettingsDocumentTest { - @Test void fieldsExposeOnlyKnownSettingsWithMissingAndTypeStatus() { - String content = "\"ProcessRewards\": FALSE # deliberately uppercase\n" - + "AutoCreateVoteSites: true\n" - + "ExtraAllSitesCheck: 'false'\n" - + "CountFakeVotes: null\n" + @Test void backendFieldsExposeCuratedTypedValuesAndStatuses() { + String content = "DebugLevel: extra\nOnlineMode: TRUE\nAutoCreateVoteSites: true\n" + + "CountFakeVotes: 'true'\nBedrockPlayerPrefix: '.'\nDisableUpdateChecking: true\n" + "SecretToken: __VOTINGPLUGIN_CONTROL_REDACTED__\n"; - Map fields = GeneralSettingsDocument.fields(content); - - assertEquals(9, fields.size()); - assertEquals(new GeneralSettingsDocument.Field(GeneralSettingsDocument.Status.AVAILABLE, false), - fields.get("ProcessRewards")); - assertEquals(new GeneralSettingsDocument.Field(GeneralSettingsDocument.Status.AVAILABLE, true), - fields.get("AutoCreateVoteSites")); - assertEquals(new GeneralSettingsDocument.Field(GeneralSettingsDocument.Status.UNSUPPORTED, null), - fields.get("ExtraAllSitesCheck")); - assertEquals(new GeneralSettingsDocument.Field(GeneralSettingsDocument.Status.UNSUPPORTED, null), - fields.get("CountFakeVotes")); - assertEquals(new GeneralSettingsDocument.Field(GeneralSettingsDocument.Status.MISSING, null), - fields.get("DisableNoServiceSiteMessage")); - assertEquals(new GeneralSettingsDocument.Field(GeneralSettingsDocument.Status.MISSING, null), - fields.get("DisableUpdateChecking")); - assertEquals(new GeneralSettingsDocument.Field(GeneralSettingsDocument.Status.MISSING, null), - fields.get("UseVoteGUIMainCommand")); - assertEquals(new GeneralSettingsDocument.Field(GeneralSettingsDocument.Status.MISSING, null), - fields.get("CloseInventoryOnVote")); - assertEquals(new GeneralSettingsDocument.Field(GeneralSettingsDocument.Status.MISSING, null), - fields.get("ExtraVoteShopCheck")); + assertEquals(13, fields.size()); + assertEquals("EXTRA", fields.get("Debug").value()); + assertEquals(true, fields.get("OnlineMode").value()); + assertEquals(GeneralSettingsDocument.Status.UNSUPPORTED, fields.get("CountFakeVotes").status()); + assertEquals(".", fields.get("BedrockPlayerPrefix").value()); + assertEquals(false, fields.get("CheckForUpdates").value()); + assertEquals(GeneralSettingsDocument.Status.MISSING, fields.get("GiveDefaultPermission").status()); } - @Test void patchChangesOnlySelectedScalarSpansAndPreservesIndependentDocuments() { - String first = "# settings\n\"ProcessRewards\": true # quoted key intentionally\n" - + "Unknown: keep\nPassword: __VOTINGPLUGIN_CONTROL_REDACTED__\n"; - String second = "Unknown: keep\nProcessRewards : FALSE # another layout\n"; - - assertEquals("# settings\n\"ProcessRewards\": false # quoted key intentionally\n" - + "Unknown: keep\nPassword: __VOTINGPLUGIN_CONTROL_REDACTED__\n", - GeneralSettingsDocument.patch(first, Map.of("ProcessRewards", false))); - assertEquals("Unknown: keep\nProcessRewards : true # another layout\n", - GeneralSettingsDocument.patch(second, Map.of("ProcessRewards", true))); + @Test void proxyProfileMapsNamesAndDebugWithoutInventingExtra() { + String content = "Debug: true\nOnlineMode: false\nAllowUnJoined: true\nBedrockPlayerPrefix: '.'\n"; + Map fields = GeneralSettingsDocument.fields(content, + GeneralSettingsDocument.Profile.PROXY); + assertEquals(4, fields.size()); + assertEquals("INFO", fields.get("Debug").value()); + assertEquals(true, fields.get("AllowUnjoined").value()); + assertEquals("Debug: false\nOnlineMode: true\nAllowUnJoined: true\nBedrockPlayerPrefix: '_'\n", + GeneralSettingsDocument.patch(content, GeneralSettingsDocument.Profile.PROXY, + Map.of("Debug", "NONE", "OnlineMode", true, "BedrockPlayerPrefix", "_"))); + assertEquals(content, GeneralSettingsDocument.patch(content, GeneralSettingsDocument.Profile.PROXY, + Map.of("Debug", "EXTRA"))); } - @Test void patchesVerifiedUiAndShopBooleansWithoutTouchingOtherConfiguration() { - String source = "# GUI behavior\nUseVoteGUIMainCommand: false # /vote\n" - + "CloseInventoryOnVote: true\nExtraVoteShopCheck: true\n" - + "VoteShop: {Unknown: preserved}\nSecret: __VOTINGPLUGIN_CONTROL_REDACTED__\n"; - - assertEquals("# GUI behavior\nUseVoteGUIMainCommand: true # /vote\n" - + "CloseInventoryOnVote: false\nExtraVoteShopCheck: true\n" - + "VoteShop: {Unknown: preserved}\nSecret: __VOTINGPLUGIN_CONTROL_REDACTED__\n", - GeneralSettingsDocument.patch(source, Map.of("UseVoteGUIMainCommand", true, - "CloseInventoryOnVote", false))); - assertEquals(new GeneralSettingsDocument.Field(GeneralSettingsDocument.Status.AVAILABLE, true), - GeneralSettingsDocument.fields(source).get("ExtraVoteShopCheck")); + @Test void patchChangesOnlySelectedScalarSpansAndPreservesSource() { + String source = "# settings\nCountFakeVotes: TRUE # keep\nDisableUpdateChecking: false\n" + + "BedrockPlayerPrefix: '.'\nUnknown: keep\nPassword: __VOTINGPLUGIN_CONTROL_REDACTED__\n"; + assertEquals("# settings\nCountFakeVotes: false # keep\nDisableUpdateChecking: true\n" + + "BedrockPlayerPrefix: 'x''y'\nUnknown: keep\nPassword: __VOTINGPLUGIN_CONTROL_REDACTED__\n", + GeneralSettingsDocument.patch(source, Map.of("CountFakeVotes", false, + "CheckForUpdates", false, "BedrockPlayerPrefix", "x'y"))); } @Test void patchIsStrictAndNeverInventsOrCoercesSettings() { - String missing = "Other: true\n"; - String unsupported = "ProcessRewards: 'true'\n"; - HashMap nullValue = new HashMap<>(); - nullValue.put("ProcessRewards", null); - + HashMap nullValue = new HashMap<>(); + nullValue.put("CountFakeVotes", null); assertThrows(IllegalArgumentException.class, - () -> GeneralSettingsDocument.patch(missing, Map.of("ProcessRewards", true))); + () -> GeneralSettingsDocument.patch("Other: true\n", Map.of("CountFakeVotes", true))); assertThrows(IllegalArgumentException.class, - () -> GeneralSettingsDocument.patch(unsupported, Map.of("ProcessRewards", true))); + () -> GeneralSettingsDocument.patch("CountFakeVotes: 'true'\n", Map.of("CountFakeVotes", true))); assertThrows(IllegalArgumentException.class, - () -> GeneralSettingsDocument.patch("ProcessRewards: true\n", Map.of("unknown", true))); + () -> GeneralSettingsDocument.patch("CountFakeVotes: true\n", Map.of("unknown", true))); assertThrows(IllegalArgumentException.class, - () -> GeneralSettingsDocument.patch("ProcessRewards: true\n", nullValue)); + () -> GeneralSettingsDocument.patch("CountFakeVotes: true\n", nullValue)); assertThrows(IllegalArgumentException.class, - () -> GeneralSettingsDocument.patch("ProcessRewards: true\n", Map.of())); - } - - @Test void noChangeReturnsTextVerbatimIncludingNonCanonicalBooleanSpelling() { - String content = "ProcessRewards: TRUE # retain source style\n"; - assertEquals(content, GeneralSettingsDocument.patch(content, Map.of("ProcessRewards", true))); - } - - @Test void supportsFlowRootWithoutReformattingIt() { - String content = "{ProcessRewards: true, unknown: __VOTINGPLUGIN_CONTROL_REDACTED__}\n"; - assertEquals("{ProcessRewards: false, unknown: __VOTINGPLUGIN_CONTROL_REDACTED__}\n", - GeneralSettingsDocument.patch(content, Map.of("ProcessRewards", false))); + () -> GeneralSettingsDocument.patch("DebugLevel: INFO\n", Map.of("Debug", "DEV"))); + assertThrows(IllegalArgumentException.class, + () -> GeneralSettingsDocument.patch("BedrockPlayerPrefix: '.'\n", + Map.of("BedrockPlayerPrefix", "x".repeat(33)))); } - @Test void usesCodePointMarksWhenTextBeforeASettingContainsNonBmpCharacters() { - String content = "Message: '😀'\nProcessRewards: true # exact location follows emoji\n"; - assertEquals("Message: '😀'\nProcessRewards: false # exact location follows emoji\n", - GeneralSettingsDocument.patch(content, Map.of("ProcessRewards", false))); + @Test void noChangeReturnsTextVerbatimAndUnicodeOffsetsRemainCorrect() { + String content = "Message: '😀'\nCountFakeVotes: TRUE # exact location follows emoji\n"; + assertEquals(content, GeneralSettingsDocument.patch(content, Map.of("CountFakeVotes", true))); + assertEquals("Message: '😀'\nCountFakeVotes: false # exact location follows emoji\n", + GeneralSettingsDocument.patch(content, Map.of("CountFakeVotes", false))); } - @Test void rejectsAliasOrAnchorInvolvementForAnEditedSetting() { - String anchored = "ProcessRewards: &shared true\nOther: *shared\n"; - String aliased = "Other: &shared true\nProcessRewards: *shared\n"; - + @Test void blockScalarsAreNotTypedEditableBecauseTheirSpanIncludesTheLineSeparator() { + String literal = "BedrockPlayerPrefix: |-\n .\nOnlineMode: true\n"; + String folded = "BedrockPlayerPrefix: >-\n .\nOnlineMode: true\n"; assertEquals(GeneralSettingsDocument.Status.UNSUPPORTED, - GeneralSettingsDocument.fields(anchored).get("ProcessRewards").status()); + GeneralSettingsDocument.fields(literal).get("BedrockPlayerPrefix").status()); assertEquals(GeneralSettingsDocument.Status.UNSUPPORTED, - GeneralSettingsDocument.fields(aliased).get("ProcessRewards").status()); + GeneralSettingsDocument.fields(folded).get("BedrockPlayerPrefix").status()); assertThrows(IllegalArgumentException.class, - () -> GeneralSettingsDocument.patch(anchored, Map.of("ProcessRewards", false))); + () -> GeneralSettingsDocument.patch(literal, Map.of("BedrockPlayerPrefix", "_"))); assertThrows(IllegalArgumentException.class, - () -> GeneralSettingsDocument.patch(aliased, Map.of("ProcessRewards", false))); + () -> GeneralSettingsDocument.patch(folded, Map.of("BedrockPlayerPrefix", "_"))); + assertEquals(true, GeneralSettingsDocument.fields(literal).get("OnlineMode").value()); } - @Test void rejectsDuplicateRootsMultipleDocumentsInvalidAndOversizedInputWithoutLeakingIt() { + @Test void rejectsAliasDuplicateMultipleInvalidAndOversizedDocuments() { + String anchored = "CountFakeVotes: &shared true\nOther: *shared\n"; + assertEquals(GeneralSettingsDocument.Status.UNSUPPORTED, + GeneralSettingsDocument.fields(anchored).get("CountFakeVotes").status()); assertThrows(IllegalArgumentException.class, - () -> GeneralSettingsDocument.fields("ProcessRewards: true\nProcessRewards: false\n")); - assertThrows(IllegalArgumentException.class, () -> GeneralSettingsDocument.fields("- ProcessRewards\n")); + () -> GeneralSettingsDocument.patch(anchored, Map.of("CountFakeVotes", false))); assertThrows(IllegalArgumentException.class, - () -> GeneralSettingsDocument.fields("ProcessRewards: true\n---\nProcessRewards: false\n")); + () -> GeneralSettingsDocument.fields("CountFakeVotes: true\nCountFakeVotes: false\n")); + assertThrows(IllegalArgumentException.class, () -> GeneralSettingsDocument.fields("- CountFakeVotes\n")); assertThrows(IllegalArgumentException.class, - () -> GeneralSettingsDocument.fields("ProcessRewards: [\n")); + () -> GeneralSettingsDocument.fields("CountFakeVotes: true\n---\nCountFakeVotes: false\n")); + assertThrows(IllegalArgumentException.class, () -> GeneralSettingsDocument.fields("CountFakeVotes: [\n")); IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, - () -> GeneralSettingsDocument.fields("ProcessRewards: " + "x".repeat(512 * 1024))); + () -> GeneralSettingsDocument.fields("CountFakeVotes: " + "x".repeat(512 * 1024))); assertTrue(!failure.getMessage().contains("x")); } } diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/GeneralSettingsOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/GeneralSettingsOperationsTest.java index d9e4625..419cc8b 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/GeneralSettingsOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/GeneralSettingsOperationsTest.java @@ -32,7 +32,7 @@ class GeneralSettingsOperationsTest { @Test void readUsesThatBackendDocumentAndRevision() throws Exception { try (Fixture fixture = fixture("backend-a")) { - String document = "# keep\nProcessRewards: true\nToken: " + REDACTED + "\n"; + String document = "# keep\nCountFakeVotes: true\nToken: " + REDACTED + "\n"; ConfigurationOperations.OperationView read = read(fixture, "backend-a", document, revision('a')); ConfigurationOperations.SettingsState state = fixture.operations.generalSettingsState(read.operationId(), @@ -40,8 +40,8 @@ class GeneralSettingsOperationsTest { assertEquals(read.operationId(), state.readOperationId()); assertEquals(fixture.session("backend-a"), state.sessionId()); assertEquals(revision('a'), state.revision()); - assertEquals(GeneralSettingsDocument.Status.AVAILABLE, state.fields().get("ProcessRewards").status()); - assertEquals(Boolean.TRUE, state.fields().get("ProcessRewards").value()); + assertEquals(GeneralSettingsDocument.Status.AVAILABLE, state.fields().get("CountFakeVotes").status()); + assertEquals(Boolean.TRUE, state.fields().get("CountFakeVotes").value()); assertEquals(GeneralSettingsDocument.Status.MISSING, state.fields().get("AutoCreateVoteSites").status()); assertNull(state.fields().get("AutoCreateVoteSites").value()); @@ -172,7 +172,7 @@ class GeneralSettingsOperationsTest { @Test void previewsArePerTargetAndOnlyChangeRequestedScalarText() throws Exception { try (Fixture fixture = fixture("backend-a", "backend-b")) { - String first = "# first comment\nProcessRewards: true # retain\nUnknown: first\nToken: " + REDACTED + "\n"; + String first = "# first comment\nCountFakeVotes: true # retain\nUnknown: first\nToken: " + REDACTED + "\n"; String second = "# second comment\nAutoCreateVoteSites: false # retain\nUnknown: second\nPassword: " + REDACTED + "\n"; ConfigurationOperations.OperationView read = fixture.operations.createRead(List.of("backend-a", "backend-b"), ManagedConfiguration.file("Config.yml", null)); @@ -180,13 +180,13 @@ class GeneralSettingsOperationsTest { read = completeRead(fixture, read, "backend-b", second, revision('b')); ConfigurationOperations.OperationView firstPreview = fixture.operations.createGeneralSettingsPreview( - read.operationId(), "backend-a", Map.of("ProcessRewards", false)); + read.operationId(), "backend-a", Map.of("CountFakeVotes", false)); ConfigurationOperations.OperationView secondPreview = fixture.operations.createGeneralSettingsPreview( read.operationId(), "backend-b", Map.of("AutoCreateVoteSites", true)); ConfigurationTask firstTask = fixture.operations.claim("backend-a", fixture.session("backend-a")); ConfigurationTask secondTask = fixture.operations.claim("backend-b", fixture.session("backend-b")); - assertEquals("# first comment\nProcessRewards: false # retain\nUnknown: first\nToken: " + REDACTED + "\n", + assertEquals("# first comment\nCountFakeVotes: false # retain\nUnknown: first\nToken: " + REDACTED + "\n", firstTask.configuration().content()); assertEquals("# second comment\nAutoCreateVoteSites: true # retain\nUnknown: second\nPassword: " + REDACTED + "\n", secondTask.configuration().content()); @@ -200,31 +200,31 @@ class GeneralSettingsOperationsTest { @Test void rejectsUnknownNonBooleanMissingAndWrongTypeEdits() throws Exception { try (Fixture fixture = fixture("backend-a")) { ConfigurationOperations.OperationView read = read(fixture, "backend-a", - "ProcessRewards: true\nExtraAllSitesCheck: 'false'\n", revision('a')); + "CountFakeVotes: true\nAutoCreateVoteSites: 'false'\n", revision('a')); assertValidation(() -> fixture.operations.createGeneralSettingsPreview(read.operationId(), "backend-a", Map.of("UnknownSetting", true))); assertValidation(() -> fixture.operations.createGeneralSettingsPreview(read.operationId(), "backend-a", - Map.of("ProcessRewards", "false"))); + Map.of("CountFakeVotes", "false"))); assertValidation(() -> fixture.operations.createGeneralSettingsPreview(read.operationId(), "backend-a", - Map.of("DisableUpdateChecking", false))); + Map.of("CheckForUpdates", false))); assertValidation(() -> fixture.operations.createGeneralSettingsPreview(read.operationId(), "backend-a", - Map.of("ExtraAllSitesCheck", false))); + Map.of("AutoCreateVoteSites", false))); } } @Test void rejectsUnsupportedCapabilityProxyTargetAndReconnectedReadTarget() throws Exception { try (Fixture fixture = fixture("backend-a")) { - ConfigurationOperations.OperationView read = read(fixture, "backend-a", "ProcessRewards: true\n", revision('a')); + ConfigurationOperations.OperationView read = read(fixture, "backend-a", "CountFakeVotes: true\n", revision('a')); fixture.registry.heartbeat("backend-a", new Heartbeat(fixture.session("backend-a"), 1, Set.of(), Set.of())); assertEquals("NODE_UNAVAILABLE", assertThrows(ValidationException.class, () -> fixture.operations.generalSettingsState(read.operationId(), "backend-a")).code()); } try (Fixture fixture = fixture("proxy-a")) { - assertEquals("INVALID_TARGET", assertThrows(ValidationException.class, + assertEquals("NODE_UNAVAILABLE", assertThrows(ValidationException.class, () -> fixture.operations.createRead(List.of("proxy-a"), ManagedConfiguration.file("Config.yml", null))).code()); } try (Fixture fixture = fixture("backend-a")) { - ConfigurationOperations.OperationView read = read(fixture, "backend-a", "ProcessRewards: true\n", revision('a')); + ConfigurationOperations.OperationView read = read(fixture, "backend-a", "CountFakeVotes: true\n", revision('a')); UUID replacement = UUID.randomUUID(); fixture.registry.register(new NodeRegistration("backend-a", replacement, "backend-a", "BUKKIT", "test", 1, Set.of(ConfigurationOperations.FILE_CAPABILITY), Set.of())); @@ -233,11 +233,31 @@ class GeneralSettingsOperationsTest { } } + @Test void proxyGeneralSettingsUseBungeeConfigAndTypedMappings() throws Exception { + try (Fixture fixture = fixture("proxy-a")) { + String document = "Debug: false\nOnlineMode: true\nAllowUnJoined: false\nBedrockPlayerPrefix: '.'\n"; + ConfigurationOperations.OperationView read = fixture.operations.createRead(List.of("proxy-a"), + ManagedConfiguration.file("bungeeconfig.yml", null)); + read = completeFileRead(fixture, read, "proxy-a", document, revision('a'), "bungeeconfig.yml"); + ConfigurationOperations.SettingsState state = fixture.operations.generalSettingsState(read.operationId(), + "proxy-a"); + assertEquals(GeneralSettingsDocument.Profile.PROXY, state.profile()); + assertEquals("NONE", state.fields().get("Debug").value()); + ConfigurationOperations.OperationView preview = fixture.operations.createGeneralSettingsPreview( + read.operationId(), "proxy-a", Map.of("Debug", "INFO", "AllowUnjoined", true)); + ConfigurationTask task = fixture.operations.claim("proxy-a", fixture.session("proxy-a")); + assertEquals("bungeeconfig.yml", task.configuration().fileName()); + assertEquals("Debug: true\nOnlineMode: true\nAllowUnJoined: true\nBedrockPlayerPrefix: '.'\n", + task.configuration().content()); + complete(fixture, preview.operationId(), "proxy-a", task, revision('a')); + } + } + @Test void stalePreviewRevisionCannotBeApprovedForApply() throws Exception { try (Fixture fixture = fixture("backend-a")) { - ConfigurationOperations.OperationView read = read(fixture, "backend-a", "ProcessRewards: true\n", revision('a')); + ConfigurationOperations.OperationView read = read(fixture, "backend-a", "CountFakeVotes: true\n", revision('a')); ConfigurationOperations.OperationView preview = fixture.operations.createGeneralSettingsPreview( - read.operationId(), "backend-a", Map.of("ProcessRewards", false)); + read.operationId(), "backend-a", Map.of("CountFakeVotes", false)); ConfigurationTask task = fixture.operations.claim("backend-a", fixture.session("backend-a")); assertEquals(revision('a'), task.expectedRevision()); preview = complete(fixture, preview.operationId(), "backend-a", task, revision('b')); @@ -253,11 +273,11 @@ class GeneralSettingsOperationsTest { @Test void boundPreviewCreatesExactSingleUseRevisionBoundApply() throws Exception { try (Fixture fixture = fixture("backend-a")) { ConfigurationOperations.OperationView read = read(fixture, "backend-a", - "# retain\nProcessRewards: true\nUnknown: value\n", revision('a')); + "# retain\nCountFakeVotes: true\nUnknown: value\n", revision('a')); ConfigurationOperations.OperationView preview = fixture.operations.createGeneralSettingsPreview( - read.operationId(), "backend-a", Map.of("ProcessRewards", false)); + read.operationId(), "backend-a", Map.of("CountFakeVotes", false)); ConfigurationTask previewTask = fixture.operations.claim("backend-a", fixture.session("backend-a")); - String proposal = "# retain\nProcessRewards: false\nUnknown: value\n"; + String proposal = "# retain\nCountFakeVotes: false\nUnknown: value\n"; assertEquals(proposal, previewTask.configuration().content()); preview = complete(fixture, preview.operationId(), "backend-a", previewTask, revision('a')); assertNotNull(preview.approvalToken()); @@ -279,9 +299,9 @@ class GeneralSettingsOperationsTest { @Test void pendingBoundPreviewIsNotEvictedByFileRetention() throws Exception { try (Fixture fixture = fixture("backend-a")) { - ConfigurationOperations.OperationView read = read(fixture, "backend-a", "ProcessRewards: true\n", revision('a')); + ConfigurationOperations.OperationView read = read(fixture, "backend-a", "CountFakeVotes: true\n", revision('a')); ConfigurationOperations.OperationView bound = fixture.operations.createGeneralSettingsPreview( - read.operationId(), "backend-a", Map.of("ProcessRewards", false)); + read.operationId(), "backend-a", Map.of("CountFakeVotes", false)); ConfigurationTask boundTask = fixture.operations.claim("backend-a", fixture.session("backend-a")); bound = complete(fixture, bound.operationId(), "backend-a", boundTask, revision('a')); UUID protectedId = bound.operationId(); @@ -301,9 +321,9 @@ class GeneralSettingsOperationsTest { @Test void abandonedVisualApprovalIsRevokedWithoutSchedulingAWriteAndCanBeEvicted() throws Exception { try (Fixture fixture = fixture("backend-a")) { - ConfigurationOperations.OperationView read = read(fixture, "backend-a", "ProcessRewards: true\n", revision('a')); + ConfigurationOperations.OperationView read = read(fixture, "backend-a", "CountFakeVotes: true\n", revision('a')); ConfigurationOperations.OperationView preview = fixture.operations.createGeneralSettingsPreview( - read.operationId(), "backend-a", Map.of("ProcessRewards", false)); + read.operationId(), "backend-a", Map.of("CountFakeVotes", false)); ConfigurationTask task = fixture.operations.claim("backend-a", fixture.session("backend-a")); preview = complete(fixture, preview.operationId(), "backend-a", task, revision('a')); UUID id = preview.operationId(); String token = preview.approvalToken(); @@ -314,7 +334,7 @@ class GeneralSettingsOperationsTest { assertNull(fixture.operations.claim("backend-a", fixture.session("backend-a"))); assertEquals("APPROVAL_REQUIRED", assertThrows(ValidationException.class, () -> fixture.operations.createApply(id, token)).code()); - for (int index = 0; index < 17; index++) read(fixture, "backend-a", "ProcessRewards: true\n", revision('a')); + for (int index = 0; index < 17; index++) read(fixture, "backend-a", "CountFakeVotes: true\n", revision('a')); assertEquals("OPERATION_NOT_FOUND", assertThrows(ValidationException.class, () -> fixture.operations.get(id)).code()); } @@ -322,9 +342,9 @@ class GeneralSettingsOperationsTest { @Test void staleVisualPreviewCannotBeRetriedWithAnOldDocumentAtANewRevision() throws Exception { try (Fixture fixture = fixture("backend-a")) { - ConfigurationOperations.OperationView read = read(fixture, "backend-a", "ProcessRewards: true\n", revision('a')); + ConfigurationOperations.OperationView read = read(fixture, "backend-a", "CountFakeVotes: true\n", revision('a')); ConfigurationOperations.OperationView preview = fixture.operations.createGeneralSettingsPreview( - read.operationId(), "backend-a", Map.of("ProcessRewards", false)); + read.operationId(), "backend-a", Map.of("CountFakeVotes", false)); ConfigurationTask task = fixture.operations.claim("backend-a", fixture.session("backend-a")); complete(fixture, preview.operationId(), "backend-a", task, revision('b')); UUID id = preview.operationId(); @@ -340,9 +360,9 @@ class GeneralSettingsOperationsTest { Fixture fixture = fixture(journalDirectory, auditDirectory, "backend-a"); try { ConfigurationOperations.OperationView read = read(fixture, "backend-a", - "ProcessRewards: true\nUnknownSecret: " + secret + "\n", revision('a')); + "CountFakeVotes: true\nUnknownSecret: " + secret + "\n", revision('a')); ConfigurationOperations.OperationView preview = fixture.operations.createGeneralSettingsPreview( - read.operationId(), "backend-a", Map.of("ProcessRewards", false)); + read.operationId(), "backend-a", Map.of("CountFakeVotes", false)); ConfigurationTask task = fixture.operations.claim("backend-a", fixture.session("backend-a")); complete(fixture, preview.operationId(), "backend-a", task, revision('b')); } finally { @@ -396,7 +416,8 @@ private Fixture fixture(Path journalDirectory, Path auditDirectory, String... no for (String nodeId : nodeIds) { String platform = nodeId.startsWith("proxy") ? "VELOCITY" : "BUKKIT"; registry.register(new NodeRegistration(nodeId, UUID.randomUUID(), nodeId, platform, "test", 1, - Set.of(ConfigurationOperations.FILE_CAPABILITY), Set.of())); + Set.of(platform.equals("BUKKIT") ? ConfigurationOperations.FILE_CAPABILITY + : ConfigurationOperations.PROXY_FILE_CAPABILITY), Set.of())); } Path auditRoot = auditDirectory == null ? directory.resolve(UUID.randomUUID().toString()) : auditDirectory; ConfigurationAuditLog audit = new ConfigurationAuditLog(auditRoot, CLOCK); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 905df84..4719284 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -530,6 +530,7 @@ private static void terminateProcess(Process process) { assertTrue(script.body().contains("path !== '/api/v1/configuration/rewards/preview'")); assertTrue(script.body().contains("Your unsaved proxy-routing draft is retained")); assertTrue(script.body().contains("function invalidateConfigurationReads() {\n settingsEditor?.invalidateReads();\n" + + " settingsHealthReader?.invalidateReads();\n settingsHealthContext = '';\n settingsHealthGeneration++;\n" + " voteSitesEditor?.invalidateReads();\n" + " if (authenticated && tabFromHash() === 'general-settings' && !settingsEditor?.state.busy) {\n" + " window.setTimeout(() => void settingsEditor?.read(false), 0);\n }\n" @@ -703,11 +704,11 @@ private static void terminateProcess(Process process) { assertTrue(script.body().contains("|${dashboardConfigurationGeneration}`")); assertTrue(script.body().contains("Configuration changed; refreshing server overview")); assertTrue(script.body().contains("if (autoLoadPending.delete(tab)) void autoLoadTab(tab);")); - assertTrue(script.body().contains("await loadNodes();\n } finally {\n suppressNodeAutoLoad--;\n }\n await Promise.all([loadEnrollments(), loadOperationHistory()]);\n if (!inspectionCapableNode())"), + assertTrue(script.body().contains("await loadNodes();\n } finally {\n suppressNodeAutoLoad--;\n }\n await Promise.all([loadEnrollments(), loadOperationHistory(), refreshConfigurationHealth()]);\n if (!inspectionCapableNode())"), "Dashboard refresh must reload node connectivity before reloading metadata and checking inspection capability."); assertTrue(script.body().contains("if (suppressNodeAutoLoad === 0) void autoLoadTab(tabFromHash());"), "An internal dashboard registry refresh must not recursively queue another dashboard load."); - assertTrue(script.body().contains("await Promise.all([loadEnrollments(), loadOperationHistory()]);\n if (!inspectionCapableNode()"), + assertTrue(script.body().contains("await Promise.all([loadEnrollments(), loadOperationHistory(), refreshConfigurationHealth()]);\n if (!inspectionCapableNode()"), "An explicit dashboard refresh must reload enrollments and operation history before inspections."); assertTrue(script.body().contains("Object.keys(operation.nodeStates || {}).length || results.length"), "Running-operation progress must count all targets, not only completed results."); @@ -1104,7 +1105,7 @@ private static void terminateProcess(Process process) { String readId = read.get("operationId").asText(); JsonNode task = json.readTree(send("POST", "/api/v1/nodes/proxy-a/operations", "{\"sessionId\":\"" + SESSION + "\"}", nodeToken).body()); - String content = "# preserve me\nProcessRewards: false\nAutoCreateVoteSites: false\nDatabase:\n Password: ''\nUnknown: server-local\n"; + String content = "# preserve me\nCountFakeVotes: false\nAutoCreateVoteSites: false\nDatabase:\n Password: ''\nUnknown: server-local\n"; Map result = Map.of("sessionId", SESSION, "success", true, "code", "OK", "message", "Read", "revision", "a".repeat(64), "configuration", Map.of("domain", "file", "fileName", "Config.yml", "content", content), "changes", java.util.List.of(), "reloaded", false, "rolledBack", false, "attemptId", task.get("attemptId").asText()); @@ -1116,17 +1117,17 @@ private static void terminateProcess(Process process) { assertFalse(typed.body().contains("Password")); assertFalse(typed.body().contains("server-local")); JsonNode fields = json.readTree(typed.body()).get("fields"); - assertFalse(fields.get("ProcessRewards").get("value").asBoolean()); - assertEquals("MISSING", fields.get("CountFakeVotes").get("status").asText()); + assertFalse(fields.get("CountFakeVotes").get("value").asBoolean()); + assertEquals("MISSING", fields.get("GiveDefaultPermission").get("status").asText()); String previewBody = json.writeValueAsString(Map.of("readOperationId", readId, "nodeId", "proxy-a", - "overrides", Map.of("ProcessRewards", true))); + "overrides", Map.of("CountFakeVotes", true))); HttpResponse preview = send("POST", "/api/v1/configuration/general-settings/preview", previewBody, adminToken); assertEquals(202, preview.statusCode()); assertFalse(preview.body().contains("server-local")); JsonNode previewTask = json.readTree(send("POST", "/api/v1/nodes/proxy-a/operations", "{\"sessionId\":\"" + SESSION + "\"}", nodeToken).body()); assertEquals("a".repeat(64), previewTask.get("expectedRevision").asText()); - assertEquals(content.replace("ProcessRewards: false", "ProcessRewards: true"), + assertEquals(content.replace("CountFakeVotes: false", "CountFakeVotes: true"), previewTask.get("configuration").get("content").asText()); assertError(send("POST", "/api/v1/configuration/general-settings/preview", previewBody.replace("true", "\"true\""), adminToken), 400, "VALIDATION_ERROR"); diff --git a/src/test/web/app-file-navigation.test.cjs b/src/test/web/app-file-navigation.test.cjs index f5ee370..ed6110e 100644 --- a/src/test/web/app-file-navigation.test.cjs +++ b/src/test/web/app-file-navigation.test.cjs @@ -79,7 +79,7 @@ test('Vote Sites Open YAML does not discard an unsaved named-reward YAML draft w test('configuration invalidation clears confirmed YAML identity and synchronizes the workbench', () => { let synchronized = 0; const context = { - settingsEditor: null, voteSitesEditor: null, rewardsEditor: null, authenticated: false, + settingsHealthReader: null, settingsHealthContext: '', settingsHealthGeneration: 0, settingsEditor: null, voteSitesEditor: null, rewardsEditor: null, authenticated: false, fileReadCache: new Map([['backend:Config.yml', {}]]), lastFileReadOperation: {}, loadedQuickSetup: {}, configurationDirty: false, configurationContent: {value: 'Enabled: true'}, configurationContentPresent: true, diff --git a/src/test/web/app-workspace.test.cjs b/src/test/web/app-workspace.test.cjs index 227518a..9236f29 100644 --- a/src/test/web/app-workspace.test.cjs +++ b/src/test/web/app-workspace.test.cjs @@ -83,7 +83,7 @@ function harness() { } }; const context = { - settingsEditor: null, voteSitesEditor: null, rewardsEditor: null, + configurationHealthChecks: () => [], settingsHealthReader: null, settingsEditor: null, voteSitesEditor: null, rewardsEditor: null, voteSitesHasDraft: () => false, ControlWorkspace, workspace: new ControlWorkspace.Workspace(), @@ -91,6 +91,7 @@ function harness() { selectedServerId: '', nodeIndex: new Map(), nodeCapabilities: new Map(), + backendTopologyTruncatedNodeIds: new Set(), selectedNodes: new Set(['stale']), voteSitesTargetIds: new Set(['stale']), fileReadCache: new Map([['stale', {}]]), @@ -142,7 +143,8 @@ function harness() { const names = ['text', 'applyAuthenticatedSession', 'isProxy', 'isBackend', 'roleLabel', 'platformLabel', 'friendlyCapability', 'managedCapabilities', 'proxyReportsFor', 'backendCard', 'nodePresence', 'nodeCard', 'ordinaryTargetIds', 'comparisonTargetIds', 'confirmDiscardWorkspaceDrafts', 'changeWorkspaceTargets', - 'inspectWorkspaceServer', 'openScopeOverview', 'enterGlobalWorkspace', 'applyNavigationRoute', 'startConfigurationOperation']; + 'inspectWorkspaceServer', 'openScopeOverview', 'enterGlobalWorkspace', 'applyNavigationRoute', 'startConfigurationOperation', + 'generalSettingsTargets', 'settingValueLabel']; vm.runInContext(names.map(declaration).join('\n'), context, {filename: 'app-workspace-helpers.js'}); return context; } @@ -252,6 +254,45 @@ test('comparisonTargetIds keeps only selected readable online Bukkit backends', assert.deepEqual([...run(context, 'comparisonTargetIds()')], ['readable']); }); +test('General Settings expands a selected managed node to every known proxy sibling', () => { + const context = harness(); + context.authenticated = true; + const backendA = {...backend('backend-a'), sessionId: 'a', acceptedCapabilities: ['config.files.v1']}; + const backendB = {...backend('backend-b'), sessionId: 'b', acceptedCapabilities: ['config.files.v1']}; + const backendOffline = {...backend('backend-offline', false), sessionId: 'c', acceptedCapabilities: ['config.files.v1']}; + const networkProxy = {...proxy('proxy'), sessionId: 'p', acceptedCapabilities: ['config.proxy-files.v1'], + backends: [{backendId: 'backend-a'}, {backendId: 'backend-b'}, {backendId: 'backend-offline'}, + {backendId: 'not-enrolled'}]}; + for (const node of [backendA, backendB, backendOffline, networkProxy]) { + context.nodeIndex.set(node.nodeId, node); + context.allNodeItems.push(node); + } + context.workspace.setTargets(['backend-a']); + const targets = run(context, 'generalSettingsTargets()'); + assert.deepEqual([...targets.map(target => target.id)], + ['backend-a', 'proxy', 'backend-b', 'backend-offline', 'not-enrolled']); + assert.equal(targets.find(target => target.id === 'backend-b').managedByProxy, true); + assert.equal(targets.find(target => target.id === 'backend-b').networkOnly, true); + assert.equal(targets.find(target => target.id === 'proxy').networkOnly, false); + assert.equal(targets.find(target => target.id === 'backend-offline').online, false); + assert.deepEqual(JSON.parse(JSON.stringify(targets.find(target => target.id === 'not-enrolled'))), { + id: 'not-enrolled', sessionId: '', online: false, fileName: 'Config.yml', platform: '', role: 'BACKEND', + managedByProxy: true, reportingProxyIds: ['proxy'], networkIncomplete: false, networkOnly: true, + supported: false + }); + assert.equal(targets.find(target => target.id === 'proxy').fileName, 'bungeeconfig.yml'); + + context.workspace.setTargets(['proxy']); + assert.deepEqual([...run(context, 'generalSettingsTargets()').map(target => target.id)], + ['proxy', 'backend-a', 'backend-b', 'backend-offline', 'not-enrolled']); +}); + +test('General Settings renders an empty Bedrock prefix as a real value', () => { + const context = harness(); + assert.equal(run(context, 'settingValueLabel("")'), 'Empty'); + assert.equal(run(context, 'settingValueLabel(null)'), 'Unavailable'); +}); + test('workspace target changes, inspection, and scope overview preserve workspace without operations', () => { const context = harness(); context.nodeIndex.set('one', backend('one')); @@ -438,3 +479,71 @@ test('source-only form and explicit coordinated previews preserve existing opera assert.equal(context.calls.filter(call => call[0] === 'post').length, 3); assert.equal(context.configurationOperationsInFlight, 0); }); + +test('global health includes registered nodes while the editing workspace remains empty', () => { + const context = harness(); + context.authenticated = true; + context.workspace.managementScope = 'GLOBAL'; + context.allNodeItems = [{...backend('a'), acceptedCapabilities: ['config.files.v1']}]; + context.nodeIndex = new Map(context.allNodeItems.map(node => [node.nodeId, node])); + assert.equal(run(context, 'generalSettingsTargets()').length, 0); + assert.deepEqual([...run(context, 'generalSettingsTargets(true)').map(target => target.id)], ['a']); +}); + +test('fresh dashboard refresh loads configuration health even without inspection capability', async () => { + const context = harness(); + const calls = context.calls; + Object.assign(context, {isWorkspaceOverview: () => false, dashboardLoading: false, inspectionInFlight: false, + refreshDashboardButton: {}, suppressNodeAutoLoad: 0, loadNodes: async () => {}, + loadEnrollments: async () => {}, loadOperationHistory: async () => {}, + refreshConfigurationHealth: async () => calls.push(['configurationHealthRead']), + inspectionCapableNode: () => false, renderMetrics: () => {}}); + vm.runInContext(declaration('refreshDashboard'), context); + await run(context, 'refreshDashboard()'); + assert.equal(calls.filter(call => call[0] === 'configurationHealthRead').length, 1); +}); + +test('health reader ignores late results after changing authentication or target context', async () => { + const context = harness(); + let finish; + let key = 'a'; + let renders = 0; + Object.assign(context, {authenticated: true, settingsHealthContext: '', + settingsHealthReader: {read: () => new Promise(resolve => {finish = resolve;})}, + configurationHealthContext: () => key, renderMetrics: () => renders++}); + vm.runInContext(declaration('refreshConfigurationHealth'), context); + const pending = run(context, 'refreshConfigurationHealth()'); + key = 'b'; finish(); await pending; + assert.equal(context.settingsHealthContext, ''); + assert.equal(renders, 0); +}); + +test('Network Doctor reads configuration health and includes typed checks on its first run', async () => { + const context = harness(); + let click; + let reads = 0; + Object.assign(context, {runNetworkDoctor: {addEventListener: (_event, handler) => {click = handler;}}, + downloadNetworkDiagnostics: {}, lastDiagnostics: null, networkDoctorResults: {}, + runInspection: async () => ({result: {configuredVoteSites: 1}}), lastOverview: null, + invalidateDashboardInspection: () => {}, finiteCount: value => value, + configurationHealthChecks: () => [{path: 'OnlineMode', status: 'WARNING'}], + refreshConfigurationHealth: async () => {reads++;}, renderJsonResult: () => {}, updateSetupChecklist: () => {}}); + const start = appSource.indexOf("runNetworkDoctor.addEventListener('click'"); + const end = appSource.indexOf("downloadNetworkDiagnostics.addEventListener", start); + vm.runInContext(appSource.slice(start, end), context); + await click(); + assert.equal(reads, 1); + assert.equal(context.lastDiagnostics.configurationChecks[0].status, 'WARNING'); +}); + +test('configuration mutation fences an in-flight health read and forces a new context', () => { + const context = harness(); + Object.assign(context, {authenticated: true, settingsHealthGeneration: 1, authenticationGeneration: 1, + settingsHealthReader: {model: {}}, settingsHealthContext: '', + ControlGeneralSettings: {healthChecks: () => [{status: 'PASS'}]}}); + vm.runInContext(['configurationHealthContext', 'configurationHealthChecks'].map(declaration).join('\n'), context); + context.settingsHealthContext = run(context, 'configurationHealthContext()'); + assert.equal(run(context, 'configurationHealthChecks()')[0].status, 'PASS'); + context.settingsHealthGeneration++; + assert.equal(run(context, 'configurationHealthChecks()')[0].status, 'UNKNOWN'); +}); diff --git a/src/test/web/authentication-state.test.cjs b/src/test/web/authentication-state.test.cjs index fe16cd0..f74d018 100644 --- a/src/test/web/authentication-state.test.cjs +++ b/src/test/web/authentication-state.test.cjs @@ -43,7 +43,7 @@ function harness() { return elements.get(selector); }; const context = { - settingsEditor: {clear() {}}, voteSitesEditor: {clear() {}}, rewardsEditor: {clear() {}}, + settingsHealthReader: null, settingsEditor: {clear() {}}, voteSitesEditor: {clear() {}}, rewardsEditor: {clear() {}}, workspace: {logout() {}}, registryAvailable: true, authenticationGeneration: 3, authenticated: true, csrfToken: 'csrf', approvedPreview: {}, approvedFilePreview: {}, approvedQuickPreview: {}, loadedQuickSetup: {}, quickSetupDirty: true, inputGeneration: 2, logout: new Element(), sidebarToggle: new Element(), globalSearch: new Element(), diff --git a/src/test/web/configuration-state.test.cjs b/src/test/web/configuration-state.test.cjs index 8ba4e53..a590ab4 100644 --- a/src/test/web/configuration-state.test.cjs +++ b/src/test/web/configuration-state.test.cjs @@ -64,10 +64,10 @@ test('signature and preview reject stale edits, revisions, resets, and target se assert.equal(state.dirty.size, 0); }); -test('rejects non-boolean edits and clears prior results when a target session changes', () => { +test('accepts typed string edits and clears prior results when a target session changes', () => { const state = new MultiTargetState([{id: 'a', sessionId: 'one'}]); - state.edit(FIELD, 'true'); - assert.equal(state.dirty.size, 0); + state.edit(FIELD, 'INFO'); + assert.equal(state.dirty.get(FIELD), 'INFO'); state.setApplyResult('a', {success: true}); state.setTargets([{id: 'a', sessionId: 'two'}]); assert.equal(state.results.size, 0); @@ -95,3 +95,118 @@ test('single target use, apply results, and zero-change preview work without sou state.setApplyResult('only', {status: 'APPLIED'}); assert.deepEqual(state.results.get('only'), {status: 'APPLIED'}); }); + +test('proxy AllowUnjoined remains effective while backend prerequisite is repaired', () => { + const state = new MultiTargetState([{id: 'proxy', sessionId: 'p', role: 'PROXY', fileName: 'bungeeconfig.yml'}, + {id: 'backend-a', sessionId: 'a', role: 'BACKEND', managedByProxy: true, reportingProxyIds: ['proxy']}, + {id: 'backend-b', sessionId: 'b', role: 'BACKEND', managedByProxy: true, reportingProxyIds: ['proxy']}]); + state.setRead('proxy', {status: 'AVAILABLE', sessionId: 'p', revision: 'p1', + fields: {AllowUnjoined: {status: 'AVAILABLE', value: false}}}); + state.setRead('backend-a', {status: 'AVAILABLE', sessionId: 'a', revision: 'a1', + fields: {AllowUnjoined: {status: 'AVAILABLE', value: true}}}); + state.setRead('backend-b', {status: 'AVAILABLE', sessionId: 'b', revision: 'b1', + fields: {AllowUnjoined: {status: 'AVAILABLE', value: false}}}); + assert.equal(state.aggregate('AllowUnjoined').value, false); + state.edit('AllowUnjoined', false); + const plans = new Map(state.plans().map(plan => [plan.id, plan])); + assert.deepEqual(plans.get('proxy').overrides, {}); + assert.deepEqual(plans.get('backend-a').overrides, {}); + assert.deepEqual(plans.get('backend-b').overrides, {AllowUnjoined: true}); +}); + +test('AllowUnjoined does not rewrite an unrelated standalone backend', () => { + const state = new MultiTargetState([{id: 'proxy', sessionId: 'p', role: 'PROXY', fileName: 'bungeeconfig.yml'}, + {id: 'managed', sessionId: 'a', role: 'BACKEND', managedByProxy: true, reportingProxyIds: ['proxy']}, + {id: 'standalone', sessionId: 'b', role: 'BACKEND', managedByProxy: false}]); + state.setRead('proxy', {status: 'AVAILABLE', sessionId: 'p', revision: 'p1', + fields: {AllowUnjoined: {status: 'AVAILABLE', value: false}}}); + state.setRead('managed', {status: 'AVAILABLE', sessionId: 'a', revision: 'a1', + fields: {AllowUnjoined: {status: 'AVAILABLE', value: false}}}); + state.setRead('standalone', {status: 'AVAILABLE', sessionId: 'b', revision: 'b1', + fields: {AllowUnjoined: {status: 'AVAILABLE', value: false}}}); + state.edit('AllowUnjoined', false); + const plans = new Map(state.plans().map(plan => [plan.id, plan])); + assert.deepEqual(plans.get('managed').overrides, {AllowUnjoined: true}); + assert.deepEqual(plans.get('standalone').overrides, {}); +}); + +test('offline proxy topology retains the backend AllowUnjoined prerequisite', () => { + const state = new MultiTargetState([ + {id: 'proxy', sessionId: 'p', role: 'PROXY', networkIncomplete: true}, + {id: 'backend', sessionId: 'b', role: 'BACKEND', managedByProxy: true, + reportingProxyIds: ['proxy'], networkIncomplete: true}]); + state.setRead('proxy', {status: 'ERROR', code: 'OFFLINE'}); + state.setRead('backend', {status: 'AVAILABLE', revision: 'r1', + fields: {AllowUnjoined: {status: 'AVAILABLE', value: true}}}); + state.edit('AllowUnjoined', false); + const plans = new Map(state.plans().map(plan => [plan.id, plan])); + assert.deepEqual(plans.get('backend').overrides, {}); + assert.equal(state.requestedValue(state.targets.get('backend'), 'AllowUnjoined', false), true); +}); + +test('each managed backend uses its own reporting proxy AllowUnjoined value', () => { + const state = new MultiTargetState([ + {id: 'proxy-a', sessionId: 'pa', role: 'PROXY'}, + {id: 'proxy-b', sessionId: 'pb', role: 'PROXY'}, + {id: 'backend-a', sessionId: 'ba', role: 'BACKEND', managedByProxy: true, reportingProxyIds: ['proxy-a']}, + {id: 'backend-b', sessionId: 'bb', role: 'BACKEND', managedByProxy: true, reportingProxyIds: ['proxy-b']}]); + state.setRead('proxy-a', {status: 'AVAILABLE', revision: 'pa1', + fields: {AllowUnjoined: {status: 'AVAILABLE', value: true}}}); + state.setRead('proxy-b', {status: 'AVAILABLE', revision: 'pb1', + fields: {AllowUnjoined: {status: 'AVAILABLE', value: false}}}); + state.setRead('backend-a', {status: 'AVAILABLE', revision: 'ba1', + fields: {AllowUnjoined: {status: 'AVAILABLE', value: true}}}); + state.setRead('backend-b', {status: 'AVAILABLE', revision: 'bb1', + fields: {AllowUnjoined: {status: 'AVAILABLE', value: true}}}); + assert.equal(state.logicalValue(state.targets.get('backend-a'), 'AllowUnjoined', true), true); + assert.equal(state.logicalValue(state.targets.get('backend-b'), 'AllowUnjoined', true), false); + assert.equal(state.aggregate('AllowUnjoined').state, 'MIXED'); +}); + +test('proxy debug maps Extra requests to its supported Info level', () => { + const state = new MultiTargetState([{id: 'proxy', sessionId: 'p', role: 'PROXY', fileName: 'bungeeconfig.yml'}, + {id: 'backend', sessionId: 'b', role: 'BACKEND'}]); + state.setRead('proxy', {status: 'AVAILABLE', sessionId: 'p', revision: 'p1', + fields: {Debug: {status: 'AVAILABLE', value: 'INFO'}}}); + state.setRead('backend', {status: 'AVAILABLE', sessionId: 'b', revision: 'b1', + fields: {Debug: {status: 'AVAILABLE', value: 'INFO'}}}); + state.edit('Debug', 'EXTRA'); + const plans = new Map(state.plans().map(plan => [plan.id, plan])); + assert.deepEqual(plans.get('proxy').overrides, {}); + assert.deepEqual(plans.get('backend').overrides, {Debug: 'EXTRA'}); +}); + +test('network synchronization skips a backend whose proxy topology is truncated', () => { + const state = new MultiTargetState([ + {id: 'proxy', sessionId: 'p', role: 'PROXY', networkIncomplete: true}, + {id: 'backend', sessionId: 'b', role: 'BACKEND', networkIncomplete: true}]); + state.setRead('proxy', {status: 'AVAILABLE', sessionId: 'p', revision: 'p1', fields: { + OnlineMode: {status: 'AVAILABLE', value: false}, BedrockPlayerPrefix: {status: 'AVAILABLE', value: '.'}}}); + state.setRead('backend', {status: 'AVAILABLE', sessionId: 'b', revision: 'b1', fields: { + OnlineMode: {status: 'AVAILABLE', value: false}, BedrockPlayerPrefix: {status: 'AVAILABLE', value: '.'}}}); + state.edit('OnlineMode', true); state.edit('BedrockPlayerPrefix', '_'); state.edit('AllowUnjoined', false); + state.plans().forEach(plan => { + assert.deepEqual(plan.overrides, {}); + assert.deepEqual(plan.skipped, [{field: 'OnlineMode', status: 'TOPOLOGY_INCOMPLETE'}, + {field: 'BedrockPlayerPrefix', status: 'TOPOLOGY_INCOMPLETE'}, + {field: 'AllowUnjoined', status: 'TOPOLOGY_INCOMPLETE'}]); + }); +}); + +test('automatically discovered sibling backends receive only network-aware edits', () => { + const state = new MultiTargetState([ + {id: 'selected', sessionId: 'a', role: 'BACKEND'}, + {id: 'sibling', sessionId: 'b', role: 'BACKEND', networkOnly: true} + ]); + const fields = { + OnlineMode: {status: 'AVAILABLE', value: false}, + CountFakeVotes: {status: 'AVAILABLE', value: false} + }; + state.setRead('selected', {status: 'AVAILABLE', sessionId: 'a', revision: '1', fields}); + state.setRead('sibling', {status: 'AVAILABLE', sessionId: 'b', revision: '2', fields}); + state.edit('OnlineMode', true).edit('CountFakeVotes', true); + const plans = new Map(state.plans().map(plan => [plan.id, plan])); + assert.deepEqual(plans.get('selected').overrides, {OnlineMode: true, CountFakeVotes: true}); + assert.deepEqual(plans.get('sibling').overrides, {OnlineMode: true}); + assert.equal(state.aggregate('CountFakeVotes').targets.length, 1); +}); diff --git a/src/test/web/general-settings.test.cjs b/src/test/web/general-settings.test.cjs index d86b2b6..9583097 100644 --- a/src/test/web/general-settings.test.cjs +++ b/src/test/web/general-settings.test.cjs @@ -27,6 +27,34 @@ test('reads eligible targets once, retains typed snapshots, and makes no automat assert.equal(editor.model.targets.get('b').status, 'ERROR'); }); +test('groups reads by target file and preserves typed string and enum overrides', async () => { + const f = fixture([{id: 'backend', sessionId: 's1', online: true, supported: true, fileName: 'Config.yml'}, + {id: 'proxy', sessionId: 's2', online: true, supported: true, fileName: 'bungeeconfig.yml'}]); + f.adapter.request = async (_path, body) => ({readOperationId: body.readOperationId, nodeId: body.nodeId, + sessionId: f.adapter.targets().find(target => target.id === body.nodeId).sessionId, revision: 'r1', + fields: {Debug: {status: 'AVAILABLE', value: 'NONE'}, BedrockPlayerPrefix: {status: 'AVAILABLE', value: '.'}}}); + const editor = create(f.adapter); await editor.read(); + const reads = f.calls.filter(call => call[0].endsWith('/read')); + assert.equal(reads.length, 2); + assert.deepEqual(new Set(reads.map(call => call[1].configuration.fileName)), new Set(['Config.yml', 'bungeeconfig.yml'])); + editor.edit('Debug', 'INFO'); editor.edit('BedrockPlayerPrefix', '^'); + assert.deepEqual(editor.model.plans().flatMap(plan => Object.keys(plan.overrides)).sort(), ['BedrockPlayerPrefix', 'Debug', 'BedrockPlayerPrefix', 'Debug'].sort()); +}); + +test('chunks more than one hundred targets for the same managed file', async () => { + const targets = Array.from({length: 101}, (_, index) => ({id: `proxy-${index}`, sessionId: `s-${index}`, + online: true, supported: true, fileName: 'bungeeconfig.yml'})); + const f = fixture(targets); + const editor = create(f.adapter); await editor.read(); + const reads = f.calls.filter(call => call[0].endsWith('/read')); + assert.equal(reads.length, 2); + assert.deepEqual(reads.map(call => call[1].nodeIds.length).sort((left, right) => left - right), [1, 100]); + assert.equal([...editor.model.targets.values()].every(target => target.status === 'AVAILABLE'), true); + editor.edit(FIELD, true); + assert.equal(await editor.preview(), false); + assert.match(editor.state.error, /At most 14 changed targets/); +}); + test('single-flight reads ignore a late result from an old context', async () => { const f = fixture(); const gate = deferred(); f.adapter.operation = async () => { await gate.promise; return {operationId: 'r', results: {a: {success: true, nodeId: 'a', sessionId: 's1', revision: 'r1'}}}; }; @@ -250,3 +278,56 @@ test('explicit edits discard abandoned exact approvals without queuing a write', assert.deepEqual(disposal[1], {previewOperationId: 'preview-a', approvalToken: 'token-a'}); assert.equal(f.calls.some(([path]) => path.endsWith('/apply')), false); }); + +test('a failed proxy file read does not discard a successful backend group', async () => { + const f = fixture([{id: 'backend', sessionId: 's1', online: true, supported: true, fileName: 'Config.yml'}, + {id: 'proxy', sessionId: 's2', online: true, supported: true, fileName: 'bungeeconfig.yml'}]); + const original = f.adapter.operation; + f.adapter.operation = async (path, body) => { + if (path.endsWith('/read') && body.configuration.fileName === 'bungeeconfig.yml') throw new Error('proxy unavailable'); + return original(path, body); + }; + f.adapter.request = async (_path, body) => ({readOperationId: body.readOperationId, nodeId: body.nodeId, + sessionId: f.adapter.targets().find(target => target.id === body.nodeId).sessionId, revision: 'r1', + fields: {[FIELD]: {status: 'AVAILABLE', value: false}}}); + const editor = create(f.adapter); await editor.read(); + assert.equal(editor.model.targets.get('backend').status, 'AVAILABLE'); + assert.equal(editor.model.targets.get('proxy').status, 'ERROR'); +}); + +const {healthChecks} = require('../../main/resources/web/general-settings.js'); +function healthModel(values) { + return {targets: new Map(values.map(target => [target.id, {status: 'AVAILABLE', role: 'BACKEND', fields: { + AutoCreateVoteSites: {status: 'AVAILABLE', value: false}, + OnlineMode: {status: 'AVAILABLE', value: true}, + BedrockPlayerPrefix: {status: 'AVAILABLE', value: '.'}, + AllowUnjoined: {status: 'AVAILABLE', value: true} + }, ...target}]))}; +} +test('fresh health checks warn for real network configuration problems without edits', () => { + const model = healthModel([{id: 'proxy', role: 'PROXY'}, {id: 'a', managedByProxy: true, reportingProxyIds: ['proxy'], + fields: {AutoCreateVoteSites: {status: 'AVAILABLE', value: true}, OnlineMode: {status: 'AVAILABLE', value: false}, + BedrockPlayerPrefix: {status: 'AVAILABLE', value: '^'}, AllowUnjoined: {status: 'AVAILABLE', value: false}}}]); + assert.deepEqual(healthChecks(model).map(check => check.status), ['WARNING', 'WARNING', 'WARNING', 'WARNING']); + assert.deepEqual(healthChecks(model).find(check => check.path === 'AllowUnjoined').nodeIds, ['a']); +}); +test('healthy proxy prerequisites do not mistake the proxy preference for a mismatch', () => { + const model = healthModel([{id: 'proxy', role: 'PROXY', fields: {OnlineMode: {status: 'AVAILABLE', value: true}, + BedrockPlayerPrefix: {status: 'AVAILABLE', value: '.'}, AllowUnjoined: {status: 'AVAILABLE', value: false}}}, + {id: 'a', managedByProxy: true, reportingProxyIds: ['proxy']}]); + assert.ok(healthChecks(model).every(check => check.status === 'PASS')); + assert.ok(healthChecks(model).every(check => check.title.endsWith('is verified'))); +}); +test('offline, unsupported and missing fields remain unknown rather than healthy', () => { + for (const target of [{status: 'ERROR'}, {status: 'UNSUPPORTED'}, {fields: {}}, {networkIncomplete: true}]) { + const checks = healthChecks(healthModel([{id: 'a', ...target}])); + assert.ok(checks.every(check => check.status === 'UNKNOWN')); + assert.ok(checks.every(check => check.title.endsWith('is not verified'))); + } +}); +test('global health does not compare unrelated standalone networks', () => { + const model = healthModel([{id: 'a'}, {id: 'b', fields: {OnlineMode: {status: 'AVAILABLE', value: false}, + BedrockPlayerPrefix: {status: 'AVAILABLE', value: '^'}, AutoCreateVoteSites: {status: 'AVAILABLE', value: false}}}]); + assert.ok(healthChecks(model).every(check => check.status === 'PASS')); + assert.ok(healthChecks(model).every(check => check.title.endsWith('is verified'))); +});