/* ┌──────────────────────────────────────────────────────────────────┐ │ Author: Ivan Murzak (https://github.com/IvanMurzak) │ │ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │ │ Copyright (c) 2025 Ivan Murzak │ │ Licensed under the Apache License, Version 2.0. │ │ See the LICENSE file in the project root for more information. │ └──────────────────────────────────────────────────────────────────┘ */ #nullable enable using System; using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; using com.IvanMurzak.Unity.MCP.Runtime.Utils; using NUnit.Framework; using static com.IvanMurzak.McpPlugin.Common.Consts.MCP.Server; namespace com.IvanMurzak.Unity.MCP.Editor.Tests { /// /// Covers the layered config-loader contract: /// flags > env vars > on-disk config > defaults /// implemented by . /// public class EnvironmentUtilsTests { const string DiskHost = "http://localhost:24029"; const string DiskLocalToken = "DISK_LOCAL_TOKEN"; const string DiskCloudToken = "DISK_CLOUD_TOKEN"; static UnityMcpPlugin.UnityConnectionConfig BuildDiskConfig(ConnectionMode mode = ConnectionMode.Cloud) { // Simulates a config that came back from disk: explicit values for both // local and cloud tokens, default mode = Cloud (matching the plugin's default). return new UnityMcpPlugin.UnityConnectionConfig { LocalHost = DiskHost, LocalToken = DiskLocalToken, CloudToken = DiskCloudToken, ConnectionMode = mode, AuthOption = AuthOption.required, KeepConnected = true, KeepServerRunning = false, TransportMethod = TransportMethod.streamableHttp }; } static IReadOnlyDictionary NoArgs() => new Dictionary(); static Func NoEnv() => _ => null; static Func Env(params (string k, string v)[] pairs) { var dict = new Dictionary(StringComparer.Ordinal); foreach (var (k, v) in pairs) dict[k] = v; return key => dict.TryGetValue(key, out var val) ? val : null; } static IReadOnlyDictionary Args(params (string k, string v)[] pairs) { var dict = new Dictionary(StringComparer.Ordinal); foreach (var (k, v) in pairs) dict[k] = v; return dict; } // --- Disk-only path: env / args absent --- [Test] public void Override_DiskOnly_NoEnvNoArgs_LeavesConfigUnchanged() { var config = BuildDiskConfig(); var record = EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), NoEnv()); Assert.IsFalse(record.HasAny, "Expected no overrides when env and args are empty."); Assert.AreEqual(DiskHost, config.LocalHost); Assert.AreEqual(DiskCloudToken, config.CloudToken); Assert.AreEqual(DiskLocalToken, config.LocalToken); Assert.AreEqual(ConnectionMode.Cloud, config.ConnectionMode); Assert.AreEqual(AuthOption.required, config.AuthOption); } // --- Env var override --- [Test] public void Override_EnvToken_WritesToCloudTokenWhenModeIsCloud() { var config = BuildDiskConfig(mode: ConnectionMode.Cloud); var env = Env((EnvironmentUtils.EnvToken, "ENV_TOKEN")); var record = EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), env); Assert.IsTrue(record.HasAny); Assert.IsTrue(record.Contains(EnvironmentUtils.FieldCloudToken), "Expected the CloudToken backing field to be tracked when mode is Cloud."); Assert.AreEqual("ENV_TOKEN", config.CloudToken); Assert.AreEqual(DiskLocalToken, config.LocalToken, "LocalToken must remain at the disk baseline when mode is Cloud."); } [Test] public void Override_EnvToken_WritesToLocalTokenWhenModeIsCustom() { var config = BuildDiskConfig(mode: ConnectionMode.Custom); var env = Env((EnvironmentUtils.EnvToken, "ENV_TOKEN")); EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), env); Assert.AreEqual("ENV_TOKEN", config.LocalToken); Assert.AreEqual(DiskCloudToken, config.CloudToken); } // --- Flag override (highest priority, beats env) --- [Test] public void Override_FlagToken_BeatsEnvToken() { var config = BuildDiskConfig(mode: ConnectionMode.Cloud); var args = Args((EnvironmentUtils.FlagToken, "FLAG_TOKEN")); var env = Env((EnvironmentUtils.EnvToken, "ENV_TOKEN")); EnvironmentUtils.ApplyEnvironmentOverrides(config, args, env); Assert.AreEqual("FLAG_TOKEN", config.CloudToken, "When both --token and UNITY_MCP_TOKEN are set, the flag must win."); } [Test] public void Override_UnityMcpStyleFlag_BeatsEnvVar() { // CLI translates --token foo into UNITY_MCP_TOKEN env, but the plugin still // accepts --UNITY_MCP_TOKEN=foo style flags directly. They must beat env vars. var config = BuildDiskConfig(mode: ConnectionMode.Cloud); var args = Args((EnvironmentUtils.EnvToken, "FLAG_VIA_FULL_NAME")); var env = Env((EnvironmentUtils.EnvToken, "ENV_TOKEN")); EnvironmentUtils.ApplyEnvironmentOverrides(config, args, env); Assert.AreEqual("FLAG_VIA_FULL_NAME", config.CloudToken); } // --- Per-field layering --- [Test] public void Override_PerField_TokenFromEnv_HostFromDisk() { var config = BuildDiskConfig(); var env = Env((EnvironmentUtils.EnvToken, "ENV_TOKEN")); EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), env); Assert.AreEqual(DiskHost, config.LocalHost, "Host must remain at the disk baseline when only the token was overridden."); Assert.AreEqual("ENV_TOKEN", config.CloudToken); } // --- Trailing-slash robustness --- [Test] public void Override_TrailingSlashOnHostUrl_IsStripped() { var config = BuildDiskConfig(); var env = Env((EnvironmentUtils.EnvCloudUrl, "http://localhost:5220/")); EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), env); Assert.AreEqual("http://localhost:5220", config.LocalHost, "Trailing slash must be stripped defensively."); } [TestCase("http://localhost:5220/", true)] [TestCase("http://127.0.0.1:5220", true)] [TestCase("http://[::1]:5220/", true)] [TestCase("https://ai-game.dev", false)] [TestCase("not-a-url", false)] [TestCase("", false)] public void IsLoopbackUrl_RecognisesLoopbackHosts(string url, bool expected) { Assert.AreEqual(expected, EnvironmentUtils.IsLoopbackUrl(url), $"IsLoopbackUrl mismatch for '{url}'."); } [Test] public void Override_LoopbackHostUrl_InfersCustomMode() { // Worktree scenario: disk says Cloud, env supplies a localhost URL. // Expected: ConnectionMode is inferred to Custom so the worktree's local // dev token routes to LocalToken (not CloudToken) and the SignalR client // talks to /hub/mcp-server (not /mcp/hub/mcp-server). var config = BuildDiskConfig(mode: ConnectionMode.Cloud); var env = Env( (EnvironmentUtils.EnvCloudUrl, "http://localhost:5220/"), (EnvironmentUtils.EnvToken, "WORKTREE_TOKEN")); var record = EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), env); Assert.AreEqual(ConnectionMode.Custom, config.ConnectionMode, "Loopback host URL should infer Custom mode."); Assert.AreEqual("http://localhost:5220", config.LocalHost); Assert.AreEqual("WORKTREE_TOKEN", config.LocalToken, "Token override must route to LocalToken once mode is inferred Custom."); Assert.AreEqual(DiskCloudToken, config.CloudToken, "CloudToken on disk must NOT be clobbered by the env override."); Assert.IsTrue(record.Contains(EnvironmentUtils.FieldConnectionMode)); Assert.IsTrue(record.Contains(EnvironmentUtils.FieldHost)); Assert.IsTrue(record.Contains(EnvironmentUtils.FieldLocalToken)); } [Test] public void Override_RemoteHostUrl_DoesNotInferCustomMode() { var config = BuildDiskConfig(mode: ConnectionMode.Cloud); var env = Env((EnvironmentUtils.EnvCloudUrl, "https://ai-game.dev")); EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), env); Assert.AreEqual(ConnectionMode.Cloud, config.ConnectionMode, "Non-loopback URL must NOT auto-flip the mode."); } [Test] public void Override_ExplicitConnectionMode_BeatsLoopbackInference() { var config = BuildDiskConfig(mode: ConnectionMode.Cloud); var env = Env( (EnvironmentUtils.EnvCloudUrl, "http://localhost:5220"), (EnvironmentUtils.EnvConnectionMode, "Cloud")); EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), env); Assert.AreEqual(ConnectionMode.Cloud, config.ConnectionMode, "Explicit UNITY_MCP_CONNECTION_MODE must beat loopback inference."); } // --- Persistence: overrides MUST NOT be written to disk --- [Test] public void Persistence_BaselineRoundTripsToDiskWithoutOverrides() { var config = BuildDiskConfig(mode: ConnectionMode.Cloud); var env = Env( (EnvironmentUtils.EnvCloudUrl, "http://localhost:5220"), (EnvironmentUtils.EnvToken, "ENV_TOKEN")); var record = EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), env); // sanity Assert.AreEqual(ConnectionMode.Custom, config.ConnectionMode); Assert.AreEqual("http://localhost:5220", config.LocalHost); Assert.AreEqual("ENV_TOKEN", config.LocalToken); // Simulate Save: restore baseline → serialize → restore overrides. EnvironmentUtils.ApplyBaseline(config, record); Assert.AreEqual(DiskHost, config.LocalHost, "Baseline restore failed for LocalHost."); Assert.AreEqual(DiskLocalToken, config.LocalToken, "Baseline restore failed for LocalToken."); Assert.AreEqual(ConnectionMode.Cloud, config.ConnectionMode, "Baseline restore failed for ConnectionMode."); var json = SerializeForDisk(config); // The serialized JSON must NOT contain the runtime override values. StringAssert.DoesNotContain("http://localhost:5220", json); StringAssert.DoesNotContain("ENV_TOKEN", json); StringAssert.Contains(DiskHost, json); StringAssert.Contains(DiskLocalToken, json); // After serialization, re-apply overrides. EnvironmentUtils.ApplyOverrides(config, record); Assert.AreEqual(ConnectionMode.Custom, config.ConnectionMode); Assert.AreEqual("http://localhost:5220", config.LocalHost); Assert.AreEqual("ENV_TOKEN", config.LocalToken); } [Test] public void Persistence_EmptyRecord_NoOpOnApplyBaselineAndOverrides() { var config = BuildDiskConfig(); var record = new EnvironmentUtils.OverrideRecord(); EnvironmentUtils.ApplyBaseline(config, record); EnvironmentUtils.ApplyOverrides(config, record); // No exceptions, values unchanged. Assert.AreEqual(DiskHost, config.LocalHost); } // --- Validation: bad inputs are tolerated --- [Test] public void Override_InvalidEnumValue_IsIgnored() { var config = BuildDiskConfig(mode: ConnectionMode.Cloud); var env = Env((EnvironmentUtils.EnvConnectionMode, "Bogus")); EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), env); Assert.AreEqual(ConnectionMode.Cloud, config.ConnectionMode, "Unparseable enum values must be silently ignored."); } [Test] public void Override_QuotedValue_IsTrimmed() { var config = BuildDiskConfig(); var env = Env((EnvironmentUtils.EnvToken, "\"QUOTED_TOKEN\"")); EnvironmentUtils.ApplyEnvironmentOverrides(config, NoArgs(), env); Assert.AreEqual("QUOTED_TOKEN", config.CloudToken); } // --- Helpers --- static string SerializeForDisk(UnityMcpPlugin.UnityConnectionConfig config) { return JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Converters = { new JsonStringEnumConverter() } }); } } }