refactor(ui/scripts): cache diagnostics + semantic forbidden-API check
Two pre-flagged follow-ups from the Monaco integration: 1. IMemoryCache for diagnostics keyed by SHA256 of the script body. Same-code Diagnose() now short-circuits the Roslyn compile and forbidden-API walk. SizeLimit 200 entries with 5-minute sliding expiration. Completions aren't cached — position + form context vary too much for a useful hit rate. 2. Forbidden-API analyzer now resolves identifiers through the SemanticModel instead of matching names. A user identifier named File / Thread / Process / etc. no longer false-positives — only references that resolve to a NamedTypeSymbol whose containing namespace is on the banned list are flagged. The diagnostic message now names the offending namespace, e.g. "Type 'File' from forbidden namespace 'System.IO' is not allowed in scripts." Refactor: extracted ISharedScriptCatalog so ScriptAnalysisService can be unit-tested without standing up SharedScriptService's EF chain. Concrete SharedScriptCatalog wraps the existing service. 16 new xUnit tests in ScriptAnalysisServiceTests: - Empty / clean / missing-semicolon paths - SCADA001 on each banned using namespace (theory) - SCADA002 on real File.ReadAllText through System.IO - No-false-positive checks for user-defined File / Thread locals - Cache returns the same response instance on repeat - Different code → different cache entries - String-literal completions for Parameters / CallScript / CallShared - General completion at file scope returns ScriptHost members Total CentralUI test count: 113 -> 129.
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
using ScadaLink.TemplateEngine;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indirection so ScriptAnalysisService can be unit-tested without standing
|
||||||
|
/// up SharedScriptService and its EF Core repository chain.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISharedScriptCatalog
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<string>> GetNamesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SharedScriptCatalog : ISharedScriptCatalog
|
||||||
|
{
|
||||||
|
private readonly SharedScriptService _service;
|
||||||
|
|
||||||
|
public SharedScriptCatalog(SharedScriptService service) => _service = service;
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<string>> GetNamesAsync()
|
||||||
|
{
|
||||||
|
var scripts = await _service.GetAllSharedScriptsAsync();
|
||||||
|
return scripts.Select(s => s.Name).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,34 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
using Microsoft.CodeAnalysis;
|
using Microsoft.CodeAnalysis;
|
||||||
using Microsoft.CodeAnalysis.CSharp;
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
using Microsoft.CodeAnalysis.Scripting;
|
using Microsoft.CodeAnalysis.Scripting;
|
||||||
using ScadaLink.TemplateEngine;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
|
||||||
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compiles user scripts as Roslyn C# Scripting fragments against
|
/// Compiles user scripts as Roslyn C# Scripting fragments against
|
||||||
/// <see cref="ScriptHost"/> globals and surfaces diagnostics + completions
|
/// <see cref="ScriptHost"/> globals and surfaces diagnostics + completions
|
||||||
/// in the shape Monaco's provider APIs expect. Lightweight — no caching;
|
/// in the shape Monaco's provider APIs expect.
|
||||||
/// each request rebuilds the script. Acceptable for human-paced edits.
|
///
|
||||||
|
/// Diagnostics are cached by code hash via IMemoryCache — Monaco debounces
|
||||||
|
/// keystrokes at 500 ms but a typing-then-pausing flow can still re-issue
|
||||||
|
/// requests for the same content (window blur/focus, etc.), so the cache
|
||||||
|
/// short-circuits repeats. Completions aren't cached: position + form
|
||||||
|
/// context vary too much for the hit rate to be useful.
|
||||||
///
|
///
|
||||||
/// Beyond plain C# analysis, layers SCADA-specific extensions:
|
/// Beyond plain C# analysis, layers SCADA-specific extensions:
|
||||||
/// - In-string completion of Parameters["..."] keys (from the request's
|
/// - In-string completion of Parameters["..."] keys (from the request's
|
||||||
/// DeclaredParameters), CallShared("...") names (from SharedScriptService),
|
/// DeclaredParameters), CallShared("...") names (from
|
||||||
/// and CallScript("...") names (from the request's SiblingScripts).
|
/// <see cref="ISharedScriptCatalog"/>), and CallScript("...") names
|
||||||
/// - Forbidden-API diagnostic for the documented script trust model.
|
/// (from the request's SiblingScripts).
|
||||||
|
/// - Forbidden-API diagnostic for the documented script trust model,
|
||||||
|
/// resolved against the SemanticModel so user identifiers that happen
|
||||||
|
/// to share names with forbidden types (e.g. <c>var File = ...</c>)
|
||||||
|
/// do not false-positive.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ScriptAnalysisService
|
public class ScriptAnalysisService
|
||||||
{
|
{
|
||||||
@@ -47,20 +58,13 @@ public class ScriptAnalysisService
|
|||||||
"System.Threading.Tasks.Sources",
|
"System.Threading.Tasks.Sources",
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly HashSet<string> ForbiddenTypeNames = new(StringComparer.Ordinal)
|
private readonly ISharedScriptCatalog _sharedScripts;
|
||||||
{
|
private readonly IMemoryCache _cache;
|
||||||
"File", "Directory", "Path", "StreamReader", "StreamWriter", "FileStream",
|
|
||||||
"Process", "ProcessStartInfo",
|
|
||||||
"Assembly", "Type", "MethodInfo", "PropertyInfo", "FieldInfo",
|
|
||||||
"Socket", "TcpClient", "UdpClient", "TcpListener",
|
|
||||||
"Thread", "ThreadPool", "Mutex", "Semaphore",
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly SharedScriptService _sharedScripts;
|
public ScriptAnalysisService(ISharedScriptCatalog sharedScripts, IMemoryCache cache)
|
||||||
|
|
||||||
public ScriptAnalysisService(SharedScriptService sharedScripts)
|
|
||||||
{
|
{
|
||||||
_sharedScripts = sharedScripts;
|
_sharedScripts = sharedScripts;
|
||||||
|
_cache = cache;
|
||||||
}
|
}
|
||||||
|
|
||||||
public DiagnoseResponse Diagnose(DiagnoseRequest request)
|
public DiagnoseResponse Diagnose(DiagnoseRequest request)
|
||||||
@@ -68,6 +72,10 @@ public class ScriptAnalysisService
|
|||||||
if (string.IsNullOrEmpty(request.Code))
|
if (string.IsNullOrEmpty(request.Code))
|
||||||
return new DiagnoseResponse(Array.Empty<DiagnosticMarker>());
|
return new DiagnoseResponse(Array.Empty<DiagnosticMarker>());
|
||||||
|
|
||||||
|
var cacheKey = "diag:" + HashCode(request.Code);
|
||||||
|
if (_cache.TryGetValue(cacheKey, out DiagnoseResponse? cached) && cached is not null)
|
||||||
|
return cached;
|
||||||
|
|
||||||
Script<object> script;
|
Script<object> script;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -75,10 +83,11 @@ public class ScriptAnalysisService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return new DiagnoseResponse(new[]
|
var failure = new DiagnoseResponse(new[]
|
||||||
{
|
{
|
||||||
new DiagnosticMarker(8, 1, 1, 1, 2, ex.Message, "SCRIPT_BUILD")
|
new DiagnosticMarker(8, 1, 1, 1, 2, ex.Message, "SCRIPT_BUILD")
|
||||||
});
|
});
|
||||||
|
return Cache(cacheKey, failure);
|
||||||
}
|
}
|
||||||
|
|
||||||
var compilation = script.GetCompilation();
|
var compilation = script.GetCompilation();
|
||||||
@@ -91,10 +100,27 @@ public class ScriptAnalysisService
|
|||||||
var tree = compilation.SyntaxTrees.FirstOrDefault();
|
var tree = compilation.SyntaxTrees.FirstOrDefault();
|
||||||
if (tree != null)
|
if (tree != null)
|
||||||
{
|
{
|
||||||
markers.AddRange(FindForbiddenApiUsages(tree));
|
var model = compilation.GetSemanticModel(tree);
|
||||||
|
markers.AddRange(FindForbiddenApiUsages(tree, model));
|
||||||
}
|
}
|
||||||
|
|
||||||
return new DiagnoseResponse(markers);
|
return Cache(cacheKey, new DiagnoseResponse(markers));
|
||||||
|
}
|
||||||
|
|
||||||
|
private DiagnoseResponse Cache(string key, DiagnoseResponse value)
|
||||||
|
{
|
||||||
|
_cache.Set(key, value, new MemoryCacheEntryOptions
|
||||||
|
{
|
||||||
|
Size = 1,
|
||||||
|
SlidingExpiration = TimeSpan.FromMinutes(5)
|
||||||
|
});
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string HashCode(string code)
|
||||||
|
{
|
||||||
|
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(code));
|
||||||
|
return Convert.ToHexString(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<CompletionsResponse> CompleteAsync(CompletionsRequest request)
|
public async Task<CompletionsResponse> CompleteAsync(CompletionsRequest request)
|
||||||
@@ -182,9 +208,9 @@ public class ScriptAnalysisService
|
|||||||
|
|
||||||
if (calleeName == "CallShared")
|
if (calleeName == "CallShared")
|
||||||
{
|
{
|
||||||
var scripts = await _sharedScripts.GetAllSharedScriptsAsync();
|
var names = await _sharedScripts.GetNamesAsync();
|
||||||
return scripts
|
return names
|
||||||
.Select(s => new CompletionItem(s.Name, s.Name, "shared script", "Method"))
|
.Select(n => new CompletionItem(n, n, "shared script", "Method"))
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,11 +246,11 @@ public class ScriptAnalysisService
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IEnumerable<DiagnosticMarker> FindForbiddenApiUsages(SyntaxTree tree)
|
private static IEnumerable<DiagnosticMarker> FindForbiddenApiUsages(SyntaxTree tree, SemanticModel model)
|
||||||
{
|
{
|
||||||
var root = tree.GetRoot();
|
var root = tree.GetRoot();
|
||||||
|
|
||||||
// Banned using directives.
|
// Banned using directives — pure namespace string match is fine here.
|
||||||
foreach (var u in root.DescendantNodes().OfType<UsingDirectiveSyntax>())
|
foreach (var u in root.DescendantNodes().OfType<UsingDirectiveSyntax>())
|
||||||
{
|
{
|
||||||
var name = u.Name?.ToString() ?? "";
|
var name = u.Name?.ToString() ?? "";
|
||||||
@@ -242,21 +268,20 @@ public class ScriptAnalysisService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Banned type identifiers (e.g., new Process(), File.ReadAllText, etc.).
|
// Banned type usages — resolved via the semantic model so a user
|
||||||
// Note: this is a name-based heuristic — false positives are possible for
|
// identifier named "File" or "Thread" does NOT trigger the diagnostic
|
||||||
// user identifiers that happen to share names with forbidden types.
|
// unless it actually resolves to a forbidden type.
|
||||||
foreach (var ident in root.DescendantNodes().OfType<IdentifierNameSyntax>())
|
foreach (var ident in root.DescendantNodes().OfType<IdentifierNameSyntax>())
|
||||||
{
|
{
|
||||||
var name = ident.Identifier.ValueText;
|
// Skip the identifier on the right side of a member access — only
|
||||||
if (ForbiddenTypeNames.Contains(name))
|
// the leftmost (the type or qualifier) is what we want to check.
|
||||||
{
|
if (ident.Parent is MemberAccessExpressionSyntax m && m.Name == ident) continue;
|
||||||
// Filter: only flag when used as a type or as a member-access target.
|
|
||||||
var parent = ident.Parent;
|
var symbol = model.GetSymbolInfo(ident).Symbol;
|
||||||
var isTypeOrAccess =
|
if (symbol is not INamedTypeSymbol type) continue;
|
||||||
parent is MemberAccessExpressionSyntax m && m.Expression == ident ||
|
|
||||||
parent is QualifiedNameSyntax ||
|
var ns = type.ContainingNamespace?.ToDisplayString() ?? "";
|
||||||
parent is ObjectCreationExpressionSyntax;
|
if (!ForbiddenNamespacePrefixes.Any(p => ns == p || ns.StartsWith(p + "."))) continue;
|
||||||
if (!isTypeOrAccess) continue;
|
|
||||||
|
|
||||||
var span = ident.GetLocation().GetLineSpan().Span;
|
var span = ident.GetLocation().GetLineSpan().Span;
|
||||||
yield return new DiagnosticMarker(
|
yield return new DiagnosticMarker(
|
||||||
@@ -265,11 +290,10 @@ public class ScriptAnalysisService
|
|||||||
StartColumn: span.Start.Character + 1,
|
StartColumn: span.Start.Character + 1,
|
||||||
EndLineNumber: span.End.Line + 1,
|
EndLineNumber: span.End.Line + 1,
|
||||||
EndColumn: span.End.Character + 1,
|
EndColumn: span.End.Character + 1,
|
||||||
Message: $"Type '{name}' is forbidden in scripts (script trust model).",
|
Message: $"Type '{type.Name}' from forbidden namespace '{ns}' is not allowed in scripts.",
|
||||||
Code: "SCADA002");
|
Code: "SCADA002");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private static CompletionItem ToCompletionItem(ISymbol symbol)
|
private static CompletionItem ToCompletionItem(ISymbol symbol)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<IDialogService, DialogService>();
|
services.AddScoped<IDialogService, DialogService>();
|
||||||
|
|
||||||
// Roslyn-backed C# analysis for the Monaco script editor.
|
// Roslyn-backed C# analysis for the Monaco script editor.
|
||||||
// Scoped because SharedScriptService (a dependency) is scoped.
|
// Scoped because SharedScriptCatalog wraps a scoped service.
|
||||||
|
services.AddMemoryCache(o => o.SizeLimit = 200);
|
||||||
|
services.AddScoped<ISharedScriptCatalog, SharedScriptCatalog>();
|
||||||
services.AddScoped<ScriptAnalysisService>();
|
services.AddScoped<ScriptAnalysisService>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using NSubstitute;
|
||||||
|
using ScadaLink.CentralUI.ScriptAnalysis;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Tests.ScriptAnalysis;
|
||||||
|
|
||||||
|
public class ScriptAnalysisServiceTests
|
||||||
|
{
|
||||||
|
private readonly ISharedScriptCatalog _catalog = Substitute.For<ISharedScriptCatalog>();
|
||||||
|
private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions { SizeLimit = 100 });
|
||||||
|
private readonly ScriptAnalysisService _svc;
|
||||||
|
|
||||||
|
public ScriptAnalysisServiceTests()
|
||||||
|
{
|
||||||
|
_catalog.GetNamesAsync().Returns(Array.Empty<string>());
|
||||||
|
_svc = new ScriptAnalysisService(_catalog, _cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EmptyCode_NoMarkers()
|
||||||
|
{
|
||||||
|
var resp = _svc.Diagnose(new DiagnoseRequest(""));
|
||||||
|
Assert.Empty(resp.Markers);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CleanScript_NoMarkers()
|
||||||
|
{
|
||||||
|
var resp = _svc.Diagnose(new DiagnoseRequest("var x = 1 + 2; return x;"));
|
||||||
|
Assert.Empty(resp.Markers);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingSemicolon_ReportsRoslynDiagnostic()
|
||||||
|
{
|
||||||
|
var resp = _svc.Diagnose(new DiagnoseRequest("var x = 1\n"));
|
||||||
|
Assert.Contains(resp.Markers, m => m.Code.StartsWith("CS"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForbiddenUsingDirective_RaisesSCADA001()
|
||||||
|
{
|
||||||
|
var resp = _svc.Diagnose(new DiagnoseRequest("using System.IO;"));
|
||||||
|
Assert.Contains(resp.Markers, m => m.Code == "SCADA001" && m.Message.Contains("System.IO"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("System.Diagnostics")]
|
||||||
|
[InlineData("System.Reflection")]
|
||||||
|
[InlineData("System.Net")]
|
||||||
|
public void ForbiddenUsing_AllBannedNamespaces(string ns)
|
||||||
|
{
|
||||||
|
var resp = _svc.Diagnose(new DiagnoseRequest($"using {ns};"));
|
||||||
|
Assert.Contains(resp.Markers, m => m.Code == "SCADA001" && m.Message.Contains(ns));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForbiddenTypeUsage_ResolvesViaSemanticModel()
|
||||||
|
{
|
||||||
|
var resp = _svc.Diagnose(new DiagnoseRequest(
|
||||||
|
"using System.IO; var s = File.ReadAllText(\"x\");"));
|
||||||
|
Assert.Contains(resp.Markers, m => m.Code == "SCADA002" && m.Message.Contains("File"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UserIdentifierNamedFile_DoesNotFalsePositive()
|
||||||
|
{
|
||||||
|
// No System.IO import; user defines their own 'File' local.
|
||||||
|
var resp = _svc.Diagnose(new DiagnoseRequest(
|
||||||
|
"var File = \"hello\"; return File.Length;"));
|
||||||
|
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA002");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UserIdentifierNamedThread_DoesNotFalsePositive()
|
||||||
|
{
|
||||||
|
var resp = _svc.Diagnose(new DiagnoseRequest(
|
||||||
|
"var Thread = 42; return Thread;"));
|
||||||
|
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA002");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DiagnosticsAreCached_SecondCallSkipsRecompile()
|
||||||
|
{
|
||||||
|
var req = new DiagnoseRequest("using System.IO;");
|
||||||
|
var first = _svc.Diagnose(req);
|
||||||
|
var second = _svc.Diagnose(req);
|
||||||
|
|
||||||
|
// Same instance reference indicates the cache returned the prior result.
|
||||||
|
Assert.Same(first, second);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DifferentCode_GetsDifferentCacheEntries()
|
||||||
|
{
|
||||||
|
var a = _svc.Diagnose(new DiagnoseRequest("var x = 1;"));
|
||||||
|
var b = _svc.Diagnose(new DiagnoseRequest("var y = 2;"));
|
||||||
|
Assert.NotSame(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ParametersStringLiteral_ReturnsDeclaredParameterNames()
|
||||||
|
{
|
||||||
|
var req = new CompletionsRequest(
|
||||||
|
CodeText: "var x = Parameters[\"",
|
||||||
|
Line: 1,
|
||||||
|
Column: 21,
|
||||||
|
DeclaredParameters: new[] { "name", "temperature" });
|
||||||
|
|
||||||
|
var resp = await _svc.CompleteAsync(req);
|
||||||
|
|
||||||
|
Assert.Contains(resp.Items, i => i.Label == "name" && i.Detail == "declared parameter");
|
||||||
|
Assert.Contains(resp.Items, i => i.Label == "temperature");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CallScriptStringLiteral_ReturnsSiblingNames()
|
||||||
|
{
|
||||||
|
var req = new CompletionsRequest(
|
||||||
|
CodeText: "var x = CallScript(\"",
|
||||||
|
Line: 1,
|
||||||
|
Column: 21,
|
||||||
|
SiblingScripts: new[] { "SiblingA", "SiblingB" });
|
||||||
|
|
||||||
|
var resp = await _svc.CompleteAsync(req);
|
||||||
|
|
||||||
|
Assert.Contains(resp.Items, i => i.Label == "SiblingA" && i.Detail == "sibling script");
|
||||||
|
Assert.Contains(resp.Items, i => i.Label == "SiblingB");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CallSharedStringLiteral_ResolvesViaCatalog()
|
||||||
|
{
|
||||||
|
_catalog.GetNamesAsync().Returns(new[] { "GetWeather", "Greet" });
|
||||||
|
|
||||||
|
var req = new CompletionsRequest(
|
||||||
|
CodeText: "var x = CallShared(\"",
|
||||||
|
Line: 1,
|
||||||
|
Column: 21);
|
||||||
|
|
||||||
|
var resp = await _svc.CompleteAsync(req);
|
||||||
|
|
||||||
|
Assert.Contains(resp.Items, i => i.Label == "GetWeather" && i.Detail == "shared script");
|
||||||
|
Assert.Contains(resp.Items, i => i.Label == "Greet");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GeneralCompletion_ReturnsInScopeSymbols()
|
||||||
|
{
|
||||||
|
// At file scope of a script, ScriptHost members + the System namespace are visible.
|
||||||
|
var req = new CompletionsRequest("var x = ", 1, 9);
|
||||||
|
var resp = await _svc.CompleteAsync(req);
|
||||||
|
Assert.Contains(resp.Items, i => i.Label == "Parameters");
|
||||||
|
Assert.Contains(resp.Items, i => i.Label == "CallShared");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user