Compare commits

...

13 Commits

Author SHA1 Message Date
Jedd Morgan c25bb99e51 Optimization for the disk store string writing to avoid memory allocations
.NET Build and Publish / build (push) Has been cancelled
2026-03-30 21:28:53 +01:00
Jedd Morgan 7f50987201 Auth flow updates (#464)
.NET Build and Publish / build (push) Has been cancelled
* Auth flow updates

* remove obsolete flag on refresh
2026-03-26 14:42:21 +01:00
Jedd Morgan 7042cdb06a feat(auth): Add new authflow functions (#462)
.NET Build and Publish / build (push) Has been cancelled
* Auth functions

* Avoid Microsoft.BCL.Memory polyfil

* refactor updating accounts

* fix tests

* Fix tests

* better auth messages

* Remote changes

* Fix tests

* Fix tests

* ditto

* typo

* More tests

* fix exception types

* revert temp change to test url

* use new endpoint

* new endpoint

* Revert temp change to server url

* Fix mistakes in tests

* using

* extra tests

* test server url

* no need to repack bcl memory since it's not a dependencgy

* Avoid parallelising account manager tests

* potential fix

* disable parallelisation for authflow tests
2026-03-24 17:17:25 +00:00
Jedd Morgan af0fc9f669 Merge pull request #439 from specklesystems/duckdev
.NET Build and Publish / build (push) Has been cancelled
feat(packfile): duckdb packfile api (duckdev -> main)
2026-03-23 11:08:57 +00:00
Jedd Morgan edbc884d74 Clean diff 2026-03-23 10:45:57 +00:00
Jedd Morgan 025d7f70ba Merge branch 'main' into duckdev 2026-03-23 10:41:22 +00:00
Jedd Morgan 70acc06f37 feat(otel): Change sig for remote (#460)
.NET Build and Publish / build (push) Has been cancelled
* Remote changes

* Fix tests

* Fix tests

* ditto
2026-03-16 15:32:33 +00:00
Jedd Morgan a2c99a537a feat(otel): Add proper support for remote spans (#458)
.NET Build and Publish / build (push) Has been cancelled
* Add proper support for remote spans

* Fix tests
2026-03-12 12:52:10 +00:00
Jedd Morgan 906ff9c3ff test: duck tests (#455)
.NET Build and Publish / build (push) Has been cancelled
* lets get some testing action!

* revert url

* we'll try these fixes

* we'll try this, if it doesn't work I'm deleting the test

* try this

* Add optional parent id to traces

* how about now

* and as zero

* Fix build

* fix code cov

* FML subscription tests are so flaky
2026-03-11 16:30:24 +00:00
Jedd Morgan 515d45528d feat(otel): traces for new send pipeline (#457)
* 😃 extra traces

* default
2026-03-11 14:51:58 +00:00
dependabot[bot] abf86eda03 chore(deps): bump docker/login-action from 3 to 4 (#456)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-10 09:50:34 +00:00
Jedd Morgan f777050c10 change order of tab delimited json (#454)
.NET Build and Publish / build (push) Has been cancelled
2026-03-05 13:40:18 +00:00
Jedd Morgan c4e956cdb4 fix(build): Option 2 - "keep channels il-repacked" (#453)
* Repacked channel

* transitive for sdk

* restore solution

* comment tweaks

* Fix print line
2026-03-05 12:47:56 +00:00
52 changed files with 1345 additions and 921 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
- name: 🔐 Login to Github Container Registry
if: ${{ inputs.use-internal-image }}
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: "ghcr.io"
username: ${{ github.actor }}
+5 -1
View File
@@ -1,7 +1,11 @@
name: PR Test
on:
pull_request:
pull_request: {}
push:
branches:
- "main" # Need to run for codecov to compare against the BASE
jobs:
build:
-7
View File
@@ -46,13 +46,6 @@ jobs:
SEMVER: ${{ steps.set-version.outputs.SEMVER }}
FILE_VERSION: ${{ steps.set-version.outputs.FILE_VERSION }}
- name: Upload coverage reports to Codecov with GitHub Action
uses: codecov/codecov-action@v5
continue-on-error: true
with:
fail_ci_if_error: true
files: tests/**/coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
- name: NuGet login (OIDC → temp API key)
uses: NuGet/login@v1
+2 -1
View File
@@ -15,6 +15,7 @@
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="[2.2.0,)" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="[2.2.0,)" />
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="[9.0.4,)" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="[2.2.0,)" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="Newtonsoft.Json.Schema" Version="4.0.1" />
@@ -27,7 +28,7 @@
<PackageVersion Include="Speckle.DoubleNumerics" Version="4.1.0" />
<PackageVersion Include="SimpleExec" Version="12.0.0" />
<PackageVersion Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
<PackageVersion Include="System.Threading.Channels" Version="10.0.0" />
<PackageVersion Include="System.Threading.Channels" Version="9.0.4" />
<PackageVersion Include="Verify.Quibble" Version="2.1.1" />
<PackageVersion Include="Verify.Xunit" Version="29.4.0" />
<PackageVersion Include="System.Text.Json" Version="8.0.5" />
+19 -26
View File
@@ -86,14 +86,6 @@
"resolved": "6.0.0",
"contentHash": "yg72rrYDapfsIUrul7aF6wwNnTJBOFvuA9VdDTQpPa8AlAriHbufeXYLBcodKjfUdkCnaiggX1U/nEP08Zb5GA=="
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "10.0.0",
"contentHash": "vFuwSLj9QJBbNR0NeNO4YVASUbokxs+i/xbuu8B+Fs4FAZg5QaFa6eGrMaRqTzzNI5tAb97T7BhSxtLckFyiRA==",
"dependencies": {
"System.Threading.Tasks.Extensions": "4.6.3"
}
},
"Microsoft.Build.Tasks.Git": {
"type": "Transitive",
"resolved": "8.0.0",
@@ -250,8 +242,8 @@
},
"System.Runtime.CompilerServices.Unsafe": {
"type": "Transitive",
"resolved": "6.1.2",
"contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw=="
"resolved": "6.0.0",
"contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg=="
},
"System.Runtime.InteropServices.WindowsRuntime": {
"type": "Transitive",
@@ -273,10 +265,10 @@
},
"System.Threading.Tasks.Extensions": {
"type": "Transitive",
"resolved": "4.6.3",
"contentHash": "7sCiwilJLYbTZELaKnc7RecBBXWXA+xMLQWZKWawBxYjp6DBlSE3v9/UcvKBvr1vv2tTOhipiogM8rRmxlhrVA==",
"resolved": "4.5.4",
"contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==",
"dependencies": {
"System.Runtime.CompilerServices.Unsafe": "6.1.2"
"System.Runtime.CompilerServices.Unsafe": "4.5.3"
}
},
"speckle.objects": {
@@ -295,12 +287,14 @@
"Microsoft.Extensions.Logging": "[2.2.0, )",
"Speckle.DoubleNumerics": "[4.1.0, )",
"Speckle.Newtonsoft.Json": "[13.0.2, )",
"Speckle.Sdk.Dependencies": "[1.0.0, )",
"System.Threading.Channels": "[10.0.0, )"
"Speckle.Sdk.Dependencies": "[1.0.0, )"
}
},
"speckle.sdk.dependencies": {
"type": "Project"
"type": "Project",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "[9.0.4, )"
}
},
"GraphQL.Client": {
"type": "CentralTransitive",
@@ -313,6 +307,15 @@
"System.Reactive": "5.0.0"
}
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "CentralTransitive",
"requested": "[9.0.4, )",
"resolved": "9.0.4",
"contentHash": "9VGI5kxIvrNG2mqLQZnUR6y/3fcnygD8eNpHR+CqfbnIXvea6nehnYknDKQTxZVPMpzpNca+7DxLBmpdB3q0Bw==",
"dependencies": {
"System.Threading.Tasks.Extensions": "4.5.4"
}
},
"Microsoft.CSharp": {
"type": "CentralTransitive",
"requested": "[4.7.0, )",
@@ -358,16 +361,6 @@
"requested": "[13.0.2, )",
"resolved": "13.0.2",
"contentHash": "g1BejUZwax5PRfL6xHgLEK23sqHWOgOj9hE7RvfRRlN00AGt8GnPYt8HedSK7UB3HiRW8zCA9Pn0iiYxCK24BA=="
},
"System.Threading.Channels": {
"type": "CentralTransitive",
"requested": "[10.0.0, )",
"resolved": "10.0.0",
"contentHash": "fwRdkJpKisUEVNaEdsL5w5EwidzuVw0BOTfzDvYB1Yg8sq1pqNfUZxBOVFgSj6i6tNhpT3HP8BEDXf1+kFkTDA==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "10.0.0",
"System.Threading.Tasks.Extensions": "4.6.3"
}
}
},
"net8.0": {
+19 -26
View File
@@ -54,14 +54,6 @@
"resolved": "6.0.0",
"contentHash": "yg72rrYDapfsIUrul7aF6wwNnTJBOFvuA9VdDTQpPa8AlAriHbufeXYLBcodKjfUdkCnaiggX1U/nEP08Zb5GA=="
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "10.0.0",
"contentHash": "vFuwSLj9QJBbNR0NeNO4YVASUbokxs+i/xbuu8B+Fs4FAZg5QaFa6eGrMaRqTzzNI5tAb97T7BhSxtLckFyiRA==",
"dependencies": {
"System.Threading.Tasks.Extensions": "4.6.3"
}
},
"Microsoft.Build.Tasks.Git": {
"type": "Transitive",
"resolved": "8.0.0",
@@ -213,8 +205,8 @@
},
"System.Runtime.CompilerServices.Unsafe": {
"type": "Transitive",
"resolved": "6.1.2",
"contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw=="
"resolved": "4.5.3",
"contentHash": "3TIsJhD1EiiT0w2CcDMN/iSSwnNnsrnbzeVHSKkaEgV85txMprmuO+Yq2AdSbeVGcg28pdNDTPK87tJhX7VFHw=="
},
"System.Runtime.InteropServices.WindowsRuntime": {
"type": "Transitive",
@@ -226,10 +218,10 @@
},
"System.Threading.Tasks.Extensions": {
"type": "Transitive",
"resolved": "4.6.3",
"contentHash": "7sCiwilJLYbTZELaKnc7RecBBXWXA+xMLQWZKWawBxYjp6DBlSE3v9/UcvKBvr1vv2tTOhipiogM8rRmxlhrVA==",
"resolved": "4.5.4",
"contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==",
"dependencies": {
"System.Runtime.CompilerServices.Unsafe": "6.1.2"
"System.Runtime.CompilerServices.Unsafe": "4.5.3"
}
},
"speckle.sdk": {
@@ -242,12 +234,14 @@
"Microsoft.Extensions.Logging": "[2.2.0, )",
"Speckle.DoubleNumerics": "[4.1.0, )",
"Speckle.Newtonsoft.Json": "[13.0.2, )",
"Speckle.Sdk.Dependencies": "[1.0.0, )",
"System.Threading.Channels": "[10.0.0, )"
"Speckle.Sdk.Dependencies": "[1.0.0, )"
}
},
"speckle.sdk.dependencies": {
"type": "Project"
"type": "Project",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "[9.0.4, )"
}
},
"GraphQL.Client": {
"type": "CentralTransitive",
@@ -260,6 +254,15 @@
"System.Reactive": "5.0.0"
}
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "CentralTransitive",
"requested": "[9.0.4, )",
"resolved": "9.0.4",
"contentHash": "9VGI5kxIvrNG2mqLQZnUR6y/3fcnygD8eNpHR+CqfbnIXvea6nehnYknDKQTxZVPMpzpNca+7DxLBmpdB3q0Bw==",
"dependencies": {
"System.Threading.Tasks.Extensions": "4.5.4"
}
},
"Microsoft.CSharp": {
"type": "CentralTransitive",
"requested": "[4.7.0, )",
@@ -305,16 +308,6 @@
"requested": "[13.0.2, )",
"resolved": "13.0.2",
"contentHash": "g1BejUZwax5PRfL6xHgLEK23sqHWOgOj9hE7RvfRRlN00AGt8GnPYt8HedSK7UB3HiRW8zCA9Pn0iiYxCK24BA=="
},
"System.Threading.Channels": {
"type": "CentralTransitive",
"requested": "[10.0.0, )",
"resolved": "10.0.0",
"contentHash": "fwRdkJpKisUEVNaEdsL5w5EwidzuVw0BOTfzDvYB1Yg8sq1pqNfUZxBOVFgSj6i6tNhpT3HP8BEDXf1+kFkTDA==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "10.0.0",
"System.Threading.Tasks.Extensions": "4.6.3"
}
}
},
"net8.0": {
@@ -5,6 +5,7 @@ public interface ISdkActivity : IDisposable
void SetTag(string key, object? value);
void RecordException(Exception e);
string TraceId { get; }
string SpanId { get; }
void SetStatus(SdkActivityStatusCode code);
void InjectHeaders(Action<string, string> header);
@@ -1,8 +1,20 @@
using System.Runtime.CompilerServices;
using Speckle.Connectors.Logging;
namespace Speckle.Sdk.Logging;
public interface ISdkActivityFactory : IDisposable
{
ISdkActivity? Start(string? name = default, [CallerMemberName] string source = "");
ISdkActivity? Start(
string? name = null,
SdkActivityKind kind = SdkActivityKind.Internal,
[CallerMemberName] string source = ""
);
ISdkActivity? StartRemote(
string traceContext,
SdkActivityKind kind,
string? name = null,
[CallerMemberName] string source = ""
);
}
@@ -0,0 +1,49 @@
using System.Threading.Channels;
namespace Speckle.Sdk.Dependencies;
/// <summary>
/// For various reasons related to our use of ILRepack.FullAuto,
/// we cannot use Channels from the SDK project.
/// We have to keep usage of it inside the Sdk.Dependencies project.
///
/// For the sake of quick development, I've wrapped the <see cref="Channel"/> class here in a type
/// that is safe to use from the SDK project.
///
/// As and when we need more functions, we can add them here.
///
/// And yes... I'm not very happy about the way we've set this up
/// </summary>
/// <typeparam name="T"></typeparam>
public sealed class RepackedChannel<T>
{
private readonly Channel<T> _channel;
public RepackedChannel(int capacity, bool singleReader, bool singleWriter)
{
_channel = Channel.CreateBounded<T>(
new BoundedChannelOptions(capacity)
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = singleReader,
SingleWriter = singleWriter,
}
);
}
public void CompleteWriter() => _channel.Writer.Complete();
public ValueTask WriteAsync(T item, CancellationToken cancellationToken) =>
_channel.Writer.WriteAsync(item, cancellationToken);
public IAsyncEnumerable<T> ReadAllAsync(CancellationToken cancellationToken) =>
_channel.Reader.ReadAllAsync(cancellationToken);
// public async Task ReadAllAsync(Func<T, Task> callback, CancellationToken cancellationToken)
// {
// await foreach (T item in _channel.Reader.ReadAllAsync(cancellationToken))
// {
// await callback.Invoke(item).ConfigureAwait(false);
// }
// }
}
@@ -28,4 +28,36 @@
<PackageReference Include="Open.ChannelExtensions" PrivateAssets="all" />
<PackageReference Include="System.Threading.Channels" PrivateAssets="all" />
</ItemGroup>
<ItemGroup Condition=" '$(TargetFramework)' == 'netstandard2.0'">
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
</ItemGroup>
<Target Name="BeforeILRepackPrepareBuild" BeforeTargets="ILRepackPrepareBuild">
<ItemGroup>
<!--
We're Being selective about which assemblies we're il-repacking
Avoiding repacling `Microsoft.Bcl.AsyncInterfaces.dll` because we need types like `ValueTask` and `IAsyncEnumerable` to be external
Yes, this does beg the question, why are we using `IlRepack.FullAuto` instead of raw ILRepack. Well the truth is, I'd like to move away from FullAuto
since it's unmaintaned and is lagging behind ILRepack version.
-->
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)System.Numerics.Vectors.dll" />
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)System.Runtime.CompilerServices.Unsafe.dll" />
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)System.Memory.dll" />
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)Open.ChannelExtensions.dll" />
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)System.Threading.Channels.dll" />
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)System.Collections.Immutable.dll" />
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)Polly.dll" />
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)Polly.Contrib.WaitAndRetry.dll" />
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)Polly.Extensions.Http.dll" />
<_ILRepackIncludeAssemblies_Items Include="$(OutputPath)Microsoft.Extensions.ObjectPool.dll" />
<_ILRepackExcludeAssemblies_Items Include="$(OutputPath)*.dll" Exclude="@(_ILRepackIncludeAssemblies_Items)" />
</ItemGroup>
<Message
Text="These are the packages we are NOT ilrepacking '@(_ILRepackExcludeAssemblies_Items)'"
Importance="high"
/>
<PropertyGroup>
<ILRepackExcludeAssemblies>@(_ILRepackExcludeAssemblies_Items)</ILRepackExcludeAssemblies>
</PropertyGroup>
</Target>
</Project>
@@ -0,0 +1,30 @@
namespace Speckle.Connectors.Logging;
public enum SdkActivityKind
{
/// <summary>
/// Default value.
/// Indicates that the Activity represents an internal operation within an application, as opposed to an operations with remote parents or children.
/// </summary>
Internal = 0,
/// <summary>
/// Server activity represents request incoming from external component.
/// </summary>
Server = 1,
/// <summary>
/// Client activity represents outgoing request to the external component.
/// </summary>
Client = 2,
/// <summary>
/// Producer activity represents output provided to external components.
/// </summary>
Producer = 3,
/// <summary>
/// Consumer activity represents output received from an external component.
/// </summary>
Consumer = 4,
}
+22 -21
View File
@@ -11,6 +11,15 @@
"ILRepack": "2.0.33"
}
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Direct",
"requested": "[9.0.4, )",
"resolved": "9.0.4",
"contentHash": "9VGI5kxIvrNG2mqLQZnUR6y/3fcnygD8eNpHR+CqfbnIXvea6nehnYknDKQTxZVPMpzpNca+7DxLBmpdB3q0Bw==",
"dependencies": {
"System.Threading.Tasks.Extensions": "4.5.4"
}
},
"Microsoft.Extensions.ObjectPool": {
"type": "Direct",
"requested": "[9.0.4, )",
@@ -82,12 +91,12 @@
},
"System.Threading.Channels": {
"type": "Direct",
"requested": "[10.0.0, )",
"resolved": "10.0.0",
"contentHash": "fwRdkJpKisUEVNaEdsL5w5EwidzuVw0BOTfzDvYB1Yg8sq1pqNfUZxBOVFgSj6i6tNhpT3HP8BEDXf1+kFkTDA==",
"requested": "[9.0.4, )",
"resolved": "9.0.4",
"contentHash": "4qBn2H6/aXBpE/Pm3wY5yusY/pEvQz99NlWHrTUji0qCmOdbhhjaALcpmbfW2ksxlPM6i6S+QFLkpOQdyfeKYQ==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "10.0.0",
"System.Threading.Tasks.Extensions": "4.6.3"
"Microsoft.Bcl.AsyncInterfaces": "9.0.4",
"System.Threading.Tasks.Extensions": "4.5.4"
}
},
"ILRepack": {
@@ -95,14 +104,6 @@
"resolved": "2.0.33",
"contentHash": "xb2h1CsOepoYwdXEPui9VcQglwABQwNf9cccZbf+acarEzF5PUp8Xx71nFXIhOgEdm6wrxAoF6xAxK4m/XFRUQ=="
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "10.0.0",
"contentHash": "vFuwSLj9QJBbNR0NeNO4YVASUbokxs+i/xbuu8B+Fs4FAZg5QaFa6eGrMaRqTzzNI5tAb97T7BhSxtLckFyiRA==",
"dependencies": {
"System.Threading.Tasks.Extensions": "4.6.3"
}
},
"Microsoft.Build.Tasks.Git": {
"type": "Transitive",
"resolved": "8.0.0",
@@ -149,15 +150,15 @@
},
"System.Runtime.CompilerServices.Unsafe": {
"type": "Transitive",
"resolved": "6.1.2",
"contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw=="
"resolved": "6.0.0",
"contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg=="
},
"System.Threading.Tasks.Extensions": {
"type": "Transitive",
"resolved": "4.6.3",
"contentHash": "7sCiwilJLYbTZELaKnc7RecBBXWXA+xMLQWZKWawBxYjp6DBlSE3v9/UcvKBvr1vv2tTOhipiogM8rRmxlhrVA==",
"resolved": "4.5.4",
"contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==",
"dependencies": {
"System.Runtime.CompilerServices.Unsafe": "6.1.2"
"System.Runtime.CompilerServices.Unsafe": "4.5.3"
}
}
},
@@ -228,9 +229,9 @@
},
"System.Threading.Channels": {
"type": "Direct",
"requested": "[10.0.0, )",
"resolved": "10.0.0",
"contentHash": "fwRdkJpKisUEVNaEdsL5w5EwidzuVw0BOTfzDvYB1Yg8sq1pqNfUZxBOVFgSj6i6tNhpT3HP8BEDXf1+kFkTDA=="
"requested": "[9.0.4, )",
"resolved": "9.0.4",
"contentHash": "4qBn2H6/aXBpE/Pm3wY5yusY/pEvQz99NlWHrTUji0qCmOdbhhjaALcpmbfW2ksxlPM6i6S+QFLkpOQdyfeKYQ=="
},
"ILRepack": {
"type": "Transitive",
+2 -2
View File
@@ -154,10 +154,10 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
activity?.SetStatus(SdkActivityStatusCode.Ok);
return ret;
}
catch (Exception)
catch (Exception ex)
{
activity?.SetStatus(SdkActivityStatusCode.Error);
// Don't record exception as it's rethrown.
activity?.RecordException(ex);
throw;
}
}
@@ -14,7 +14,8 @@ public record ModelIngestionCreateInput(
string modelId,
string projectId,
string progressMessage,
SourceDataInput sourceData
SourceDataInput sourceData,
int? maxIdleTimeoutSeconds = null
);
public record ModelIngestionUpdateInput(string ingestionId, string projectId, string progressMessage, double? progress);
+2 -32
View File
@@ -1,11 +1,8 @@
using System.Runtime.InteropServices;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Common;
namespace Speckle.Sdk.Credentials;
[ClassInterface(ClassInterfaceType.AutoDual)]
[ComVisible(true)]
public class Account : IEquatable<Account>
{
private string _id;
@@ -37,6 +34,8 @@ public class Account : IEquatable<Account>
public string? refreshToken { get; set; }
public bool isDefault { get; set; }
[Obsolete("Not used in v3")]
public bool isOnline { get; set; } = true;
public ServerInfo serverInfo { get; set; }
@@ -101,33 +100,4 @@ public class Account : IEquatable<Account>
}
#endregion
internal const string LOCAL_IDENTIFIER_DEPRECATION_MESSAGE = "Local identifiers no longer nesseary";
/// <summary>
/// Retrieves the local identifier for the current user.
/// </summary>
/// <returns>
/// Returns a <see cref="Uri"/> object representing the local identifier for the current user.
/// The local identifier is created by appending the user ID as a query parameter to the server URL.
/// </returns>
/// <remarks>
/// Notice that the generated Uri is not intended to be used as a functioning Uri, but rather as a
/// unique identifier for a specific account in a local environment. The format of the Uri, containing a query parameter with the user ID,
/// serves this specific purpose. Therefore, it should not be used for forming network requests or
/// expecting it to lead to an actual webpage. The primary intent of this Uri is for unique identification in a Uri format.
/// </remarks>
/// <example>
/// This sample shows how to call the GetLocalIdentifier method.
/// <code>
/// Uri localIdentifier = GetLocalIdentifier();
/// Console.WriteLine(localIdentifier);
/// </code>
/// For a fictional `User ID: 123` and `Server: https://speckle.xyz`, the output might look like this:
/// <code>
/// https://speckle.xyz?id=123
/// </code>
/// </example>
[Obsolete(LOCAL_IDENTIFIER_DEPRECATION_MESSAGE)]
internal Uri GetLocalIdentifier() => new($"{serverInfo.url}?id={userInfo.id}");
}
+116 -544
View File
@@ -1,145 +1,35 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using GraphQL;
using GraphQL.Client.Http;
using Microsoft.Extensions.Logging;
using Speckle.InterfaceGenerator;
using Speckle.Newtonsoft.Json;
using Speckle.Sdk.Api.GraphQL;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Api.GraphQL.Models.Responses;
using Speckle.Sdk.Common;
using Speckle.Sdk.Helpers;
using Speckle.Sdk.Logging;
using Speckle.Sdk.SQLite;
using Stream = System.IO.Stream;
namespace Speckle.Sdk.Credentials;
public partial interface IAccountManager : IDisposable;
/// <summary>
/// Manage accounts locally for desktop applications.
/// Manages <see cref="Account"/> data in the local sqlite account store
/// </summary>
[GenerateAutoInterface]
public sealed class AccountManager(
ISpeckleApplication application,
ILogger<AccountManager> logger,
IGraphQLClientFactory graphQLClientFactory,
ISpeckleHttp speckleHttp,
IAccountFactory accountFactory,
IAuthFlow authFlow,
ISqLiteJsonCacheManagerFactory sqLiteJsonCacheManagerFactory
) : IAccountManager
{
public const string DEFAULT_SERVER_URL = "https://app.speckle.systems";
private readonly ISqLiteJsonCacheManager _accountStorage = sqLiteJsonCacheManagerFactory.CreateForUser("Accounts");
private static volatile bool s_isAddingAccount;
private readonly ISqLiteJsonCacheManager _accountAddLockStorage = sqLiteJsonCacheManagerFactory.CreateForUser(
"AccountAddFlow"
);
[AutoInterfaceIgnore]
public void Dispose()
{
_accountStorage.Dispose();
_accountAddLockStorage.Dispose();
}
/// <summary>
/// Gets the basic information about a server.
/// </summary>
/// <param name="server">Server Information</param>
/// <returns></returns>
/// <exception cref="GraphQLHttpRequestException">Request failed on the HTTP layer (received a non-successful response code)</exception>
/// <exception cref="AggregateException"><inheritdoc cref="GraphQLErrorHandler.EnsureGraphQLSuccess(IGraphQLResponse)"/></exception>
public async Task<ServerInfo> GetServerInfo(Uri server, CancellationToken cancellationToken = default)
{
using var gqlClient = graphQLClientFactory.CreateGraphQLClient(server, null);
//lang=graphql
const string QUERY_STRING = "query { serverInfo { name company migration { movedFrom movedTo } } }";
var request = new GraphQLRequest { Query = QUERY_STRING };
var response = await gqlClient.SendQueryAsync<ServerInfoResponse>(request, cancellationToken).ConfigureAwait(false);
response.EnsureGraphQLSuccess();
ServerInfo serverInfo = response.Data.serverInfo;
serverInfo.url = server.ToString().TrimEnd('/');
return response.Data.serverInfo;
}
/// <summary>
/// Gets basic user information given a token and a server.
/// </summary>
/// <param name="token"></param>
/// <param name="server">Server URL</param>
/// <returns></returns>
/// <exception cref="GraphQLHttpRequestException">Request failed on the HTTP layer (received a non-successful response code)</exception>
/// <exception cref="AggregateException"><inheritdoc cref="GraphQLErrorHandler.EnsureGraphQLSuccess(IGraphQLResponse)"/></exception>
public async Task<UserInfo> GetUserInfo(string token, Uri server, CancellationToken cancellationToken = default)
{
using var gqlClient = graphQLClientFactory.CreateGraphQLClient(server, token);
//language=graphql
const string QUERY = """
query {
data:activeUser {
name
email
id
company
}
}
""";
var request = new GraphQLRequest { Query = QUERY };
var response = await gqlClient
.SendQueryAsync<RequiredResponse<UserInfo>>(request, cancellationToken)
.ConfigureAwait(false);
response.EnsureGraphQLSuccess();
return response.Data.data;
}
/// <summary>
/// The Default Server URL for authentication, can be overridden by placing a file with the alternatrive url in the Speckle folder or with an ENV_VAR
/// </summary>
public Uri GetDefaultServerUrl()
{
var customServerUrl = "";
// first mechanism, check for local file
var customServerFile = Path.Combine(SpecklePathProvider.UserSpeckleFolderPath, "server");
if (File.Exists(customServerFile))
{
customServerUrl = File.ReadAllText(customServerFile);
}
// second mechanism, check ENV VAR
var customServerEnvVar = Environment.GetEnvironmentVariable("SPECKLE_SERVER");
if (!string.IsNullOrEmpty(customServerEnvVar))
{
customServerUrl = customServerEnvVar;
}
if (!string.IsNullOrEmpty(customServerUrl))
{
if (Uri.TryCreate(customServerUrl, UriKind.Absolute, out Uri? url))
{
return url;
}
}
return new Uri(DEFAULT_SERVER_URL);
}
/// <param name="id">The Id of the account to fetch</param>
@@ -151,37 +41,6 @@ public sealed class AccountManager(
?? throw new SpeckleAccountManagerException($"Account {id} not found");
}
/// <summary>
/// Upgrades an account from the account.serverInfo.movedFrom account to the account.serverInfo.movedTo account
/// </summary>
/// <param name="id">Id of the account to upgrade</param>
public void UpgradeAccount(string id)
{
Account account = GetAccount(id);
if (account.serverInfo.migration?.movedTo is not Uri upgradeUri)
{
throw new SpeckleAccountManagerException(
$"Server with url {account.serverInfo.url} does not have information about the upgraded server"
);
}
account.serverInfo.migration.movedTo = null;
account.serverInfo.migration.movedFrom = new Uri(account.serverInfo.url);
account.serverInfo.url = upgradeUri.ToString().TrimEnd('/');
// setting the id to null will force it to be recreated
account.id = null!; //TODO this is gross so remove when id is nullable
RemoveAccount(id);
_accountStorage.UpdateObject(account.id.NotNull(), JsonConvert.SerializeObject(account));
}
public IEnumerable<Account> GetAccounts(string serverUrl)
{
return GetAccounts(new Uri(serverUrl));
}
/// <summary>
/// Returns all unique accounts matching the serverUrl provided. If an account exists on more than one server,
/// typically because it has been migrated, then only the upgraded account (and therefore server) are returned.
@@ -245,7 +104,6 @@ public sealed class AccountManager(
static bool IsInvalid(Account ac) => ac.userInfo == null || ac.serverInfo == null;
var sqlAccounts = _accountStorage.GetAllObjects().Select(x => JsonConvert.DeserializeObject<Account>(x.Json));
var localAccounts = GetLocalAccounts();
foreach (var acc in sqlAccounts)
{
@@ -259,119 +117,55 @@ public sealed class AccountManager(
yield return acc;
}
}
foreach (var acc in localAccounts)
{
yield return acc;
}
}
/// <summary>
/// Gets the local accounts
/// These are accounts not handled by Manager and are stored in json format in a local directory
/// Refetches all local accounts (in local db), including <see cref="ServerInfo"/> and <see cref="UserInfo"/>.
/// If the <see cref="Account.token"/> looks to be expired, this function will also attempt to use the <see cref="Account.refreshToken"/> to refresh it.
/// Will write the changes to the local accounts db
/// </summary>
/// <returns></returns>
private IList<Account> GetLocalAccounts()
/// <seealso cref="UpdateAccount"/>
/// <param name="cancellationToken"></param>
/// <exception cref="AggregateException"></exception>
public async Task UpdateAccount(Account account, CancellationToken cancellationToken = default)
{
var accountsDir = SpecklePathProvider.AccountsFolderPath;
if (!Directory.Exists(accountsDir))
string oldAccountId = account.id;
await UpdateAccountInMemory(account, cancellationToken).ConfigureAwait(false);
if (oldAccountId != account.id)
{
return Array.Empty<Account>();
// ID may have changed, e.g. users email changed, or server url migrated
_accountStorage.DeleteObject(oldAccountId);
}
var accounts = new List<Account>();
string[] files = Directory.GetFiles(accountsDir, "*.json", SearchOption.AllDirectories);
foreach (var file in files)
{
try
{
var json = File.ReadAllText(file);
Account? account = JsonConvert.DeserializeObject<Account>(json);
if (
account is not null
&& !string.IsNullOrEmpty(account.token)
&& !string.IsNullOrEmpty(account.userInfo.id)
&& !string.IsNullOrEmpty(account.userInfo.email)
&& !string.IsNullOrEmpty(account.userInfo.name)
&& !string.IsNullOrEmpty(account.serverInfo.url)
&& !string.IsNullOrEmpty(account.serverInfo.name)
)
{
accounts.Add(account);
}
}
catch (Exception ex) when (!ex.IsFatal())
{
logger.LogWarning(ex, "Failed to load json account at {filePath}", file);
}
}
return accounts;
_accountStorage.UpdateObject(account.id, JsonConvert.SerializeObject(account));
}
/// <summary>
/// Refetches user and server info for each account
/// </summary>
/// <param name="app"> It is defaultAppId in the server. By default it is "sca" to not break existing parts that this function involves.</param>
/// <returns></returns>
public async Task UpdateAccounts(CancellationToken ct = default, string app = "sca")
{
// need to ToList() the GetAccounts call or the UpdateObject call at the end of this method
// will not work because sqlite does not support concurrent db calls
foreach (var account in GetAccounts().ToList())
{
try
{
Uri url = new(account.serverInfo.url);
var userServerInfo = await accountFactory.GetUserServerInfo(url, account.token, ct).ConfigureAwait(false);
//the token has expired
//TODO: once we get a token expired exception from the server use that instead
if (userServerInfo.activeUser == null || userServerInfo.serverInfo == null)
{
// We were initially was handling refresh token here bc quite a while ago server was returning null
// for activeUser and serverInfo instead of throwing exception. In short, our logic moved into catch block to cover both.
throw new SpeckleException("Token is expired");
}
account.isOnline = true;
account.userInfo = userServerInfo.activeUser;
account.serverInfo = userServerInfo.serverInfo;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex) when (!ex.IsFatal())
{
await RefreshAndSetAccountToken(account, app).ConfigureAwait(false);
}
ct.ThrowIfCancellationRequested();
_accountStorage.UpdateObject(account.id, JsonConvert.SerializeObject(account));
}
}
/// <summary>
/// Mutates the account with new tokens.
/// Refetches the <paramref name="account"/> information, including <see cref="ServerInfo"/> and <see cref="UserInfo"/>
///
/// Will only mutate <paramref name="account"/> in memory only, and only if successful.
/// </summary>
/// <seealso cref="UpdateAccount"/>
/// <param name="account"></param>
/// <param name="app"></param>
private async Task RefreshAndSetAccountToken(Account account, string app)
/// <param name="cancellationToken"></param>
/// <exception cref="GraphQLHttpRequestException"></exception>
public async Task UpdateAccountInMemory(Account account, CancellationToken cancellationToken = default)
{
try
Uri url = account.serverInfo.migration?.movedTo ?? new(account.serverInfo.url);
ActiveUserServerInfoResponse userServerInfo = await accountFactory
.GetUserServerInfo(url, account.token, cancellationToken)
.ConfigureAwait(false);
if (userServerInfo.activeUser == null)
{
Uri url = new(account.serverInfo.url);
var tokenResponse = await GetRefreshedToken(account.refreshToken, url, app).ConfigureAwait(false);
account.token = tokenResponse.token;
account.refreshToken = tokenResponse.refreshToken;
account.isOnline = true;
}
catch (Exception ex) when (!ex.IsFatal())
{
account.isOnline = false;
throw new SpeckleException("GraphQL response indicated that the ActiveUser could not be found");
}
account.userInfo = userServerInfo.activeUser;
account.serverInfo = userServerInfo.serverInfo;
//This is a bit gross, since id is not marked nullable
//but this will force re-generate the id (e.g. if the user's email, or servers url has changed)
account.id = null!;
}
/// <summary>
@@ -412,325 +206,103 @@ public sealed class AccountManager(
}
/// <summary>
/// Retrieves the local identifier for the specified account.
/// Adds an account to local storage by prompting the user to log in via their browser.
/// </summary>
/// <param name="account">The account for which to retrieve the local identifier.</param>
/// <returns>The local identifier for the specified account in the form of "SERVER_URL?u=USER_ID".</returns>
/// <remarks>
/// <inheritdoc cref="Account.GetLocalIdentifier"/>
/// </remarks>
[Obsolete(Account.LOCAL_IDENTIFIER_DEPRECATION_MESSAGE)]
public Uri? GetLocalIdentifierForAccount(Account account)
{
var identifier = account.GetLocalIdentifier();
// Validate account is stored locally
var searchResult = GetAccountForLocalIdentifier(identifier);
return searchResult == null ? null : identifier;
}
public async Task<UserInfo> Validate(Account account)
{
Uri server = new(account.serverInfo.url);
return await GetUserInfo(account.token, server).ConfigureAwait(false);
}
/// <summary>
/// Gets the account that corresponds to the given local identifier.
/// </summary>
/// <param name="localIdentifier">The local identifier of the account.</param>
/// <returns>The account that matches the local identifier, or null if no match is found.</returns>
[Obsolete(Account.LOCAL_IDENTIFIER_DEPRECATION_MESSAGE)]
public Account? GetAccountForLocalIdentifier(Uri localIdentifier)
{
var searchResult = GetAccounts()
.FirstOrDefault(acc =>
{
var id = acc.GetLocalIdentifier();
return id == localIdentifier;
});
return searchResult;
}
private Uri EnsureCorrectServerUrl(Uri? server)
{
var localUrl = server;
if (localUrl == null)
{
localUrl = GetDefaultServerUrl();
logger.LogDebug("The provided server url was null or empty. Changed to the default url {serverUrl}", localUrl);
}
return localUrl;
}
private void EnsureGetAccessCodeFlowIsSupported()
{
if (!HttpListener.IsSupported)
{
logger.LogError("HttpListener not supported");
throw new PlatformNotSupportedException("Your operating system is not supported");
}
}
private async Task<string> GetAccessCode(Uri server, string challenge, TimeSpan timeout)
{
EnsureGetAccessCodeFlowIsSupported();
logger.LogDebug("Starting auth process for {server}/authn/verify/sca/{challenge}", server, challenge);
var accessCode = "";
Process.Start(new ProcessStartInfo($"{server}/authn/verify/sca/{challenge}") { UseShellExecute = true });
var task = Task.Run(() =>
{
using var listener = new HttpListener();
var localUrl = "http://localhost:29363/";
listener.Prefixes.Add(localUrl);
listener.Start();
logger.LogDebug("Listening for auth redirects on {localUrl}", localUrl);
// Note: The GetContext method blocks while waiting for a request.
HttpListenerContext context = listener.GetContext();
HttpListenerRequest request = context.Request;
HttpListenerResponse response = context.Response;
accessCode = request.QueryString["access_code"];
logger.LogDebug("Got access code {accessCode}", accessCode);
string message =
accessCode != null
? "Success!<br/><br/>You can close this window now.<script>window.close();</script>"
: "Oups, something went wrong...!";
var responseString =
$"<HTML><BODY Style='background: linear-gradient(to top right, #ffffff, #c8e8ff); font-family: Roboto, sans-serif; font-size: 2rem; font-weight: 500; text-align: center;'><br/>{message}</BODY></HTML>";
byte[] buffer = Encoding.UTF8.GetBytes(responseString);
response.ContentLength64 = buffer.Length;
Stream output = response.OutputStream;
output.Write(buffer, 0, buffer.Length);
output.Close();
logger.LogDebug("Processed finished processing the access code");
listener.Stop();
listener.Close();
});
var completedTask = await Task.WhenAny(task, Task.Delay(timeout)).ConfigureAwait(false);
// this is means the task timed out
if (completedTask != task)
{
logger.LogWarning(
"Local auth flow failed to complete within the timeout window. Access code is {accessCode}",
accessCode
);
throw new AuthFlowException("Local auth flow failed to complete within the timeout window");
}
if (task.IsFaulted && task.Exception is not null)
{
logger.LogError(
task.Exception,
"Getting access code flow failed with {exceptionMessage}",
task.Exception.Message
);
throw new AuthFlowException($"Auth flow failed: {task.Exception.Message}", task.Exception);
}
// task completed within timeout
logger.LogInformation(
"Local auth flow completed successfully within the timeout window. Access code is {accessCode}",
accessCode
);
return accessCode;
}
private async Task<Account> CreateAccount(string accessCode, string challenge, Uri server)
{
try
{
var tokenResponse = await GetToken(accessCode, challenge, server).ConfigureAwait(false);
var account = await accountFactory
.CreateAccount(server, tokenResponse.token, tokenResponse.refreshToken)
.ConfigureAwait(false);
account.isDefault = !GetAccounts().Any();
logger.LogInformation("Successfully created account for {serverUrl}", server);
return account;
}
catch (Exception ex) when (!ex.IsFatal())
{
throw new SpeckleAccountManagerException("Failed to create account from access code and challenge", ex);
}
}
private void TryLockAccountAddFlow(TimeSpan timespan)
{
// use a static variable to quickly
// prevent launching this flow multiple times
if (s_isAddingAccount)
{
// this should probably throw with an error message
throw new SpeckleAccountFlowLockedException("The account add flow is already launched.");
}
// this uses the SQLite transport to store locks
var lockIds = _accountAddLockStorage.GetAllObjects().Select(x => x.Id).OrderByDescending(d => d).ToList();
var now = DateTime.Now;
foreach (var l in lockIds)
{
var lockArray = l.Split('@');
var lockName = lockArray.Length == 2 ? lockArray[0] : "the other app";
var lockTime =
lockArray.Length == 2
? DateTime.ParseExact(lockArray[1], "o", null)
: DateTime.ParseExact(lockArray[0], "o", null);
if (lockTime > now)
{
var lockString = string.Format("{0:mm} minutes {0:ss} seconds", lockTime - now);
throw new SpeckleAccountFlowLockedException(
$"The account add flow was already started in {lockName}, retry in {lockString}"
);
}
}
var lockId = application.ApplicationAndVersion + "@" + DateTime.Now.Add(timespan).ToString("o");
// using the lock release time as an id and value
// for ease of deletion and retrieval
_accountAddLockStorage.SaveObject(lockId, lockId);
s_isAddingAccount = true;
}
private void UnlockAccountAddFlow()
{
s_isAddingAccount = false;
// make sure all old locks are removed
foreach (var (id, _) in _accountAddLockStorage.GetAllObjects())
{
_accountAddLockStorage.DeleteObject(id);
}
}
/// <summary>
/// Adds an account by propting the user to log in via a web flow
/// </summary>
/// <param name="server">Server to use to add the account, if not provied the default Server will be used</param>
/// <example>
/// <code>
/// Account account = await AuthenticateAccount(new Uri("https://app.speckle.systems"), TimeSpan.FromMinutes(1));
/// </code>
/// </example>
/// <param name="serverUrl"></param>
/// <param name="timeout">Timeout for user to auth with browser, recommend 1 min timeout</param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task AddAccount(Uri? server = null)
public async Task<Account> AuthenticateAccount(Uri serverUrl, TimeSpan timeout, CancellationToken cancellationToken)
{
logger.LogDebug("Starting to add account for {serverUrl}", server);
logger.LogDebug("Starting to add account for {ServerUrl}", serverUrl);
server = EnsureCorrectServerUrl(server);
TokenExchangeResponse tokenResponse = await authFlow
.TriggerAuthFlowWithTimeout(serverUrl, AuthApp.ConnectorsV3, timeout, cancellationToken)
.ConfigureAwait(false);
// locking for 1 minute
var timeout = TimeSpan.FromMinutes(1);
// this is not part of the try finally block
// we do not want to clean up the existing locks
TryLockAccountAddFlow(timeout);
var challenge = GenerateChallenge();
return await CreateAndAddAccount(serverUrl, tokenResponse, cancellationToken).ConfigureAwait(false);
}
try
public async Task<Account> CreateAndAddAccount(
Uri serverUrl,
TokenExchangeResponse tokenResponse,
CancellationToken cancellationToken
)
{
var account = await accountFactory
.CreateAccount(serverUrl, tokenResponse.token, tokenResponse.refreshToken, cancellationToken)
.ConfigureAwait(false);
account.isDefault = !GetAccounts().Any();
_accountStorage.SaveObject(account.id, JsonConvert.SerializeObject(account));
logger.LogInformation("Successfully authenticated account {AccountId} for {ServerUrl}", account.id, serverUrl);
return account;
}
/// <summary>
/// The Default Server URL for authentication, can be overridden by placing a file with the alternative url in the Speckle folder or with an ENV_VAR
/// </summary>
[Obsolete("Unused")]
public Uri GetDefaultServerUrl()
{
var customServerUrl = "";
// first mechanism, check for local file
var customServerFile = Path.Combine(SpecklePathProvider.UserSpeckleFolderPath, "server");
if (File.Exists(customServerFile))
{
string accessCode = await GetAccessCode(server, challenge, timeout).ConfigureAwait(false);
if (string.IsNullOrEmpty(accessCode))
customServerUrl = File.ReadAllText(customServerFile);
}
// second mechanism, check ENV VAR
var customServerEnvVar = Environment.GetEnvironmentVariable("SPECKLE_SERVER");
if (!string.IsNullOrEmpty(customServerEnvVar))
{
customServerUrl = customServerEnvVar;
}
if (!string.IsNullOrEmpty(customServerUrl))
{
if (Uri.TryCreate(customServerUrl, UriKind.Absolute, out Uri? url))
{
throw new SpeckleAccountManagerException("Access code is invalid");
return url;
}
}
var account = await CreateAccount(accessCode, challenge, server).ConfigureAwait(false);
//if the account already exists it will not be added again
_accountStorage.SaveObject(account.id, JsonConvert.SerializeObject(account));
logger.LogDebug("Finished adding account {accountId} for {serverUrl}", account.id, server);
}
catch (SpeckleAccountManagerException ex)
{
logger.LogCritical(ex, "Failed to add account: {exceptionMessage}", ex.Message);
// rethrowing any known errors
throw;
}
catch (Exception ex) when (!ex.IsFatal())
{
logger.LogCritical(ex, "Failed to add account: {exceptionMessage}", ex.Message);
throw new SpeckleAccountManagerException($"Failed to add account: {ex.Message}", ex);
}
finally
{
UnlockAccountAddFlow();
}
return new Uri(DEFAULT_SERVER_URL);
}
private async Task<TokenExchangeResponse> GetToken(string accessCode, string challenge, Uri server)
[Obsolete("Use Uri overload")]
public IEnumerable<Account> GetAccounts(string serverUrl)
{
try
{
using var client = speckleHttp.CreateHttpClient();
var body = new
{
appId = "sca",
appSecret = "sca",
accessCode,
challenge,
};
using var content = new StringContent(JsonConvert.SerializeObject(body));
content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
var response = await client.PostAsync(new Uri(server, "/auth/token"), content).ConfigureAwait(false);
return JsonConvert
.DeserializeObject<TokenExchangeResponse>(await response.Content.ReadAsStringAsync().ConfigureAwait(false))
.NotNull();
}
catch (Exception ex) when (!ex.IsFatal())
{
throw new SpeckleException($"Failed to get authentication token from {server}", ex);
}
return GetAccounts(new Uri(serverUrl));
}
private async Task<TokenExchangeResponse> GetRefreshedToken(string? refreshToken, Uri server, string app = "sca")
{
try
{
using var client = speckleHttp.CreateHttpClient();
[Obsolete("Use UpdateAccount instead for more control over error handling", true)]
public Task UpdateAccounts(CancellationToken ct = default, string app = "sca") => throw new NotImplementedException();
var body = new
{
appId = app,
appSecret = app,
refreshToken,
};
[Obsolete("Use UpdateAccount instead", true)]
public void UpgradeAccount(string id) => throw new NotImplementedException();
using var content = new StringContent(JsonConvert.SerializeObject(body));
content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
var response = await client.PostAsync(new Uri(server, "/auth/token"), content).ConfigureAwait(false);
[Obsolete($"Use {nameof(AuthenticateAccount)} instead", true)]
public Task AddAccount(Uri? server = null) => throw new NotImplementedException();
return JsonConvert
.DeserializeObject<TokenExchangeResponse>(await response.Content.ReadAsStringAsync().ConfigureAwait(false))
.NotNull();
}
catch (Exception ex) when (!ex.IsFatal())
{
throw new SpeckleException($"Failed to get refreshed token from {server}", ex);
}
}
[Obsolete("Use serverInfo stored on a client instead", true)]
public Task<ServerInfo> GetServerInfo(Uri server, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
private static string GenerateChallenge()
{
#if NET8_0
byte[] challengeData = RandomNumberGenerator.GetBytes(32);
#else
using RNGCryptoServiceProvider rng = new();
byte[] challengeData = new byte[32];
rng.GetBytes(challengeData);
#endif
//escaped chars like % do not play nice with the server
return Regex.Replace(Convert.ToBase64String(challengeData), @"[^\w\.@-]", "");
}
[Obsolete("Use userInfo stored on a client instead", true)]
public Task<UserInfo> GetUserInfo(string token, Uri server, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
[Obsolete("Accounts must now be stored in sqlite db, no more json workaround", true)]
public IList<Account> GetLocalAccounts() => throw new NotImplementedException();
[Obsolete("Use UpdateAccount or UpdateAccountInMemory Instead", true)]
public IList<Account> Validate() => throw new NotImplementedException();
}
+13
View File
@@ -0,0 +1,13 @@
namespace Speckle.Sdk.Credentials;
public readonly record struct AuthApp(string AppId, string AppSecret, Uri CallbackUrl)
{
//These values are defined on the server, and specify the scopes the app is requesting
public static AuthApp ConnectorsV3 { get; } =
new()
{
AppId = "connectrV3",
AppSecret = "connectrV3",
CallbackUrl = new Uri("http://localhost:29355"),
};
}
+330
View File
@@ -0,0 +1,330 @@
using System.Diagnostics;
using System.Diagnostics.Contracts;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using Speckle.InterfaceGenerator;
using Speckle.Newtonsoft.Json;
using Speckle.Sdk.Common;
using Speckle.Sdk.Helpers;
using Speckle.Sdk.Logging;
namespace Speckle.Sdk.Credentials;
/// <summary>
/// Authentication flow with the Speckle Server to create a application token for the <c>connectorsV3</c> application
/// Starts the browser based authentication flow where the user's browser will be opened, they'll be asked to
/// confirm permission, then an access code will be given via a <see cref="HttpListener"/> which will be exchanged
/// for a <see cref="TokenExchangeResponse"/>
/// </summary>
/// <remarks>
/// Note, this class is not coupled in any way to <see cref="Account"/>
/// lets keep it that way...
/// See instead <see cref="AccountManager"/>
/// </remarks>
[GenerateAutoInterface]
public sealed class AuthFlow(ISdkActivityFactory activityFactory, ISpeckleHttp speckleHttp) : IAuthFlow
{
private readonly JsonSerializerSettings _serializerSettings = new()
{
MissingMemberHandling = MissingMemberHandling.Error,
NullValueHandling = NullValueHandling.Ignore,
};
public async Task<TokenExchangeResponse> TriggerAuthFlowWithTimeout(
Uri serverUrl,
AuthApp authApp,
TimeSpan timeout,
CancellationToken cancellationToken
)
{
using HttpClient client = speckleHttp.CreateHttpClient();
Uri tokenEndpoint = new(serverUrl, "/oauth/token");
string codeVerifier = GenerateCodeVerifier();
Uri authnVerify;
using var req = await client.GetAsync(tokenEndpoint, cancellationToken).ConfigureAwait(false);
bool useLegacyEndpoint = req.StatusCode != HttpStatusCode.OK;
if (useLegacyEndpoint)
{
string challenge = codeVerifier; // Old endpoint only supports PKCE "plain" method
authnVerify = new($"/authn/verify/{authApp.AppId}/{challenge}", UriKind.Relative);
tokenEndpoint = new(serverUrl, "/auth/token");
}
else
{
string challenge = GenerateCodeChallenge(codeVerifier);
authnVerify = new($"/authn/verify/{authApp.AppId}/{challenge}?code_challenge_method=S256", UriKind.Relative);
}
Uri endpoint = new(serverUrl, authnVerify);
_ = Process.Start(new ProcessStartInfo(endpoint.ToString()) { UseShellExecute = true });
string accessCode = await RunListenerWithTimeout(authApp.CallbackUrl, timeout, cancellationToken)
.ConfigureAwait(false);
object body = useLegacyEndpoint
? new
{
appId = authApp.AppId,
appSecret = authApp.AppSecret,
accessCode = accessCode,
challenge = codeVerifier,
}
: new
{
appId = authApp.AppId,
accessCode = accessCode,
codeVerifier = codeVerifier,
};
return await ExchangeAccessCodeForToken(
client,
JsonConvert.SerializeObject(body, _serializerSettings),
tokenEndpoint,
cancellationToken
)
.ConfigureAwait(false);
}
/// <summary>
///
/// </summary>
/// <param name="applicationCallbackUrl"></param>
/// <param name="timeout"></param>
/// <param name="userCancellation"></param>
/// <returns></returns>
/// <exception cref="OperationCanceledException"><paramref name="userCancellation"/> requested cancel</exception>
/// <exception cref="TimeoutException">timeout was reached</exception>
public async Task<string> RunListenerWithTimeout(
Uri applicationCallbackUrl,
TimeSpan timeout,
CancellationToken userCancellation
)
{
using CancellationTokenSource cancelOnTimeout = new(timeout);
using CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(
cancelOnTimeout.Token,
userCancellation
);
try
{
using var activity = activityFactory.Start("Listening for authflow access code");
return await RunListener(applicationCallbackUrl, linkedSource.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (userCancellation.IsCancellationRequested)
{
throw;
}
catch (OperationCanceledException ex) when (cancelOnTimeout.IsCancellationRequested)
{
throw new TimeoutException($"Auth flow was cancelled after {timeout:g} timeout", ex);
}
}
/// <summary>
///
/// </summary>
/// <param name="refreshToken"></param>
/// <param name="serverUrl"></param>
/// <param name="authApp">Auth app, needs to match the app that generated the refresh token originally</param>
/// <param name="cancellationToken"></param>
/// <exception cref="HttpRequestException">HTTP exceptions</exception>
/// <exception cref="JsonSerializationException">Server response was invalid or partial</exception>
/// <exception cref="ArgumentOutOfRangeException ">Invalid <paramref name="serverUrl"/> (must be absolute url)</exception>
/// <exception cref="OperationCanceledException"><paramref name="cancellationToken"/> requested cancel</exception>
/// <returns></returns>
public async Task<TokenExchangeResponse> GetRefreshedToken(
string? refreshToken,
Uri serverUrl,
AuthApp authApp,
CancellationToken cancellationToken
)
{
using var client = speckleHttp.CreateHttpClient();
var body = new
{
appId = authApp.AppId,
appSecret = authApp.AppSecret,
refreshToken = refreshToken,
};
using var content = new StringContent(JsonConvert.SerializeObject(body, _serializerSettings));
content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
var response = await client
.PostAsync(new Uri(serverUrl, "/auth/token"), content, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
#if NET8_0_OR_GREATER
string read = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
#else
string read = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
#endif
return JsonConvert.DeserializeObject<TokenExchangeResponse>(read, _serializerSettings).NotNull();
}
private static async Task<HttpListenerContext> GetContext(HttpListener listener, CancellationToken cancellationToken)
{
//GetContextAsync doesn't support cancellation, so we have to do this song and dance...
Task timeoutTask = Task.Delay(Timeout.Infinite, cancellationToken);
Task<HttpListenerContext> getContextTask = listener.GetContextAsync();
Task completed = await Task.WhenAny(getContextTask, timeoutTask).ConfigureAwait(false);
if (completed == getContextTask)
{
return getContextTask.Result;
}
cancellationToken.ThrowIfCancellationRequested();
throw new InvalidOperationException("Cancellation should have thrown, this shouldn't be possible");
}
public static async Task<string> RunListener(Uri localUrl, CancellationToken cancellationToken)
{
using HttpListener listener = new();
listener.Prefixes.Add(localUrl.ToString());
listener.Start();
HttpListenerContext context = await GetContext(listener, cancellationToken).ConfigureAwait(false);
HttpListenerRequest request = context.Request;
using HttpListenerResponse response = context.Response;
string? accessCode = request.QueryString["access_code"];
string? denied = request.QueryString["denied"];
bool isDenied = denied == "true";
if (isDenied)
{
//lang=html
WriteResponse(
"""
<h1>Denied!</h1>
<br/><br/>
Please close this window and return to your Speckle Connector.
"""
);
throw new AuthFlowException("Authentication flow was denied"); //denied presumably by the user
}
else if (accessCode != null)
{
//lang=html
WriteResponse(
"""
<h1>Success!</h1>
<br/><br/>
Your Speckle Connector is now authorized
<br/><br/>
You may now close this window and return to your Speckle Connector
"""
);
return accessCode;
}
else
{
//lang=html
WriteResponse(
"""
<h1>Failed!</h1>
<br/><br/>
Something went wrong trying to authorize your Speckle Connector
<br/><br/>
Please close this window and try again from your Speckle Connector.
"""
);
throw new AuthFlowException("Failed to receive access code");
}
void WriteResponse(string message)
{
//lang=html
string responseString = $"""
<HTML>
<BODY Style='background: #FAFAFAFF; font-family: Inter, Roboto, sans-serif; font-size: 1rem; font-weight: 500; text-align: center;'>
<br/>
{message}
</BODY>
</HTML>
""";
byte[] buffer = Encoding.UTF8.GetBytes(responseString);
response.ContentLength64 = buffer.Length;
response.OutputStream.Write(buffer, 0, buffer.Length);
}
}
private async Task<TokenExchangeResponse> ExchangeAccessCodeForToken(
HttpClient client,
string jsonContent,
Uri tokenEndpoint,
CancellationToken cancellationToken
)
{
using StringContent content = new(jsonContent);
content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
using HttpResponseMessage response = await client
.PostAsync(tokenEndpoint, content, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
#if NET8_0_OR_GREATER
string read = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
#else
string read = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
#endif
return JsonConvert.DeserializeObject<TokenExchangeResponse>(read, _serializerSettings).NotNull();
}
[Pure]
public static string GenerateCodeVerifier()
{
#if NET8_0_OR_GREATER
Span<byte> codeVerifierData = stackalloc byte[32];
RandomNumberGenerator.Fill(codeVerifierData);
#else
using RNGCryptoServiceProvider rng = new();
byte[] codeVerifierData = new byte[32];
rng.GetBytes(codeVerifierData);
#endif
return Base64UrlEncode(codeVerifierData);
}
[Pure]
public static string GenerateCodeChallenge(string codeVerifier)
{
#if NET8_0_OR_GREATER
int byteCount = Encoding.UTF8.GetByteCount(codeVerifier.AsSpan());
Span<byte> codeVerifierBytes = stackalloc byte[byteCount];
Encoding.UTF8.GetBytes(codeVerifier, codeVerifierBytes);
Span<byte> challengeData = stackalloc byte[SHA256.HashSizeInBytes];
SHA256.HashData(codeVerifierBytes, challengeData);
#else
byte[] codeVerifierBytes = Encoding.UTF8.GetBytes(codeVerifier);
using SHA256 hash = SHA256.Create();
byte[] challengeData = hash.ComputeHash(codeVerifierBytes);
#endif
return Base64UrlEncode(challengeData);
}
[Pure]
private static string Base64UrlEncode(
#if NET8_0_OR_GREATER
ReadOnlySpan<byte> bytes
#else
byte[] bytes
#endif
)
{
// Base64Url is available in .NET 9, or via the Microsoft.Bcl.Memory polyfill
// But for simplicity r.e. dll dependencies, we're doing it the dumb way...
return Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('=');
}
}
@@ -1,14 +0,0 @@
namespace Speckle.Sdk.Credentials;
#pragma warning disable CA2237
public sealed class AuthFlowException : Exception
#pragma warning restore CA2237
{
public AuthFlowException(string? message, Exception? innerException)
: base(message, innerException) { }
public AuthFlowException(string? message)
: base(message) { }
public AuthFlowException() { }
}
+11 -11
View File
@@ -1,5 +1,16 @@
namespace Speckle.Sdk.Credentials;
public sealed class AuthFlowException : SpeckleException
{
public AuthFlowException(string? message, Exception? innerException)
: base(message, innerException) { }
public AuthFlowException(string? message)
: base(message) { }
public AuthFlowException() { }
}
public class SpeckleAccountManagerException : SpeckleException
{
public SpeckleAccountManagerException(string message)
@@ -10,14 +21,3 @@ public class SpeckleAccountManagerException : SpeckleException
public SpeckleAccountManagerException() { }
}
public class SpeckleAccountFlowLockedException : SpeckleAccountManagerException
{
public SpeckleAccountFlowLockedException(string message)
: base(message) { }
public SpeckleAccountFlowLockedException() { }
public SpeckleAccountFlowLockedException(string message, Exception? innerException)
: base(message, innerException) { }
}
+8 -5
View File
@@ -6,16 +6,19 @@ namespace Speckle.Sdk.Credentials;
internal sealed class ActiveUserServerInfoResponse
{
[property: JsonProperty(Required = Required.AllowNull)]
public UserInfo? activeUser { get; init; }
public required UserInfo? activeUser { get; init; }
[property: JsonProperty(Required = Required.Always)]
public ServerInfo serverInfo { get; init; }
public required ServerInfo serverInfo { get; init; }
}
internal sealed class TokenExchangeResponse
public sealed class TokenExchangeResponse
{
public string token { get; init; }
public string refreshToken { get; init; }
[JsonRequired]
public required string token { get; init; }
[JsonRequired]
public required string refreshToken { get; init; }
}
public sealed class UserInfo
@@ -1,8 +1,12 @@
namespace Speckle.Sdk.Logging;
using Speckle.Connectors.Logging;
namespace Speckle.Sdk.Logging;
public sealed class NullActivityFactory : ISdkActivityFactory
{
public void Dispose() { }
public ISdkActivity? Start(string? name = default, string source = "") => null;
public ISdkActivity? Start(string? name, SdkActivityKind kind, string source) => null;
public ISdkActivity? StartRemote(string traceContext, SdkActivityKind kind, string? name, string source) => null;
}
@@ -7,32 +7,33 @@ namespace Speckle.Sdk.Models;
public enum DynamicBaseMemberType
{
/// <summary>
/// The typed members of the DynamicBase object
/// The typed members of the <see cref="DynamicBase"/> object
/// </summary>
Instance = 1,
/// <summary>
/// The dynamically added members of the DynamicBase object
/// The dynamically added members of the <see cref="DynamicBase"/> object
/// </summary>
Dynamic = 2,
/// <summary>
/// The typed members flagged with ObsoleteAttribute attribute.
/// The typed members flagged with <see cref="ObsoleteAttribute"/> attribute.
/// </summary>
Obsolete = 4,
/// <summary>
/// The typed methods flagged with TODO:
/// Old feature supported in v2 for grasshopper
/// </summary>
[Obsolete("Feature no longer supported")]
SchemaComputed = 16,
/// <summary>
/// All the typed members, including ones with ObsoleteAttribute attributes.
/// All the typed members, including ones with <see cref="ObsoleteAttribute"/> attributes.
/// </summary>
InstanceAll = Instance + Obsolete,
/// <summary>
/// All the members, including dynamic and instance members flagged with ObsoleteAttribute attributes
/// All the members, including dynamic and instance members flagged with <see cref="ObsoleteAttribute"/> attributes
/// </summary>
All = InstanceAll + Dynamic,
}
@@ -14,20 +14,20 @@ public partial interface IIngestionProgressManager : IProgress<CardProgress>;
/// An <see langword="IProgress{IngestionProgressEventArgs}"/> implementation for the entire client side Ingestion progress update reporting
/// Will throttles ingestion progress messages and reports their progress
/// </summary>
/// <remarks>
/// Normally we would pick quite a coarse updateInterval to try and spamming the server (1-5s)
/// </remarks>
[GenerateAutoInterface]
public sealed class IngestionProgressManager(
ILogger<IngestionProgressManager> logger,
IClient speckleClient,
ModelIngestion ingestion,
string projectId,
TimeSpan updateInterval,
CancellationToken cancellationToken
) : IIngestionProgressManager
{
/// <remarks>
/// Normally we would pick quite a coarse throttle window to try and avoid over pressure (1-5s)
/// </remarks>
private Task? _lastUpdate;
public Task? LastUpdate { get; private set; }
private long _lastUpdatedAt;
private readonly object _lock = new();
@@ -48,15 +48,15 @@ public sealed class IngestionProgressManager(
trimmedMessage = value.Status.TrimEnd('.');
_lastUpdate = speckleClient
LastUpdate = speckleClient
.Ingestion.UpdateProgress(
new ModelIngestionUpdateInput(ingestion.id, projectId, trimmedMessage, value.Progress),
new ModelIngestionUpdateInput(ingestion.id, ingestion.projectId, trimmedMessage, value.Progress),
cancellationToken
)
.ContinueWith(
HandleFaultedContinuation,
Continuation,
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default
);
}
@@ -67,7 +67,7 @@ public sealed class IngestionProgressManager(
/// <returns><see langword="true"/> if the update should be ignored, otherwise <see langword="false"/></returns>
private bool ShouldIgnoreProgressUpdate()
{
if (_lastUpdate is not null && !_lastUpdate.IsCompleted)
if (LastUpdate is not null && !LastUpdate.IsCompleted)
{
return true;
}
@@ -76,7 +76,7 @@ public sealed class IngestionProgressManager(
return msSinceLastUpdate < updateInterval;
}
private void HandleFaultedContinuation(Task updateTask)
private void Continuation(Task updateTask)
{
// The progress report failed... could be many reasons.
// For now, we're not letting this fail the Ingestion in any way
@@ -12,11 +12,10 @@ public sealed class IngestionProgressManagerFactory(ILogger<IngestionProgressMan
public IIngestionProgressManager CreateInstance(
IClient speckleClient,
ModelIngestion ingestion,
string projectId,
TimeSpan updateInterval,
CancellationToken cancellationToken
)
{
return new IngestionProgressManager(logger, speckleClient, ingestion, projectId, updateInterval, cancellationToken);
return new IngestionProgressManager(logger, speckleClient, ingestion, updateInterval, cancellationToken);
}
}
@@ -18,9 +18,9 @@ public sealed class RenderedStreamProgress(IProgress<CardProgress> progress) : I
);
}
private static readonly string[] s_suffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
private static readonly string[] s_suffixes = ["B", "KB", "MB", "GB", "TB", "PB"];
private static (string suffix, double scaleFactor) GetFileSizeRendering(long value)
internal static (string suffix, double scaleFactor) GetFileSizeRendering(long value)
{
if (value <= 0)
{
+25 -12
View File
@@ -1,40 +1,48 @@
using System.IO.Compression;
using System.Threading.Channels;
using Microsoft.Extensions.Logging;
using Speckle.InterfaceGenerator;
using Speckle.Sdk.Dependencies;
using Speckle.Sdk.Helpers;
using Speckle.Sdk.Logging;
namespace Speckle.Sdk.Pipelines.Send;
[GenerateAutoInterface]
public sealed class DiskStoreFactory(ILogger<DiskStore> logger) : IDiskStoreFactory
public sealed class DiskStoreFactory(ILogger<DiskStore> logger, ISdkActivityFactory activityFactory) : IDiskStoreFactory
{
public DiskStore CreateInstance(CancellationToken cancellationToken) => new(logger, cancellationToken);
public DiskStore CreateInstance(CancellationToken cancellationToken) =>
new(logger, activityFactory, cancellationToken);
}
public sealed class DiskStore
{
private readonly Channel<UploadItem> _channel;
private readonly RepackedChannel<UploadItem> _channel;
private readonly Task<DisposableFile> _writeToDiskTask;
private readonly ILogger<DiskStore> _logger;
private readonly ISdkActivityFactory _activityFactory;
private readonly CancellationToken _cancellationToken;
internal DiskStore(ILogger<DiskStore> logger, CancellationToken cancellationToken)
internal DiskStore(
ILogger<DiskStore> logger,
ISdkActivityFactory activityFactory,
CancellationToken cancellationToken
)
{
_logger = logger;
_activityFactory = activityFactory;
_cancellationToken = cancellationToken;
_channel = Channel.CreateBounded<UploadItem>(
new BoundedChannelOptions(1000) { FullMode = BoundedChannelFullMode.Wait, SingleReader = true }
);
_channel = new RepackedChannel<UploadItem>(1000, true, false);
_writeToDiskTask = Task.Run(WriteFile, cancellationToken);
}
public ValueTask PushAsync(UploadItem item) => _channel.Writer.WriteAsync(item, _cancellationToken);
public async Task PushAsync(UploadItem item) =>
await _channel.WriteAsync(item, _cancellationToken).ConfigureAwait(false);
public async Task<DisposableFile> CompleteAsync()
{
_channel.Writer.Complete();
using var a = _activityFactory.Start("Waiting for DiskStore to complete");
_channel.CompleteWriter();
return await _writeToDiskTask.ConfigureAwait(false);
}
@@ -55,9 +63,14 @@ public sealed class DiskStore
using var gzip = new GZipStream(fileStream, CompressionLevel.Optimal);
using var writer = new StreamWriter(gzip);
await foreach (var item in _channel.Reader.ReadAllAsync(_cancellationToken).ConfigureAwait(false))
await foreach (var item in _channel.ReadAllAsync(_cancellationToken).ConfigureAwait(false))
{
await writer.WriteLineAsync($"{item.Id}\t{item.Json}\t{item.SpeckleType}").ConfigureAwait(false);
await writer.WriteAsync(item.Id).ConfigureAwait(false);
await writer.WriteAsync('\t').ConfigureAwait(false);
await writer.WriteAsync(item.SpeckleType).ConfigureAwait(false);
await writer.WriteAsync('\t').ConfigureAwait(false);
await writer.WriteAsync(item.Json.Value).ConfigureAwait(false);
await writer.WriteLineAsync().ConfigureAwait(false);
}
#if NET8_0_OR_GREATER
await writer.FlushAsync(_cancellationToken).ConfigureAwait(false);
@@ -70,11 +70,5 @@ public sealed class SendPipeline : IDisposable
await _uploader.Send(fileStreamUpload).ConfigureAwait(false);
}
public async Task<string> WaitForUploadAndServerProcessing()
{
// TODO: in some way, wait for the server to process the upload and return the actual new version id
return await Task.FromResult("todo").ConfigureAwait(false);
}
public void Dispose() => _uploader.Dispose();
}
+12 -7
View File
@@ -1,16 +1,17 @@
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.Logging;
using Speckle.InterfaceGenerator;
using Speckle.Newtonsoft.Json;
using Speckle.Sdk.Credentials;
using Speckle.Sdk.Helpers;
using Speckle.Sdk.Logging;
using Speckle.Sdk.Pipelines.Progress;
namespace Speckle.Sdk.Pipelines.Send;
[GenerateAutoInterface]
public sealed class UploaderFactory(ISpeckleHttp httpClientFactory, ILogger<Uploader> logger) : IUploaderFactory
public sealed class UploaderFactory(ISpeckleHttp httpClientFactory, ISdkActivityFactory activityFactory)
: IUploaderFactory
{
public Uploader CreateInstance(
string projectId,
@@ -18,7 +19,7 @@ public sealed class UploaderFactory(ISpeckleHttp httpClientFactory, ILogger<Uplo
Account account,
IProgress<StreamProgressArgs> progress,
CancellationToken cancellationToken
) => new(projectId, ingestionId, logger, httpClientFactory, account, progress, cancellationToken);
) => new(projectId, ingestionId, activityFactory, httpClientFactory, account, progress, cancellationToken);
}
public sealed class Uploader : IDisposable
@@ -28,13 +29,13 @@ public sealed class Uploader : IDisposable
private readonly CancellationToken _cancellationToken;
private readonly HttpClient _speckleClient;
private readonly HttpClient _s3Client;
private readonly ILogger<Uploader> _logger;
private readonly ISdkActivityFactory _activity;
private readonly IProgress<StreamProgressArgs> _progress;
internal Uploader(
string projectId,
string ingestionId,
ILogger<Uploader> logger,
ISdkActivityFactory activity,
ISpeckleHttp httpClientFactory,
Account speckleAccount,
IProgress<StreamProgressArgs> progress,
@@ -43,7 +44,7 @@ public sealed class Uploader : IDisposable
{
_projectId = projectId;
_ingestionId = ingestionId;
_logger = logger;
_activity = activity;
_cancellationToken = cancellationToken;
_progress = progress;
_speckleClient = httpClientFactory.CreateHttpClient(authorizationToken: speckleAccount.token);
@@ -62,6 +63,8 @@ public sealed class Uploader : IDisposable
private async Task<PresignedUploadResponse> GetPresignedUrl()
{
using var a = _activity.Start("Get Presigned Url");
var signUri = new Uri($"projects/{_projectId}/modelingestion/{_ingestionId}/uploads/sign", UriKind.Relative);
using var signResponse = await _speckleClient.PostAsync(signUri, null, _cancellationToken).ConfigureAwait(false);
@@ -80,7 +83,7 @@ public sealed class Uploader : IDisposable
private async Task<string> UploadToS3(Stream fileStream, PresignedUploadResponse presignedUploadResponse)
{
_logger.LogInformation("Uploading file to pre-signed url");
using var a = _activity.Start("Uploading file to pre-signed url");
Stream progressStream = new ProgressStream(fileStream, _progress);
@@ -107,6 +110,8 @@ public sealed class Uploader : IDisposable
private async Task TriggerProcessing(TriggerUploadRequest request)
{
using var a = _activity.Start("Triggering Processing");
Uri processUri = new($"projects/{_projectId}/modelingestion/{_ingestionId}/uploads/process", UriKind.Relative);
string requestBody = JsonConvert.SerializeObject(request);
using var content = new StringContent(requestBody, Encoding.UTF8, "application/json");
-1
View File
@@ -35,7 +35,6 @@
<PackageReference Include="Microsoft.CSharp" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="System.Threading.Channels" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Speckle.Sdk.Dependencies\Speckle.Sdk.Dependencies.csproj" />
+18 -24
View File
@@ -90,16 +90,6 @@
"resolved": "13.0.2",
"contentHash": "g1BejUZwax5PRfL6xHgLEK23sqHWOgOj9hE7RvfRRlN00AGt8GnPYt8HedSK7UB3HiRW8zCA9Pn0iiYxCK24BA=="
},
"System.Threading.Channels": {
"type": "Direct",
"requested": "[10.0.0, )",
"resolved": "10.0.0",
"contentHash": "fwRdkJpKisUEVNaEdsL5w5EwidzuVw0BOTfzDvYB1Yg8sq1pqNfUZxBOVFgSj6i6tNhpT3HP8BEDXf1+kFkTDA==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "10.0.0",
"System.Threading.Tasks.Extensions": "4.6.3"
}
},
"GraphQL.Client.Abstractions": {
"type": "Transitive",
"resolved": "6.0.0",
@@ -121,14 +111,6 @@
"resolved": "6.0.0",
"contentHash": "yg72rrYDapfsIUrul7aF6wwNnTJBOFvuA9VdDTQpPa8AlAriHbufeXYLBcodKjfUdkCnaiggX1U/nEP08Zb5GA=="
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "10.0.0",
"contentHash": "vFuwSLj9QJBbNR0NeNO4YVASUbokxs+i/xbuu8B+Fs4FAZg5QaFa6eGrMaRqTzzNI5tAb97T7BhSxtLckFyiRA==",
"dependencies": {
"System.Threading.Tasks.Extensions": "4.6.3"
}
},
"Microsoft.Build.Tasks.Git": {
"type": "Transitive",
"resolved": "8.0.0",
@@ -280,8 +262,8 @@
},
"System.Runtime.CompilerServices.Unsafe": {
"type": "Transitive",
"resolved": "6.1.2",
"contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw=="
"resolved": "4.5.3",
"contentHash": "3TIsJhD1EiiT0w2CcDMN/iSSwnNnsrnbzeVHSKkaEgV85txMprmuO+Yq2AdSbeVGcg28pdNDTPK87tJhX7VFHw=="
},
"System.Runtime.InteropServices.WindowsRuntime": {
"type": "Transitive",
@@ -293,14 +275,26 @@
},
"System.Threading.Tasks.Extensions": {
"type": "Transitive",
"resolved": "4.6.3",
"contentHash": "7sCiwilJLYbTZELaKnc7RecBBXWXA+xMLQWZKWawBxYjp6DBlSE3v9/UcvKBvr1vv2tTOhipiogM8rRmxlhrVA==",
"resolved": "4.5.4",
"contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==",
"dependencies": {
"System.Runtime.CompilerServices.Unsafe": "6.1.2"
"System.Runtime.CompilerServices.Unsafe": "4.5.3"
}
},
"speckle.sdk.dependencies": {
"type": "Project"
"type": "Project",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "[9.0.4, )"
}
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "CentralTransitive",
"requested": "[9.0.4, )",
"resolved": "9.0.4",
"contentHash": "9VGI5kxIvrNG2mqLQZnUR6y/3fcnygD8eNpHR+CqfbnIXvea6nehnYknDKQTxZVPMpzpNca+7DxLBmpdB3q0Bw==",
"dependencies": {
"System.Threading.Tasks.Extensions": "4.5.4"
}
}
},
"net8.0": {
@@ -74,11 +74,6 @@
"resolved": "1.17.0",
"contentHash": "8x+HCVTl/HHTGpscH3vMBhV8sknN/muZFw9s3TsI8SA6+c43cOTCi2+jE4KsU8pNLbJ++iF2ZFcpcXHXtDglnw=="
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "1.1.0",
"contentHash": "1Am6l4Vpn3/K32daEqZI+FFr96OlZkgwK2LcT3pZ2zWubR5zTPW3/FkO1Rat9kb7oQOa4rxgl9LJHc5tspCWfg=="
},
"Microsoft.Build.Tasks.Git": {
"type": "Transitive",
"resolved": "8.0.0",
@@ -385,6 +380,12 @@
"System.Reactive": "5.0.0"
}
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "CentralTransitive",
"requested": "[9.0.4, )",
"resolved": "1.1.0",
"contentHash": "1Am6l4Vpn3/K32daEqZI+FFr96OlZkgwK2LcT3pZ2zWubR5zTPW3/FkO1Rat9kb7oQOa4rxgl9LJHc5tspCWfg=="
},
"Microsoft.Data.Sqlite": {
"type": "CentralTransitive",
"requested": "[7.0.5, )",
@@ -52,7 +52,7 @@ public class ServerObjectManagerTests : MoqTest
http.Setup(x => x.CreateHttpClient(It.IsAny<HttpClientHandler>(), timeout, token)).Returns(httpClient);
var activityFactory = Create<ISdkActivityFactory>();
activityFactory.Setup(x => x.Start(null, "DownloadObjects")).Returns((ISdkActivity?)null);
activityFactory.Setup(x => x.Start(null, default, "DownloadObjects")).Returns((ISdkActivity?)null);
var serverObjectManager = new ServerObjectManager(
http.Object,
@@ -91,7 +91,7 @@ public class ServerObjectManagerTests : MoqTest
http.Setup(x => x.CreateHttpClient(It.IsAny<HttpClientHandler>(), timeout, token)).Returns(httpClient);
var activityFactory = Create<ISdkActivityFactory>();
activityFactory.Setup(x => x.Start(null, "DownloadSingleObject")).Returns((ISdkActivity?)null);
activityFactory.Setup(x => x.Start(null, default, "DownloadSingleObject")).Returns((ISdkActivity?)null);
var serverObjectManager = new ServerObjectManager(
http.Object,
@@ -132,7 +132,7 @@ public class ServerObjectManagerTests : MoqTest
http.Setup(x => x.CreateHttpClient(It.IsAny<HttpClientHandler>(), timeout, token)).Returns(httpClient);
var activityFactory = Create<ISdkActivityFactory>();
activityFactory.Setup(x => x.Start(null, "HasObjects")).Returns((ISdkActivity?)null);
activityFactory.Setup(x => x.Start(null, default, "HasObjects")).Returns((ISdkActivity?)null);
var serverObjectManager = new ServerObjectManager(
http.Object,
@@ -171,7 +171,7 @@ public class ServerObjectManagerTests : MoqTest
http.Setup(x => x.CreateHttpClient(It.IsAny<HttpClientHandler>(), timeout, token)).Returns(httpClient);
var activityFactory = Create<ISdkActivityFactory>();
activityFactory.Setup(x => x.Start(null, "UploadObjects")).Returns((ISdkActivity?)null);
activityFactory.Setup(x => x.Start(null, default, "UploadObjects")).Returns((ISdkActivity?)null);
var serverObjectManager = new ServerObjectManager(
http.Object,
@@ -13,7 +13,7 @@ public class SubscriptionResourceTests : IAsyncLifetime
#if DEBUG
private const int WAIT_PERIOD = 3000; // WSL is slow AF, so for local runs, we're being extra generous
#else
private const int WAIT_PERIOD = 400; // For CI runs, a much smaller wait time is acceptable
private const int WAIT_PERIOD = 600; // For CI runs, a much smaller wait time is acceptable
#endif
private const int TIMEOUT = WAIT_PERIOD + WAIT_PERIOD + 600;
private IClient _testUser;
@@ -1,8 +0,0 @@
{
"Type": "AggregateException",
"InnerException": {
"Data": {},
"Message": "FORBIDDEN: Your auth token does not have the required scope: workspace:read.",
"Type": "SpeckleGraphQLForbiddenException"
}
}
@@ -1,8 +0,0 @@
{
"Type": "AggregateException",
"InnerException": {
"Data": {},
"Message": "FORBIDDEN: Your auth token does not have the required scope: workspace:read.",
"Type": "SpeckleGraphQLForbiddenException"
}
}
@@ -1,4 +1,5 @@
using Speckle.Sdk.Api;
using FluentAssertions;
using Speckle.Sdk.Api;
using Speckle.Sdk.Api.GraphQL.Resources;
namespace Speckle.Sdk.Tests.Integration.API.GraphQL.Resources;
@@ -25,13 +26,15 @@ public class WorkspaceResourceTests
public async Task TestGetWorkspace()
{
var ex = await Assert.ThrowsAsync<AggregateException>(async () => _ = await Sut.Get("non-existent-id"));
await Verify(ex);
ex.InnerExceptions.Should().HaveCount(1);
ex.InnerExceptions.Should().AllBeOfType<SpeckleGraphQLForbiddenException>();
}
[Fact]
public async Task TestGetProjects()
{
var ex = await Assert.ThrowsAsync<AggregateException>(async () => _ = await Sut.GetProjects("non-existent-id"));
await Verify(ex);
ex.InnerExceptions.Should().HaveCount(1);
ex.InnerExceptions.Should().AllBeOfType<SpeckleGraphQLForbiddenException>();
}
}
@@ -0,0 +1,7 @@
namespace Speckle.Sdk.Tests.Integration;
[CollectionDefinition(nameof(RequiresSqLiteAccountDb), DisableParallelization = true)]
public sealed class RequiresSqLiteAccountDb;
[CollectionDefinition(nameof(RequiresAuthFlowPort), DisableParallelization = true)]
public sealed class RequiresAuthFlowPort;
@@ -0,0 +1,107 @@
using Microsoft.Extensions.DependencyInjection;
using Speckle.Sdk.Api;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Credentials;
namespace Speckle.Sdk.Tests.Integration.Credentials;
[Collection(nameof(RequiresSqLiteAccountDb))]
public class AccountManagerTests
{
private IAccountManager _sut;
public AccountManagerTests()
{
_sut = Fixtures.ServiceProvider.GetRequiredService<IAccountManager>();
}
[Fact]
public async Task UpdateAccount_UpdatesUserInfo()
{
using IClient user = await Fixtures.SeedUserWithClient();
string realAccountId = user.Account.id;
UserInfo realUserData = user.Account.userInfo;
UserInfo staleData = new()
{
avatar = "my old avatar",
company = "my old company",
email = "my.old.email@example.com",
id = realUserData.id,
name = "my old name",
};
// Mutate with "fake" data to simulate a stale account data
user.Account.userInfo = staleData;
user.Account.id = null!; //force re-generate id
Assert.NotEqual(realAccountId, user.Account.id);
await _sut.UpdateAccountInMemory(user.Account);
Assert.Equal(realUserData.avatar, user.Account.userInfo.avatar);
Assert.Equal(realUserData.company, user.Account.userInfo.company);
Assert.Equal(realUserData.email, user.Account.userInfo.email);
Assert.Equal(realUserData.id, user.Account.userInfo.id);
Assert.Equal(realUserData.name, user.Account.userInfo.name);
Assert.Equal(realAccountId, user.Account.id);
}
[Fact]
public async Task UpdateAccount_UpdatesServerInfo()
{
using IClient user = await Fixtures.SeedUserWithClient();
string realAccountId = user.Account.id;
ServerInfo realServerData = user.Account.serverInfo;
ServerInfo staleData = new()
{
company = "This old company",
description = "this old description",
name = "This old name",
url = realServerData.url,
version = "0.0.123",
};
// Mutate with "fake" data to simulate a stale account data
user.Account.serverInfo = staleData;
user.Account.id = null!; //force re-generate id
Assert.Equal(realAccountId, user.Account.id); //account id should not change since we didn't change server url
await _sut.UpdateAccountInMemory(user.Account);
Assert.Equal(realServerData.company, user.Account.serverInfo.company);
Assert.Equal(realServerData.description, user.Account.serverInfo.description);
Assert.Equal(realServerData.name, user.Account.serverInfo.name);
Assert.Equal(realServerData.url, user.Account.serverInfo.url);
Assert.Equal(realServerData.version, user.Account.serverInfo.version);
Assert.Equal(realAccountId, user.Account.id);
}
[Fact]
public async Task UpdateAccount_ServerInfoMigration()
{
using IClient user = await Fixtures.SeedUserWithClient();
string realAccountId = user.Account.id;
ServerInfo realServerData = user.Account.serverInfo;
ServerInfo staleData = new()
{
company = "This old company",
description = "this old description",
name = "This old name",
url = realServerData.url,
version = "0.0.123",
};
// Mutate with "fake" data to simulate a stale account data
user.Account.serverInfo = staleData;
user.Account.id = null!; //force re-generate id
Assert.Equal(realAccountId, user.Account.id); //account id should not change since we didn't change server url
await _sut.UpdateAccountInMemory(user.Account);
Assert.Equal(realServerData.company, user.Account.serverInfo.company);
Assert.Equal(realServerData.description, user.Account.serverInfo.description);
Assert.Equal(realServerData.name, user.Account.serverInfo.name);
Assert.Equal(realServerData.url, user.Account.serverInfo.url);
Assert.Equal(realServerData.version, user.Account.serverInfo.version);
Assert.Equal(realAccountId, user.Account.id);
}
}
@@ -0,0 +1,84 @@
using System.Net;
using Microsoft.Extensions.DependencyInjection;
using Speckle.Sdk.Api;
using Speckle.Sdk.Credentials;
namespace Speckle.Sdk.Tests.Integration.Credentials;
[Collection(nameof(RequiresAuthFlowPort))]
public class AuthFlowExceptionalTests : IAsyncLifetime
{
private IAuthFlow _authFlow;
private IClient _client;
private readonly Uri _url = AuthApp.ConnectorsV3.CallbackUrl;
[Fact]
public async Task GetRefreshToken_Cancellation()
{
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
_ = await _authFlow.GetRefreshedToken(
_client.Account.refreshToken,
_client.ServerUrl,
Fixtures.TestAuthApp,
new(true)
)
);
}
[Fact]
public async Task GetRefreshToken_UnknownApp()
{
//interestingly, the server responds with a 401 Unauthorized despite internally being a bad request
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
_ = await _authFlow.GetRefreshedToken(
_client.Account.refreshToken,
_client.ServerUrl,
new()
{
AppId = "doesn't exist",
AppSecret = "doesn't exist",
CallbackUrl = new("invalid://localhost"),
},
CancellationToken.None
)
);
}
[Fact]
public async Task GetRefreshToken_NullRefreshToken()
{
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
_ = await _authFlow.GetRefreshedToken(null, _client.ServerUrl, AuthApp.ConnectorsV3, CancellationToken.None)
);
}
[Fact]
public async Task SimultaneousListeners_SamePort_OneFails()
{
using CancellationTokenSource ct = new();
var task1 = AuthFlow.RunListener(_url, ct.Token);
await Task.Delay(50, CancellationToken.None);
await Assert.ThrowsAsync<HttpListenerException>(async () => await AuthFlow.RunListener(_url, ct.Token));
if (task1.IsCompleted)
{
throw new InvalidOperationException("Was expecting task to still be running", task1.Exception);
}
await ct.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await task1);
}
public async Task InitializeAsync()
{
_authFlow = Fixtures.ServiceProvider.GetRequiredService<IAuthFlow>();
_client = await Fixtures.SeedUserWithClient();
}
public Task DisposeAsync()
{
_client.Dispose();
return Task.CompletedTask;
}
}
@@ -0,0 +1,94 @@
using Microsoft.Extensions.DependencyInjection;
using Speckle.Sdk.Credentials;
namespace Speckle.Sdk.Tests.Integration.Credentials;
[Collection(nameof(RequiresAuthFlowPort))]
public sealed class AuthFlowTests
{
private readonly IAuthFlow _authFlow;
private readonly Uri _url = AuthApp.ConnectorsV3.CallbackUrl;
public AuthFlowTests()
{
_authFlow = Fixtures.ServiceProvider.GetRequiredService<IAuthFlow>();
}
[Fact]
public async Task RunListener_ReturnsAccessCode_WhenQueryContainsAccessCode()
{
var listenerTask = AuthFlow.RunListener(_url, CancellationToken.None);
using var client = new HttpClient();
const string EXPECTED_ACCESS_CODE = "abcdef123456";
var response = await client.GetAsync(new Uri(_url, $"?access_code={EXPECTED_ACCESS_CODE}"));
response.EnsureSuccessStatusCode();
string result = await listenerTask;
Assert.Equal(EXPECTED_ACCESS_CODE, result);
}
[Fact]
public async Task RunListener_Throws_InvalidAccessCode()
{
var listenerTask = AuthFlow.RunListener(_url, CancellationToken.None);
using var client = new HttpClient();
var response = await client.GetAsync(new Uri(_url, ""));
response.EnsureSuccessStatusCode();
await Assert.ThrowsAsync<AuthFlowException>(async () =>
{
_ = await listenerTask;
});
}
[Fact]
public async Task RunListener_Throws_Cancellation()
{
using CancellationTokenSource cancellationTokenSource = new();
var listenerTask = AuthFlow.RunListener(_url, cancellationTokenSource.Token);
await cancellationTokenSource.CancelAsync();
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
{
_ = await listenerTask;
});
}
[Theory]
[InlineData(0.1)]
[InlineData(1)]
[InlineData(5)]
public async Task RunListener_Timeout(double timeS)
{
await Assert.ThrowsAsync<TimeoutException>(async () =>
{
_ = await _authFlow.RunListenerWithTimeout(_url, TimeSpan.FromSeconds(timeS), CancellationToken.None);
});
}
[Fact]
public async Task CanGetRefreshToken()
{
using var user = await Fixtures.SeedUserWithClient();
var tokenExchange = await _authFlow.GetRefreshedToken(
user.Account.refreshToken,
user.ServerUrl,
Fixtures.TestAuthApp,
CancellationToken.None
);
Assert.NotNull(tokenExchange.token);
Assert.NotNull(tokenExchange.refreshToken);
user.Account.token = tokenExchange.token;
user.Account.refreshToken = tokenExchange.refreshToken;
var apiTest = await user.ActiveUser.Get();
Assert.NotNull(apiTest);
}
}
@@ -27,7 +27,12 @@ namespace Speckle.Sdk.Tests.Integration;
public static class Fixtures
{
public static readonly ServerInfo Server = new() { url = "http://localhost:3000", name = "Docker Server" };
public static readonly AuthApp TestAuthApp = new()
{
AppId = "spklwebapp",
AppSecret = "spklwebapp",
CallbackUrl = new Uri("invaid://localhost"),
};
public static IServiceProvider ServiceProvider { get; set; }
static Fixtures()
@@ -95,8 +100,8 @@ public static class Fixtures
Dictionary<string, string> tokenBody = new()
{
["accessCode"] = accessCode,
["appId"] = "spklwebapp",
["appSecret"] = "spklwebapp",
["appId"] = TestAuthApp.AppId,
["appSecret"] = TestAuthApp.AppSecret,
["challenge"] = "challengingchallenge",
};
@@ -109,8 +114,11 @@ public static class Fixtures
);
var token = deserialised.NotNull()["token"].NotNull();
var refreshToken = deserialised.NotNull()["refreshToken"].NotNull();
return await ServiceProvider.GetRequiredService<IAccountFactory>().CreateAccount(new(Server.url), token);
return await ServiceProvider
.GetRequiredService<IAccountFactory>()
.CreateAccount(new(Server.url), token, refreshToken);
}
public static Base GenerateSimpleObject()
@@ -0,0 +1,76 @@
using Microsoft.Extensions.DependencyInjection;
using Speckle.Sdk.Api;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Common;
using Speckle.Sdk.Pipelines.Progress;
namespace Speckle.Sdk.Tests.Integration.Pipelines.Progress;
[Trait("Server", "Internal")]
public class IngestionProgressManagerTests : IAsyncLifetime
{
private IIngestionProgressManagerFactory _factory;
private IClient _client;
private Project _project;
private Model _model;
private ModelIngestion _ingestion;
public async Task InitializeAsync()
{
var serviceProvider = TestServiceSetup.GetServiceProvider();
_factory = serviceProvider.GetRequiredService<IIngestionProgressManagerFactory>();
_client = await Fixtures.SeedUserWithClient();
_project = await _client.Project.Create(new("test", null, default));
_model = await _client.Model.Create(new("test", null, _project.id));
_ingestion = await _client.Ingestion.Create(
new(_model.id, _project.id, "Testing ingestion", new("integrationTests", "0.0.0", null, null))
);
}
[Fact]
public async Task TestProgress_NoThrottle()
{
var sut = _factory.CreateInstance(_client, _ingestion, TimeSpan.Zero, CancellationToken.None);
const string FIRST_MESSAGE = "This is a test 123";
const string SECOND_MESSAGE = "This is another test 321";
// first message (should go through)
sut.Report(new CardProgress(FIRST_MESSAGE, 0.123123123d));
await sut.LastUpdate.NotNull();
var res = await _client.Ingestion.Get(_ingestion.id, _project.id, CancellationToken.None);
Assert.Equal(FIRST_MESSAGE, res.statusData.progressMessage);
// second message (should also go through)
sut.Report(new CardProgress(SECOND_MESSAGE, 0.321321321d));
await sut.LastUpdate.NotNull();
res = await _client.Ingestion.Get(_ingestion.id, _project.id, CancellationToken.None);
Assert.Equal(SECOND_MESSAGE, res.statusData.progressMessage);
}
[Fact]
public async Task TestProgress_WithThrottle()
{
var sut = _factory.CreateInstance(_client, _ingestion, TimeSpan.FromMilliseconds(500), CancellationToken.None);
const string EXPECTED_MESSAGE = "First message should go through 123";
await Task.Delay(TimeSpan.FromMilliseconds(600));
// first message (should go through)
sut.Report(new CardProgress(EXPECTED_MESSAGE, 0.123123123d));
// second message (should be dropped)
sut.Report(new CardProgress("Second message, should be dropped", 0.321321321d));
await sut.LastUpdate.NotNull();
var res = await _client.Ingestion.Get(_ingestion.id, _project.id, CancellationToken.None);
Assert.Equal(EXPECTED_MESSAGE, res.statusData.progressMessage);
}
public Task DisposeAsync()
{
_client.Dispose();
return Task.CompletedTask;
}
}
@@ -16,4 +16,7 @@
<ProjectReference Include="..\..\src\Speckle.Sdk\Speckle.Sdk.csproj" />
<ProjectReference Include="..\Speckle.Sdk.Testing\Speckle.Sdk.Testing.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Pipelines\Send\" />
</ItemGroup>
</Project>
@@ -92,11 +92,6 @@
"resolved": "1.17.0",
"contentHash": "8x+HCVTl/HHTGpscH3vMBhV8sknN/muZFw9s3TsI8SA6+c43cOTCi2+jE4KsU8pNLbJ++iF2ZFcpcXHXtDglnw=="
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "1.1.0",
"contentHash": "1Am6l4Vpn3/K32daEqZI+FFr96OlZkgwK2LcT3pZ2zWubR5zTPW3/FkO1Rat9kb7oQOa4rxgl9LJHc5tspCWfg=="
},
"Microsoft.Build.Tasks.Git": {
"type": "Transitive",
"resolved": "8.0.0",
@@ -377,6 +372,12 @@
"System.Reactive": "5.0.0"
}
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "CentralTransitive",
"requested": "[9.0.4, )",
"resolved": "1.1.0",
"contentHash": "1Am6l4Vpn3/K32daEqZI+FFr96OlZkgwK2LcT3pZ2zWubR5zTPW3/FkO1Rat9kb7oQOa4rxgl9LJHc5tspCWfg=="
},
"Microsoft.Data.Sqlite": {
"type": "CentralTransitive",
"requested": "[7.0.5, )",
@@ -3,7 +3,6 @@ using Moq;
using Speckle.Newtonsoft.Json;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Credentials;
using Speckle.Sdk.Helpers;
using Speckle.Sdk.SQLite;
using Speckle.Sdk.Testing;
@@ -28,14 +27,11 @@ public sealed class AccountManagerTests : MoqTest
) => throw new NotImplementedException();
}
private readonly Mock<ISpeckleApplication> _mockApplication;
private readonly Mock<ILogger<AccountManager>> _mockLogger;
private readonly Mock<IGraphQLClientFactory> _mockGraphQLClientFactory;
private readonly Mock<ISpeckleHttp> _mockSpeckleHttp;
private readonly IAccountFactory _mockAccountFactory;
private readonly Mock<ISqLiteJsonCacheManagerFactory> _mockSqLiteJsonCacheManagerFactory;
private readonly Mock<ISqLiteJsonCacheManager> _mockAccountStorage;
private readonly Mock<ISqLiteJsonCacheManager> _mockAccountAddLockStorage;
private readonly Mock<IAuthFlow> _mockAuthFlow;
#pragma warning disable CA2213
private readonly AccountManager _accountManager;
@@ -43,27 +39,19 @@ public sealed class AccountManagerTests : MoqTest
public AccountManagerTests()
{
_mockApplication = Create<ISpeckleApplication>();
_mockLogger = Create<ILogger<AccountManager>>(MockBehavior.Loose);
_mockGraphQLClientFactory = Create<IGraphQLClientFactory>();
_mockSpeckleHttp = Create<ISpeckleHttp>();
_mockAccountFactory = new TestAccountFactory();
_mockSqLiteJsonCacheManagerFactory = Create<ISqLiteJsonCacheManagerFactory>();
_mockAuthFlow = Create<IAuthFlow>();
_mockAccountStorage = Create<ISqLiteJsonCacheManager>();
_mockAccountAddLockStorage = Create<ISqLiteJsonCacheManager>();
_mockSqLiteJsonCacheManagerFactory.Setup(f => f.CreateForUser("Accounts")).Returns(_mockAccountStorage.Object);
_mockSqLiteJsonCacheManagerFactory
.Setup(f => f.CreateForUser("AccountAddFlow"))
.Returns(_mockAccountAddLockStorage.Object);
_accountManager = new AccountManager(
_mockApplication.Object,
_mockLogger.Object,
_mockGraphQLClientFactory.Object,
_mockSpeckleHttp.Object,
_mockAccountFactory,
_mockAuthFlow.Object,
_mockSqLiteJsonCacheManagerFactory.Object
);
}
@@ -330,71 +318,6 @@ public sealed class AccountManagerTests : MoqTest
);
}
[Fact]
public void GetLocalIdentifierForAccount_ReturnsIdentifier_WhenAccountExists()
{
// Arrange
var account = CreateTestAccount("test-account");
var expectedUri = new Uri($"{account.serverInfo.url}?id={account.userInfo.id}");
_mockAccountStorage.Setup(s => s.GetAllObjects()).Returns(new[] { ("bad", JsonConvert.SerializeObject(account)) });
// Act
var result = _accountManager.GetLocalIdentifierForAccount(account);
// Assert
Assert.NotNull(result);
Assert.Equal(expectedUri, result);
}
[Fact]
public void GetLocalIdentifierForAccount_ReturnsNull_WhenAccountDoesNotExist()
{
// Arrange
var account = CreateTestAccount("non-existent-account");
_mockAccountStorage.Setup(s => s.GetAllObjects()).Returns([]);
// Act
var result = _accountManager.GetLocalIdentifierForAccount(account);
// Assert
Assert.Null(result);
}
[Fact]
public void GetAccountForLocalIdentifier_ReturnsAccount_WhenMatches()
{
// Arrange
var account = CreateTestAccount("test-account");
var localIdentifier = new Uri($"{account.serverInfo.url}?id={account.userInfo.id}");
_mockAccountStorage.Setup(s => s.GetAllObjects()).Returns(new[] { ("bad", JsonConvert.SerializeObject(account)) });
// Act
var result = _accountManager.GetAccountForLocalIdentifier(localIdentifier);
// Assert
Assert.NotNull(result);
Assert.Equal(account.id, result!.id);
}
[Fact]
public void GetAccountForLocalIdentifier_ReturnsNull_WhenNoMatch()
{
// Arrange
var account = CreateTestAccount("test-account");
var localIdentifier = new Uri("https://different.url?u=different-user");
_mockAccountStorage.Setup(s => s.GetAllObjects()).Returns(new[] { ("bad", JsonConvert.SerializeObject(account)) });
// Act
var result = _accountManager.GetAccountForLocalIdentifier(localIdentifier);
// Assert
Assert.Null(result);
}
// Helper method to create a test account
private static Account CreateTestAccount(string id)
{
@@ -48,7 +48,7 @@ public class CredentialInfrastructure : IDisposable
{
Fixtures.UpdateOrSaveAccount(s_testAccount1);
Fixtures.UpdateOrSaveAccount(s_testAccount2);
Fixtures.SaveLocalAccount(s_testAccount3);
Fixtures.UpdateOrSaveAccount(s_testAccount3);
var serviceProvider = TestServiceSetup.GetServiceProvider();
_accountManager = serviceProvider.GetRequiredService<IAccountManager>();
@@ -60,7 +60,6 @@ public class CredentialInfrastructure : IDisposable
Fixtures.DeleteLocalAccount(s_testAccount1.id);
Fixtures.DeleteLocalAccount(s_testAccount2.id);
Fixtures.DeleteLocalAccount(s_testAccount3.id);
Fixtures.DeleteLocalAccountFile();
}
[Fact]
@@ -93,7 +92,7 @@ public class CredentialInfrastructure : IDisposable
{
var accs = _accountManager.GetAccounts(target.serverInfo.url).ToList();
accs.Count.Should().Be(1);
accs.Should().HaveCount(1);
var acc = accs[0];
@@ -103,24 +102,4 @@ public class CredentialInfrastructure : IDisposable
acc.refreshToken.Should().Be(target.refreshToken);
acc.token.Should().Be(target.token);
}
[Fact]
public void EnsureLocalIdentifiers_AreUniqueAcrossServers()
{
// Accounts with the same user ID in different servers should always result in different local identifiers.
string id = "12345";
var acc1 = new Account
{
serverInfo = new ServerInfo { url = "https://speckle.xyz" },
userInfo = new UserInfo { id = id },
}.GetLocalIdentifier();
var acc2 = new Account
{
serverInfo = new ServerInfo { url = "https://app.speckle.systems" },
userInfo = new UserInfo { id = id },
}.GetLocalIdentifier();
acc1.Should().NotBe(acc2);
}
}
@@ -0,0 +1,36 @@
using Speckle.Sdk.Credentials;
namespace Speckle.Sdk.Tests.Unit.Credentials;
public class AuthFlowTests
{
private const int REPEAT = 20;
[Fact]
public void GenerateChallenge_ReturnsValidUniqueChallenge()
{
var codeVerifiers = Enumerable.Range(0, REPEAT).Select(_ => AuthFlow.GenerateCodeVerifier()).ToArray();
Assert.All(
codeVerifiers,
item =>
{
Assert.Equal(43, item.Length);
Assert.Matches(@"^[A-Za-z0-9\-_+/]*$", item);
}
);
Assert.Equivalent(codeVerifiers, codeVerifiers.Distinct());
var challenges = codeVerifiers.Select(AuthFlow.GenerateCodeChallenge).ToArray();
Assert.All(
challenges,
item =>
{
Assert.Equal(43, item.Length);
Assert.Matches(@"^[A-Za-z0-9\-_+/]*$", item);
}
);
Assert.Equivalent(challenges, challenges.Distinct());
}
}
-14
View File
@@ -1,7 +1,6 @@
using Newtonsoft.Json;
using Speckle.Sdk.Common;
using Speckle.Sdk.Credentials;
using Speckle.Sdk.Logging;
using Speckle.Sdk.Transports;
namespace Speckle.Sdk.Tests.Unit;
@@ -10,11 +9,6 @@ public abstract class Fixtures
{
private static readonly SQLiteTransport s_accountStorage = new(scope: "Accounts");
private static readonly string s_accountPath = Path.Combine(
SpecklePathProvider.AccountsFolderPath,
"TestAccount.json"
);
public static void UpdateOrSaveAccount(Account account)
{
DeleteLocalAccount(account.id.NotNull());
@@ -22,13 +16,5 @@ public abstract class Fixtures
s_accountStorage.SaveObjectSync(account.id, serializedObject);
}
public static void SaveLocalAccount(Account account)
{
var json = JsonConvert.SerializeObject(account);
File.WriteAllText(s_accountPath, json);
}
public static void DeleteLocalAccount(string id) => s_accountStorage.DeleteObject(id);
public static void DeleteLocalAccountFile() => File.Delete(s_accountPath);
}
@@ -0,0 +1,21 @@
using Moq;
using Speckle.Sdk.Pipelines.Progress;
namespace Speckle.Sdk.Tests.Unit.Pipelines.Progress;
public class AggregateProgressTests
{
[Fact]
public void Report_InvokesReportOnAllInnerProgresses()
{
var mock1 = new Mock<IProgress<int>>();
var mock2 = new Mock<IProgress<int>>();
const int TEST_VALUE = 42;
var target = new AggregateProgress<int>(mock1.Object, mock2.Object);
target.Report(TEST_VALUE);
mock1.Verify(x => x.Report(TEST_VALUE), Times.Once);
mock2.Verify(x => x.Report(TEST_VALUE), Times.Once);
}
}
@@ -0,0 +1,72 @@
using System.Diagnostics.CodeAnalysis;
using Moq;
using Speckle.Sdk.Pipelines.Progress;
namespace Speckle.Sdk.Tests.Unit.Pipelines.Progress;
[SuppressMessage(
"Performance",
"CA1835:Prefer the \'Memory\'-based overloads for \'ReadAsync\' and \'WriteAsync\'",
Justification = "Need to test it"
)]
public class ProgressStreamTests : IDisposable
{
private readonly Mock<Stream> _innerStreamMock;
private readonly Mock<IProgress<StreamProgressArgs>> _progressMock;
private readonly ProgressStream _sut;
public ProgressStreamTests()
{
// Setup the mocks
_innerStreamMock = new Mock<Stream>();
_innerStreamMock.Setup(s => s.Length).Returns(1024L);
_progressMock = new Mock<IProgress<StreamProgressArgs>>();
// Inject mocks into the System Under Test
_sut = new ProgressStream(_innerStreamMock.Object, _progressMock.Object);
}
[Fact]
public async Task ReadAsync_Should_CallInnerStreamAndReportProgress()
{
// Arrange
var buffer = new byte[10];
_innerStreamMock
.Setup(s => s.ReadAsync(buffer, 0, buffer.Length, CancellationToken.None))
.Returns(Task.FromResult(5));
// Act
await _sut.ReadAsync(buffer, 0, buffer.Length);
// Assert - Inner Stream Read was called
_innerStreamMock.Verify(s => s.ReadAsync(buffer, 0, buffer.Length, CancellationToken.None), Times.Once);
// Assert - Progress Report was called with the correct byte count
_progressMock.Verify(p => p.Report(It.IsAny<StreamProgressArgs>()), Times.Once);
}
[Fact]
public async Task WriteAsync_Should_CallInnerStreamAndReportProgress()
{
// Arrange
var buffer = new byte[10];
_innerStreamMock
.Setup(s => s.WriteAsync(buffer, 0, buffer.Length, CancellationToken.None))
.Returns(Task.FromResult(5));
// Act
await _sut.WriteAsync(buffer, 0, buffer.Length);
// Assert - Inner Stream Write was called
_innerStreamMock.Verify(s => s.WriteAsync(buffer, 0, buffer.Length, CancellationToken.None), Times.Once);
// Assert - Progress Report was called with the correct byte count
_progressMock.Verify(p => p.Report(It.IsAny<StreamProgressArgs>()), Times.Once);
}
public void Dispose()
{
_sut.Dispose();
}
}
@@ -0,0 +1,46 @@
using Speckle.Sdk.Pipelines.Progress;
namespace Speckle.Sdk.Tests.Unit.Pipelines.Progress;
public class RenderedStreamProgressTests
{
[Theory]
[InlineData(1, "B", 1.0)]
[InlineData(1024, "B", 1.0)]
[InlineData(1024 + 1, "KB", 1.0 / 1024)]
[InlineData(1024 * 1024, "KB", 1.0 / 1024)]
[InlineData(1024 * 1024 + 1, "MB", 1.0 / (1024 * 1024))]
[InlineData(1024 * 1024 * 1024, "MB", 1.0 / (1024 * 1024))]
[InlineData(1024 * 1024 * 1024 + 1, "GB", 1.0 / (1024 * 1024 * 1024))]
[InlineData(1024L * 1024L * 1024L * 1024L, "GB", 1.0 / (1024L * 1024L * 1024L))]
public void GetFileSizeRendering_WithPositiveValue_ReturnsCorrectSuffix(
long value,
string expectedSuffix,
double expectedScaleFactor
)
{
var result = RenderedStreamProgress.GetFileSizeRendering(value);
Assert.Equal(expectedSuffix, result.suffix);
Assert.Equal(expectedScaleFactor, result.scaleFactor);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(-1000)]
public void GetFileSizeRendering_WithNonPositiveValue_ReturnsBytesSuffix(long value)
{
var result = RenderedStreamProgress.GetFileSizeRendering(value);
Assert.Equal("B", result.suffix);
Assert.Equal(1d, result.scaleFactor);
}
[Theory]
[InlineData(long.MaxValue)]
public void GetFileSizeRendering_WithVeryLargeValue_ThrowsArgumentOutOfRangeException(long value)
{
Assert.Throws<ArgumentOutOfRangeException>(() => RenderedStreamProgress.GetFileSizeRendering(value));
}
}