|
| 1 | +/* |
| 2 | +┌──────────────────────────────────────────────────────────────────┐ |
| 3 | +│ Author: Ivan Murzak (https://github.com/IvanMurzak) │ |
| 4 | +│ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │ |
| 5 | +│ Copyright (c) 2025 Ivan Murzak │ |
| 6 | +│ Licensed under the Apache License, Version 2.0. │ |
| 7 | +│ See the LICENSE file in the project root for more information. │ |
| 8 | +└──────────────────────────────────────────────────────────────────┘ |
| 9 | +*/ |
| 10 | + |
| 11 | +#nullable enable |
| 12 | +using System.Text.Json; |
| 13 | +using System.Text.Json.Serialization; |
| 14 | +using NUnit.Framework; |
| 15 | +using AuthOption = com.IvanMurzak.McpPlugin.Common.Consts.MCP.Server.AuthOption; |
| 16 | + |
| 17 | +namespace com.IvanMurzak.Unity.MCP.Editor.Tests |
| 18 | +{ |
| 19 | + /// <summary> |
| 20 | + /// Guards the mcp-authorize i2 local-token lifecycle fix (Unity-MCP #897 / BUG-B): the local server |
| 21 | + /// token (<see cref="UnityMcpPlugin.UnityConnectionConfig.LocalToken"/>) must be seeded by |
| 22 | + /// <c>SetDefault()</c> and must survive a mode re-apply and a serialize/deserialize (domain reload + |
| 23 | + /// save/load) round-trip UNCHANGED. A silently regenerated local token orphans the already-written |
| 24 | + /// client <c>.mcp.json</c> (stale <c>Bearer</c>) and produces a Claude Code 401. |
| 25 | + /// </summary> |
| 26 | + public class LocalTokenStabilityTests |
| 27 | + { |
| 28 | + const string PersistedLocalToken = "PERSISTED_LOCAL_TOKEN"; |
| 29 | + |
| 30 | + // Mirror the production serializer options so this round-trip exercises the SAME JSON shape the |
| 31 | + // plugin persists and reloads across a domain reload: |
| 32 | + // - Save() -> WriteIndented + CamelCase + JsonStringEnumConverter |
| 33 | + // - GetOrCreateConfig -> PropertyNameCaseInsensitive + JsonStringEnumConverter |
| 34 | + // (both in Editor/Scripts/UnityMcpPluginEditor.Config.cs). |
| 35 | + static JsonSerializerOptions SaveOptions() => new JsonSerializerOptions |
| 36 | + { |
| 37 | + WriteIndented = true, |
| 38 | + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, |
| 39 | + Converters = { new JsonStringEnumConverter() } |
| 40 | + }; |
| 41 | + |
| 42 | + static JsonSerializerOptions LoadOptions() => new JsonSerializerOptions |
| 43 | + { |
| 44 | + PropertyNameCaseInsensitive = true, |
| 45 | + Converters = { new JsonStringEnumConverter() } |
| 46 | + }; |
| 47 | + |
| 48 | + static UnityMcpPlugin.UnityConnectionConfig RoundTrip(UnityMcpPlugin.UnityConnectionConfig config) |
| 49 | + { |
| 50 | + var json = JsonSerializer.Serialize(config, SaveOptions()); |
| 51 | + return JsonSerializer.Deserialize<UnityMcpPlugin.UnityConnectionConfig>(json, LoadOptions())!; |
| 52 | + } |
| 53 | + |
| 54 | + static UnityMcpPlugin.UnityConnectionConfig CustomTokenConfig() => new UnityMcpPlugin.UnityConnectionConfig |
| 55 | + { |
| 56 | + ConnectionMode = ConnectionMode.Custom, |
| 57 | + AuthOption = AuthOption.token, |
| 58 | + LocalToken = PersistedLocalToken, |
| 59 | + }; |
| 60 | + |
| 61 | + [Test] |
| 62 | + public void SetDefault_SeedsLocalToken_NotCloudToken() |
| 63 | + { |
| 64 | + // A freshly-constructed config (the ctor calls SetDefault) must carry a non-empty LOCAL token |
| 65 | + // and a null cloud token. The prior bug routed GenerateToken() into CloudToken because |
| 66 | + // SetDefault sets ConnectionMode=Cloud BEFORE assigning the token via the mode-routed setter, |
| 67 | + // leaving LocalToken unseeded (and re-mintable by the generate-if-empty fallback). |
| 68 | + var config = new UnityMcpPlugin.UnityConnectionConfig(); |
| 69 | + |
| 70 | + Assert.IsFalse(string.IsNullOrEmpty(config.LocalToken), |
| 71 | + "SetDefault must seed a non-empty LocalToken so the generate-if-empty fallback never has to mint it."); |
| 72 | + Assert.IsNull(config.CloudToken, |
| 73 | + "SetDefault must leave CloudToken null — the cloud token comes from Cloud sign-in, not GenerateToken()."); |
| 74 | + } |
| 75 | + |
| 76 | + [Test] |
| 77 | + public void LocalToken_StableAcrossModeReapply() |
| 78 | + { |
| 79 | + var config = CustomTokenConfig(); |
| 80 | + |
| 81 | + // Re-apply / toggle the connection mode; the local token must never move. |
| 82 | + config.ConnectionMode = ConnectionMode.Cloud; |
| 83 | + config.ConnectionMode = ConnectionMode.Custom; |
| 84 | + |
| 85 | + Assert.AreEqual(PersistedLocalToken, config.LocalToken, |
| 86 | + "Re-applying the connection mode must not regenerate the local token."); |
| 87 | + Assert.AreEqual(PersistedLocalToken, config.Token, |
| 88 | + "In Custom mode the effective Token must resolve to the (unchanged) LocalToken."); |
| 89 | + } |
| 90 | + |
| 91 | + [Test] |
| 92 | + public void LocalToken_SurvivesSaveLoadRoundTrip() |
| 93 | + { |
| 94 | + var reloaded = RoundTrip(CustomTokenConfig()); |
| 95 | + |
| 96 | + Assert.AreEqual(PersistedLocalToken, reloaded.LocalToken, |
| 97 | + "The persisted local token must survive a serialize/deserialize (domain reload / save-load) round-trip."); |
| 98 | + Assert.AreEqual(ConnectionMode.Custom, reloaded.ConnectionMode); |
| 99 | + Assert.AreEqual(AuthOption.token, reloaded.AuthOption); |
| 100 | + } |
| 101 | + |
| 102 | + [Test] |
| 103 | + public void RoundTrip_LeavesLocalTokenNonEmpty_SoGenerateIfEmptyNeverRegenerates() |
| 104 | + { |
| 105 | + // GetOrCreateConfig re-mints LocalToken only when it is null/empty after load. A persisted |
| 106 | + // token must therefore round-trip NON-EMPTY so that guard stays a no-op and never re-mints it. |
| 107 | + var reloaded = RoundTrip(CustomTokenConfig()); |
| 108 | + |
| 109 | + Assert.IsFalse(string.IsNullOrEmpty(reloaded.LocalToken), |
| 110 | + "A reloaded config must carry a non-empty LocalToken so the generate-if-empty fallback stays a no-op."); |
| 111 | + } |
| 112 | + |
| 113 | + [Test] |
| 114 | + public void LocalToken_SerializesUnderTokenJsonField() |
| 115 | + { |
| 116 | + var json = JsonSerializer.Serialize(CustomTokenConfig(), SaveOptions()); |
| 117 | + using var doc = JsonDocument.Parse(json); |
| 118 | + |
| 119 | + Assert.IsTrue(doc.RootElement.TryGetProperty("token", out var tokenProp), |
| 120 | + "LocalToken must serialize under the \"token\" JSON field the loader reads back."); |
| 121 | + Assert.AreEqual(PersistedLocalToken, tokenProp.GetString()); |
| 122 | + } |
| 123 | + } |
| 124 | +} |
0 commit comments