mirror of
https://github.com/microsoft/mcp.git
synced 2026-11-04 12:01:31 +00:00
Add Fabric Core Update Item Definition tool (#4065)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
1 parent
407bed9aa4
commit
007561fb18
29 files changed
+2141
-70
No files matched your search
@@ -427,6 +427,7 @@ The configured Fabric Core HTTP client does not automatically follow redirects.
|
||||
| `core_list-workspaces` | Lists one page of accessible workspace management metadata, optionally filtered by the caller's workspace roles. Returns continuation information and can request workspace-specific API endpoints. |
|
||||
| `core_search-catalog` | Searches one page of OneLake catalog metadata across workspaces. Continue with the returned token without repeating search or filter. |
|
||||
| `core_update-item` | Updates an existing item's display name or description and returns metadata only. |
|
||||
| `core_update-item-definition` | Replaces a known item's complete definition from inline content or explicitly selected saved files. Optionally applies supplied `.platform` metadata and waits for a no-result operation. |
|
||||
| `core_update-workspace` | Renames a workspace or updates or clears its description by UUID, leaving omitted properties unchanged. Requires workspace Admin access. |
|
||||
|
||||
**Create Item (`core_create-item`)**
|
||||
@@ -550,6 +551,87 @@ Creation is **never automatically resubmitted**. Stopping MCP waiting does not c
|
||||
|
||||
Failures suppress raw backend messages, exception details, stack traces, and local paths. HTTP 403 can indicate permissions or unavailable tenant/capacity features; status-less network failures retain HTTP 503. Before acceptance is known, a timeout, cancellation, network failure, or invalid response can leave the mutation outcome uncertain; check whether creation occurred before retrying.
|
||||
|
||||
**Update Item Definition (`core_update-item-definition`)**
|
||||
|
||||
Calls [Update Item Definition](https://learn.microsoft.com/rest/api/fabric/core/items/update-item-definition)
|
||||
with one `POST /workspaces/{workspaceId}/items/{itemId}/updateDefinition`. Both `workspace-id` and
|
||||
`item-id` are required nonempty UUIDs. This is a **complete replacement, not a changed-files patch**:
|
||||
provide all required definition parts, not just the files edited since the last upload. The tool
|
||||
never fetches the old definition, merges parts, resolves names, downloads schemas, creates dependencies,
|
||||
or performs a metadata read-back.
|
||||
|
||||
Exactly one definition source is required, reusing Create Item's input and file infrastructure:
|
||||
|
||||
| Source | Arguments | Availability |
|
||||
|--------|-----------|--------------|
|
||||
| Inline | `definition`: JSON object **string** with `parts` and optional `format` | Local CLI, stdio, and HTTP |
|
||||
| Saved files | `definition-directory`: existing absolute directory; `definition-files`: explicit JSON array **string** of `partPath` and optional `sourcePath`; optional `definition-format` | Local CLI and stdio only |
|
||||
|
||||
The same portable-path, strict JSON, Base64, byte/count, and local-file safeguards described above
|
||||
apply. Saved files come from the **MCP hosting machine**, not a remote client's filesystem or unsaved
|
||||
editor buffers. Save changes and explicitly list every part; no folder scan, wildcard, automatic
|
||||
`.platform` inclusion, or source-file modification occurs. Over HTTP, filesystem options are hidden
|
||||
and rejected. The input service independently rechecks local-file capability before any filesystem
|
||||
access, while inline updates remain available.
|
||||
|
||||
Validation is **generic and content-independent**: it does not enforce item-type or format-specific
|
||||
filenames, required parts, or layout combinations. The supplied format, portable part paths, and
|
||||
payload bytes pass through unchanged for default, known, and future formats. There is no item-type
|
||||
hint, `.platform` interpretation, metadata preflight, or local completeness claim. Consult
|
||||
`docs_item-definitions` for the bundled reference and supply a complete definition; Fabric validates
|
||||
supported formats, required parts, content, and bindings.
|
||||
|
||||
`options` supplies the API's optional top-level item-specific JSON **object string**. It is limited
|
||||
to **1 MiB of UTF-8 input and 32 nesting levels**, rejects duplicate properties at any depth, and
|
||||
is validated before file reads. Omitted options stay omitted; `{}`, false values, null members,
|
||||
and future workload properties are preserved. Fabric validates supported properties for the
|
||||
actual item type. These options never control polling.
|
||||
|
||||
`update-metadata` requires an explicit Boolean value. Omission leaves the query parameter omitted;
|
||||
`false` sends `?updateMetadata=false`; **only explicit `true` requests applying metadata from a
|
||||
supplied `.platform` part**. The part's bytes are always preserved: the tool does not remove it
|
||||
when metadata application is disabled, add it when absent, or rewrite its contents or bindings.
|
||||
The flag can be sent without a `.platform` part; it does not cause the tool to invent metadata.
|
||||
This API **does not change the sensitivity label**. There are no `sensitivity-label-settings`,
|
||||
`folder-id`, `creation-payload`, display-name, or description arguments on this tool.
|
||||
|
||||
This example **replaces a real item's definition** when run with authorized credentials. Review
|
||||
the complete file selection first. It includes `.platform` without opting into metadata application:
|
||||
|
||||
```powershell
|
||||
fabmcp core update-item-definition `
|
||||
--workspace-id cfafbeb1-8037-4d0c-896e-a46fb27ff229 --item-id 5b218778-e7a5-4d73-8187-f10824047715 `
|
||||
--definition-directory 'C:\Fabric\Sales.Notebook' `
|
||||
--definition-files '[{"partPath":"artifact.content.ipynb","sourcePath":"sales.ipynb"},{"partPath":".platform"}]' `
|
||||
--definition-format ipynb --sync true --max-wait-seconds 120
|
||||
```
|
||||
|
||||
For inline content, supply `--definition $definition` instead of all three filesystem options,
|
||||
using a complete definition JSON string like the Create Item example above. Add
|
||||
`--update-metadata true` only when the supplied `.platform` metadata should also be applied.
|
||||
|
||||
A documented **empty HTTP 200** returns `results.operation.status=Succeeded` immediately, in either
|
||||
sync mode. A **202** returns an `Accepted` receipt without polling by default. With `sync=true`,
|
||||
the shared runner polls **operation state only**; confirmed `Succeeded` finishes without a
|
||||
`/result` or Get Item request because this endpoint has no result body. Its result contains only
|
||||
the operation receipt, never echoed definitions, options, paths, or fabricated item metadata.
|
||||
|
||||
`max-wait-seconds` defaults to 120 and allows positive finite values up to 600 seconds, including
|
||||
fractions. `early-poll` defaults to true; the shared initial early-probe and subsequent Retry-After
|
||||
rules above apply. Only status GETs may be retried on 429/503; the initiating POST is never retried.
|
||||
Budget expiry returns HTTP 202 with `Pending` and last-known state. A reported failure returns a
|
||||
safe `Failed` receipt; read failures preserve a `TrackingStopped` receipt. Caller cancellation
|
||||
stops only local waiting and preserves a known receipt when the client can still receive it.
|
||||
Resume a pending update with `core_get-operation-state` and stop at `Succeeded`: **do not call
|
||||
`core_get-operation-result` or resubmit the update to extend a wait**. Missing operation IDs,
|
||||
unexpected success codes/bodies, and transport failures are surfaced without assuming success or
|
||||
following response URLs.
|
||||
|
||||
Read **and write** item permissions are required. Delegated callers need `Item.ReadWrite.All`
|
||||
or the appropriate item-specific `ReadWrite.All` scope. Service-principal and managed-identity
|
||||
support depends on item type. Development coverage uses substituted HTTP and credentials; it
|
||||
does not authorize live updates or establish actual OBO permissions or recorded playback.
|
||||
|
||||
**Long-running operation tools**
|
||||
|
||||
The [Get Operation State](https://learn.microsoft.com/rest/api/fabric/core/long-running-operations/get-operation-state) and [Get Operation Result](https://learn.microsoft.com/rest/api/fabric/core/long-running-operations/get-operation-result) tools each take only `operation-id`, a nonempty UUID. Each invocation performs **one GET**, with no implicit polling, retries, mutation, or cancellation. URLs are constructed from the fixed Fabric API base and the validated ID. Returned links are not followed or returned as action URLs, and a response cannot replace the requested operation ID.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
changes:
|
||||
- section: "Features Added"
|
||||
description: "Added core_update-item-definition to replace complete item definitions from inline content or explicitly selected saved files, with generic safety validation, unchanged definition formats and bytes, optional metadata application, and bounded no-result operation polling."
|
||||
@@ -13,6 +13,8 @@ For every deletion prompt, explain the scope and request consent before sending
|
||||
|
||||
Workspace capacity assignment changes a live resource. Use only with an explicitly approved workspace, capacity, and identity; do not run these assignments as automated development checks.
|
||||
|
||||
Definition updates replace the complete definition of an existing item. Review the full selected parts and obtain authorization before a live update; these prompts are not permission to modify a workspace. Never upload only changed files, merge with a fetched definition, or retrieve an operation result after a no-result update succeeds.
|
||||
|
||||
`context-required` means the caller must supply the required resource context and authorization. Sections and tool names are sorted alphabetically.
|
||||
|
||||
## Core
|
||||
@@ -73,6 +75,13 @@ Workspace capacity assignment changes a live resource. Use only with an explicit
|
||||
| core_update-item | Rename Fabric item 5b218778-e7a5-4d73-8187-f10824047715 in workspace cfafbeb1-8037-4d0c-896e-a46fb27ff229 to ProjectNotebook, leaving its description unchanged. | none |
|
||||
| core_update-item | Set the description of Fabric item 5b218778-e7a5-4d73-8187-f10824047715 in workspace cfafbeb1-8037-4d0c-896e-a46fb27ff229 to "Monthly reporting", without changing its name or definition. | none |
|
||||
| core_update-item | Clear only the description of Fabric item 5b218778-e7a5-4d73-8187-f10824047715 in workspace cfafbeb1-8037-4d0c-896e-a46fb27ff229 by setting it to an empty string. | none |
|
||||
| core_update-item-definition | Replace the complete definition of item 5b218778-e7a5-4d73-8187-f10824047715 in workspace cfafbeb1-8037-4d0c-896e-a46fb27ff229 using saved sales.ipynb from C:\Fabric\Sales.Notebook, mapped to artifact.content.ipynb with format ipynb. Preserve the format and bytes, upload no other files, and return without polling. | none |
|
||||
| core_update-item-definition | Update item \<item-id> in workspace \<workspace-id> from the complete inline definition JSON I supplied. Do not read any files on the HTTP server or look up the item's type. | context-required |
|
||||
| core_update-item-definition | Submit my complete definition with its supplied format and safe custom part layout to item \<item-id> in workspace \<workspace-id>. Keep generic safety checks but leave supported formats, required filenames, and content validation to Fabric. | context-required |
|
||||
| core_update-item-definition | Replace the definition of item \<item-id> in workspace \<workspace-id> with the full explicit saved-file mapping I supplied, including .platform. Preserve those bytes but do not request applying its metadata. | context-required |
|
||||
| core_update-item-definition | Upload my complete definition to item \<item-id> in workspace \<workspace-id> and explicitly set update-metadata to true to apply metadata from the supplied .platform part. Do not change the sensitivity label or add creation properties. | context-required |
|
||||
| core_update-item-definition | Replace item \<item-id>'s definition in workspace \<workspace-id> with my inline content and item-specific options JSON. Wait up to 120 seconds and finish when operation state says Succeeded, without requesting /result or getting the item. | context-required |
|
||||
| core_update-item-definition | Submit my complete replacement definition once for item \<item-id> in workspace \<workspace-id>, wait at most 60 seconds, and honor the initial Retry-After rather than polling early. If it remains pending, return the operation ID instead of submitting the update again. | context-required |
|
||||
| core_update-workspace | Rename Fabric workspace \<workspace-id> to 'Finance Analytics' without changing its description. | none |
|
||||
| core_update-workspace | Set the description of Fabric workspace \<workspace-id> to 'Quarterly reporting' and leave its name unchanged. | none |
|
||||
| core_update-workspace | Clear the description of Fabric workspace \<workspace-id> without renaming it. | none |
|
||||
|
||||
+17
@@ -321,6 +321,23 @@ public class ServerStartupTests
|
||||
Assert.Equal("number", creationOptions.GetProperty("max-wait-seconds").GetProperty("type").GetString());
|
||||
Assert.Equal(["display-name", "item-type"], itemCreate.GetProperty("inputSchema").GetProperty("required").EnumerateArray()
|
||||
.Select(value => value.GetString()).Order());
|
||||
var definitionUpdate = Assert.Single(tools, tool => tool.GetProperty("name").GetString() == "core_update-item-definition");
|
||||
Assert.False(definitionUpdate.GetProperty("annotations").GetProperty("readOnlyHint").GetBoolean());
|
||||
Assert.True(definitionUpdate.GetProperty("annotations").GetProperty("destructiveHint").GetBoolean());
|
||||
Assert.True(definitionUpdate.GetProperty("annotations").GetProperty("idempotentHint").GetBoolean());
|
||||
Assert.Equal(["item-id", "workspace-id"], definitionUpdate.GetProperty("inputSchema").GetProperty("required").EnumerateArray()
|
||||
.Select(value => value.GetString()).Order());
|
||||
var definitionUpdateOptions = definitionUpdate.GetProperty("inputSchema").GetProperty("properties");
|
||||
Assert.Equal("string", definitionUpdateOptions.GetProperty("definition").GetProperty("type").GetString());
|
||||
Assert.Equal("string", definitionUpdateOptions.GetProperty("options").GetProperty("type").GetString());
|
||||
Assert.False(definitionUpdateOptions.TryGetProperty("definition-item-type", out _));
|
||||
Assert.Equal(["boolean", "null"], definitionUpdateOptions.GetProperty("update-metadata").GetProperty("type").EnumerateArray()
|
||||
.Select(value => value.GetString()));
|
||||
Assert.False(definitionUpdateOptions.GetProperty("update-metadata").TryGetProperty("default", out _));
|
||||
foreach (var option in new[] { "definition-directory", "definition-files", "definition-format" })
|
||||
{
|
||||
Assert.Equal(localFiles, definitionUpdateOptions.TryGetProperty(option, out _));
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> ReadToolResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
|
||||
@@ -137,14 +137,14 @@ public sealed class ItemCreateCommand(
|
||||
protected override HttpStatusCode GetStatusCode(Exception ex) =>
|
||||
ex switch
|
||||
{
|
||||
FabricItemDefinitionInputException or ItemCreateInputException => HttpStatusCode.BadRequest,
|
||||
FabricItemDefinitionInputException or FabricItemInputException => HttpStatusCode.BadRequest,
|
||||
FabricOperationCanceledException => HttpStatusCode.RequestTimeout,
|
||||
_ => base.GetStatusCode(ex)
|
||||
};
|
||||
|
||||
protected override string GetErrorMessage(Exception ex) => ex switch
|
||||
{
|
||||
FabricItemDefinitionInputException or ItemCreateInputException => ex.Message,
|
||||
FabricItemDefinitionInputException or FabricItemInputException => ex.Message,
|
||||
CredentialUnavailableException =>
|
||||
"Fabric credentials are unavailable. Authenticate the configured Fabric identity before creating an item",
|
||||
FabricOperationException { StatusCode: HttpStatusCode.BadGateway } => ex.Message,
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
using System.Net;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Azure.Identity;
|
||||
using Fabric.Mcp.Tools.Core.Models;
|
||||
using Fabric.Mcp.Tools.Core.Options;
|
||||
using Fabric.Mcp.Tools.Core.Services;
|
||||
using Fabric.Mcp.Tools.Core.Validation;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Mcp.Core.Commands;
|
||||
using Microsoft.Mcp.Core.Models.Command;
|
||||
|
||||
namespace Fabric.Mcp.Tools.Core.Commands;
|
||||
|
||||
[CommandMetadata(
|
||||
Id = "e15155f3-c5e4-423b-9d88-ce4e8d701a4f",
|
||||
Name = "update-item-definition",
|
||||
Title = "Update Fabric Item Definition",
|
||||
Description = """
|
||||
Replaces the entire definition of an existing Fabric item using known nonempty workspace-id and item-id UUIDs.
|
||||
Requires exactly one complete definition source: inline definition JSON, or an existing local
|
||||
definition-directory with an explicit definition-files mapping and optional definition-format.
|
||||
Saved-file input reads only selected saved files on the hosting machine; HTTP callers must use inline content.
|
||||
This is not a changed-files patch: it never gets the old definition, merges parts, or resolves names.
|
||||
Shared validation checks InlineBase64 payloads, portable paths, and bounded byte/count budgets.
|
||||
Definition format, safe part layout, and payload bytes pass through unchanged, without item-type or format-specific rules.
|
||||
Fabric validates required parts, supported formats, item-type requirements, content, dependencies, and item-specific options.
|
||||
Optional top-level options is bounded JSON for the item's Update Definition API, not LRO wait settings.
|
||||
update-metadata must be explicitly true to request metadata updates from a supplied .platform part.
|
||||
Omission leaves that query parameter omitted. .platform is never added, removed, or rewritten.
|
||||
Updating a definition does not affect the sensitivity label; no label, folder, or creation properties are accepted.
|
||||
Sends one POST. A synchronous 200 returns a Succeeded receipt with no item/result body.
|
||||
A 202 returns a resumable operation receipt. With sync=true, asynchronously poll state for up to
|
||||
max-wait-seconds (default 120, maximum 600); confirmed Succeeded completes without a /result or Get Item call.
|
||||
early-poll=true permits one probe after at most 3 seconds instead of the initial Retry-After.
|
||||
Later polls and 429/503 read retries honor validated hints. Wait expiry preserves last-known state.
|
||||
Client cancellation stops only the local wait, not the Fabric operation.
|
||||
Resume pending updates with get-operation-state, never by resubmitting the update or requesting a result.
|
||||
Requires read and write item permissions and, for delegated callers, Item.ReadWrite.All or the
|
||||
item-specific ReadWrite.All scope. Service-principal and managed-identity support depends on item type.
|
||||
Does not perform preflight reads, download schemas, create dependencies, or follow arbitrary URLs.
|
||||
""",
|
||||
OperationPlane = ToolOperationPlane.Control,
|
||||
Destructive = true,
|
||||
Idempotent = true,
|
||||
LocalRequired = false,
|
||||
OpenWorld = false,
|
||||
ReadOnly = false,
|
||||
Secret = false)]
|
||||
public sealed class ItemDefinitionUpdateCommand(
|
||||
ILogger<ItemDefinitionUpdateCommand> logger,
|
||||
IFabricCoreService fabricCoreService,
|
||||
IFabricItemDefinitionInputService definitionInputService) : FabricCoreCommand<ItemDefinitionUpdateOptions, ItemDefinitionUpdateCommandResult>
|
||||
{
|
||||
private readonly ILogger<ItemDefinitionUpdateCommand> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
private readonly IFabricCoreService _fabricCoreService = fabricCoreService ?? throw new ArgumentNullException(nameof(fabricCoreService));
|
||||
private readonly IFabricItemDefinitionInputService _definitionInputService = definitionInputService ?? throw new ArgumentNullException(nameof(definitionInputService));
|
||||
|
||||
public override JsonTypeInfo<ItemDefinitionUpdateCommandResult> ResultTypeInfo => CoreJsonContext.Default.ItemDefinitionUpdateCommandResult;
|
||||
|
||||
public override void ValidateOptions(ItemDefinitionUpdateOptions options, ValidationResult validationResult)
|
||||
{
|
||||
base.ValidateOptions(options, validationResult);
|
||||
try
|
||||
{
|
||||
ValidateInput(options);
|
||||
}
|
||||
catch (FabricItemInputException ex)
|
||||
{
|
||||
validationResult.Errors.Add(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task<CommandResponse> ExecuteAsync(
|
||||
CommandContext context, ItemDefinitionUpdateOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ValidateInput(options);
|
||||
// Parse workload options before any file access. Their item-specific semantics belong to Fabric.
|
||||
var apiOptions = FabricItemJsonInput.ParseObject(options.Options, "--options");
|
||||
var input = options.DefinitionInput;
|
||||
var definition = await _definitionInputService.ReadAsync(input?.Definition,
|
||||
input?.DefinitionDirectory, input?.DefinitionFiles, input?.DefinitionFormat, cancellationToken) ??
|
||||
throw new FabricItemInputException("Provide a complete replacement definition using exactly one inline or saved-file source.");
|
||||
var result = await _fabricCoreService.UpdateItemDefinitionAsync(options.WorkspaceId, options.ItemId,
|
||||
new(definition, apiOptions), options.UpdateMetadata,
|
||||
new(options.Sync, options.MaxWaitSeconds, options.EarlyPoll), cancellationToken);
|
||||
|
||||
SetResult(context, result);
|
||||
context.Response.Status = result.Operation switch
|
||||
{
|
||||
{ Status: FabricOperationStatus.Succeeded } => HttpStatusCode.OK,
|
||||
{ Status: FabricOperationStatus.Failed } => HttpStatusCode.BadGateway,
|
||||
{ Status: FabricOperationStatus.TrackingStopped or FabricOperationStatus.ResultUnavailable, Issue.HttpStatus: { } status } => (HttpStatusCode)status,
|
||||
_ => HttpStatusCode.Accepted
|
||||
};
|
||||
if (result.Operation.Issue is { } issue)
|
||||
{
|
||||
context.Response.Message = issue.Message;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is FabricOperationCanceledException || ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogError("Error updating a Fabric item definition ({ExceptionType}).", ex.GetType().Name);
|
||||
HandleException(context, ex);
|
||||
}
|
||||
|
||||
return context.Response;
|
||||
}
|
||||
|
||||
protected override void HandleException(CommandContext context, Exception ex)
|
||||
{
|
||||
base.HandleException(context, ex);
|
||||
if (ex is FabricOperationCanceledException canceled)
|
||||
{
|
||||
SetResult(context, new(canceled.Operation));
|
||||
context.Response.Message = canceled.Message;
|
||||
}
|
||||
if (ex is CommandValidationException validationException)
|
||||
{
|
||||
context.Response.Message = GetValidationErrorMessage(validationException,
|
||||
"Invalid definition update request. Provide nonempty workspace and item UUIDs, exactly one complete definition source, " +
|
||||
"and valid options; --update-metadata requires an explicit true or false.");
|
||||
}
|
||||
}
|
||||
|
||||
protected override HttpStatusCode GetStatusCode(Exception ex) => ex switch
|
||||
{
|
||||
FabricItemDefinitionInputException or FabricItemInputException => HttpStatusCode.BadRequest,
|
||||
FabricOperationCanceledException => HttpStatusCode.RequestTimeout,
|
||||
_ => base.GetStatusCode(ex)
|
||||
};
|
||||
|
||||
protected override string GetErrorMessage(Exception ex) => ex switch
|
||||
{
|
||||
FabricItemDefinitionInputException or FabricItemInputException => ex.Message,
|
||||
CredentialUnavailableException or AuthenticationFailedException =>
|
||||
"Fabric authentication failed. Authenticate the configured identity and check its access to the item",
|
||||
FabricOperationException { StatusCode: HttpStatusCode.BadGateway } or FabricThrottledException => ex.Message,
|
||||
OperationCanceledException or TimeoutException =>
|
||||
"The definition update timed out or was canceled. Check the item's state before retrying; Fabric may still complete the update",
|
||||
_ => GetStatusCode(ex) switch
|
||||
{
|
||||
HttpStatusCode.BadRequest =>
|
||||
"Fabric rejected the definition update. Check the workspace and item UUIDs, complete definition, format, and item-specific options",
|
||||
HttpStatusCode.Unauthorized =>
|
||||
"Authentication failed. Check the configured Fabric identity and its access to the item",
|
||||
HttpStatusCode.Forbidden =>
|
||||
"Fabric denied the definition update. Read and write item permissions are required; delegated callers need Item.ReadWrite.All " +
|
||||
"or the item-specific scope. Service-principal and managed-identity support depends on item type",
|
||||
HttpStatusCode.NotFound =>
|
||||
"The Fabric workspace or item was not found, or the caller does not have access",
|
||||
HttpStatusCode.Conflict =>
|
||||
"The definition update conflicts with the item's current state. Check the item before retrying",
|
||||
HttpStatusCode.TooManyRequests =>
|
||||
"Fabric throttled the definition update. No automatic mutation retry was attempted",
|
||||
_ =>
|
||||
"The Fabric definition update could not be confirmed. Check the item's state before retrying; do not automatically resubmit"
|
||||
}
|
||||
};
|
||||
|
||||
private static void ValidateInput(ItemDefinitionUpdateOptions options)
|
||||
{
|
||||
if (!Guid.TryParse(options.WorkspaceId, out var workspaceId) || workspaceId == Guid.Empty ||
|
||||
!Guid.TryParse(options.ItemId, out var itemId) || itemId == Guid.Empty)
|
||||
{
|
||||
throw new FabricItemInputException("--workspace-id and --item-id must be nonempty UUIDs.");
|
||||
}
|
||||
if (!FabricOperationWaitOptions.IsValidBudget(options.MaxWaitSeconds))
|
||||
{
|
||||
throw new FabricItemInputException(FormattableString.Invariant(
|
||||
$"--max-wait-seconds must be finite, greater than zero, and at most {FabricOperationWaitOptions.MaximumWaitSeconds} seconds."));
|
||||
}
|
||||
if (options.DefinitionInput?.HasInput is not true)
|
||||
{
|
||||
throw new FabricItemInputException("Provide a complete replacement definition using exactly one inline or saved-file source.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ public class FabricCoreSetup : IAreaSetup
|
||||
services.AddSingleton<CapacityGetCommand>();
|
||||
services.AddSingleton<CapacityListCommand>();
|
||||
services.AddSingleton<ItemCreateCommand>(CreateItemCreateCommand);
|
||||
services.AddSingleton<ItemDefinitionUpdateCommand>(CreateItemDefinitionUpdateCommand);
|
||||
services.AddSingleton<ItemDeleteCommand>(CreateItemDeleteCommand);
|
||||
services.AddSingleton<ItemGetCommand>();
|
||||
services.AddSingleton<ItemListCommand>();
|
||||
@@ -69,6 +70,7 @@ public class FabricCoreSetup : IAreaSetup
|
||||
"- List accessible workspaces and their management metadata, optionally filtered by the caller's workspace roles\n" +
|
||||
"- List item metadata within a known workspace or folder, optionally filtered by type\n" +
|
||||
"- Create new Fabric items, optionally from inline definitions or explicitly selected local definition files\n" +
|
||||
"- Replace a known item's complete definition from inline content or explicitly selected saved files\n" +
|
||||
"- Track accepted long-running operations and read their JSON or empty results\n" +
|
||||
"- Create Fabric workspaces, optionally assigning an existing capacity and domain\n" +
|
||||
"- Rename a known workspace or update or clear its description\n" +
|
||||
@@ -81,6 +83,7 @@ public class FabricCoreSetup : IAreaSetup
|
||||
fabricCore.AddCommand<CapacityGetCommand>(serviceProvider);
|
||||
fabricCore.AddCommand<CapacityListCommand>(serviceProvider);
|
||||
fabricCore.AddCommand<ItemCreateCommand>(serviceProvider);
|
||||
fabricCore.AddCommand<ItemDefinitionUpdateCommand>(serviceProvider);
|
||||
fabricCore.AddCommand<ItemDeleteCommand>(serviceProvider);
|
||||
fabricCore.AddCommand<ItemGetCommand>(serviceProvider);
|
||||
fabricCore.AddCommand<ItemListCommand>(serviceProvider);
|
||||
@@ -130,6 +133,46 @@ public class FabricCoreSetup : IAreaSetup
|
||||
return command;
|
||||
}
|
||||
|
||||
private static ItemDefinitionUpdateCommand CreateItemDefinitionUpdateCommand(IServiceProvider serviceProvider)
|
||||
{
|
||||
var definitionInput = serviceProvider.GetRequiredService<IFabricItemDefinitionInputService>();
|
||||
var command = new ItemDefinitionUpdateCommand(
|
||||
serviceProvider.GetRequiredService<ILogger<ItemDefinitionUpdateCommand>>(),
|
||||
serviceProvider.GetRequiredService<IFabricCoreService>(),
|
||||
definitionInput);
|
||||
var definition = command.GetCommand();
|
||||
var inputOptions = definition.Options.ToArray();
|
||||
if (!definitionInput.SupportsLocalFiles)
|
||||
{
|
||||
// Only this command's collection changes; cached binder options remain available to local hosts.
|
||||
foreach (var option in inputOptions.Where(static option =>
|
||||
option.Name is "--definition-directory" or "--definition-files" or "--definition-format"))
|
||||
{
|
||||
definition.Options.Remove(option);
|
||||
}
|
||||
}
|
||||
if (definition.Options.SingleOrDefault(static option => option.Name == "--update-metadata") is not Option<bool?> updateMetadata ||
|
||||
updateMetadata.Required || updateMetadata.HasDefaultValue)
|
||||
{
|
||||
throw new InvalidOperationException("The update-item-definition command requires optional Boolean --update-metadata without a default.");
|
||||
}
|
||||
updateMetadata.Arity = ArgumentArity.ExactlyOne;
|
||||
definition.Validators.Add(result =>
|
||||
{
|
||||
if (inputOptions.Any(option => result.GetResult(option) is { IdentifierTokenCount: > 1 }))
|
||||
{
|
||||
result.AddError("Specify each definition-update option at most once.");
|
||||
}
|
||||
var metadata = result.GetResult(updateMetadata);
|
||||
if (metadata is { Implicit: false } &&
|
||||
(metadata.Tokens.Count != 1 || !bool.TryParse(metadata.Tokens[0].Value, out _)))
|
||||
{
|
||||
result.AddError("Specify --update-metadata with an explicit true or false value.");
|
||||
}
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
private static ItemDeleteCommand CreateItemDeleteCommand(IServiceProvider serviceProvider)
|
||||
{
|
||||
var command = new ItemDeleteCommand(
|
||||
|
||||
@@ -19,6 +19,8 @@ namespace Fabric.Mcp.Tools.Core.Models;
|
||||
[JsonSerializable(typeof(FabricSensitivityLabelSettings))]
|
||||
[JsonSerializable(typeof(CreateItemRequest))]
|
||||
[JsonSerializable(typeof(ItemCreateCommandResult))]
|
||||
[JsonSerializable(typeof(ItemDefinitionUpdateCommandResult))]
|
||||
[JsonSerializable(typeof(UpdateItemDefinitionRequest))]
|
||||
[JsonSerializable(typeof(ItemDeleteCommandResult))]
|
||||
[JsonSerializable(typeof(FabricItemMetadata))]
|
||||
[JsonSerializable(typeof(ItemGetCommandResult))]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
namespace Fabric.Mcp.Tools.Core.Models;
|
||||
|
||||
public sealed record ItemDefinitionUpdateCommandResult(FabricOperationReceipt Operation);
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Fabric.Mcp.Tools.Core.Models;
|
||||
|
||||
public sealed record UpdateItemDefinitionRequest(FabricItemDefinition Definition, JsonElement? Options = null);
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
using Fabric.Mcp.Tools.Core.Services;
|
||||
using Microsoft.Mcp.Core.Options;
|
||||
|
||||
namespace Fabric.Mcp.Tools.Core.Options;
|
||||
|
||||
public sealed class ItemDefinitionUpdateOptions()
|
||||
{
|
||||
[Option(Description = "The nonempty UUID of the existing Fabric workspace. Workspace names are not resolved.")]
|
||||
public required string WorkspaceId { get; set; }
|
||||
|
||||
[Option(Description = "The nonempty UUID of the existing Fabric item whose entire definition will be replaced. Item names are not resolved.")]
|
||||
public required string ItemId { get; set; }
|
||||
|
||||
[OptionContainer<DefinitionInputOptions>(Prefix = "")]
|
||||
public DefinitionInputOptions? DefinitionInput { get; set; }
|
||||
|
||||
[Option(Description = "The API's top-level item-specific options as a JSON object string, up to 1 MiB (1048576 UTF-8 bytes) and 32 nesting levels, with no duplicate properties. Consult the actual item type's Update Definition API for supported options. Not LRO wait settings; use sync, max-wait-seconds, and early-poll for waiting.")]
|
||||
public string? Options { get; set; }
|
||||
|
||||
[Option(Description = "Explicit true or false. Only true requests metadata updates from .platform when that part is supplied. Omit to leave the API query parameter omitted; false is sent explicitly. The tool never adds, removes, or rewrites .platform and this API never changes the item's sensitivity label.")]
|
||||
public bool? UpdateMetadata { get; set; }
|
||||
|
||||
[Option(Description = "Wait asynchronously for accepted definition replacement to complete. Defaults to false, returning an operation receipt without polling. This is a no-result API: only operation state is polled, never /result.", DefaultValue = false)]
|
||||
public bool Sync { get; set; }
|
||||
|
||||
[Option(Description = "Finite positive local wait budget in seconds after acceptance when sync is true. Defaults to 120; maximum 600 (10 minutes). Fractional seconds are allowed. Expiry returns a resumable receipt and does not cancel Fabric's operation.", DefaultValue = FabricOperationWaitOptions.DefaultMaxWaitSeconds)]
|
||||
public double MaxWaitSeconds { get; set; } = FabricOperationWaitOptions.DefaultMaxWaitSeconds;
|
||||
|
||||
[Option(Description = "Allow one early status probe after at most 3 seconds, even if the initial 202 Retry-After is longer. Defaults to true; false honors the initial hint. Later polls and 429/503 retries honor validated hints.", DefaultValue = true)]
|
||||
public bool EarlyPoll { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Fabric.Mcp.Tools.Core.Models;
|
||||
using Fabric.Mcp.Tools.Core.Validation;
|
||||
|
||||
namespace Fabric.Mcp.Tools.Core.Services;
|
||||
|
||||
public partial class FabricCoreService
|
||||
{
|
||||
public async Task<ItemDefinitionUpdateCommandResult> UpdateItemDefinitionAsync(
|
||||
string workspaceId,
|
||||
string itemId,
|
||||
UpdateItemDefinitionRequest request,
|
||||
bool? updateMetadata = null,
|
||||
FabricOperationWaitOptions? waitOptions = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (!Guid.TryParse(workspaceId, out var parsedWorkspaceId) || parsedWorkspaceId == Guid.Empty)
|
||||
{
|
||||
throw new ArgumentException("Workspace ID must be a nonempty UUID.", nameof(workspaceId));
|
||||
}
|
||||
if (!Guid.TryParse(itemId, out var parsedItemId) || parsedItemId == Guid.Empty)
|
||||
{
|
||||
throw new ArgumentException("Item ID must be a nonempty UUID.", nameof(itemId));
|
||||
}
|
||||
|
||||
waitOptions ??= new();
|
||||
waitOptions.Validate();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
_definitionValidator.Validate(request.Definition, cancellationToken);
|
||||
var options = FabricItemJsonInput.ParseObject(request.Options?.GetRawText(), "--options");
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var url = $"{FabricEndpoints.GetFabricApiBaseUrl()}/workspaces/{parsedWorkspaceId:D}/items/{parsedItemId:D}/updateDefinition";
|
||||
if (updateMetadata is { } requestedUpdateMetadata)
|
||||
{
|
||||
url += requestedUpdateMetadata ? "?updateMetadata=true" : "?updateMetadata=false";
|
||||
}
|
||||
var jsonContent = JsonSerializer.Serialize(request with { Options = options }, CoreJsonContext.Default.UpdateItemDefinitionRequest);
|
||||
using var response = await SendFabricHttpRequestAsync(HttpMethod.Post, url, jsonContent,
|
||||
completionOption: HttpCompletionOption.ResponseHeadersRead, cancellationToken: cancellationToken);
|
||||
|
||||
// This endpoint never returns an item or operation result, even after LRO success.
|
||||
var contract = new FabricOperationContract<object>(new HashSet<HttpStatusCode> { HttpStatusCode.OK }, ReadResult: null);
|
||||
var outcome = await new FabricOperationRunner(_timeProvider).HandleAsync(
|
||||
response, contract, waitOptions, GetOperationStateAsync, GetOperationResultAsync, cancellationToken);
|
||||
return new(outcome.Operation ?? new(FabricOperationStatus.Succeeded, FabricOperationHttp.GetOperationId(response)));
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,16 @@ using Microsoft.Mcp.Core.Commands;
|
||||
|
||||
namespace Fabric.Mcp.Tools.Core.Services;
|
||||
|
||||
public partial class FabricCoreService(HttpClient httpClient, TokenCredential? credential = null, TimeProvider? timeProvider = null) : IFabricCoreService
|
||||
public partial class FabricCoreService(
|
||||
HttpClient httpClient,
|
||||
TokenCredential? credential = null,
|
||||
TimeProvider? timeProvider = null,
|
||||
FabricItemDefinitionFileLimits? definitionLimits = null) : IFabricCoreService
|
||||
{
|
||||
private readonly HttpClient _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
private readonly TokenCredential _credential = credential ?? new DefaultAzureCredential();
|
||||
private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
|
||||
private readonly FabricItemDefinitionValidator _definitionValidator = new(definitionLimits ?? new());
|
||||
private const string UserAgentHeaderName = "User-Agent";
|
||||
private const string UserAgentHeaderValue = "Fabric Core MCP";
|
||||
private const string InvalidUpdateResponseMessage = "Fabric returned an invalid Update Item response.";
|
||||
|
||||
@@ -56,7 +56,7 @@ public sealed class FabricItemDefinitionInputService(
|
||||
if (definition is not null)
|
||||
{
|
||||
result = Parse(definition, FabricItemDefinitionInputJsonContext.Default.FabricItemDefinition,
|
||||
"--definition must be a JSON object with parts and optional format, not a file path or a create-request envelope.");
|
||||
"--definition must be a JSON object with parts and optional format, not a file path or a request envelope.");
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -55,7 +55,7 @@ internal static class FabricOperationHttp
|
||||
|
||||
throw new FabricOperationException(
|
||||
"UnexpectedResponse",
|
||||
"Fabric did not return the documented response. Check the operation or whether creation occurred before submitting another mutation.",
|
||||
"Fabric did not return the documented response. Check the operation or whether the mutation completed before submitting another mutation.",
|
||||
response.IsSuccessStatusCode ? HttpStatusCode.BadGateway : response.StatusCode,
|
||||
retryAfter);
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ internal sealed class FabricOperationRunner(TimeProvider timeProvider)
|
||||
Status = FabricOperationStatus.AcceptedWithoutOperationId,
|
||||
RecommendedPollAfterSeconds = null,
|
||||
Issue = new("MissingOperationId",
|
||||
"Fabric accepted the mutation but returned no usable operation ID. Completion is unconfirmed. Check whether creation occurred; do not automatically resubmit.")
|
||||
"Fabric accepted the mutation but returned no usable operation ID. Completion is unconfirmed. Check whether the mutation completed; do not automatically resubmit.")
|
||||
});
|
||||
}
|
||||
if (!options.Sync)
|
||||
@@ -218,7 +218,7 @@ internal sealed class FabricOperationRunner(TimeProvider timeProvider)
|
||||
? FabricOperationStatus.ResultUnavailable
|
||||
: FabricOperationStatus.Pending,
|
||||
Issue = new("WaitBudgetExpired", receipt.Status == FabricOperationStatus.Succeeded
|
||||
? "The operation succeeded, but its result was not retrieved within the local wait budget. Resume with get-operation-result; do not recreate the item."
|
||||
? "The operation succeeded, but its result was not retrieved within the local wait budget. Resume with get-operation-result; do not resubmit the mutation."
|
||||
: "The local wait budget expired. Completion remains unconfirmed. Resume with get-operation-state; stopping this wait did not cancel Fabric's operation.")
|
||||
};
|
||||
|
||||
|
||||
@@ -22,6 +22,15 @@ public interface IFabricCoreService
|
||||
FabricOperationWaitOptions? waitOptions = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Replaces a known item's complete definition with one POST and optional no-result LRO state polling.</summary>
|
||||
Task<ItemDefinitionUpdateCommandResult> UpdateItemDefinitionAsync(
|
||||
string workspaceId,
|
||||
string itemId,
|
||||
UpdateItemDefinitionRequest request,
|
||||
bool? updateMetadata = null,
|
||||
FabricOperationWaitOptions? waitOptions = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Reads operation state once, without polling or interpreting HTTP success as operation success.</summary>
|
||||
Task<OperationStateResult> GetOperationStateAsync(string operationId, CancellationToken cancellationToken);
|
||||
|
||||
|
||||
+1
-1
@@ -4,4 +4,4 @@
|
||||
namespace Fabric.Mcp.Tools.Core.Validation;
|
||||
|
||||
/// <summary>Fixed input guidance without payload values, unknown property names, or parser details.</summary>
|
||||
internal sealed class ItemCreateInputException(string message) : Exception(message);
|
||||
internal sealed class FabricItemInputException(string message) : Exception(message);
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Fabric.Mcp.Tools.Core.Validation;
|
||||
|
||||
internal static class FabricItemJsonInput
|
||||
{
|
||||
internal const int MaxObjectBytes = 1024 * 1024;
|
||||
internal const int MaxObjectDepth = 32;
|
||||
|
||||
internal static JsonElement? ParseObject(string? json, string option, int maxBytes = MaxObjectBytes)
|
||||
{
|
||||
if (json is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (json.Length > maxBytes || Encoding.UTF8.GetByteCount(json) > maxBytes)
|
||||
{
|
||||
throw new FabricItemInputException(FormattableString.Invariant(
|
||||
$"{option} must not exceed {maxBytes} UTF-8 bytes."));
|
||||
}
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json, new JsonDocumentOptions
|
||||
{
|
||||
MaxDepth = MaxObjectDepth,
|
||||
AllowDuplicateProperties = false
|
||||
});
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new FabricItemInputException($"{option} must be a JSON object, not null, an array, or a scalar.");
|
||||
}
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw new FabricItemInputException(FormattableString.Invariant(
|
||||
$"{option} must contain valid JSON with no duplicate properties and at most {MaxObjectDepth} nesting levels."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Fabric.Mcp.Tools.Core.Models;
|
||||
using Fabric.Mcp.Tools.Core.Options;
|
||||
@@ -10,9 +9,7 @@ namespace Fabric.Mcp.Tools.Core.Validation;
|
||||
|
||||
internal static class ItemCreateRequestInput
|
||||
{
|
||||
internal const int MaxObjectBytes = 1024 * 1024;
|
||||
internal const int MaxLabelSettingsBytes = 4096;
|
||||
internal const int MaxObjectDepth = 32;
|
||||
|
||||
internal static CreateItemRequest CreateRequest(ItemCreateOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -20,12 +17,12 @@ internal static class ItemCreateRequestInput
|
||||
var hasDefinitionInput = options.DefinitionInput?.HasInput is true;
|
||||
if (options.CreationPayload is not null && hasDefinitionInput)
|
||||
{
|
||||
throw new ItemCreateInputException(
|
||||
throw new FabricItemInputException(
|
||||
"--creation-payload cannot be combined with any inline or saved-file definition option.");
|
||||
}
|
||||
if (options.Options is not null && !hasDefinitionInput)
|
||||
{
|
||||
throw new ItemCreateInputException("--options requires an inline or saved-file definition source.");
|
||||
throw new FabricItemInputException("--options requires an inline or saved-file definition source.");
|
||||
}
|
||||
|
||||
Guid? folderId = null;
|
||||
@@ -33,7 +30,7 @@ internal static class ItemCreateRequestInput
|
||||
{
|
||||
if (!Guid.TryParse(options.FolderId, out var parsedFolderId) || parsedFolderId == Guid.Empty)
|
||||
{
|
||||
throw new ItemCreateInputException("--folder-id must be a nonempty UUID for an existing Fabric folder.");
|
||||
throw new FabricItemInputException("--folder-id must be a nonempty UUID for an existing Fabric folder.");
|
||||
}
|
||||
folderId = parsedFolderId;
|
||||
}
|
||||
@@ -46,48 +43,17 @@ internal static class ItemCreateRequestInput
|
||||
Type = options.ItemType,
|
||||
Description = options.Description,
|
||||
FolderId = folderId,
|
||||
CreationPayload = ParseObject(options.CreationPayload, "--creation-payload", MaxObjectBytes),
|
||||
Options = ParseObject(options.Options, "--options", MaxObjectBytes),
|
||||
CreationPayload = FabricItemJsonInput.ParseObject(options.CreationPayload, "--creation-payload"),
|
||||
Options = FabricItemJsonInput.ParseObject(options.Options, "--options"),
|
||||
SensitivityLabelSettings = ParseLabelSettings(options.SensitivityLabelSettings)
|
||||
};
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return request;
|
||||
}
|
||||
|
||||
private static JsonElement? ParseObject(string? json, string option, int maxBytes)
|
||||
{
|
||||
if (json is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (json.Length > maxBytes || Encoding.UTF8.GetByteCount(json) > maxBytes)
|
||||
{
|
||||
throw new ItemCreateInputException(FormattableString.Invariant(
|
||||
$"{option} must not exceed {maxBytes} UTF-8 bytes."));
|
||||
}
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json, new JsonDocumentOptions
|
||||
{
|
||||
MaxDepth = MaxObjectDepth,
|
||||
AllowDuplicateProperties = false
|
||||
});
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new ItemCreateInputException($"{option} must be a JSON object, not null, an array, or a scalar.");
|
||||
}
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw new ItemCreateInputException(FormattableString.Invariant(
|
||||
$"{option} must contain valid JSON with no duplicate properties and at most {MaxObjectDepth} nesting levels."));
|
||||
}
|
||||
}
|
||||
|
||||
private static FabricSensitivityLabelSettings? ParseLabelSettings(string? json)
|
||||
{
|
||||
var value = ParseObject(json, "--sensitivity-label-settings", MaxLabelSettingsBytes);
|
||||
var value = FabricItemJsonInput.ParseObject(json, "--sensitivity-label-settings", MaxLabelSettingsBytes);
|
||||
if (value is null)
|
||||
{
|
||||
return null;
|
||||
@@ -97,21 +63,21 @@ internal static class ItemCreateRequestInput
|
||||
try
|
||||
{
|
||||
settings = value.Value.Deserialize(FabricItemCreateInputJsonContext.Default.FabricSensitivityLabelSettings) ??
|
||||
throw new ItemCreateInputException("--sensitivity-label-settings must be a JSON object.");
|
||||
throw new FabricItemInputException("--sensitivity-label-settings must be a JSON object.");
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw new ItemCreateInputException(
|
||||
throw new FabricItemInputException(
|
||||
"--sensitivity-label-settings requires labelId (a nonempty UUID) and accepts optional sensitivityLabelApplyStrategy (a string).");
|
||||
}
|
||||
if (settings.LabelId == Guid.Empty)
|
||||
{
|
||||
throw new ItemCreateInputException("--sensitivity-label-settings requires a nonempty labelId UUID.");
|
||||
throw new FabricItemInputException("--sensitivity-label-settings requires a nonempty labelId UUID.");
|
||||
}
|
||||
if (value.Value.TryGetProperty("sensitivityLabelApplyStrategy", out _) &&
|
||||
settings.SensitivityLabelApplyStrategy is not ("ApplyOrFail" or "Ignore"))
|
||||
{
|
||||
throw new ItemCreateInputException(
|
||||
throw new FabricItemInputException(
|
||||
"A supplied sensitivityLabelApplyStrategy must be ApplyOrFail or Ignore. Omit it to retain Fabric's ApplyOrFail default.");
|
||||
}
|
||||
|
||||
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
using System.Net;
|
||||
using Fabric.Mcp.Tools.Core.Commands;
|
||||
using Fabric.Mcp.Tools.Core.Models;
|
||||
using Fabric.Mcp.Tools.Core.Options;
|
||||
using Fabric.Mcp.Tools.Core.Services;
|
||||
using Fabric.Mcp.Tools.Core.Tests.TestSupport;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Mcp.Core.Commands;
|
||||
using Microsoft.Mcp.Tests.Client;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using Xunit;
|
||||
|
||||
namespace Fabric.Mcp.Tools.Core.Tests.Commands;
|
||||
|
||||
public sealed class ItemDefinitionUpdateCommandTests()
|
||||
: CommandUnitTestsBase<ItemDefinitionUpdateCommand, IFabricCoreService>, IAsyncLifetime
|
||||
{
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
new FabricCoreSetup().ConfigureServices(Services);
|
||||
Services.AddSingleton(Service);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_RequiresDependenciesAndRegistersTheMutatingCommand()
|
||||
{
|
||||
var input = Substitute.For<IFabricItemDefinitionInputService>();
|
||||
Assert.Throws<ArgumentNullException>(() => new ItemDefinitionUpdateCommand(null!, Service, input));
|
||||
Assert.Throws<ArgumentNullException>(() => new ItemDefinitionUpdateCommand(Logger, null!, input));
|
||||
Assert.Throws<ArgumentNullException>(() => new ItemDefinitionUpdateCommand(Logger, Service, null!));
|
||||
Assert.Equal("update-item-definition", Command.Name);
|
||||
Assert.True(Command.Metadata.Destructive);
|
||||
Assert.True(Command.Metadata.Idempotent);
|
||||
Assert.False(Command.Metadata.ReadOnly);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("workspace-id", "private-workspace")]
|
||||
[InlineData("workspace-id", "00000000-0000-0000-0000-000000000000")]
|
||||
[InlineData("item-id", "")]
|
||||
[InlineData("item-id", "../private-item")]
|
||||
[InlineData("item-id", "00000000-0000-0000-0000-000000000000")]
|
||||
[InlineData("max-wait-seconds", "NaN")]
|
||||
[InlineData("max-wait-seconds", "0")]
|
||||
[InlineData("max-wait-seconds", "600.01")]
|
||||
public async Task Validation_FailsBeforeDefinitionInputOrService(string option, string value)
|
||||
{
|
||||
var options = new ItemDefinitionUpdateOptions
|
||||
{
|
||||
WorkspaceId = option == "workspace-id" ? value : FabricOperationTestData.WorkspaceId,
|
||||
ItemId = option == "item-id" ? value : FabricOperationTestData.ItemId,
|
||||
MaxWaitSeconds = option == "max-wait-seconds" ? double.Parse(value, System.Globalization.CultureInfo.InvariantCulture) : 120,
|
||||
DefinitionInput = new()
|
||||
{
|
||||
DefinitionDirectory = "private-directory",
|
||||
DefinitionFiles = "private-invalid-json"
|
||||
}
|
||||
};
|
||||
var input = Substitute.For<IFabricItemDefinitionInputService>();
|
||||
var command = new ItemDefinitionUpdateCommand(Logger, Service, input);
|
||||
var validation = new ValidationResult();
|
||||
|
||||
command.ValidateOptions(options, validation);
|
||||
Assert.NotEmpty(validation.Errors);
|
||||
var response = await command.ExecuteAsync(new(), options, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.Status);
|
||||
Assert.DoesNotContain("private-", response.Message);
|
||||
Assert.Empty(input.ReceivedCalls());
|
||||
Assert.Empty(Service.ReceivedCalls());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Binding_ForwardsDefinitionOptionsMetadataWaitSettingsAndCancellation()
|
||||
{
|
||||
Service.UpdateItemDefinitionAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<UpdateItemDefinitionRequest>(),
|
||||
Arg.Any<bool?>(), Arg.Any<FabricOperationWaitOptions>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ItemDefinitionUpdateCommandResult(new(FabricOperationStatus.Succeeded)));
|
||||
|
||||
var response = await ExecuteAsync("--update-metadata", "true", "--options", """{"setting":false}""",
|
||||
"--sync", "true", "--max-wait-seconds", "0.5", "--early-poll", "false");
|
||||
|
||||
var result = ValidateAndDeserializeResponse(response, CoreJsonContext.Default.ItemDefinitionUpdateCommandResult);
|
||||
Assert.Equal(FabricOperationStatus.Succeeded, result.Operation.Status);
|
||||
await Service.Received(1).UpdateItemDefinitionAsync(FabricOperationTestData.WorkspaceId, FabricOperationTestData.ItemId,
|
||||
Arg.Is<UpdateItemDefinitionRequest>(request => request.Definition.Parts.Count == 1 &&
|
||||
request.Definition.Parts[0].Path == "content" && !request.Options!.Value.GetProperty("setting").GetBoolean()),
|
||||
true, Arg.Is<FabricOperationWaitOptions>(wait => wait.Sync && wait.MaxWaitSeconds == 0.5 && !wait.EarlyPoll),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(FabricOperationStatus.Accepted, HttpStatusCode.Accepted)]
|
||||
[InlineData(FabricOperationStatus.Pending, HttpStatusCode.Accepted)]
|
||||
[InlineData(FabricOperationStatus.Failed, HttpStatusCode.BadGateway)]
|
||||
[InlineData(FabricOperationStatus.TrackingStopped, HttpStatusCode.Forbidden)]
|
||||
[InlineData(FabricOperationStatus.ResultUnavailable, HttpStatusCode.BadGateway)]
|
||||
public async Task ReceiptStatus_IsPreservedWithoutClaimingUnconfirmedSuccess(FabricOperationStatus state, HttpStatusCode status)
|
||||
{
|
||||
var operation = new FabricOperationReceipt(state, Guid.Parse(FabricOperationTestData.OperationId),
|
||||
Issue: new("SafeCode", "Safe guidance", (int)status));
|
||||
Service.UpdateItemDefinitionAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<UpdateItemDefinitionRequest>(),
|
||||
Arg.Any<bool?>(), Arg.Any<FabricOperationWaitOptions>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ItemDefinitionUpdateCommandResult(operation));
|
||||
|
||||
var response = await ExecuteAsync();
|
||||
|
||||
Assert.Equal(status, response.Status);
|
||||
Assert.Equal("Safe guidance", response.Message);
|
||||
Assert.NotNull(response.Results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Binding_DefaultsPreserveMetadataOmissionAndSubmitOnlyBehavior()
|
||||
{
|
||||
Service.UpdateItemDefinitionAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<UpdateItemDefinitionRequest>(),
|
||||
Arg.Any<bool?>(), Arg.Any<FabricOperationWaitOptions>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ItemDefinitionUpdateCommandResult(new(FabricOperationStatus.Accepted)));
|
||||
|
||||
var response = await ExecuteAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.Status);
|
||||
await Service.Received(1).UpdateItemDefinitionAsync(FabricOperationTestData.WorkspaceId, FabricOperationTestData.ItemId,
|
||||
Arg.Is<UpdateItemDefinitionRequest>(request => request.Options == null), null,
|
||||
Arg.Is<FabricOperationWaitOptions>(wait => !wait.Sync && wait.MaxWaitSeconds == 120 && wait.EarlyPoll),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.BadRequest)]
|
||||
[InlineData(HttpStatusCode.Unauthorized)]
|
||||
[InlineData(HttpStatusCode.Forbidden)]
|
||||
[InlineData(HttpStatusCode.NotFound)]
|
||||
[InlineData(HttpStatusCode.Conflict)]
|
||||
[InlineData(HttpStatusCode.TooManyRequests)]
|
||||
[InlineData(HttpStatusCode.InternalServerError)]
|
||||
public async Task BackendFailure_PreservesStatusWithoutLeakingContent(HttpStatusCode status)
|
||||
{
|
||||
Service.UpdateItemDefinitionAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<UpdateItemDefinitionRequest>(),
|
||||
Arg.Any<bool?>(), Arg.Any<FabricOperationWaitOptions>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new HttpRequestException(FabricCoreErrorTestData.PrivateDetails, null, status));
|
||||
|
||||
var response = await ExecuteAsync();
|
||||
|
||||
Assert.Equal(status, response.Status);
|
||||
FabricCoreErrorTestData.AssertSanitized(response);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallerCancellationAfterAcceptance_KeepsTheOperationReceipt()
|
||||
{
|
||||
var operation = new FabricOperationReceipt(FabricOperationStatus.TrackingStopped, Guid.Parse(FabricOperationTestData.OperationId),
|
||||
Issue: new("CallerCanceled", "The caller stopped waiting, not the Fabric operation.", (int)HttpStatusCode.RequestTimeout));
|
||||
Service.UpdateItemDefinitionAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<UpdateItemDefinitionRequest>(),
|
||||
Arg.Any<bool?>(), Arg.Any<FabricOperationWaitOptions>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new FabricOperationCanceledException(operation, new CancellationToken(canceled: true)));
|
||||
|
||||
var response = await ExecuteAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.RequestTimeout, response.Status);
|
||||
Assert.NotNull(response.Results);
|
||||
Assert.Contains("The Fabric operation was not canceled", response.Message);
|
||||
}
|
||||
|
||||
private Task<Microsoft.Mcp.Core.Models.Command.CommandResponse> ExecuteAsync(params string[] arguments) =>
|
||||
ExecuteCommandAsync(["--workspace-id", FabricOperationTestData.WorkspaceId, "--item-id", FabricOperationTestData.ItemId,
|
||||
"--definition", ItemDefinitionUpdateTestData.Definition, .. arguments]);
|
||||
}
|
||||
+1
@@ -39,6 +39,7 @@ public sealed class FabricCoreCatalogTests()
|
||||
("core_list-workspaces", typeof(WorkspaceListCommand), true),
|
||||
("core_search-catalog", typeof(CatalogSearchCommand), true),
|
||||
("core_update-item", typeof(ItemUpdateCommand), false),
|
||||
("core_update-item-definition", typeof(ItemDefinitionUpdateCommand), false),
|
||||
("core_update-workspace", typeof(WorkspaceUpdateCommand), false)
|
||||
];
|
||||
|
||||
|
||||
+723
@@ -0,0 +1,723 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
// cspell:words ipynb PBIR TMDL TMSL
|
||||
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Azure.Core;
|
||||
using Fabric.Mcp.Tools.Core.Commands;
|
||||
using Fabric.Mcp.Tools.Core.Models;
|
||||
using Fabric.Mcp.Tools.Core.Options;
|
||||
using Fabric.Mcp.Tools.Core.Services;
|
||||
using Fabric.Mcp.Tools.Core.Tests.TestSupport;
|
||||
using Fabric.Mcp.Tools.Core.Validation;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Mcp.Core.Areas.Server;
|
||||
using Microsoft.Mcp.Core.Areas.Server.Commands.ToolLoading;
|
||||
using Microsoft.Mcp.Core.Areas.Server.Options;
|
||||
using Microsoft.Mcp.Core.Commands;
|
||||
using Microsoft.Mcp.Tests.Client.Helpers;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
using static Fabric.Mcp.Tools.Core.Tests.TestSupport.ItemDefinitionUpdateTestData;
|
||||
|
||||
namespace Fabric.Mcp.Tools.Core.Tests;
|
||||
|
||||
public sealed class ItemDefinitionUpdateMcpTests()
|
||||
{
|
||||
public static TheoryData<string, string, StructuredOutputMode?> RoutingCases
|
||||
{
|
||||
get
|
||||
{
|
||||
var data = new TheoryData<string, string, StructuredOutputMode?>();
|
||||
foreach (var route in new[] { "all", "namespace", "single" })
|
||||
{
|
||||
foreach (var transport in new[] { TransportTypes.StdIo, TransportTypes.Http })
|
||||
{
|
||||
foreach (var output in new StructuredOutputMode?[] { null, StructuredOutputMode.Compact, StructuredOutputMode.Duplicated })
|
||||
{
|
||||
data.Add(route, transport, output);
|
||||
}
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Discovery_PreservesInlineSupportAndIsolatesFileOptionsByHost()
|
||||
{
|
||||
using var handler = NoHttp();
|
||||
var credential = FabricOperationTestData.Credential();
|
||||
foreach (var transport in new[] { TransportTypes.Http, TransportTypes.StdIo, TransportTypes.Http })
|
||||
{
|
||||
await using var provider = CreateServices(handler, credential, transport);
|
||||
var loader = provider.GetRequiredService<CommandFactoryToolLoader>();
|
||||
var catalog = await loader.ListToolsHandler(McpTestUtilities.CreateToolListRequest(), TestContext.Current.CancellationToken);
|
||||
var tool = Assert.Single(catalog.Tools, static tool => tool.Name == ToolName);
|
||||
var properties = tool.InputSchema.GetProperty("properties");
|
||||
Assert.Equal(["item-id", "workspace-id"],
|
||||
tool.InputSchema.GetProperty("required").EnumerateArray().Select(static value => value.GetString()).Order());
|
||||
foreach (var name in new[] { "workspace-id", "item-id", "definition", "options" })
|
||||
{
|
||||
Assert.Equal("string", properties.GetProperty(name).GetProperty("type").GetString());
|
||||
}
|
||||
foreach (var name in new[] { "definition-directory", "definition-files", "definition-format" })
|
||||
{
|
||||
Assert.Equal(transport == TransportTypes.StdIo, properties.TryGetProperty(name, out _));
|
||||
}
|
||||
foreach (var name in new[] { "folder-id", "creation-payload", "sensitivity-label-settings", "display-name", "item-type", "definition-item-type", "definition-input" })
|
||||
{
|
||||
Assert.False(properties.TryGetProperty(name, out _));
|
||||
}
|
||||
Assert.Equal(["boolean", "null"], properties.GetProperty("update-metadata").GetProperty("type").EnumerateArray()
|
||||
.Select(static value => value.GetString()));
|
||||
Assert.False(properties.GetProperty("update-metadata").TryGetProperty("default", out _));
|
||||
Assert.Equal("boolean", properties.GetProperty("sync").GetProperty("type").GetString());
|
||||
Assert.Equal("boolean", properties.GetProperty("early-poll").GetProperty("type").GetString());
|
||||
Assert.Equal("number", properties.GetProperty("max-wait-seconds").GetProperty("type").GetString());
|
||||
Assert.True(tool.Annotations?.DestructiveHint);
|
||||
Assert.True(tool.Annotations?.IdempotentHint);
|
||||
Assert.False(tool.Annotations?.ReadOnlyHint);
|
||||
Assert.False(tool.Annotations?.OpenWorldHint);
|
||||
var command = provider.GetRequiredService<ItemDefinitionUpdateCommand>();
|
||||
Assert.False(command.Metadata.LocalRequired);
|
||||
Assert.False(command.Metadata.Secret);
|
||||
Assert.Equal("e15155f3-c5e4-423b-9d88-ce4e8d701a4f", command.Id);
|
||||
Assert.Same(CoreJsonContext.Default.ItemDefinitionUpdateCommandResult, command.ResultTypeInfo);
|
||||
Assert.Equal(["operation"], tool.OutputSchema!.Value.GetProperty("properties").EnumerateObject().Select(static property => property.Name));
|
||||
}
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
Assert.Empty(credential.ReceivedCalls());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RoutingCases))]
|
||||
public async Task InlineDefinition_HasOnePostAndTypedNoResultCompletionAcrossRoutes(
|
||||
string route, string transport, StructuredOutputMode? output)
|
||||
{
|
||||
using var handler = new FabricCoreHttpMessageHandler(async (request, token) =>
|
||||
{
|
||||
Assert.Equal(HttpMethod.Post, request.Method);
|
||||
Assert.Equal(Url, request.RequestUri?.AbsoluteUri);
|
||||
using var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(token));
|
||||
Assert.Equal(["definition"], body.RootElement.EnumerateObject().Select(static property => property.Name));
|
||||
using var expected = JsonDocument.Parse(Definition);
|
||||
Assert.True(JsonElement.DeepEquals(expected.RootElement, body.RootElement.GetProperty("definition")));
|
||||
return new(HttpStatusCode.OK);
|
||||
});
|
||||
var credential = FabricOperationTestData.Credential();
|
||||
var files = Substitute.For<IFabricItemDefinitionFileService>();
|
||||
await using var provider = CreateServices(handler, credential, transport, output, files);
|
||||
|
||||
var result = await Loader(provider, route).CallToolHandler(Request(route, Arguments()), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var payload = Payload(result, route);
|
||||
var typed = JsonSerializer.Deserialize(payload, CoreJsonContext.Default.ItemDefinitionUpdateCommandResult);
|
||||
Assert.NotNull(typed);
|
||||
Assert.Equal(FabricOperationStatus.Succeeded, typed.Operation.Status);
|
||||
Assert.Null(typed.Operation.OperationId);
|
||||
Assert.Equal(["operation"], payload.EnumerateObject().Select(static property => property.Name));
|
||||
Assert.DoesNotContain("e30=", Text(result));
|
||||
Assert.Empty(files.ReceivedCalls());
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
await credential.Received(1).GetTokenAsync(Arg.Any<TokenRequestContext>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("all", null)]
|
||||
[InlineData("namespace", false)]
|
||||
[InlineData("single", true)]
|
||||
public async Task SavedFiles_SendTheCompleteExplicitSelectionWithoutChangingFilesOrPlatform(string route, bool? metadata)
|
||||
{
|
||||
using var directory = new FabricDefinitionTestDirectory();
|
||||
Directory.CreateDirectory(Path.Combine(directory.Path, "notebooks"));
|
||||
byte[] notebook = [0xef, 0xbb, 0xbf, .. Encoding.UTF8.GetBytes("{\"cells\":[]}\r\n")];
|
||||
byte[] platform = Encoding.UTF8.GetBytes("""{"metadata":{"type":"Notebook","displayName":"Selected name"}}""");
|
||||
var source = Path.Combine(directory.Path, "notebooks", "sales.ipynb");
|
||||
await File.WriteAllBytesAsync(source, notebook, TestContext.Current.CancellationToken);
|
||||
await File.WriteAllBytesAsync(Path.Combine(directory.Path, ".platform"), platform, TestContext.Current.CancellationToken);
|
||||
await File.WriteAllBytesAsync(Path.Combine(directory.Path, "empty.bin"), [], TestContext.Current.CancellationToken);
|
||||
await File.WriteAllTextAsync(Path.Combine(directory.Path, ".env"), "private-unselected", TestContext.Current.CancellationToken);
|
||||
using var handler = new FabricCoreHttpMessageHandler(async (request, token) =>
|
||||
{
|
||||
Assert.Equal(HttpMethod.Post, request.Method);
|
||||
var query = metadata switch { true => "?updateMetadata=true", false => "?updateMetadata=false", _ => "" };
|
||||
Assert.Equal(Url + query, request.RequestUri?.AbsoluteUri);
|
||||
using var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(token));
|
||||
Assert.Equal(["definition", "options"], body.RootElement.EnumerateObject().Select(static property => property.Name).Order());
|
||||
var definition = body.RootElement.GetProperty("definition");
|
||||
Assert.Equal("ipynb", definition.GetProperty("format").GetString());
|
||||
var parts = definition.GetProperty("parts").EnumerateArray().ToArray();
|
||||
Assert.Equal(["artifact.content.ipynb", ".platform", "assets/empty.bin"], parts.Select(static part => part.GetProperty("path").GetString()));
|
||||
Assert.Equal(notebook, Convert.FromBase64String(parts[0].GetProperty("payload").GetString()!));
|
||||
Assert.Equal(platform, Convert.FromBase64String(parts[1].GetProperty("payload").GetString()!));
|
||||
Assert.Equal("", parts[2].GetProperty("payload").GetString());
|
||||
Assert.False(body.RootElement.GetProperty("options").GetProperty("futureSetting").GetBoolean());
|
||||
Assert.DoesNotContain("private-", body.RootElement.GetRawText());
|
||||
Assert.DoesNotContain(directory.Path, body.RootElement.GetRawText());
|
||||
return new(HttpStatusCode.OK);
|
||||
});
|
||||
await using var provider = CreateServices(handler, FabricOperationTestData.Credential());
|
||||
var arguments = Arguments(inline: false);
|
||||
arguments["definition-directory"] = directory.Path;
|
||||
arguments["definition-files"] = """
|
||||
[{"partPath":"artifact.content.ipynb","sourcePath":"notebooks/sales.ipynb"},{"partPath":".platform"},{"partPath":"assets/empty.bin","sourcePath":"empty.bin"}]
|
||||
""";
|
||||
arguments["definition-format"] = "ipynb";
|
||||
arguments["options"] = """{"futureSetting":false}""";
|
||||
if (metadata is not null)
|
||||
{
|
||||
arguments["update-metadata"] = metadata;
|
||||
}
|
||||
|
||||
var result = await Loader(provider, route).CallToolHandler(Request(route, arguments), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
Assert.Equal(notebook, await File.ReadAllBytesAsync(source, TestContext.Current.CancellationToken));
|
||||
Assert.Equal(platform, await File.ReadAllBytesAsync(Path.Combine(directory.Path, ".platform"), TestContext.Current.CancellationToken));
|
||||
Assert.False(File.Exists(Path.Combine(directory.Path, "artifact.content.ipynb")));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("all")]
|
||||
[InlineData("namespace")]
|
||||
[InlineData("single")]
|
||||
public async Task HttpFiles_AreRejectedBeforeFileAccessAuthenticationOrPost(string route)
|
||||
{
|
||||
using var handler = NoHttp();
|
||||
var credential = FabricOperationTestData.Credential();
|
||||
var files = Substitute.For<IFabricItemDefinitionFileService>();
|
||||
await using var provider = CreateServices(handler, credential, TransportTypes.Http, files: files);
|
||||
var arguments = Arguments(inline: false);
|
||||
arguments["definition-directory"] = "private-host-directory";
|
||||
arguments["definition-files"] = """[{"partPath":"content"}]""";
|
||||
|
||||
var result = await Loader(provider, route).CallToolHandler(Request(route, arguments), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.IsError);
|
||||
Assert.DoesNotContain("private-", Text(result));
|
||||
Assert.Empty(files.ReceivedCalls());
|
||||
Assert.Empty(credential.ReceivedCalls());
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DirectCommand_StillEnforcesTheIndependentServiceFileGuard()
|
||||
{
|
||||
using var handler = NoHttp();
|
||||
var credential = FabricOperationTestData.Credential();
|
||||
var files = Substitute.For<IFabricItemDefinitionFileService>();
|
||||
await using var provider = CreateServices(handler, credential, files: files);
|
||||
var command = provider.GetRequiredService<ItemDefinitionUpdateCommand>();
|
||||
provider.GetRequiredService<Microsoft.Extensions.Options.IOptions<ServerRuntimeConfiguration>>().Value.Transport = TransportTypes.Http;
|
||||
|
||||
var response = await command.ExecuteAsync(new(), new ItemDefinitionUpdateOptions
|
||||
{
|
||||
WorkspaceId = FabricOperationTestData.WorkspaceId,
|
||||
ItemId = FabricOperationTestData.ItemId,
|
||||
DefinitionInput = new()
|
||||
{
|
||||
DefinitionDirectory = "private-host-directory",
|
||||
DefinitionFiles = "private-invalid-json"
|
||||
}
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.Status);
|
||||
Assert.Contains("inline --definition", response.Message);
|
||||
Assert.DoesNotContain("private-", response.Message);
|
||||
Assert.Empty(files.ReceivedCalls());
|
||||
Assert.Empty(credential.ReceivedCalls());
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null, null, null)]
|
||||
[InlineData(Definition, "private-directory", """[{"partPath":"content"}]""", null)]
|
||||
[InlineData(Definition, null, null, "ipynb")]
|
||||
[InlineData(null, "private-directory", null, null)]
|
||||
[InlineData(null, null, """[{"partPath":"content"}]""", null)]
|
||||
[InlineData(null, null, null, "ipynb")]
|
||||
[InlineData(null, "private-directory", "[]", null)]
|
||||
[InlineData(null, "private-directory", """[{"partPath":"content","private-unknown":true}]""", null)]
|
||||
[InlineData(null, "private-directory", """[{"partPath":"content","partPath":"other"}]""", null)]
|
||||
public async Task InvalidSources_FailBeforeIo(string? definition, string? directory, string? filesJson, string? format)
|
||||
{
|
||||
using var handler = NoHttp();
|
||||
var credential = FabricOperationTestData.Credential();
|
||||
var files = Substitute.For<IFabricItemDefinitionFileService>();
|
||||
await using var provider = CreateServices(handler, credential, files: files);
|
||||
var arguments = Arguments(inline: false);
|
||||
arguments["definition"] = definition;
|
||||
arguments["definition-directory"] = directory;
|
||||
arguments["definition-files"] = filesJson;
|
||||
arguments["definition-format"] = format;
|
||||
|
||||
var result = await Loader(provider, "all").CallToolHandler(Request("all", arguments), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.IsError);
|
||||
Assert.DoesNotContain("private-", Text(result));
|
||||
Assert.Empty(files.ReceivedCalls());
|
||||
Assert.Empty(credential.ReceivedCalls());
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("private-invalid-json")]
|
||||
[InlineData("null")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("{}")]
|
||||
[InlineData("""{"definition":{"parts":[]}}""")]
|
||||
[InlineData("""{"parts":[]}""")]
|
||||
[InlineData("""{"parts":[null]}""")]
|
||||
[InlineData("""{"parts":[{"path":"content","payload":"e30="}]}""")]
|
||||
[InlineData("""{"parts":[{"path":"content","payload":"e30=","payloadType":"Uri"}]}""")]
|
||||
[InlineData("""{"parts":[{"path":"content","payload":"private-payload","payloadType":"InlineBase64"}]}""")]
|
||||
[InlineData("""{"parts":[{"path":"content","payload":"e30=","payloadType":"InlineBase64","private-field":true}]}""")]
|
||||
[InlineData("""{"parts":[{"path":"content","path":"other","payload":"e30=","payloadType":"InlineBase64"}]}""")]
|
||||
[InlineData("""{"parts":[{"path":"../private-file","payload":"e30=","payloadType":"InlineBase64"}]}""")]
|
||||
public async Task InvalidInlineInput_IsNotSilentlyAccepted(string definition)
|
||||
{
|
||||
using var handler = NoHttp();
|
||||
var credential = FabricOperationTestData.Credential();
|
||||
await using var provider = CreateServices(handler, credential);
|
||||
var arguments = Arguments();
|
||||
arguments["definition"] = definition;
|
||||
|
||||
var result = await Loader(provider, "all").CallToolHandler(Request("all", arguments), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.IsError);
|
||||
Assert.DoesNotContain("private-", Text(result));
|
||||
Assert.Empty(credential.ReceivedCalls());
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("../private-outside")]
|
||||
[InlineData("missing-private-file")]
|
||||
[InlineData("content:private-stream")]
|
||||
[InlineData("oversized")]
|
||||
public async Task UnsafeMissingOrOversizedFiles_FailWithoutARequest(string source)
|
||||
{
|
||||
using var directory = new FabricDefinitionTestDirectory();
|
||||
await File.WriteAllBytesAsync(Path.Combine(directory.Path, "oversized"), new byte[9], TestContext.Current.CancellationToken);
|
||||
using var handler = NoHttp();
|
||||
var credential = FabricOperationTestData.Credential();
|
||||
await using var provider = CreateServices(handler, credential, limits: new(MaxParts: 2, MaxPartBytes: 8, MaxTotalBytes: 8));
|
||||
var arguments = Arguments(inline: false);
|
||||
arguments["definition-directory"] = directory.Path;
|
||||
arguments["definition-files"] = $$"""[{"partPath":"content","sourcePath":"{{source}}"}]""";
|
||||
|
||||
var result = await Loader(provider, "all").CallToolHandler(Request("all", arguments), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.IsError);
|
||||
Assert.DoesNotContain(directory.Path, Text(result));
|
||||
Assert.DoesNotContain("private-", Text(result));
|
||||
Assert.Empty(credential.ReceivedCalls());
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("null")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("true")]
|
||||
[InlineData("{")]
|
||||
[InlineData("""{"setting":{"private-duplicate":1,"private-duplicate":2}}""")]
|
||||
public async Task InvalidOptions_AreValidatedBeforeReadingFiles(string json)
|
||||
{
|
||||
using var handler = NoHttp();
|
||||
var credential = FabricOperationTestData.Credential();
|
||||
var files = Substitute.For<IFabricItemDefinitionFileService>();
|
||||
await using var provider = CreateServices(handler, credential, files: files);
|
||||
var arguments = Arguments(inline: false);
|
||||
arguments["definition-directory"] = "private-path";
|
||||
arguments["definition-files"] = """[{"partPath":"content"}]""";
|
||||
arguments["options"] = json;
|
||||
|
||||
var result = await Loader(provider, "all").CallToolHandler(Request("all", arguments), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.IsError);
|
||||
Assert.Contains("--options", Text(result));
|
||||
Assert.DoesNotContain("private-", Text(result));
|
||||
Assert.Empty(files.ReceivedCalls());
|
||||
Assert.Empty(credential.ReceivedCalls());
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ascii", false)]
|
||||
[InlineData("ascii", true)]
|
||||
[InlineData("utf8", false)]
|
||||
[InlineData("utf8", true)]
|
||||
[InlineData("depth", false)]
|
||||
[InlineData("depth", true)]
|
||||
public async Task Options_EnforceExactByteAndDepthLimitsThroughTheRealCommand(string kind, bool exceed)
|
||||
{
|
||||
string json;
|
||||
if (kind == "depth")
|
||||
{
|
||||
var depth = FabricItemJsonInput.MaxObjectDepth + (exceed ? 1 : 0);
|
||||
json = "{\"data\":" + new string('[', depth - 1) + "0" + new string(']', depth - 1) + "}";
|
||||
}
|
||||
else
|
||||
{
|
||||
var character = kind == "utf8" ? '\u00e9' : 'x';
|
||||
var bytesPerCharacter = Encoding.UTF8.GetByteCount(character.ToString());
|
||||
var size = FabricItemJsonInput.MaxObjectBytes - 11;
|
||||
json = "{\"data\":\"" + new string(character, size / bytesPerCharacter) + new string('x', size % bytesPerCharacter) + "\"}";
|
||||
Assert.Equal(FabricItemJsonInput.MaxObjectBytes, Encoding.UTF8.GetByteCount(json));
|
||||
if (exceed)
|
||||
{
|
||||
json += " ";
|
||||
}
|
||||
}
|
||||
using var handler = new FabricCoreHttpMessageHandler(async (request, token) =>
|
||||
{
|
||||
using var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(token));
|
||||
using var expected = JsonDocument.Parse(json);
|
||||
Assert.True(JsonElement.DeepEquals(expected.RootElement, body.RootElement.GetProperty("options")));
|
||||
return new(HttpStatusCode.OK);
|
||||
});
|
||||
var credential = FabricOperationTestData.Credential();
|
||||
await using var provider = CreateServices(handler, credential, TransportTypes.Http);
|
||||
var arguments = Arguments();
|
||||
arguments["options"] = json;
|
||||
|
||||
var result = await Loader(provider, "all").CallToolHandler(Request("all", arguments), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(exceed, result.IsError);
|
||||
Assert.Equal(exceed ? 0 : 1, handler.CallCount);
|
||||
if (exceed)
|
||||
{
|
||||
Assert.Empty(credential.ReceivedCalls());
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, false)]
|
||||
[InlineData(null, true)]
|
||||
[InlineData("ipynb", false)]
|
||||
[InlineData("ipynb", true)]
|
||||
[InlineData("FabricGitSource", false)]
|
||||
[InlineData("FabricGitSource", true)]
|
||||
[InlineData("TMDL", false)]
|
||||
[InlineData("TMDL", true)]
|
||||
[InlineData("TMSL", false)]
|
||||
[InlineData("TMSL", true)]
|
||||
[InlineData("PBIR", false)]
|
||||
[InlineData("PBIR", true)]
|
||||
[InlineData("PBIR-Legacy", false)]
|
||||
[InlineData("PBIR-Legacy", true)]
|
||||
[InlineData("FutureFormat", false)]
|
||||
[InlineData("FutureFormat", true)]
|
||||
public async Task SafeArbitraryLayouts_PreserveFormatAndBytesWithoutLocalWorkloadRules(string? format, bool savedFiles)
|
||||
{
|
||||
using var directory = new FabricDefinitionTestDirectory();
|
||||
byte[] bytes = [0xff, 0x00, 0x42, 0x0d, 0x0a];
|
||||
var platform = Encoding.UTF8.GetBytes("""{"metadata":{"type":"Notebook"}}""");
|
||||
var definition = new FabricItemDefinition(
|
||||
[new("custom/payload.bin", Convert.ToBase64String(bytes), "InlineBase64"),
|
||||
new(".platform", Convert.ToBase64String(platform), "InlineBase64")], format);
|
||||
var json = JsonSerializer.Serialize(definition, CoreJsonContext.Default.FabricItemDefinition);
|
||||
if (savedFiles)
|
||||
{
|
||||
await File.WriteAllBytesAsync(Path.Combine(directory.Path, "saved.bin"), bytes, TestContext.Current.CancellationToken);
|
||||
await File.WriteAllBytesAsync(Path.Combine(directory.Path, ".platform"), platform, TestContext.Current.CancellationToken);
|
||||
}
|
||||
using var handler = new FabricCoreHttpMessageHandler(async (request, token) =>
|
||||
{
|
||||
Assert.Equal(HttpMethod.Post, request.Method);
|
||||
Assert.Equal(Url, request.RequestUri?.AbsoluteUri);
|
||||
using var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(token));
|
||||
Assert.Equal(["definition"], body.RootElement.EnumerateObject().Select(static property => property.Name));
|
||||
using var expected = JsonDocument.Parse(json);
|
||||
Assert.True(JsonElement.DeepEquals(expected.RootElement, body.RootElement.GetProperty("definition")));
|
||||
return new(HttpStatusCode.OK);
|
||||
});
|
||||
var credential = FabricOperationTestData.Credential();
|
||||
await using var provider = CreateServices(handler, credential, savedFiles ? TransportTypes.StdIo : TransportTypes.Http);
|
||||
var arguments = Arguments(inline: false);
|
||||
if (savedFiles)
|
||||
{
|
||||
arguments["definition-directory"] = directory.Path;
|
||||
arguments["definition-files"] = """[{"partPath":"custom/payload.bin","sourcePath":"saved.bin"},{"partPath":".platform"}]""";
|
||||
if (format is not null)
|
||||
{
|
||||
arguments["definition-format"] = format;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
arguments["definition"] = json;
|
||||
}
|
||||
|
||||
var result = await Loader(provider, "all").CallToolHandler(Request("all", arguments), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
await credential.Received(1).GetTokenAsync(Arg.Any<TokenRequestContext>(), Arg.Any<CancellationToken>());
|
||||
if (savedFiles)
|
||||
{
|
||||
Assert.Equal(bytes, await File.ReadAllBytesAsync(Path.Combine(directory.Path, "saved.bin"), TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("ipynb")]
|
||||
[InlineData("FabricGitSource")]
|
||||
[InlineData("TMDL")]
|
||||
[InlineData("TMSL")]
|
||||
[InlineData("PBIR")]
|
||||
[InlineData("PBIR-Legacy")]
|
||||
[InlineData("FutureFormat")]
|
||||
public async Task GenericGuards_RejectUnsafePathsInvalidBase64AndOversizePayloadsRegardlessOfFormat(string? format)
|
||||
{
|
||||
using var handler = NoHttp();
|
||||
var credential = FabricOperationTestData.Credential();
|
||||
await using var provider = CreateServices(handler, credential, TransportTypes.Http,
|
||||
limits: new(MaxParts: 2, MaxPartBytes: 2, MaxTotalBytes: 2));
|
||||
FabricItemDefinition[] invalid =
|
||||
[
|
||||
new([new("../private-path", "e30=", "InlineBase64")], format),
|
||||
new([new("content", "???=", "InlineBase64")], format),
|
||||
new([new("content", "e30=", "Uri")], format),
|
||||
new([new("content", Convert.ToBase64String([1, 2, 3]), "InlineBase64")], format),
|
||||
new([], format)
|
||||
];
|
||||
foreach (var definition in invalid)
|
||||
{
|
||||
var arguments = Arguments();
|
||||
arguments["definition"] = JsonSerializer.Serialize(definition, CoreJsonContext.Default.FabricItemDefinition);
|
||||
|
||||
var result = await Loader(provider, "all").CallToolHandler(Request("all", arguments), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.IsError);
|
||||
Assert.DoesNotContain("private-", Text(result));
|
||||
}
|
||||
Assert.Empty(credential.ReceivedCalls());
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("null", null)]
|
||||
[InlineData("true", true)]
|
||||
[InlineData("false", false)]
|
||||
[InlineData("\"true\"", true)]
|
||||
[InlineData("[\"false\"]", false)]
|
||||
public async Task UpdateMetadata_PreservesOmissionAndExplicitValues(string? value, bool? expected)
|
||||
{
|
||||
using var handler = new FabricCoreHttpMessageHandler((request, _) =>
|
||||
{
|
||||
var query = expected switch { true => "?updateMetadata=true", false => "?updateMetadata=false", _ => "" };
|
||||
Assert.Equal(Url + query, request.RequestUri?.AbsoluteUri);
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
});
|
||||
await using var provider = CreateServices(handler, FabricOperationTestData.Credential());
|
||||
var arguments = Arguments();
|
||||
if (value is not null)
|
||||
{
|
||||
using var json = JsonDocument.Parse(value);
|
||||
arguments["update-metadata"] = json.RootElement.Clone();
|
||||
}
|
||||
|
||||
var result = await Loader(provider, "all").CallToolHandler(Request("all", arguments), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("--update-metadata")]
|
||||
[InlineData("--update-metadata", "private-invalid")]
|
||||
[InlineData("--update-metadata", "true", "--update-metadata", "false")]
|
||||
[InlineData("--definition", Definition)]
|
||||
[InlineData("--options", "{}", "--options", "{}")]
|
||||
[InlineData("--creation-payload", "{}")]
|
||||
[InlineData("--folder-id", FabricOperationTestData.ItemId)]
|
||||
[InlineData("--sensitivity-label-settings", "{}")]
|
||||
[InlineData("--definition-item-type", "Notebook")]
|
||||
public async Task Cli_RejectsAmbiguousOrUnsupportedOptions(params string[] extra)
|
||||
{
|
||||
using var handler = NoHttp();
|
||||
var credential = FabricOperationTestData.Credential();
|
||||
await using var provider = CreateServices(handler, credential);
|
||||
var command = provider.GetRequiredService<ItemDefinitionUpdateCommand>();
|
||||
IBaseCommand executable = command;
|
||||
|
||||
var response = await executable.ExecuteAsync(new(), command.GetCommand().Parse(
|
||||
["--workspace-id", FabricOperationTestData.WorkspaceId, "--item-id", FabricOperationTestData.ItemId, "--definition", Definition, .. extra]),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.Status);
|
||||
Assert.DoesNotContain("private-", response.Message);
|
||||
Assert.Empty(credential.ReceivedCalls());
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("all", false)]
|
||||
[InlineData("namespace", false)]
|
||||
[InlineData("single", false)]
|
||||
[InlineData("all", true)]
|
||||
[InlineData("namespace", true)]
|
||||
[InlineData("single", true)]
|
||||
public async Task AcceptedUpdate_OnlyWaitsWhenRequestedAndNeverRetrievesAResult(string route, bool sync)
|
||||
{
|
||||
var clock = new OperationTestTimeProvider();
|
||||
using var handler = new FabricCoreHttpMessageHandler((request, _) =>
|
||||
{
|
||||
if (request.Method == HttpMethod.Post)
|
||||
{
|
||||
Assert.Equal(Url, request.RequestUri?.AbsoluteUri);
|
||||
return Task.FromResult(FabricOperationTestData.Accepted());
|
||||
}
|
||||
Assert.Equal($"/v1/operations/{FabricOperationTestData.OperationId}", request.RequestUri?.AbsolutePath);
|
||||
return Task.FromResult(FabricOperationTestData.JsonResponse("""{"status":"Succeeded"}"""));
|
||||
});
|
||||
await using var provider = CreateServices(handler, FabricOperationTestData.Credential(), TransportTypes.Http, clock: clock);
|
||||
var arguments = Arguments();
|
||||
arguments["sync"] = sync;
|
||||
var task = Loader(provider, route).CallToolHandler(Request(route, arguments), TestContext.Current.CancellationToken).AsTask();
|
||||
if (sync)
|
||||
{
|
||||
await clock.WaitForTimerAsync(3, TestContext.Current.CancellationToken);
|
||||
clock.Advance(3);
|
||||
}
|
||||
var result = await task.WaitAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var operation = Payload(result, route).GetProperty("operation");
|
||||
Assert.Equal(sync ? "Succeeded" : "Accepted", operation.GetProperty("status").GetString());
|
||||
Assert.Equal(FabricOperationTestData.OperationId, operation.GetProperty("operationId").GetString());
|
||||
Assert.Equal(sync ? 2 : 1, handler.CallCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData(StructuredOutputMode.Compact)]
|
||||
[InlineData(StructuredOutputMode.Duplicated)]
|
||||
public async Task CallerCancellation_PreservesTheAcceptedReferenceInErrorOutput(StructuredOutputMode? output)
|
||||
{
|
||||
using var caller = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
||||
var clock = new OperationTestTimeProvider();
|
||||
using var handler = new FabricCoreHttpMessageHandler((_, _) => Task.FromResult(FabricOperationTestData.Accepted()));
|
||||
await using var provider = CreateServices(handler, FabricOperationTestData.Credential(),
|
||||
TransportTypes.Http, output, clock: clock);
|
||||
var arguments = Arguments();
|
||||
arguments["sync"] = true;
|
||||
|
||||
var task = Loader(provider, "all").CallToolHandler(Request("all", arguments), caller.Token).AsTask();
|
||||
await clock.WaitForTimerAsync(3, TestContext.Current.CancellationToken);
|
||||
await caller.CancelAsync();
|
||||
var result = await task.WaitAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.IsError);
|
||||
Assert.Null(result.StructuredContent);
|
||||
var operation = Payload(result, "all").GetProperty("operation");
|
||||
Assert.Equal(FabricOperationTestData.OperationId, operation.GetProperty("operationId").GetString());
|
||||
Assert.Equal("CallerCanceled", operation.GetProperty("issue").GetProperty("code").GetString());
|
||||
using var response = JsonDocument.Parse(Text(result));
|
||||
Assert.Equal(408, response.RootElement.GetProperty("status").GetInt32());
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
Assert.Empty(clock.Timers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentCalls_KeepDefinitionOptionsAndMetadataOptInIndependent()
|
||||
{
|
||||
var bothStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var arrivals = 0;
|
||||
using var handler = new FabricCoreHttpMessageHandler(async (request, token) =>
|
||||
{
|
||||
using var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(token));
|
||||
var marker = body.RootElement.GetProperty("options").GetProperty("marker").GetString();
|
||||
var part = Assert.Single(body.RootElement.GetProperty("definition").GetProperty("parts").EnumerateArray());
|
||||
Assert.Equal(marker, Encoding.UTF8.GetString(Convert.FromBase64String(part.GetProperty("payload").GetString()!)));
|
||||
Assert.Equal(marker == "first" ? "?updateMetadata=true" : "", request.RequestUri?.Query);
|
||||
if (Interlocked.Increment(ref arrivals) == 2)
|
||||
{
|
||||
bothStarted.TrySetResult();
|
||||
}
|
||||
await bothStarted.Task.WaitAsync(TimeSpan.FromSeconds(10), token);
|
||||
return new(HttpStatusCode.OK);
|
||||
});
|
||||
await using var provider = CreateServices(handler, FabricOperationTestData.Credential(), TransportTypes.Http);
|
||||
var loader = Loader(provider, "all");
|
||||
|
||||
var results = await Task.WhenAll(new[] { "first", "second" }.Select(marker =>
|
||||
{
|
||||
var arguments = Arguments();
|
||||
arguments["definition"] = JsonSerializer.Serialize(new FabricItemDefinition(
|
||||
[new("content", Convert.ToBase64String(Encoding.UTF8.GetBytes(marker)), "InlineBase64")]), CoreJsonContext.Default.FabricItemDefinition);
|
||||
arguments["options"] = $$"""{"marker":"{{marker}}"}""";
|
||||
if (marker == "first")
|
||||
{
|
||||
arguments["update-metadata"] = true;
|
||||
}
|
||||
return loader.CallToolHandler(Request("all", arguments), TestContext.Current.CancellationToken).AsTask();
|
||||
}));
|
||||
|
||||
Assert.All(results, static result => Assert.False(result.IsError));
|
||||
Assert.Equal(2, handler.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadOnlyMode_HidesAndRejectsTheTool()
|
||||
{
|
||||
using var handler = NoHttp();
|
||||
var credential = FabricOperationTestData.Credential();
|
||||
await using var provider = CreateServices(handler, credential, readOnly: true);
|
||||
var loader = provider.GetRequiredService<CommandFactoryToolLoader>();
|
||||
var catalog = await loader.ListToolsHandler(McpTestUtilities.CreateToolListRequest(), TestContext.Current.CancellationToken);
|
||||
Assert.DoesNotContain(catalog.Tools, static tool => tool.Name == ToolName);
|
||||
|
||||
var result = await loader.CallToolHandler(Request("all", Arguments()), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.IsError);
|
||||
Assert.Empty(credential.ReceivedCalls());
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("accept", "accept", true)]
|
||||
[InlineData("accept", "reject", false)]
|
||||
[InlineData("decline", "accept", false)]
|
||||
[InlineData("cancel", "accept", false)]
|
||||
public async Task Replacement_RequiresDestructiveConsentWhenSupported(string action, string decision, bool accepted)
|
||||
{
|
||||
using var handler = new FabricCoreHttpMessageHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
|
||||
var credential = FabricOperationTestData.Credential();
|
||||
await using var provider = CreateServices(handler, credential, disableElicitation: false);
|
||||
var request = Request("all", Arguments());
|
||||
request.Server.ClientCapabilities.Returns(new ClientCapabilities { Elicitation = new() { Form = new() } });
|
||||
request.Server.SendRequestAsync(Arg.Any<JsonRpcRequest>(), Arg.Any<CancellationToken>()).Returns(new JsonRpcResponse
|
||||
{
|
||||
Id = new RequestId(1),
|
||||
Result = new JsonObject
|
||||
{
|
||||
["action"] = action,
|
||||
["content"] = new JsonObject { ["decision"] = decision }
|
||||
}
|
||||
});
|
||||
|
||||
var result = await Loader(provider, "all").CallToolHandler(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(!accepted, result.IsError);
|
||||
Assert.Equal(accepted ? 1 : 0, handler.CallCount);
|
||||
if (!accepted)
|
||||
{
|
||||
Assert.Empty(credential.ReceivedCalls());
|
||||
}
|
||||
await request.Server.Received(1).SendRequestAsync(
|
||||
Arg.Is<JsonRpcRequest>(message => message.Method == "elicitation/create"), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ Offline tests for the Fabric Core toolset.
|
||||
- **Options/DefinitionInputOptionsTests.cs**: AOT-aware optional source composition, unchanged flat names/types, operation-neutral descriptions, omitted groups, and independent per-invocation binding
|
||||
- **Validation/ItemCreateRequestInputTests.cs**: Optional folder UUID normalization, creation-payload/definition/options combinations, exact UTF-8 byte/depth limits, opaque workload values, strict typed label settings, source-generated serialization, omission/default preservation, and cancellation
|
||||
- **ItemCreateDefinitionMcpTests.cs**: Real registration and substituted HTTP/credentials across all/namespace/single routing, local and HTTP runtime configurations, and output modes; explicit saved-file packaging, HTTP option removal/rejection, creation properties on both input paths, validation before file access, no label-policy fallback, unchanged no-definition creation, and concurrent input isolation
|
||||
- **Commands/ItemDefinitionUpdateCommandTests.cs**: Update definition input validation before file access, wait/metadata/options binding, no-result receipts, sanitized errors, and cancellation receipts
|
||||
- **Services/FabricCoreServiceItemDefinitionUpdateTests.cs**: One normalized POST, complete definition/options preservation, metadata query omission/false/true, `.platform` semantics, empty 200 and accepted 202 handling, no-result polling, deadlines, read retries/failures, cancellation/disposal, and concurrent identity isolation
|
||||
- **ItemDefinitionUpdateMcpTests.cs**: Real registration across routes, transports, and output modes; inline/saved-file updates, unchanged arbitrary safe layouts and bytes for default/known/unknown formats, format-independent generic guards, HTTP file rejection and independent invocation-time service guard, exact options limits, source/JSON/file failures, metadata opt-in, read-only exclusion, and destructive consent
|
||||
- **Commands/WorkspaceAssignToCapacityCommandTests.cs**: Required GUID validation, submit-only 202/pending receipts, mutating annotations, sanitized errors, and caller cancellation
|
||||
- **Services/WorkspaceCapacityAssignmentServiceTests.cs**: One body-bearing POST, empty 202 responses, no polling or body reads, long-second retry-header compatibility, cancellation/disposal, and per-invocation credentials
|
||||
- **Commands/WorkspaceAssignToCapacityMcpTests.cs**: Accepted/pending receipts across all/namespace/single routing and output modes, including the existing single-proxy text fallback
|
||||
@@ -173,7 +176,7 @@ These checks are not an OS sandbox against hostile processes concurrently replac
|
||||
entries; the local filesystem and its permissions must be trusted.
|
||||
|
||||
Tools dedicated to disk reads or writes should declare `LocalRequired = true`, reusing the existing
|
||||
whole-tool HTTP restrictions. Create Item remains remote-capable: its registered command omits only
|
||||
whole-tool HTTP restrictions. Create Item and Update Item Definition remain remote-capable: each registered command omits only
|
||||
the saved-file options on HTTP hosts, and `FabricItemDefinitionInputService` separately rejects local
|
||||
input before parsing paths or calling the file service unless the configured transport is stdio.
|
||||
Command logic and the low-level file converter contain no transport checks. Inline content remains
|
||||
@@ -246,7 +249,9 @@ Registered-handler tests verify one POST and no hidden reads, while the LRO serv
|
||||
with/without definitions and the additional creation properties, synchronous completion, submit-only
|
||||
acceptance, and explicit waiting.
|
||||
|
||||
`ItemCreateRequestInput` prepares optional folder IDs, creation payloads, definition options, and
|
||||
`FabricItemJsonInput` supplies the shared content-independent bounded object parser for Create Item
|
||||
and Update Item Definition, with safe `FabricItemInputException` guidance. `ItemCreateRequestInput`
|
||||
prepares optional folder IDs, creation payloads, definition options, and
|
||||
sensitivity label settings before definition files can be read. Creation payloads conflict with every
|
||||
definition option; the API's top-level options object requires a definition and never controls LRO
|
||||
waiting. Workload JSON remains opaque `JsonElement` data with one-MiB UTF-8 input budgets, 32-level
|
||||
@@ -262,7 +267,7 @@ strategies are rejected, not silently discarded. Tests
|
||||
verify that errors cause no second POST, label omission, or strategy downgrade. Workload semantics,
|
||||
folder existence, and label availability/permissions are left to Fabric; there are no preflight calls.
|
||||
|
||||
Future API adapters also own LRO receipts, waiting and result retrieval. A 202 receipt is not a
|
||||
API adapters also own LRO receipts, waiting and any documented result retrieval. A 202 receipt is not a
|
||||
definition to export. Keep endpoint-specific result sizes separate from these file budgets:
|
||||
the existing `FabricOperationHttp` 1 MiB JSON limit is unchanged by this infrastructure.
|
||||
|
||||
@@ -304,6 +309,35 @@ Update Item coverage is offline only. Tests never call real Fabric APIs or mutat
|
||||
Registered-handler tests exercise stdio/HTTP configuration, not actual transports, real OBO authorization,
|
||||
service-principal/managed-identity access, or recorded playback.
|
||||
|
||||
## Item Definition Updates
|
||||
|
||||
`core_update-item-definition` requires known workspace/item UUIDs and exactly one complete definition
|
||||
source. It reuses `IFabricItemDefinitionInputService`, `IFabricItemDefinitionFileService`, the wire
|
||||
models, the shared `DefinitionInputOptions` group through `OptionContainer`, and shared validation
|
||||
without fetching or merging the old definition. Public input names stay flat and the absent group
|
||||
does not invent a source; Update requires a source while Create permits omission. There is no
|
||||
item-type hint or format-specific layout policy. Generic validation applies regardless of format;
|
||||
safe arbitrary layouts, supplied format strings, and payload bytes reach Fabric unchanged.
|
||||
`.platform` never supplies an inferred type, and no metadata preflight is performed.
|
||||
IDs, wait settings, and options are checked before file access. The service also validates typed
|
||||
definitions/options before authentication. Configured definition budgets are shared with file input.
|
||||
|
||||
The payload contains only `definition` and optional `options`. Nullable `update-metadata` preserves
|
||||
query omission, explicit false, and explicit true. Only true requests applying supplied `.platform`
|
||||
metadata; bytes are never rewritten or auto-selected. Sensitivity labels are outside this API.
|
||||
|
||||
The endpoint-specific `FabricOperationContract` explicitly has no result reader and accepts only
|
||||
empty 200 completion or 202 acceptance. A synchronous completion returns a `Succeeded` receipt;
|
||||
sync waiting after 202 reads state only and stops at `Succeeded` without `/result` or Get Item.
|
||||
Timeouts, cancellation, and read failures retain resumable receipts and never resubmit the POST.
|
||||
Generic LRO result-size limits are unchanged. Shared uncertain-outcome guidance now refers to the
|
||||
mutation rather than assuming every caller is creating an item.
|
||||
|
||||
Tests use isolated temporary files, substituted HTTP/credentials, and the existing virtual clock.
|
||||
Catalog and real server-startup coverage include the new schema without copying full catalogs.
|
||||
These checks do not perform live Fabric mutations, test real permissions, or establish recorded
|
||||
playback. The Create Item regression suite covers the shared parser extraction.
|
||||
|
||||
## Item Deletion
|
||||
|
||||
Delete Item retains the shared authenticated send's buffered `ResponseContentRead` default and uses `FabricCoreHttpHelpers.GetRetryAfter` without changing its nullable-header fallback, status/error wording, or uncertain-deletion outcomes. Tests reuse `FabricCoreHttpMessageHandler`, capture requests inside callbacks for disposal assertions, and retain `ItemDeleteTrackingContent` and all operation-specific cases.
|
||||
@@ -333,6 +367,9 @@ dotnet test --project tools\Fabric.Mcp.Tools.Core\tests\Fabric.Mcp.Tools.Core.Te
|
||||
# Run definition packaging/input and Create Item coverage
|
||||
dotnet test --project tools\Fabric.Mcp.Tools.Core\tests\Fabric.Mcp.Tools.Core.Tests\Fabric.Mcp.Tools.Core.Tests.csproj --filter-class '*FabricItemDefinition*' '*ItemCreate*' '*FabricOperationMcpTests'
|
||||
|
||||
# Run definition updates and shared definition/Create Item/LRO regressions
|
||||
dotnet test --project tools\Fabric.Mcp.Tools.Core\tests\Fabric.Mcp.Tools.Core.Tests\Fabric.Mcp.Tools.Core.Tests.csproj --filter-class '*ItemDefinitionUpdate*' '*ItemCreate*' '*FabricItemDefinition*' '*FabricOperation*' '*FabricCoreCatalogTests'
|
||||
|
||||
# Run LRO service, polling, command/schema, and Create Item integration tests
|
||||
dotnet test --project tools\Fabric.Mcp.Tools.Core\tests\Fabric.Mcp.Tools.Core.Tests\Fabric.Mcp.Tools.Core.Tests.csproj --filter-class '*FabricOperation*' '*ItemCreate*'
|
||||
|
||||
|
||||
+1
-1
@@ -217,7 +217,7 @@ public sealed class FabricCoreServiceItemCreateLroTests()
|
||||
var error = await Assert.ThrowsAsync<FabricOperationException>(() => CreateAsync(service, new(), TestContext.Current.CancellationToken));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadGateway, error.StatusCode);
|
||||
Assert.Contains("whether creation occurred", error.Message);
|
||||
Assert.Contains("whether the mutation completed", error.Message);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
}
|
||||
|
||||
|
||||
+524
@@ -0,0 +1,524 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Azure.Core;
|
||||
using Fabric.Mcp.Tools.Core.Models;
|
||||
using Fabric.Mcp.Tools.Core.Services;
|
||||
using Fabric.Mcp.Tools.Core.Tests.TestSupport;
|
||||
using Fabric.Mcp.Tools.Core.Validation;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Fabric.Mcp.Tools.Core.Tests.Services;
|
||||
|
||||
public sealed class FabricCoreServiceItemDefinitionUpdateTests()
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(false, false, null)]
|
||||
[InlineData(true, false, null)]
|
||||
[InlineData(false, true, null)]
|
||||
[InlineData(true, true, null)]
|
||||
[InlineData(false, false, false)]
|
||||
[InlineData(true, false, false)]
|
||||
[InlineData(false, true, false)]
|
||||
[InlineData(true, true, false)]
|
||||
[InlineData(false, false, true)]
|
||||
[InlineData(true, false, true)]
|
||||
[InlineData(false, true, true)]
|
||||
[InlineData(true, true, true)]
|
||||
public async Task Update_UsesOnePostAndOnlyRequestedStateReads(bool sync, bool accepted, bool? updateMetadata)
|
||||
{
|
||||
var clock = new OperationTestTimeProvider();
|
||||
var methods = new List<HttpMethod>();
|
||||
using var handler = new FabricCoreHttpMessageHandler(async (request, token) =>
|
||||
{
|
||||
methods.Add(request.Method);
|
||||
Assert.Equal("operation-test-token", request.Headers.Authorization?.Parameter);
|
||||
if (request.Method == HttpMethod.Post)
|
||||
{
|
||||
var query = updateMetadata switch { true => "?updateMetadata=true", false => "?updateMetadata=false", _ => "" };
|
||||
Assert.Equal(ItemDefinitionUpdateTestData.Url + query, request.RequestUri?.AbsoluteUri);
|
||||
using var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(token));
|
||||
Assert.Equal(["definition"], body.RootElement.EnumerateObject().Select(static property => property.Name));
|
||||
using var expected = JsonDocument.Parse(ItemDefinitionUpdateTestData.Definition);
|
||||
Assert.True(JsonElement.DeepEquals(expected.RootElement, body.RootElement.GetProperty("definition")));
|
||||
return accepted ? FabricOperationTestData.Accepted() : new(HttpStatusCode.OK);
|
||||
}
|
||||
Assert.Equal(HttpMethod.Get, request.Method);
|
||||
Assert.Equal($"{FabricEndpoints.FabricApiBaseUrl}/operations/{FabricOperationTestData.OperationId}", request.RequestUri?.AbsoluteUri);
|
||||
return FabricOperationTestData.JsonResponse("""{"status":"Succeeded"}""");
|
||||
});
|
||||
using var client = new HttpClient(handler);
|
||||
var credential = FabricOperationTestData.Credential();
|
||||
var service = new FabricCoreService(client, credential, clock);
|
||||
|
||||
var task = service.UpdateItemDefinitionAsync(
|
||||
$"{{{FabricOperationTestData.WorkspaceId.ToUpperInvariant()}}}", Guid.Parse(FabricOperationTestData.ItemId).ToString("N"),
|
||||
Request(), updateMetadata, new(Sync: sync), TestContext.Current.CancellationToken);
|
||||
if (sync && accepted)
|
||||
{
|
||||
await clock.WaitForTimerAsync(3, TestContext.Current.CancellationToken);
|
||||
clock.Advance(3);
|
||||
}
|
||||
var result = await task.WaitAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(accepted && !sync ? FabricOperationStatus.Accepted : FabricOperationStatus.Succeeded, result.Operation.Status);
|
||||
Assert.Equal(accepted ? Guid.Parse(FabricOperationTestData.OperationId) : null, result.Operation.OperationId);
|
||||
Assert.Equal(accepted && sync ? "Succeeded" : null, result.Operation.LastState?.Status);
|
||||
Assert.Equal(accepted && !sync ? 3d : null, result.Operation.RecommendedPollAfterSeconds);
|
||||
Assert.Equal(sync && accepted ? 2 : 1, handler.CallCount);
|
||||
Assert.Equal(1, methods.Count(static method => method == HttpMethod.Post));
|
||||
Assert.Empty(clock.Timers);
|
||||
await credential.Received(handler.CallCount).GetTokenAsync(
|
||||
Arg.Is<TokenRequestContext>(context => context.Scopes.SequenceEqual(FabricEndpoints.FabricScopes)), Arg.Any<CancellationToken>());
|
||||
using var output = JsonDocument.Parse(JsonSerializer.Serialize(result, CoreJsonContext.Default.ItemDefinitionUpdateCommandResult));
|
||||
Assert.Equal(["operation"], output.RootElement.EnumerateObject().Select(static property => property.Name));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, false)]
|
||||
[InlineData(false, false)]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(null, true)]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(true, true)]
|
||||
public async Task PlatformAndOpaqueOptions_ArePreservedWithoutMetadataInference(bool? updateMetadata, bool platform)
|
||||
{
|
||||
using var options = JsonDocument.Parse("""{"future":{"enabled":false,"empty":"","missing":null,"values":[9007199254740993,2.50]},"sync":true}""");
|
||||
var request = Request() with
|
||||
{
|
||||
Definition = new(platform
|
||||
? [new("content", "e30=", "InlineBase64"), new(".platform", Convert.ToBase64String(Encoding.UTF8.GetBytes("""{"metadata":{"type":"Notebook","displayName":"Changed"}}""")), "InlineBase64")]
|
||||
: [new("content", "e30=", "InlineBase64")], "FutureFormat"),
|
||||
Options = options.RootElement.Clone()
|
||||
};
|
||||
using var handler = new FabricCoreHttpMessageHandler(async (message, token) =>
|
||||
{
|
||||
using var body = JsonDocument.Parse(await message.Content!.ReadAsStringAsync(token));
|
||||
Assert.Equal(["definition", "options"], body.RootElement.EnumerateObject().Select(static property => property.Name).Order());
|
||||
using var expected = JsonDocument.Parse(JsonSerializer.Serialize(request, CoreJsonContext.Default.UpdateItemDefinitionRequest));
|
||||
Assert.True(JsonElement.DeepEquals(expected.RootElement, body.RootElement));
|
||||
return new(HttpStatusCode.OK);
|
||||
});
|
||||
using var client = new HttpClient(handler);
|
||||
var service = new FabricCoreService(client, FabricOperationTestData.Credential());
|
||||
|
||||
var result = await service.UpdateItemDefinitionAsync(FabricOperationTestData.WorkspaceId, FabricOperationTestData.ItemId,
|
||||
request, updateMetadata, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(FabricOperationStatus.Succeeded, result.Operation.Status);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", true)]
|
||||
[InlineData("00000000-0000-0000-0000-000000000000", true)]
|
||||
[InlineData("../private-path?x=1", true)]
|
||||
[InlineData("", false)]
|
||||
[InlineData("00000000-0000-0000-0000-000000000000", false)]
|
||||
[InlineData("https://private-host/item", false)]
|
||||
public async Task InvalidIds_FailBeforeAuthentication(string id, bool workspace)
|
||||
{
|
||||
using var handler = ItemDefinitionUpdateTestData.NoHttp();
|
||||
using var client = new HttpClient(handler);
|
||||
var credential = FabricOperationTestData.Credential();
|
||||
var service = new FabricCoreService(client, credential);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.UpdateItemDefinitionAsync(
|
||||
workspace ? id : FabricOperationTestData.WorkspaceId, workspace ? FabricOperationTestData.ItemId : id,
|
||||
Request(), cancellationToken: TestContext.Current.CancellationToken));
|
||||
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
Assert.Empty(credential.ReceivedCalls());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, double.NaN)]
|
||||
[InlineData(true, double.PositiveInfinity)]
|
||||
[InlineData(false, 0)]
|
||||
[InlineData(true, -1)]
|
||||
[InlineData(false, 600.001)]
|
||||
[InlineData(true, 600.001)]
|
||||
public async Task InvalidWaitBudget_FailsBeforeAuthentication(bool sync, double seconds)
|
||||
{
|
||||
using var handler = ItemDefinitionUpdateTestData.NoHttp();
|
||||
using var client = new HttpClient(handler);
|
||||
var credential = FabricOperationTestData.Credential();
|
||||
var service = new FabricCoreService(client, credential);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => UpdateAsync(service, new(sync, seconds), TestContext.Current.CancellationToken));
|
||||
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
Assert.Empty(credential.ReceivedCalls());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("null")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("""{"private-duplicate":1,"private-duplicate":2}""")]
|
||||
[InlineData("""{"data":{"private-duplicate":1,"private-duplicate":2}}""")]
|
||||
public async Task DirectService_ValidatesTypedOptionsBeforeAuthentication(string json)
|
||||
{
|
||||
using var handler = ItemDefinitionUpdateTestData.NoHttp();
|
||||
using var client = new HttpClient(handler);
|
||||
var credential = FabricOperationTestData.Credential();
|
||||
var service = new FabricCoreService(client, credential);
|
||||
using var options = JsonDocument.Parse(json);
|
||||
|
||||
var error = await Assert.ThrowsAsync<FabricItemInputException>(() => service.UpdateItemDefinitionAsync(
|
||||
FabricOperationTestData.WorkspaceId, FabricOperationTestData.ItemId, Request() with { Options = options.RootElement.Clone() },
|
||||
cancellationToken: TestContext.Current.CancellationToken));
|
||||
|
||||
Assert.DoesNotContain("private-", error.Message);
|
||||
Assert.Empty(credential.ReceivedCalls());
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DirectService_ValidatesDefinitionWithSharedLimitsBeforeAuthentication()
|
||||
{
|
||||
using var handler = ItemDefinitionUpdateTestData.NoHttp();
|
||||
using var client = new HttpClient(handler);
|
||||
var credential = FabricOperationTestData.Credential();
|
||||
var service = new FabricCoreService(client, credential, definitionLimits: new(MaxParts: 1, MaxPartBytes: 1, MaxTotalBytes: 1));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidDataException>(() => UpdateAsync(service, new(), TestContext.Current.CancellationToken));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.UpdateItemDefinitionAsync(
|
||||
FabricOperationTestData.WorkspaceId, FabricOperationTestData.ItemId, new(new([])),
|
||||
cancellationToken: TestContext.Current.CancellationToken));
|
||||
|
||||
Assert.Empty(credential.ReceivedCalls());
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.Created, HttpStatusCode.BadGateway)]
|
||||
[InlineData(HttpStatusCode.NoContent, HttpStatusCode.BadGateway)]
|
||||
[InlineData(HttpStatusCode.Redirect, HttpStatusCode.Redirect)]
|
||||
[InlineData(HttpStatusCode.BadRequest, HttpStatusCode.BadRequest)]
|
||||
[InlineData(HttpStatusCode.Forbidden, HttpStatusCode.Forbidden)]
|
||||
[InlineData(HttpStatusCode.NotFound, HttpStatusCode.NotFound)]
|
||||
[InlineData(HttpStatusCode.TooManyRequests, HttpStatusCode.TooManyRequests)]
|
||||
[InlineData(HttpStatusCode.ServiceUnavailable, HttpStatusCode.ServiceUnavailable)]
|
||||
public async Task UnexpectedPostStatus_NeverRetriesReadsBackendContentOrFollowsLocation(HttpStatusCode status, HttpStatusCode expected)
|
||||
{
|
||||
using var handler = new FabricCoreHttpMessageHandler((_, _) =>
|
||||
{
|
||||
var response = new HttpResponseMessage(status) { Content = new UnreadableHttpContent() };
|
||||
response.Headers.Location = new("https://private-host/operation");
|
||||
return Task.FromResult(response);
|
||||
});
|
||||
using var client = new HttpClient(handler);
|
||||
var service = new FabricCoreService(client, FabricOperationTestData.Credential());
|
||||
|
||||
var error = await Assert.ThrowsAsync<FabricOperationException>(() => UpdateAsync(service, new(Sync: true), TestContext.Current.CancellationToken));
|
||||
|
||||
Assert.Equal(expected, error.StatusCode);
|
||||
Assert.DoesNotContain("private-", error.Message);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("null")]
|
||||
[InlineData("{}")]
|
||||
[InlineData("""{"id":"private-backend-data"}""")]
|
||||
[InlineData("{")]
|
||||
public async Task UnexpectedSynchronousBody_IsReportedWithoutReturningItOrReadingAResult(string body)
|
||||
{
|
||||
using var handler = new FabricCoreHttpMessageHandler((_, _) => Task.FromResult(FabricOperationTestData.JsonResponse(body)));
|
||||
using var client = new HttpClient(handler);
|
||||
var service = new FabricCoreService(client, FabricOperationTestData.Credential());
|
||||
|
||||
var result = await UpdateAsync(service, new(Sync: true), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(FabricOperationStatus.ResultUnavailable, result.Operation.Status);
|
||||
Assert.Equal((int)HttpStatusCode.BadGateway, result.Operation.Issue?.HttpStatus);
|
||||
Assert.DoesNotContain("private-", JsonSerializer.Serialize(result, CoreJsonContext.Default.ItemDefinitionUpdateCommandResult));
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcceptedResponse_DisposesContentWithoutReadingIt()
|
||||
{
|
||||
using var content = new UnreadableHttpContent();
|
||||
using var handler = new FabricCoreHttpMessageHandler((_, _) =>
|
||||
{
|
||||
var response = FabricOperationTestData.Accepted();
|
||||
response.Content = content;
|
||||
return Task.FromResult(response);
|
||||
});
|
||||
using var client = new HttpClient(handler);
|
||||
var service = new FabricCoreService(client, FabricOperationTestData.Credential());
|
||||
|
||||
var result = await UpdateAsync(service, new(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(FabricOperationStatus.Accepted, result.Operation.Status);
|
||||
Assert.True(content.IsDisposed);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThrottledInitiatingPost_DoesNotEnterTheReadRetryLoop()
|
||||
{
|
||||
using var content = new UnreadableHttpContent();
|
||||
using var handler = new FabricCoreHttpMessageHandler((_, _) =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests) { Content = content };
|
||||
response.Headers.RetryAfter = new(TimeSpan.FromSeconds(9));
|
||||
return Task.FromResult(response);
|
||||
});
|
||||
using var client = new HttpClient(handler);
|
||||
var service = new FabricCoreService(client, FabricOperationTestData.Credential());
|
||||
|
||||
var error = await Assert.ThrowsAsync<FabricThrottledException>(() =>
|
||||
UpdateAsync(service, new(Sync: true), TestContext.Current.CancellationToken));
|
||||
|
||||
Assert.Equal(TimeSpan.FromSeconds(9), error.RetryAfter.Delta);
|
||||
Assert.True(content.IsDisposed);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcceptedWithoutOperationId_DoesNotRecoverFromLocationOrResubmit()
|
||||
{
|
||||
using var handler = new FabricCoreHttpMessageHandler((_, _) =>
|
||||
{
|
||||
var response = FabricOperationTestData.Accepted(operationId: null);
|
||||
response.Headers.Location = new($"{FabricEndpoints.FabricApiBaseUrl}/operations/{FabricOperationTestData.OperationId}");
|
||||
return Task.FromResult(response);
|
||||
});
|
||||
using var client = new HttpClient(handler);
|
||||
var service = new FabricCoreService(client, FabricOperationTestData.Credential());
|
||||
|
||||
var result = await UpdateAsync(service, new(Sync: true), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(FabricOperationStatus.AcceptedWithoutOperationId, result.Operation.Status);
|
||||
Assert.Null(result.Operation.OperationId);
|
||||
Assert.Equal("MissingOperationId", result.Operation.Issue?.Code);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task WaitDeadline_PreservesUnknownStateWithoutAnotherPostOrRead(bool early)
|
||||
{
|
||||
var clock = new OperationTestTimeProvider();
|
||||
var requests = new List<HttpMethod>();
|
||||
using var handler = new FabricCoreHttpMessageHandler((request, _) =>
|
||||
{
|
||||
requests.Add(request.Method);
|
||||
return Task.FromResult(request.Method == HttpMethod.Post
|
||||
? FabricOperationTestData.Accepted()
|
||||
: FabricOperationTestData.JsonResponse("""{"status":"FutureStatus","percentComplete":60}""", retryAfter: "600"));
|
||||
});
|
||||
using var client = new HttpClient(handler);
|
||||
var service = new FabricCoreService(client, FabricOperationTestData.Credential(), clock);
|
||||
|
||||
var task = UpdateAsync(service, new(Sync: true, MaxWaitSeconds: 600, EarlyPoll: early), TestContext.Current.CancellationToken);
|
||||
var firstDelay = early ? 3 : 20;
|
||||
await clock.WaitForTimerAsync(firstDelay, TestContext.Current.CancellationToken);
|
||||
clock.Advance(firstDelay);
|
||||
await clock.WaitForTimerAsync(600 - firstDelay, TestContext.Current.CancellationToken);
|
||||
clock.Advance(600 - firstDelay);
|
||||
var result = await task.WaitAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(FabricOperationStatus.Pending, result.Operation.Status);
|
||||
Assert.Equal("FutureStatus", result.Operation.LastState?.Status);
|
||||
Assert.Equal(60, result.Operation.LastState?.PercentComplete);
|
||||
Assert.Equal("WaitBudgetExpired", result.Operation.Issue?.Code);
|
||||
Assert.Equal([HttpMethod.Post, HttpMethod.Get], requests);
|
||||
Assert.Empty(clock.Timers);
|
||||
clock.Advance(600);
|
||||
Assert.Equal(2, handler.CallCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.TooManyRequests)]
|
||||
[InlineData(HttpStatusCode.ServiceUnavailable)]
|
||||
public async Task RetryableStateFailure_OnlyRetriesStateAndHonorsTheHint(HttpStatusCode status)
|
||||
{
|
||||
var clock = new OperationTestTimeProvider();
|
||||
var reads = 0;
|
||||
using var handler = new FabricCoreHttpMessageHandler((request, _) =>
|
||||
{
|
||||
if (request.Method == HttpMethod.Post)
|
||||
{
|
||||
return Task.FromResult(FabricOperationTestData.Accepted());
|
||||
}
|
||||
Assert.Equal($"/v1/operations/{FabricOperationTestData.OperationId}", request.RequestUri?.AbsolutePath);
|
||||
reads++;
|
||||
return Task.FromResult(reads == 1
|
||||
? FabricOperationTestData.JsonResponse("""{"message":"private-error"}""", status, "9")
|
||||
: FabricOperationTestData.JsonResponse("""{"status":"Succeeded"}"""));
|
||||
});
|
||||
using var client = new HttpClient(handler);
|
||||
var service = new FabricCoreService(client, FabricOperationTestData.Credential(), clock);
|
||||
|
||||
var task = UpdateAsync(service, new(Sync: true), TestContext.Current.CancellationToken);
|
||||
await clock.WaitForTimerAsync(3, TestContext.Current.CancellationToken);
|
||||
clock.Advance(3);
|
||||
await clock.WaitForTimerAsync(9, TestContext.Current.CancellationToken);
|
||||
clock.Advance(9);
|
||||
var result = await task.WaitAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(FabricOperationStatus.Succeeded, result.Operation.Status);
|
||||
Assert.Null(result.Operation.Issue);
|
||||
Assert.Equal(3, handler.CallCount);
|
||||
Assert.Equal(2, reads);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("failed", FabricOperationStatus.Failed)]
|
||||
[InlineData("forbidden", FabricOperationStatus.TrackingStopped)]
|
||||
[InlineData("timeout", FabricOperationStatus.TrackingStopped)]
|
||||
public async Task StateFailures_PreserveReceiptAndNeverReadAResult(string failure, FabricOperationStatus expected)
|
||||
{
|
||||
var clock = new OperationTestTimeProvider();
|
||||
using var handler = new FabricCoreHttpMessageHandler((request, _) =>
|
||||
{
|
||||
if (request.Method == HttpMethod.Post)
|
||||
{
|
||||
return Task.FromResult(FabricOperationTestData.Accepted());
|
||||
}
|
||||
Assert.Equal($"/v1/operations/{FabricOperationTestData.OperationId}", request.RequestUri?.AbsolutePath);
|
||||
return failure switch
|
||||
{
|
||||
"failed" => Task.FromResult(FabricOperationTestData.JsonResponse("""{"status":"Failed","error":{"errorCode":"InvalidDefinition","message":"private-content"}}""")),
|
||||
"forbidden" => Task.FromResult(FabricOperationTestData.JsonResponse("""{"message":"private-content"}""", HttpStatusCode.Forbidden)),
|
||||
_ => throw new TimeoutException("private-content")
|
||||
};
|
||||
});
|
||||
using var client = new HttpClient(handler);
|
||||
var service = new FabricCoreService(client, FabricOperationTestData.Credential(), clock);
|
||||
|
||||
var task = UpdateAsync(service, new(Sync: true), TestContext.Current.CancellationToken);
|
||||
await clock.WaitForTimerAsync(3, TestContext.Current.CancellationToken);
|
||||
clock.Advance(3);
|
||||
var result = await task.WaitAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(expected, result.Operation.Status);
|
||||
Assert.Equal(Guid.Parse(FabricOperationTestData.OperationId), result.Operation.OperationId);
|
||||
Assert.DoesNotContain("private-content", JsonSerializer.Serialize(result, CoreJsonContext.Default.ItemDefinitionUpdateCommandResult));
|
||||
Assert.Equal(2, handler.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallerCancellation_StopsOnlyLocalWaitingAndRetainsAcceptance()
|
||||
{
|
||||
using var caller = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
||||
var clock = new OperationTestTimeProvider();
|
||||
using var handler = new FabricCoreHttpMessageHandler((_, _) => Task.FromResult(FabricOperationTestData.Accepted()));
|
||||
using var client = new HttpClient(handler);
|
||||
var credential = FabricOperationTestData.Credential();
|
||||
var service = new FabricCoreService(client, credential, clock);
|
||||
|
||||
var task = UpdateAsync(service, new(Sync: true), caller.Token);
|
||||
await clock.WaitForTimerAsync(3, TestContext.Current.CancellationToken);
|
||||
await caller.CancelAsync();
|
||||
var error = await Assert.ThrowsAsync<FabricOperationCanceledException>(() => task);
|
||||
|
||||
Assert.Equal(caller.Token, error.CancellationToken);
|
||||
Assert.Equal(Guid.Parse(FabricOperationTestData.OperationId), error.Operation.OperationId);
|
||||
Assert.Equal(FabricOperationStatus.TrackingStopped, error.Operation.Status);
|
||||
Assert.Equal("CallerCanceled", error.Operation.Issue?.Code);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
credential.ClearReceivedCalls();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => UpdateAsync(service, new(), caller.Token));
|
||||
Assert.Empty(credential.ReceivedCalls());
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitBudget_CancelsAnInFlightStateBodyAndDisposesIt()
|
||||
{
|
||||
var clock = new OperationTestTimeProvider();
|
||||
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using var stream = new OperationTestStream(Encoding.UTF8.GetBytes("""{"status":"Running"}"""), async (_, token) =>
|
||||
{
|
||||
started.TrySetResult();
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, token);
|
||||
});
|
||||
using var handler = new FabricCoreHttpMessageHandler((request, _) =>
|
||||
{
|
||||
if (request.Method == HttpMethod.Post)
|
||||
{
|
||||
return Task.FromResult(FabricOperationTestData.Accepted());
|
||||
}
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(stream) };
|
||||
response.Content.Headers.ContentType = new("application/json");
|
||||
return Task.FromResult(response);
|
||||
});
|
||||
using var client = new HttpClient(handler);
|
||||
var service = new FabricCoreService(client, FabricOperationTestData.Credential(), clock);
|
||||
|
||||
var task = UpdateAsync(service, new(Sync: true, MaxWaitSeconds: 4), TestContext.Current.CancellationToken);
|
||||
await clock.WaitForTimerAsync(3, TestContext.Current.CancellationToken);
|
||||
clock.Advance(3);
|
||||
await started.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
clock.Advance(1);
|
||||
var result = await task.WaitAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(FabricOperationStatus.Pending, result.Operation.Status);
|
||||
Assert.Equal("WaitBudgetExpired", result.Operation.Issue?.Code);
|
||||
Assert.True(stream.Disposed);
|
||||
Assert.Equal(2, handler.CallCount);
|
||||
Assert.Empty(clock.Timers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentRequests_KeepCredentialsBodiesAndTargetsIndependent()
|
||||
{
|
||||
var identity = new AsyncLocal<string>();
|
||||
var credential = Substitute.For<TokenCredential>();
|
||||
credential.GetTokenAsync(Arg.Any<TokenRequestContext>(), Arg.Any<CancellationToken>())
|
||||
.Returns(_ => new AccessToken(identity.Value!, DateTimeOffset.MaxValue));
|
||||
var seen = new ConcurrentBag<string>();
|
||||
var bothStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var arrivals = 0;
|
||||
using var handler = new FabricCoreHttpMessageHandler(async (request, token) =>
|
||||
{
|
||||
var caller = request.Headers.Authorization!.Parameter!;
|
||||
using var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(token));
|
||||
Assert.Equal(caller, body.RootElement.GetProperty("options").GetProperty("caller").GetString());
|
||||
Assert.Equal(caller, Encoding.UTF8.GetString(Convert.FromBase64String(
|
||||
Assert.Single(body.RootElement.GetProperty("definition").GetProperty("parts").EnumerateArray()).GetProperty("payload").GetString()!)));
|
||||
Assert.Equal($"/v1/workspaces/{FabricOperationTestData.WorkspaceId}/items/{caller}/updateDefinition", request.RequestUri!.AbsolutePath);
|
||||
seen.Add(caller);
|
||||
if (Interlocked.Increment(ref arrivals) == 2)
|
||||
{
|
||||
bothStarted.TrySetResult();
|
||||
}
|
||||
await bothStarted.Task.WaitAsync(TimeSpan.FromSeconds(10), token);
|
||||
return new(HttpStatusCode.OK);
|
||||
});
|
||||
using var client = new HttpClient(handler);
|
||||
var service = new FabricCoreService(client, credential);
|
||||
|
||||
var results = await Task.WhenAll(new[] { FabricOperationTestData.ItemId, FabricOperationTestData.WorkspaceId }.Select(async id =>
|
||||
{
|
||||
identity.Value = id;
|
||||
using var options = JsonDocument.Parse($$"""{"caller":"{{id}}"}""");
|
||||
var definition = new FabricItemDefinition([new("content", Convert.ToBase64String(Encoding.UTF8.GetBytes(id)), "InlineBase64")]);
|
||||
return await service.UpdateItemDefinitionAsync(FabricOperationTestData.WorkspaceId, id,
|
||||
new(definition, options.RootElement.Clone()), cancellationToken: TestContext.Current.CancellationToken);
|
||||
}));
|
||||
|
||||
Assert.All(results, static result => Assert.Equal(FabricOperationStatus.Succeeded, result.Operation.Status));
|
||||
Assert.Equal(2, seen.Distinct().Count());
|
||||
Assert.Equal(2, handler.CallCount);
|
||||
await credential.Received(2).GetTokenAsync(Arg.Any<TokenRequestContext>(), Arg.Any<CancellationToken>());
|
||||
Assert.Null(client.DefaultRequestHeaders.Authorization);
|
||||
}
|
||||
|
||||
private static UpdateItemDefinitionRequest Request() => new(new([new("content", "e30=", "InlineBase64")]));
|
||||
|
||||
private static Task<ItemDefinitionUpdateCommandResult> UpdateAsync(
|
||||
FabricCoreService service, FabricOperationWaitOptions options, CancellationToken cancellationToken) =>
|
||||
service.UpdateItemDefinitionAsync(FabricOperationTestData.WorkspaceId, FabricOperationTestData.ItemId,
|
||||
Request(), waitOptions: options, cancellationToken: cancellationToken);
|
||||
}
|
||||
+1
-1
@@ -52,7 +52,7 @@ public sealed class FabricOperationRunnerTests()
|
||||
.HandleAsync(response, NoResultContract(), new(Sync: true), NoStateRead, NoResultRead, TestContext.Current.CancellationToken));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadGateway, error.StatusCode);
|
||||
Assert.Contains("whether creation occurred", error.Message);
|
||||
Assert.Contains("whether the mutation completed", error.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
using System.Text.Json;
|
||||
using Azure.Core;
|
||||
using Fabric.Mcp.Tools.Core.Models;
|
||||
using Fabric.Mcp.Tools.Core.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Mcp.Core.Areas;
|
||||
using Microsoft.Mcp.Core.Areas.Server;
|
||||
using Microsoft.Mcp.Core.Areas.Server.Commands.ToolLoading;
|
||||
using Microsoft.Mcp.Core.Areas.Server.Options;
|
||||
using Microsoft.Mcp.Core.Commands;
|
||||
using Microsoft.Mcp.Core.Configuration;
|
||||
using Microsoft.Mcp.Core.Services.Telemetry;
|
||||
using Microsoft.Mcp.Tests.Client.Helpers;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
using OptionsFactory = Microsoft.Extensions.Options.Options;
|
||||
|
||||
namespace Fabric.Mcp.Tools.Core.Tests.TestSupport;
|
||||
|
||||
internal static class ItemDefinitionUpdateTestData
|
||||
{
|
||||
internal const string ToolName = "core_update-item-definition";
|
||||
internal const string Definition = """{"parts":[{"path":"content","payload":"e30=","payloadType":"InlineBase64"}]}""";
|
||||
internal const string Url = $"{FabricEndpoints.FabricApiBaseUrl}/workspaces/{FabricOperationTestData.WorkspaceId}/items/{FabricOperationTestData.ItemId}/updateDefinition";
|
||||
|
||||
internal static ServiceProvider CreateServices(
|
||||
HttpMessageHandler handler,
|
||||
TokenCredential credential,
|
||||
string transport = TransportTypes.StdIo,
|
||||
StructuredOutputMode? output = StructuredOutputMode.Compact,
|
||||
IFabricItemDefinitionFileService? files = null,
|
||||
FabricItemDefinitionFileLimits? limits = null,
|
||||
TimeProvider? clock = null,
|
||||
bool readOnly = false,
|
||||
bool disableElicitation = true)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var setup = new FabricCoreSetup();
|
||||
setup.ConfigureServices(services);
|
||||
services.ConfigureHttpClientDefaults(builder => builder.ConfigurePrimaryHttpMessageHandler(() => handler));
|
||||
services.AddSingleton(credential);
|
||||
services.AddLogging();
|
||||
if (files is not null)
|
||||
{
|
||||
services.AddSingleton(files);
|
||||
}
|
||||
if (limits is not null)
|
||||
{
|
||||
services.AddSingleton(limits);
|
||||
}
|
||||
if (clock is not null)
|
||||
{
|
||||
services.AddSingleton(clock);
|
||||
}
|
||||
services.AddSingleton<IAreaSetup>(setup);
|
||||
services.AddSingleton(Substitute.For<ITelemetryService>());
|
||||
services.AddSingleton(OptionsFactory.Create(new McpServerConfiguration
|
||||
{
|
||||
RootCommandGroupName = "fabmcp",
|
||||
Name = "Fabric.Mcp.Server",
|
||||
ShortName = "fabric",
|
||||
DisplayName = "Microsoft Fabric MCP Server",
|
||||
Version = "1.0.0",
|
||||
Description = "Offline definition update tests",
|
||||
IsTelemetryEnabled = false
|
||||
}));
|
||||
services.AddSingleton(OptionsFactory.Create(new ServerRuntimeConfiguration
|
||||
{
|
||||
Namespace = ["core"],
|
||||
Transport = transport,
|
||||
StructuredOutputMode = output,
|
||||
ReadOnly = readOnly,
|
||||
DangerouslyDisableElicitation = disableElicitation
|
||||
}));
|
||||
services.AddSingleton<ICommandFactory, CommandFactory>();
|
||||
services.AddSingleton<ICommandContextAccessor, CommandContextAccessor>();
|
||||
services.AddSingleton<CommandFactoryToolLoader>();
|
||||
services.AddSingleton<NamespaceToolLoader>();
|
||||
services.AddSingleton<SingleProxyToolLoader>();
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
internal static IToolLoader Loader(IServiceProvider provider, string route) => route switch
|
||||
{
|
||||
"all" => provider.GetRequiredService<CommandFactoryToolLoader>(),
|
||||
"namespace" => provider.GetRequiredService<NamespaceToolLoader>(),
|
||||
"single" => provider.GetRequiredService<SingleProxyToolLoader>(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(route))
|
||||
};
|
||||
|
||||
internal static RequestContext<CallToolRequestParams> Request(string route, Dictionary<string, object?> arguments)
|
||||
{
|
||||
if (route == "all")
|
||||
{
|
||||
return McpTestUtilities.CreateToolCallRequest(ToolName, arguments);
|
||||
}
|
||||
var parameters = new Dictionary<string, object?>
|
||||
{
|
||||
["intent"] = "Replace the complete definition of the specified item",
|
||||
["command"] = ToolName,
|
||||
["parameters"] = arguments
|
||||
};
|
||||
if (route == "single")
|
||||
{
|
||||
parameters["tool"] = "core";
|
||||
}
|
||||
return McpTestUtilities.CreateToolCallRequest(route == "namespace" ? "core" : "fabric", parameters);
|
||||
}
|
||||
|
||||
internal static Dictionary<string, object?> Arguments(bool inline = true)
|
||||
{
|
||||
var arguments = new Dictionary<string, object?>
|
||||
{
|
||||
["workspace-id"] = FabricOperationTestData.WorkspaceId,
|
||||
["item-id"] = FabricOperationTestData.ItemId
|
||||
};
|
||||
if (inline)
|
||||
{
|
||||
arguments["definition"] = Definition;
|
||||
}
|
||||
return arguments;
|
||||
}
|
||||
|
||||
internal static string Text(CallToolResult result) => Assert.IsType<TextContentBlock>(Assert.Single(result.Content)).Text;
|
||||
|
||||
internal static JsonElement Payload(CallToolResult result, string route)
|
||||
{
|
||||
if (result.StructuredContent is { } structured)
|
||||
{
|
||||
return route == "namespace" ? structured.GetProperty("result") : structured;
|
||||
}
|
||||
using var document = JsonDocument.Parse(Text(result));
|
||||
return document.RootElement.GetProperty("results").Clone();
|
||||
}
|
||||
|
||||
internal static FabricCoreHttpMessageHandler NoHttp() =>
|
||||
new((_, _) => throw new InvalidOperationException("No HTTP request is expected."));
|
||||
}
|
||||
+13
-13
@@ -56,7 +56,7 @@ public sealed class ItemCreateRequestInputTests()
|
||||
var options = Options();
|
||||
options.FolderId = value;
|
||||
|
||||
var error = Assert.Throws<ItemCreateInputException>(() => CreateRequest(options));
|
||||
var error = Assert.Throws<FabricItemInputException>(() => CreateRequest(options));
|
||||
|
||||
Assert.Contains("--folder-id", error.Message);
|
||||
Assert.DoesNotContain("private-", error.Message);
|
||||
@@ -88,7 +88,7 @@ public sealed class ItemCreateRequestInputTests()
|
||||
break;
|
||||
}
|
||||
|
||||
var error = Assert.Throws<ItemCreateInputException>(() => CreateRequest(options));
|
||||
var error = Assert.Throws<FabricItemInputException>(() => CreateRequest(options));
|
||||
|
||||
Assert.Contains("--creation-payload cannot be combined", error.Message);
|
||||
}
|
||||
@@ -102,7 +102,7 @@ public sealed class ItemCreateRequestInputTests()
|
||||
options.Options = "{}";
|
||||
options.CreationPayload = withPayload ? "{}" : null;
|
||||
|
||||
var error = Assert.Throws<ItemCreateInputException>(() => CreateRequest(options));
|
||||
var error = Assert.Throws<FabricItemInputException>(() => CreateRequest(options));
|
||||
|
||||
Assert.Contains("--options requires", error.Message);
|
||||
}
|
||||
@@ -119,7 +119,7 @@ public sealed class ItemCreateRequestInputTests()
|
||||
Assert.Null(request.Definition);
|
||||
Assert.NotNull(request.CreationPayload);
|
||||
options.Options = "{}";
|
||||
var error = Assert.Throws<ItemCreateInputException>(() => CreateRequest(options));
|
||||
var error = Assert.Throws<FabricItemInputException>(() => CreateRequest(options));
|
||||
Assert.Contains("--options requires", error.Message);
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ public sealed class ItemCreateRequestInputTests()
|
||||
{
|
||||
foreach (var name in new[] { "creation-payload", "options", "sensitivity-label-settings" })
|
||||
{
|
||||
var error = Assert.Throws<ItemCreateInputException>(() => CreateRequest(WithJson(name, json)));
|
||||
var error = Assert.Throws<FabricItemInputException>(() => CreateRequest(WithJson(name, json)));
|
||||
|
||||
Assert.Contains("--" + name, error.Message);
|
||||
Assert.DoesNotContain("private-", error.Message);
|
||||
@@ -180,13 +180,13 @@ public sealed class ItemCreateRequestInputTests()
|
||||
const string prefix = "{\"data\":\"";
|
||||
const string suffix = "\"}";
|
||||
var bytesPerCharacter = Encoding.UTF8.GetByteCount(character.ToString());
|
||||
var contentBytes = ItemCreateRequestInput.MaxObjectBytes - prefix.Length - suffix.Length;
|
||||
var contentBytes = FabricItemJsonInput.MaxObjectBytes - prefix.Length - suffix.Length;
|
||||
var json = prefix + new string(character, contentBytes / bytesPerCharacter) +
|
||||
new string('x', contentBytes % bytesPerCharacter) + suffix;
|
||||
Assert.Equal(ItemCreateRequestInput.MaxObjectBytes, Encoding.UTF8.GetByteCount(json));
|
||||
Assert.Equal(FabricItemJsonInput.MaxObjectBytes, Encoding.UTF8.GetByteCount(json));
|
||||
|
||||
Assert.NotNull(CreateRequest(WithJson(name, json)));
|
||||
var error = Assert.Throws<ItemCreateInputException>(() => CreateRequest(WithJson(name, json + " ")));
|
||||
var error = Assert.Throws<FabricItemInputException>(() => CreateRequest(WithJson(name, json + " ")));
|
||||
|
||||
Assert.Contains("1048576 UTF-8 bytes", error.Message);
|
||||
}
|
||||
@@ -198,10 +198,10 @@ public sealed class ItemCreateRequestInputTests()
|
||||
{
|
||||
static string Nested(int depth) => "{\"data\":" + new string('[', depth - 1) + "0" + new string(']', depth - 1) + "}";
|
||||
|
||||
var request = CreateRequest(WithJson(name, Nested(ItemCreateRequestInput.MaxObjectDepth)));
|
||||
var request = CreateRequest(WithJson(name, Nested(FabricItemJsonInput.MaxObjectDepth)));
|
||||
Assert.NotEmpty(JsonSerializer.Serialize(request, CoreJsonContext.Default.CreateItemRequest));
|
||||
var error = Assert.Throws<ItemCreateInputException>(() =>
|
||||
CreateRequest(WithJson(name, Nested(ItemCreateRequestInput.MaxObjectDepth + 1))));
|
||||
var error = Assert.Throws<FabricItemInputException>(() =>
|
||||
CreateRequest(WithJson(name, Nested(FabricItemJsonInput.MaxObjectDepth + 1))));
|
||||
|
||||
Assert.Contains("32 nesting levels", error.Message);
|
||||
}
|
||||
@@ -242,7 +242,7 @@ public sealed class ItemCreateRequestInputTests()
|
||||
[InlineData("""{"LabelId":"b7b4f4d9-3f0d-4b3e-8f3d-4f6d3f4f3f4f"}""")]
|
||||
public static void InvalidLabelSettings_RejectRatherThanSilentlyDroppingOrWeakeningPolicy(string json)
|
||||
{
|
||||
var error = Assert.Throws<ItemCreateInputException>(() => CreateRequest(WithJson("sensitivity-label-settings", json)));
|
||||
var error = Assert.Throws<FabricItemInputException>(() => CreateRequest(WithJson("sensitivity-label-settings", json)));
|
||||
|
||||
Assert.DoesNotContain("private-", error.Message);
|
||||
}
|
||||
@@ -254,7 +254,7 @@ public sealed class ItemCreateRequestInputTests()
|
||||
var json = settings + new string(' ', ItemCreateRequestInput.MaxLabelSettingsBytes - settings.Length);
|
||||
|
||||
Assert.NotNull(CreateRequest(WithJson("sensitivity-label-settings", json)).SensitivityLabelSettings);
|
||||
var error = Assert.Throws<ItemCreateInputException>(() => CreateRequest(WithJson("sensitivity-label-settings", json + " ")));
|
||||
var error = Assert.Throws<FabricItemInputException>(() => CreateRequest(WithJson("sensitivity-label-settings", json + " ")));
|
||||
|
||||
Assert.Contains("4096 UTF-8 bytes", error.Message);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user