Flesh out GraphQL client

This commit is contained in:
Jordan Dominion
2024-09-17 20:12:25 -04:00
parent 8e030eda38
commit c9c24a5dbf
22 changed files with 982 additions and 138 deletions
@@ -0,0 +1,97 @@
using System;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StrawberryShake;
using Tgstation.Server.Common.Extensions;
namespace Tgstation.Server.Client.GraphQL
{
/// <inheritdoc cref="IAuthenticatedGraphQLServerClient" />
sealed class AuthenticatedGraphQLServerClient : GraphQLServerClient, IAuthenticatedGraphQLServerClient
{
/// <inheritdoc />
public ITransferClient TransferClient => restClient!.Transfer;
/// <summary>
/// A <see cref="Func{T, TResult}"/> that takes a bearer token as input and outputs a <see cref="ITransferClient"/> that uses it.
/// </summary>
readonly Func<string, IRestServerClient>? getRestClientForToken;
/// <summary>
/// The current <see cref="IRestServerClient"/>.
/// </summary>
IRestServerClient? restClient;
/// <summary>
/// Initializes a new instance of the <see cref="AuthenticatedGraphQLServerClient"/> class.
/// </summary>
/// <param name="graphQLClient">The <see cref="IGraphQLClient"/> to use.</param>
/// <param name="serviceProvider">The <see cref="IAsyncDisposable"/> to use.</param>
/// <param name="logger">The <see cref="ILogger"/> to use.</param>
/// <param name="restClient">The value of <see cref="restClient"/>.</param>
public AuthenticatedGraphQLServerClient(
IGraphQLClient graphQLClient,
IAsyncDisposable serviceProvider,
ILogger<GraphQLServerClient> logger,
IRestServerClient restClient)
: base(graphQLClient, serviceProvider, logger)
{
this.restClient = restClient ?? throw new ArgumentNullException(nameof(restClient));
}
/// <summary>
/// Initializes a new instance of the <see cref="AuthenticatedGraphQLServerClient"/> class.
/// </summary>
/// <param name="graphQLClient">The <see cref="IGraphQLClient"/> to use.</param>
/// <param name="serviceProvider">The <see cref="IAsyncDisposable"/> to use.</param>
/// <param name="logger">The <see cref="ILogger"/> to use.</param>
/// <param name="setAuthenticationHeader">The <see cref="Action{T}"/> to call to set the async local <see cref="AuthenticationHeaderValue"/> for requests.</param>
/// <param name="basicCredentialsHeader">The basic <see cref="AuthenticationHeaderValue"/> to use for reauthentication.</param>
/// <param name="loginResult">The <see cref="ILoginResult"/> <see cref="IOperationResult{TResultData}"/> containing the initial JWT to use.</param>
/// <param name="getRestClientForToken">The value of <see cref="getRestClientForToken"/>.</param>
public AuthenticatedGraphQLServerClient(
IGraphQLClient graphQLClient,
IAsyncDisposable serviceProvider,
ILogger<GraphQLServerClient> logger,
Action<AuthenticationHeaderValue> setAuthenticationHeader,
AuthenticationHeaderValue? basicCredentialsHeader,
IOperationResult<ILoginResult> loginResult,
Func<string, IRestServerClient> getRestClientForToken)
: base(
graphQLClient,
serviceProvider,
logger,
setAuthenticationHeader,
basicCredentialsHeader,
loginResult)
{
this.getRestClientForToken = getRestClientForToken ?? throw new ArgumentNullException(nameof(getRestClientForToken));
restClient = getRestClientForToken(loginResult.Data!.Login.Bearer!.EncodedToken);
}
/// <inheritdoc />
public sealed override ValueTask DisposeAsync()
#pragma warning disable CA2012 // Use ValueTasks correctly
=> ValueTaskExtensions.WhenAll(
base.DisposeAsync(),
restClient!.DisposeAsync());
#pragma warning restore CA2012 // Use ValueTasks correctly
/// <inheritdoc />
protected sealed override async ValueTask<AuthenticationHeaderValue> CreateUpdatedAuthenticationHeader(string bearer)
{
var baseTask = base.CreateUpdatedAuthenticationHeader(bearer);
if (restClient != null)
await restClient.DisposeAsync().ConfigureAwait(false);
if (getRestClientForToken != null)
restClient = getRestClientForToken(bearer);
return await baseTask.ConfigureAwait(false);
}
}
}
@@ -0,0 +1,51 @@
using System;
namespace Tgstation.Server.Client.GraphQL
{
/// <summary>
/// <see cref="Exception"/> thrown when automatic <see cref="IGraphQLServerClient"/> authentication fails.
/// </summary>
public sealed class AuthenticationException : Exception
{
/// <summary>
/// The <see cref="ILogin_Login_Errors_ErrorMessageError"/>.
/// </summary>
public ILogin_Login_Errors_ErrorMessageError? ErrorMessage { get; }
/// <summary>
/// Initializes a new instance of the <see cref="AuthenticationException"/> class.
/// </summary>
/// <param name="errorMessage">The value of <see cref="ErrorMessage"/>.</param>
public AuthenticationException(ILogin_Login_Errors_ErrorMessageError errorMessage)
: base(errorMessage?.Message)
{
ErrorMessage = errorMessage ?? throw new ArgumentNullException(nameof(errorMessage));
}
/// <summary>
/// Initializes a new instance of the <see cref="AuthenticationException"/> class.
/// </summary>
public AuthenticationException()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AuthenticationException"/> class.
/// </summary>
/// <param name="message">The <see cref="Exception.Message"/>.</param>
public AuthenticationException(string message)
: base(message)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AuthenticationException"/> class.
/// </summary>
/// <param name="message">The <see cref="Exception.Message"/>.</param>
/// <param name="innerException">The <see cref="Exception.InnerException"/>.</param>
public AuthenticationException(string message, Exception innerException)
: base(message, innerException)
{
}
}
}
@@ -0,0 +1,42 @@
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
namespace Tgstation.Server.Client.GraphQL
{
/// <summary>
/// <see cref="DelegatingHandler"/> that applies the <see cref="AuthenticationHeaderValue"/>.
/// </summary>
sealed class AuthorizationMessageHandler : DelegatingHandler
{
/// <summary>
/// The <see cref="AsyncLocal{T}"/> <see cref="AuthenticationHeaderValue"/> to be applied.
/// </summary>
public static AsyncLocal<AuthenticationHeaderValue?> Header { get; } = new AsyncLocal<AuthenticationHeaderValue?>();
/// <summary>
/// <see langword="class"/> override for <see cref="Header"/>.
/// </summary>
readonly AuthenticationHeaderValue? headerOverride;
/// <summary>
/// Initializes a new instance of the <see cref="AuthorizationMessageHandler"/> class.
/// </summary>
/// <param name="headerOverride">The value of <see cref="headerOverride"/>.</param>
public AuthorizationMessageHandler(AuthenticationHeaderValue? headerOverride)
{
this.headerOverride = headerOverride;
}
/// <inheritdoc />
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var currentAuthHeader = headerOverride ?? Header.Value;
if (currentAuthHeader != null)
request.Headers.Authorization = currentAuthHeader;
return base.SendAsync(request, cancellationToken);
}
}
}
@@ -0,0 +1,18 @@
query OAuthInformation {
swarm {
currentNode {
gateway {
information {
oAuthProviderInfos {
key
value {
clientId
redirectUri
serverUrl
}
}
}
}
}
}
}
@@ -0,0 +1,11 @@
query ServerVersion {
swarm {
currentNode {
gateway {
information {
version
}
}
}
}
}
@@ -1,11 +1,35 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StrawberryShake;
using Tgstation.Server.Api;
namespace Tgstation.Server.Client.GraphQL
{
/// <inheritdoc />
class GraphQLServerClient : IGraphQLServerClient
{
/// <summary>
/// If the <see cref="GraphQLServerClient"/> was initially authenticated.
/// </summary>
[MemberNotNullWhen(true, nameof(setAuthenticationHeader))]
[MemberNotNullWhen(true, nameof(bearerCredentialsTask))]
bool Authenticated => basicCredentialsHeader != null;
/// <summary>
/// If the <see cref="GraphQLServerClient"/> supports reauthentication.
/// </summary>
[MemberNotNullWhen(true, nameof(bearerCredentialsHeaderTaskLock))]
[MemberNotNullWhen(true, nameof(basicCredentialsHeader))]
bool CanReauthenticate => basicCredentialsHeader != null;
/// <summary>
/// The <see cref="IGraphQLClient"/> for the <see cref="GraphQLServerClient"/>.
/// </summary>
@@ -16,27 +40,232 @@ namespace Tgstation.Server.Client.GraphQL
/// </summary>
readonly IAsyncDisposable serviceProvider;
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="GraphQLServerClient"/>.
/// </summary>
readonly ILogger<GraphQLServerClient> logger;
/// <summary>
/// The <see cref="Action{T}"/> which sets the <see cref="AuthenticationHeaderValue"/> for HTTP request in the current async context.
/// </summary>
readonly Action<AuthenticationHeaderValue>? setAuthenticationHeader;
/// <summary>
/// The <see cref="AuthenticationHeaderValue"/> containing the authenticated user's password credentials.
/// </summary>
readonly AuthenticationHeaderValue? basicCredentialsHeader;
/// <summary>
/// <see langword="lock"/> <see cref="object"/> used to synchronize access to <see cref="bearerCredentialsTask"/>.
/// </summary>
readonly object? bearerCredentialsHeaderTaskLock;
/// <summary>
/// A <see cref="Task{TResult}"/> resulting in a <see cref="ValueTuple{T1, T2}"/> containing the current <see cref="AuthenticationHeaderValue"/> for the <see cref="ApiHeaders.BearerAuthenticationScheme"/> and the <see cref="DateTime"/> it expires.
/// </summary>
Task<(AuthenticationHeaderValue Header, DateTime Exp)?>? bearerCredentialsTask;
/// <summary>
/// Throws an <see cref="AuthenticationException"/> for a login error that previously occured outside of the current call context.
/// </summary>
/// <exception cref="AuthenticationException">Always thrown.</exception>
[DoesNotReturn]
static void ThrowOtherCallerFailedAuthException()
=> throw new AuthenticationException("Another caller failed to authenticate!");
/// <summary>
/// Checks if a given <paramref name="operationResult"/> errored out with authentication errors.
/// </summary>
/// <param name="operationResult">The <see cref="IOperationResult"/>.</param>
/// <returns><see langword="true"/> if <paramref name="operationResult"/> errored due to authentication issues, <see langword="false"/> otherwise.</returns>
static bool IsAuthenticationError(IOperationResult operationResult)
=> operationResult.Data == null
&& operationResult.Errors.Any(
error => error.Extensions?.TryGetValue(
"code",
out object? codeExtension) == true
&& codeExtension is string codeExtensionString
&& codeExtensionString == "AUTH_NOT_AUTHENTICATED");
/// <summary>
/// Initializes a new instance of the <see cref="GraphQLServerClient"/> class.
/// </summary>
/// <param name="graphQLClient">The value of <see cref="graphQLClient"/>.</param>
/// <param name="serviceProvider">The value of <see cref="serviceProvider"/>.</param>
/// <param name="logger">The value of <see cref="logger"/>.</param>
public GraphQLServerClient(
IGraphQLClient graphQLClient,
IAsyncDisposable serviceProvider)
IAsyncDisposable serviceProvider,
ILogger<GraphQLServerClient> logger)
{
this.graphQLClient = graphQLClient ?? throw new ArgumentNullException(nameof(graphQLClient));
this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Initializes a new instance of the <see cref="GraphQLServerClient"/> class.
/// </summary>
/// <param name="graphQLClient">The value of <see cref="graphQLClient"/>.</param>
/// <param name="serviceProvider">The value of <see cref="serviceProvider"/>.</param>
/// <param name="logger">The value of <see cref="logger"/>.</param>
/// <param name="setAuthenticationHeader">The value of <see cref="setAuthenticationHeader"/>.</param>
/// <param name="basicCredentialsHeader">The value of <see cref="basicCredentialsHeader"/>.</param>
/// <param name="loginResult">The <see cref="ILoginResult"/> <see cref="IOperationResult{TResultData}"/> containing the initial JWT to use.</param>
protected GraphQLServerClient(
IGraphQLClient graphQLClient,
IAsyncDisposable serviceProvider,
ILogger<GraphQLServerClient> logger,
Action<AuthenticationHeaderValue> setAuthenticationHeader,
AuthenticationHeaderValue? basicCredentialsHeader,
IOperationResult<ILoginResult> loginResult)
: this(graphQLClient, serviceProvider, logger)
{
this.setAuthenticationHeader = setAuthenticationHeader ?? throw new ArgumentNullException(nameof(setAuthenticationHeader));
ArgumentNullException.ThrowIfNull(loginResult);
this.basicCredentialsHeader = basicCredentialsHeader;
var task = CreateCredentialsTuple(loginResult);
if (!task.IsCompleted)
throw new InvalidOperationException($"Expected {nameof(CreateCredentialsTuple)} to not await in constructor!");
bearerCredentialsTask = Task.FromResult<(AuthenticationHeaderValue Header, DateTime Exp)?>(task.Result);
if (Authenticated)
bearerCredentialsHeaderTaskLock = new object();
}
/// <inheritdoc />
public ValueTask DisposeAsync() => serviceProvider.DisposeAsync();
public virtual ValueTask DisposeAsync() => serviceProvider.DisposeAsync();
/// <inheritdoc />
public virtual ValueTask RunQuery(Func<IGraphQLClient, ValueTask> queryExector)
public ValueTask<IOperationResult<TResultData>> RunOperationAsync<TResultData>(Func<IGraphQLClient, ValueTask<IOperationResult<TResultData>>> queryExector, CancellationToken cancellationToken)
where TResultData : class
{
ArgumentNullException.ThrowIfNull(queryExector);
return queryExector(graphQLClient);
return WrapAuthentication(queryExector, cancellationToken);
}
/// <inheritdoc />
public ValueTask<IOperationResult<TResultData>> RunOperation<TResultData>(Func<IGraphQLClient, Task<IOperationResult<TResultData>>> queryExector, CancellationToken cancellationToken)
where TResultData : class
{
ArgumentNullException.ThrowIfNull(queryExector);
return WrapAuthentication(async localClient => await queryExector(localClient), cancellationToken);
}
/// <summary>
/// Create a <see cref="AuthenticationHeaderValue"/> from a given <paramref name="bearer"/> token.
/// </summary>
/// <param name="bearer">The <see cref="ApiHeaders.BearerAuthenticationScheme"/> <see cref="string"/>.</param>
/// <returns>A new <see cref="AuthenticationHeaderValue"/>.</returns>
protected virtual ValueTask<AuthenticationHeaderValue> CreateUpdatedAuthenticationHeader(string bearer)
=> ValueTask.FromResult(
new AuthenticationHeaderValue(
ApiHeaders.BearerAuthenticationScheme,
bearer));
/// <summary>
/// Executes a given <paramref name="operationExecutor"/>, potentially accounting for authentication issues.
/// </summary>
/// <typeparam name="TResultData">The <see cref="Type"/> of the <see cref="IOperationResult{TResultData}"/>'s <see cref="IOperationResult{TResultData}.Data"/>.</typeparam>
/// <param name="operationExecutor">A <see cref="Func{T, TResult}"/> which executes a single query on a given <see cref="IGraphQLClient"/> and returns a <see cref="ValueTask{TResult}"/> resulting in the <typeparamref name="TResultData"/> <see cref="IOperationResult{TResultData}"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IOperationResult{TResultData}"/>.</returns>
async ValueTask<IOperationResult<TResultData>> WrapAuthentication<TResultData>(Func<IGraphQLClient, ValueTask<IOperationResult<TResultData>>> operationExecutor, CancellationToken cancellationToken)
where TResultData : class
{
if (!Authenticated)
return await operationExecutor(graphQLClient).ConfigureAwait(false);
var tuple = await bearerCredentialsTask.ConfigureAwait(false);
if (!tuple.HasValue)
ThrowOtherCallerFailedAuthException();
async ValueTask<AuthenticationHeaderValue> Reauthenticate(AuthenticationHeaderValue currentToken, CancellationToken cancellationToken)
{
if (!CanReauthenticate)
throw new AuthenticationException("Authentication expired or invalid and cannot re-authenticate.");
TaskCompletionSource<(AuthenticationHeaderValue Header, DateTime Exp)?>? tcs = null;
do
{
var bearerCredentialsTaskLocal = bearerCredentialsTask;
if (!bearerCredentialsTaskLocal!.IsCompleted)
{
var currentTuple = await bearerCredentialsTaskLocal.ConfigureAwait(false);
if (!currentTuple.HasValue)
ThrowOtherCallerFailedAuthException();
return currentTuple.Value.Header;
}
lock (bearerCredentialsHeaderTaskLock!)
{
if (bearerCredentialsTask == bearerCredentialsTaskLocal)
{
var result = bearerCredentialsTaskLocal.Result;
if (result?.Header != currentToken)
{
if (!result.HasValue)
ThrowOtherCallerFailedAuthException();
return result.Value.Header;
}
tcs = new TaskCompletionSource<(AuthenticationHeaderValue, DateTime)?>();
bearerCredentialsTask = tcs.Task;
}
}
}
while (tcs == null);
setAuthenticationHeader!(basicCredentialsHeader!);
var loginResult = await graphQLClient.Login.ExecuteAsync(cancellationToken).ConfigureAwait(false);
try
{
var tuple = await CreateCredentialsTuple(loginResult).ConfigureAwait(false);
tcs.SetResult(tuple);
return tuple.Header;
}
catch (AuthenticationException)
{
tcs.SetResult(null);
throw;
}
}
var (currentAuthHeader, expires) = tuple.Value;
if (expires <= DateTimeOffset.UtcNow)
currentAuthHeader = await Reauthenticate(currentAuthHeader, cancellationToken).ConfigureAwait(false);
setAuthenticationHeader(currentAuthHeader);
var operationResult = await operationExecutor(graphQLClient);
if (IsAuthenticationError(operationResult))
{
currentAuthHeader = await Reauthenticate(currentAuthHeader, cancellationToken).ConfigureAwait(false);
setAuthenticationHeader(currentAuthHeader);
return await operationExecutor(graphQLClient);
}
return operationResult;
}
/// <summary>
/// Attempt to create the <see cref="ValueTuple{T1, T2}"/> for <see cref="bearerCredentialsTask"/>.
/// </summary>
/// <param name="loginResult">The <see cref="ILoginResult"/> <see cref="IOperationResult{TResultData}"/> to process.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new credentials <see cref="ValueTuple{T1, T2}"/>.</returns>
/// <exception cref="AuthenticationException">Thrown if the <paramref name="loginResult"/> errored.</exception>
async ValueTask<(AuthenticationHeaderValue Header, DateTime Exp)> CreateCredentialsTuple(IOperationResult<ILoginResult> loginResult)
{
var bearer = loginResult.EnsureSuccess(logger);
var header = await CreateUpdatedAuthenticationHeader(bearer.EncodedToken);
return (Header: header, Exp: bearer.ValidTo);
}
}
}
@@ -1,11 +1,18 @@
using System;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StrawberryShake;
using Tgstation.Server.Api;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Client.GraphQL.Serializers;
using Tgstation.Server.Common.Extensions;
namespace Tgstation.Server.Client.GraphQL
{
@@ -17,6 +24,45 @@ namespace Tgstation.Server.Client.GraphQL
/// </summary>
readonly IRestServerClientFactory restClientFactory;
/// <summary>
/// Sets up a <see cref="ServiceProvider"/> for providing the <see cref="IGraphQLClient"/>.
/// </summary>
/// <param name="host">The <see cref="Uri"/> of the target tgstation-server.</param>
/// <param name="addAuthorizationHandler">If the <see cref="AuthorizationMessageHandler"/> should be configured.</param>
/// <param name="headerOverride">The <see cref="AuthenticationHeaderValue"/> override for the <see cref="AuthorizationMessageHandler"/>.</param>
/// <param name="oAuthProvider">The <see cref="OAuthProvider"/>, if any.</param>
/// <returns>A new <see cref="ServiceProvider"/>.</returns>
static ServiceProvider SetupServiceProvider(Uri host, bool addAuthorizationHandler, AuthenticationHeaderValue? headerOverride = null, OAuthProvider? oAuthProvider = null)
{
var serviceCollection = new ServiceCollection();
var clientBuilder = serviceCollection
.AddGraphQLClient();
var graphQLEndpoint = new Uri(host, Routes.GraphQL);
clientBuilder.ConfigureHttpClient(
client =>
{
client.BaseAddress = graphQLEndpoint;
client.DefaultRequestHeaders.Add(ApiHeaders.ApiVersionHeader, $"Tgstation.Server.Api/{ApiHeaders.Version.Semver()}");
if (oAuthProvider.HasValue)
{
client.DefaultRequestHeaders.Add(ApiHeaders.OAuthProviderHeader, oAuthProvider.ToString());
}
},
clientBuilder =>
{
if (addAuthorizationHandler)
clientBuilder.AddHttpMessageHandler(() => new AuthorizationMessageHandler(headerOverride));
});
serviceCollection.AddSerializer<UnsignedIntSerializer>();
serviceCollection.AddSerializer<SemverSerializer>();
serviceCollection.AddSerializer<JwtSerializer>();
return serviceCollection.BuildServiceProvider();
}
/// <summary>
/// Initializes a new instance of the <see cref="GraphQLServerClientFactory"/> class.
/// </summary>
@@ -29,39 +75,138 @@ namespace Tgstation.Server.Client.GraphQL
/// <inheritdoc />
public ValueTask<IAuthenticatedGraphQLServerClient> CreateFromLogin(Uri host, string username, string password, bool attemptLoginRefresh = true, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
var basicCredentials = new AuthenticationHeaderValue(
ApiHeaders.BasicAuthenticationScheme,
Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{username}:{password}")));
return CreateWithAuthCall(
host,
basicCredentials,
null,
attemptLoginRefresh,
cancellationToken);
}
/// <inheritdoc />
public ValueTask<IAuthenticatedGraphQLServerClient> CreateFromOAuth(Uri host, string oAuthCode, OAuthProvider oAuthProvider, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
var oAuthCredentials = new AuthenticationHeaderValue(
ApiHeaders.OAuthAuthenticationScheme,
oAuthCode);
return CreateWithAuthCall(
host,
oAuthCredentials,
oAuthProvider,
false,
cancellationToken);
}
/// <inheritdoc />
public IAuthenticatedGraphQLServerClient CreateFromToken(Uri host, string token)
{
throw new NotImplementedException();
var authenticationHeader = new AuthenticationHeaderValue(
ApiHeaders.BearerAuthenticationScheme,
token);
var serviceProvider = SetupServiceProvider(
host,
true,
authenticationHeader);
return new AuthenticatedGraphQLServerClient(
serviceProvider.GetRequiredService<IGraphQLClient>(),
serviceProvider,
serviceProvider.GetRequiredService<ILogger<GraphQLServerClient>>(),
CreateAuthenticatedTransferClient(host, token));
}
/// <inheritdoc />
public IGraphQLServerClient CreateUnauthenticated(Uri host)
{
var serviceCollection = new ServiceCollection();
var clientBuilder = serviceCollection
.AddGraphQLClient();
var graphQLEndpoint = new Uri(host, Routes.GraphQL);
clientBuilder.ConfigureHttpClient(client => client.BaseAddress = graphQLEndpoint);
serviceCollection.AddSerializer<UnsignedIntSerializer>();
serviceCollection.AddSerializer<SemverSerializer>();
var serviceProvider = serviceCollection.BuildServiceProvider();
var serviceProvider = SetupServiceProvider(host, false);
return new GraphQLServerClient(
serviceProvider.GetRequiredService<IGraphQLClient>(),
serviceProvider);
serviceProvider,
serviceProvider.GetRequiredService<ILogger<GraphQLServerClient>>());
}
/// <summary>
/// Create an <see cref="IAuthenticatedGraphQLServerClient"/> from a remote login call.
/// </summary>
/// <param name="host">The URL to access TGS.</param>
/// <param name="initialCredentials">The initial <see cref="AuthenticationHeaderValue"/> to use to login.</param>
/// <param name="oAuthProvider">The <see cref="OAuthProvider"/>, if any.</param>
/// <param name="attemptLoginRefresh">If the client should attempt to renew its sessions with the <paramref name="initialCredentials"/>.</param>
/// <param name="cancellationToken">Optional <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new <see cref="IAuthenticatedGraphQLServerClient"/>.</returns>
/// <exception cref="AuthenticationException">Thrown when authentication fails.</exception>
async ValueTask<IAuthenticatedGraphQLServerClient> CreateWithAuthCall(
Uri host,
AuthenticationHeaderValue initialCredentials,
OAuthProvider? oAuthProvider,
bool attemptLoginRefresh,
CancellationToken cancellationToken)
{
var serviceProvider = SetupServiceProvider(
host,
true,
oAuthProvider: oAuthProvider);
try
{
var client = serviceProvider.GetRequiredService<IGraphQLClient>();
IOperationResult<ILoginResult> result;
var previousAuthHeader = AuthorizationMessageHandler.Header.Value;
AuthorizationMessageHandler.Header.Value = initialCredentials;
try
{
result = await client.Login.ExecuteAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
AuthorizationMessageHandler.Header.Value = previousAuthHeader;
}
var serverClient = new AuthenticatedGraphQLServerClient(
client,
serviceProvider,
serviceProvider.GetRequiredService<ILogger<GraphQLServerClient>>(),
newHeader => AuthorizationMessageHandler.Header.Value = newHeader,
attemptLoginRefresh ? initialCredentials : null,
result,
bearer => CreateAuthenticatedTransferClient(host, bearer));
await Task.Yield();
return serverClient;
}
catch
{
await serviceProvider.DisposeAsync().ConfigureAwait(false);
throw;
}
}
/// <summary>
/// Create a <see cref="ITransferClient"/> for a given <paramref name="host"/> and <paramref name="bearer"/> token.
/// </summary>
/// <param name="host">The URL to access TGS.</param>
/// <param name="bearer">The bearer token to access the API with.</param>
/// <returns>A new <see cref="IRestServerClient"/>.</returns>
IRestServerClient CreateAuthenticatedTransferClient(Uri host, string bearer)
{
var restClient = restClientFactory.CreateFromToken(
host,
new TokenResponse
{
Bearer = bearer,
});
return restClient;
}
}
}
@@ -1,6 +1,9 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using StrawberryShake;
namespace Tgstation.Server.Client.GraphQL
{
/// <summary>
@@ -9,10 +12,25 @@ namespace Tgstation.Server.Client.GraphQL
public interface IGraphQLServerClient : IAsyncDisposable
{
/// <summary>
/// Runs a given <paramref name="queryExector"/>. It may be invoked multiple times depending on the behavior of the <see cref="IGraphQLServerClient"/>.
/// Runs a given <paramref name="operationExecutor"/>. It may be invoked multiple times depending on the behavior of the <see cref="IGraphQLServerClient"/> if reauthentication is required.
/// </summary>
/// <param name="queryExector">A <see cref="Func{T, TResult}"/> which executes a single query on a given <see cref="IGraphQLClient"/> and returns a <see cref="ValueTask"/> representing the running operation.</param>
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
ValueTask RunQuery(Func<IGraphQLClient, ValueTask> queryExector);
/// <typeparam name="TResultData">The <see cref="Type"/> of the <see cref="IOperationResult{TResultData}"/>'s <see cref="IOperationResult{TResultData}.Data"/>.</typeparam>
/// <param name="operationExecutor">A <see cref="Func{T, TResult}"/> which executes a single query on a given <see cref="IGraphQLClient"/> and returns a <see cref="ValueTask{TResult}"/> resulting in the <typeparamref name="TResultData"/> <see cref="IOperationResult{TResultData}"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IOperationResult{TResultData}"/>.</returns>
/// <exception cref="AuthenticationException">Thrown when automatic reauthentication fails.</exception>
ValueTask<IOperationResult<TResultData>> RunOperationAsync<TResultData>(Func<IGraphQLClient, ValueTask<IOperationResult<TResultData>>> operationExecutor, CancellationToken cancellationToken)
where TResultData : class;
/// <summary>
/// Runs a given <paramref name="operationExecutor"/>. It may be invoked multiple times depending on the behavior of the <see cref="IGraphQLServerClient"/> if reauthentication is required.
/// </summary>
/// <typeparam name="TResultData">The <see cref="Type"/> of the <see cref="IOperationResult{TResultData}"/>'s <see cref="IOperationResult{TResultData}.Data"/>.</typeparam>
/// <param name="operationExecutor">A <see cref="Func{T, TResult}"/> which executes a single query on a given <see cref="IGraphQLClient"/> and returns a <see cref="ValueTask{TResult}"/> resulting in the <typeparamref name="TResultData"/> <see cref="IOperationResult{TResultData}"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IOperationResult{TResultData}"/>.</returns>
/// <exception cref="AuthenticationException">Thrown when automatic reauthentication fails.</exception>
ValueTask<IOperationResult<TResultData>> RunOperation<TResultData>(Func<IGraphQLClient, Task<IOperationResult<TResultData>>> operationExecutor, CancellationToken cancellationToken)
where TResultData : class;
}
}
@@ -2,8 +2,6 @@
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models.Response;
namespace Tgstation.Server.Client.GraphQL
{
/// <summary>
@@ -24,9 +22,10 @@ namespace Tgstation.Server.Client.GraphQL
/// <param name="host">The URL to access TGS.</param>
/// <param name="username">The username to for the <see cref="IGraphQLServerClient"/>.</param>
/// <param name="password">The password for the <see cref="IGraphQLServerClient"/>.</param>
/// <param name="attemptLoginRefresh">Attempt to refresh the received <see cref="TokenResponse"/> when it expires or becomes invalid. <paramref name="username"/> and <paramref name="password"/> will be stored in memory if this is <see langword="true"/>.</param>
/// <param name="attemptLoginRefresh">Attempt to refresh the received bearer token when it expires or becomes invalid. <paramref name="username"/> and <paramref name="password"/> will be stored in memory if this is <see langword="true"/>.</param>
/// <param name="cancellationToken">Optional <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new <see cref="IAuthenticatedGraphQLServerClient"/>.</returns>
/// <exception cref="AuthenticationException">Thrown when authentication fails.</exception>
ValueTask<IAuthenticatedGraphQLServerClient> CreateFromLogin(
Uri host,
string username,
@@ -42,6 +41,7 @@ namespace Tgstation.Server.Client.GraphQL
/// <param name="oAuthProvider">The <see cref="OAuthProvider"/>.</param>
/// <param name="cancellationToken">Optional <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new <see cref="IAuthenticatedGraphQLServerClient"/>.</returns>
/// <exception cref="AuthenticationException">Thrown when authentication fails.</exception>
ValueTask<IAuthenticatedGraphQLServerClient> CreateFromOAuth(
Uri host,
string oAuthCode,
@@ -52,7 +52,7 @@ namespace Tgstation.Server.Client.GraphQL
/// Create a <see cref="IRestServerClient"/>.
/// </summary>
/// <param name="host">The URL to access TGS.</param>
/// <param name="token">The <see cref="TokenResponse"/> to access the API with.</param>
/// <param name="token">The bearer token to access the API with.</param>
/// <returns>A new <see cref="IGraphQLServerClient"/>.</returns>
IAuthenticatedGraphQLServerClient CreateFromToken(
Uri host,
@@ -0,0 +1,74 @@
using System;
using System.Linq;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.JsonWebTokens;
using StrawberryShake;
namespace Tgstation.Server.Client.GraphQL
{
/// <summary>
/// Extensions for <see cref="ILoginResult"/>.
/// </summary>
static class LoginResultExtensions
{
/// <summary>
/// Check a given <paramref name="loginResult"/> for errors.
/// </summary>
/// <param name="loginResult">The <see cref="IOperationResult{TResultData}"/> containing the <see cref="ILoginResult"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> to write to.</param>
/// <returns>The <see cref="JsonWebToken"/> from the successful <paramref name="loginResult"/>.</returns>
/// <exception cref="AuthenticationException">Thrown when the <paramref name="loginResult"/> is errored.</exception>
public static JsonWebToken EnsureSuccess(this IOperationResult<ILoginResult> loginResult, ILogger logger)
{
ArgumentNullException.ThrowIfNull(loginResult);
try
{
loginResult.EnsureNoErrors();
}
catch (GraphQLClientException ex)
{
throw new AuthenticationException("Login attempt errored at the GraphQL level!", ex);
}
var data = loginResult.Data!.Login;
var errors = data.Errors;
if (errors != null)
{
foreach (var error in errors)
{
if (error is ILogin_Login_Errors_ErrorMessageError errorMessageError)
logger.LogError(
"Authentication error ({code}): {message}{additionalData}",
errorMessageError.ErrorCode?.ToString() ?? "No Code",
errorMessageError.Message,
errorMessageError.AdditionalData != null
? $"{Environment.NewLine}{errorMessageError.AdditionalData}"
: String.Empty);
else
logger.LogError(
"Unknown authentication error: {error}",
error);
}
}
var bearer = data.Bearer;
if (bearer == null)
{
if (errors != null)
{
var errorMessage = errors.OfType<ILogin_Login_Errors_ErrorMessageError>().FirstOrDefault();
if (errorMessage != null)
throw new AuthenticationException(errorMessage);
throw new AuthenticationException($"Null bearer field and {errors.Count} non-ErrorMessage errors:{(errors.Count > 0 ? $"{Environment.NewLine}\t- {String.Join($"{Environment.NewLine}\t- ", errors)}" : String.Empty)}");
}
throw new AuthenticationException($"Null bearer and error fields!");
}
return bearer;
}
}
}
@@ -0,0 +1,35 @@
using System;
using Microsoft.IdentityModel.JsonWebTokens;
using StrawberryShake.Serialization;
#pragma warning disable CA1812 // not detecting usage via annotation in schema.extensions.graphql
namespace Tgstation.Server.Client.GraphQL.Serializers
{
/// <summary>
/// <see cref="ScalarSerializer{TSerialized, TRuntime}"/> for <see cref="JsonWebToken"/>s.
/// </summary>
sealed class JwtSerializer : ScalarSerializer<string, JsonWebToken>
{
/// <summary>
/// Initializes a new instance of the <see cref="JwtSerializer"/> class.
/// </summary>
public JwtSerializer()
: base("Jwt")
{
}
/// <inheritdoc />
public override JsonWebToken Parse(string serializedValue)
=> new(serializedValue ?? throw new ArgumentNullException(nameof(serializedValue)));
/// <inheritdoc />
protected override string Format(JsonWebToken runtimeValue)
{
ArgumentNullException.ThrowIfNull(runtimeValue);
return runtimeValue.EncodedToken;
}
}
}
@@ -4,12 +4,12 @@ using StrawberryShake.Serialization;
using Tgstation.Server.Common.Extensions;
#pragma warning disable CA1812 // not detecting service provider usage
#pragma warning disable CA1812 // not detecting usage via annotation in schema.extensions.graphql
namespace Tgstation.Server.Client.GraphQL.Serializers
{
/// <summary>
/// <see cref="ScalarSerializer{TSerialized, TRuntime}"/> for <see cref="UInt32"/>s.
/// <see cref="ScalarSerializer{TSerialized, TRuntime}"/> for <see cref="Version"/>s.
/// </summary>
sealed class SemverSerializer : ScalarSerializer<string, Version>
{
@@ -2,7 +2,7 @@
using StrawberryShake.Serialization;
#pragma warning disable CA1812 // not detecting service provider usage
#pragma warning disable CA1812 // not detecting usage via annotation in schema.extensions.graphql
namespace Tgstation.Server.Client.GraphQL.Serializers
{
@@ -4,10 +4,12 @@
<PropertyGroup>
<TargetFramework>$(TgsFrameworkVersion)</TargetFramework>
<Version>$(TgsApiVersion)</Version>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<!-- GraphQL connector and code generator -->
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="8.0.0" />
<PackageReference Include="StrawberryShake.Server" Version="13.9.12" />
</ItemGroup>
@@ -15,3 +15,4 @@ extend schema @key(fields: "id")
extend scalar UnsignedInt @serializationType(name: "global::System.UInt32") @runtimeType(name: "global::System.UInt32")
extend scalar Semver @serializationType(name: "global::System.String") @runtimeType(name: "global::System.Version")
extend scalar Jwt @serializationType(name: "global::System.String") @runtimeType(name: "global::Microsoft.IdentityModel.JsonWebTokens.JsonWebToken")
@@ -273,7 +273,11 @@ namespace Tgstation.Server.Host.Authority
}
/// <inheritdoc />
#pragma warning disable CA1502
#pragma warning disable CA1506 // TODO: Decomplexify
public async ValueTask<AuthorityResponse<User>> Update(UserUpdateRequest model, CancellationToken cancellationToken)
#pragma warning restore CA1502
#pragma warning restore CA1506
{
ArgumentNullException.ThrowIfNull(model);
@@ -1,5 +1,6 @@
using HotChocolate;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Host.GraphQL.Types.Scalars;
using Tgstation.Server.Host.Models;
namespace Tgstation.Server.Host.GraphQL.Mutations.Payloads
@@ -12,6 +13,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations.Payloads
/// <summary>
/// The JSON Web Token (JWT) to use as a Bearer token for accessing the server. Contains an expiry time.
/// </summary>
[GraphQLType<JwtType>]
public required string Bearer { get; init; }
/// <summary>
@@ -0,0 +1,38 @@
using System;
using HotChocolate.Language;
using HotChocolate.Types;
namespace Tgstation.Server.Host.GraphQL.Types.Scalars
{
/// <summary>
/// A <see cref="ScalarType{TRuntimeType, TLiteral}"/> for encoded JSON Web Tokens.
/// </summary>
public sealed class JwtType : ScalarType<string, StringValueNode>
{
/// <summary>
/// Initializes a new instance of the <see cref="JwtType"/> class.
/// </summary>
public JwtType()
: base("Jwt")
{
Description = "Represents an encoded JSON Web Token";
SpecifiedBy = new Uri("https://datatracker.ietf.org/doc/html/rfc7519");
}
/// <inheritdoc />
public override IValueNode ParseResult(object? resultValue)
=> ParseValue(resultValue);
/// <inheritdoc />
protected override string ParseLiteral(StringValueNode valueSyntax)
{
ArgumentNullException.ThrowIfNull(valueSyntax);
return valueSyntax.Value;
}
/// <inheritdoc />
protected override StringValueNode ParseValue(string runtimeValue)
=> new(runtimeValue);
}
}
@@ -62,7 +62,7 @@ namespace Tgstation.Server.Host.GraphQL.Types.Scalars
/// <inheritdoc />
protected override StringValueNode ParseValue(Version runtimeValue)
=> new StringValueNode(runtimeValue.Semver().ToString());
=> new(runtimeValue.Semver().ToString());
/// <inheritdoc />
protected override bool IsInstanceOfType(StringValueNode valueSyntax)
@@ -1,48 +1,57 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using StrawberryShake;
using Tgstation.Server.Client;
using Tgstation.Server.Client.GraphQL;
using Tgstation.Server.Common.Extensions;
namespace Tgstation.Server.Tests.Live
{
sealed class MultiServerClient
sealed class MultiServerClient : IAsyncDisposable
{
readonly IRestServerClient restServerClient;
readonly IGraphQLServerClient graphQLServerClient;
readonly bool useGraphQL;
public IRestServerClient RestClient { get; }
public IGraphQLServerClient GraphQLClient { get; }
public MultiServerClient(IRestServerClient restServerClient, IGraphQLServerClient graphQLServerClient)
{
this.restServerClient = restServerClient ?? throw new ArgumentNullException(nameof(restServerClient));
this.graphQLServerClient = graphQLServerClient ?? throw new ArgumentNullException(nameof(graphQLServerClient));
this.useGraphQL = Boolean.TryParse(Environment.GetEnvironmentVariable("TGS_TEST_GRAPHQL"), out var result) && result;
RestClient = restServerClient ?? throw new ArgumentNullException(nameof(restServerClient));
GraphQLClient = graphQLServerClient ?? throw new ArgumentNullException(nameof(graphQLServerClient));
}
public static bool UseGraphQL => Boolean.TryParse(Environment.GetEnvironmentVariable("TGS_TEST_GRAPHQL"), out var result) && result;
public ValueTask DisposeAsync()
=> ValueTaskExtensions.WhenAll(
RestClient.DisposeAsync(),
GraphQLClient.DisposeAsync());
public ValueTask Execute(
Func<IRestServerClient, ValueTask> restAction,
Func<IGraphQLClient, ValueTask> graphQLAction)
Func<IGraphQLServerClient, ValueTask> graphQLAction)
{
if (useGraphQL)
return graphQLServerClient.RunQuery(graphQLAction);
if (UseGraphQL)
return graphQLAction(GraphQLClient);
return restAction(restServerClient);
return restAction(RestClient);
}
public async ValueTask ExecuteReadOnlyConfirmEquivalence<TRestResult, TGraphQLResult>(
Func<IRestServerClient, ValueTask<TRestResult>> restAction,
Func<IGraphQLClient, ValueTask<TGraphQLResult>> graphQLAction,
Func<TRestResult, TGraphQLResult, bool> comparison)
Func<IGraphQLClient, Task<IOperationResult<TGraphQLResult>>> graphQLAction,
Func<TRestResult, TGraphQLResult, bool> comparison,
CancellationToken cancellationToken)
where TGraphQLResult : class
{
var restTask = restAction(this.restServerClient);
TGraphQLResult graphQLResult = default;
await this.graphQLServerClient.RunQuery(async gqlClient => graphQLResult = await graphQLAction(gqlClient));
var restTask = restAction(RestClient);
var graphQLResult = await GraphQLClient.RunOperation(graphQLAction, cancellationToken);
var restResult = await restTask;
Assert.IsTrue(comparison(restResult, graphQLResult), "REST/GraphQL results differ!");
Assert.IsTrue(comparison(restResult, graphQLResult.Data), "REST/GraphQL results differ!");
}
}
}
@@ -463,11 +463,7 @@ namespace Tgstation.Server.Tests.Live
static async Task TestGraphQLLogin(IRestServerClientFactory clientFactory, IRestServerClient restClient, CancellationToken cancellationToken)
{
await using var gqlClient = new GraphQLServerClientFactory(clientFactory).CreateUnauthenticated(restClient.Url);
IOperationResult<ILoginResult> result = null;
await gqlClient.RunQuery(async client =>
{
result = await client.Login.ExecuteAsync(cancellationToken);
});
var result = await gqlClient.RunOperation(client => client.Login.ExecuteAsync(cancellationToken), cancellationToken);
Assert.IsNotNull(result.Data);
Assert.IsNull(result.Data.Login.Bearer);
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
@@ -27,6 +27,8 @@ using Newtonsoft.Json;
using Npgsql;
using StrawberryShake;
using Tgstation.Server.Api;
using Tgstation.Server.Api.Extensions;
using Tgstation.Server.Api.Models;
@@ -73,7 +75,14 @@ namespace Tgstation.Server.Tests.Live
_ = mainDMPort.Value;
}
readonly RestServerClientFactory clientFactory = new (new ProductHeaderValue(Assembly.GetExecutingAssembly().GetName().Name, Assembly.GetExecutingAssembly().GetName().Version.ToString()));
readonly RestServerClientFactory restClientFactory;
readonly GraphQLServerClientFactory graphQLClientFactory;
public TestLiveServer()
{
restClientFactory = new(new ProductHeaderValue(Assembly.GetExecutingAssembly().GetName().Name, Assembly.GetExecutingAssembly().GetName().Version.ToString()));
graphQLClientFactory = new GraphQLServerClientFactory(restClientFactory);
}
public static List<System.Diagnostics.Process> GetEngineServerProcessesOnPort(EngineType engineType, ushort? port)
{
@@ -295,7 +304,7 @@ namespace Tgstation.Server.Tests.Live
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json));
request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/" + ApiHeaders.Version);
request.Headers.Authorization = new AuthenticationHeaderValue(ApiHeaders.OAuthAuthenticationScheme, adminClient.Token.Bearer);
request.Headers.Authorization = new AuthenticationHeaderValue(ApiHeaders.OAuthAuthenticationScheme, adminClient.RestClient.Token.Bearer);
request.Headers.Add(ApiHeaders.OAuthProviderHeader, Api.Models.OAuthProvider.GitHub.ToString());
using var response = await httpClient.SendAsync(request, cancellationToken);
Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode);
@@ -306,7 +315,7 @@ namespace Tgstation.Server.Tests.Live
//attempt to update to stable
var responseModel = await TestWithoutAndWithPermission(
() => adminClient.Administration.Update(
() => adminClient.RestClient.Administration.Update(
new ServerUpdateRequest
{
NewVersion = TestUpdateVersion,
@@ -314,7 +323,7 @@ namespace Tgstation.Server.Tests.Live
},
null,
cancellationToken),
adminClient,
adminClient.RestClient,
AdministrationRights.ChangeVersion);
Assert.IsNotNull(responseModel);
@@ -323,7 +332,7 @@ namespace Tgstation.Server.Tests.Live
try
{
var serverInfoTask = adminClient.ServerInformation(cancellationToken).AsTask();
var serverInfoTask = adminClient.RestClient.ServerInformation(cancellationToken).AsTask();
var completedTask = await Task.WhenAny(serverTask, serverInfoTask);
if (completedTask == serverInfoTask)
{
@@ -376,7 +385,7 @@ namespace Tgstation.Server.Tests.Live
var downloadStream = await download.GetResult(cancellationToken);
var responseModel = await TestWithoutAndWithPermission(
() => adminClient.Administration.Update(
() => adminClient.RestClient.Administration.Update(
new ServerUpdateRequest
{
NewVersion = TestUpdateVersion,
@@ -384,7 +393,7 @@ namespace Tgstation.Server.Tests.Live
},
downloadStream,
cancellationToken),
adminClient,
adminClient.RestClient,
AdministrationRights.UploadVersion);
Assert.IsNotNull(responseModel);
@@ -424,7 +433,7 @@ namespace Tgstation.Server.Tests.Live
var testUpdateVersion = new Version(5, 11, 20);
await using var adminClient = await CreateAdminClient(server.ApiUrl, cancellationToken);
await ApiAssert.ThrowsException<ConflictException, ServerUpdateResponse>(
() => adminClient.Administration.Update(
() => adminClient.RestClient.Administration.Update(
new ServerUpdateRequest
{
NewVersion = testUpdateVersion
@@ -474,7 +483,7 @@ namespace Tgstation.Server.Tests.Live
{
await using var controllerClient = await CreateAdminClient(controller.ApiUrl, cancellationToken);
var controllerInfo = await controllerClient.ServerInformation(cancellationToken);
var controllerInfo = await controllerClient.RestClient.ServerInformation(cancellationToken);
static void CheckInfo(ServerInformationResponse serverInformation)
{
@@ -489,7 +498,7 @@ namespace Tgstation.Server.Tests.Live
CheckInfo(controllerInfo);
// test update
var responseModel = await controllerClient.Administration.Update(
var responseModel = await controllerClient.RestClient.Administration.Update(
new ServerUpdateRequest
{
NewVersion = TestUpdateVersion
@@ -577,7 +586,7 @@ namespace Tgstation.Server.Tests.Live
await using var node1Client = await CreateAdminClient(node1.ApiUrl, cancellationToken);
await using var node2Client = await CreateAdminClient(node2.ApiUrl, cancellationToken);
var controllerInfo = await controllerClient.ServerInformation(cancellationToken);
var controllerInfo = await controllerClient.RestClient.ServerInformation(cancellationToken);
async Task WaitForSwarmServerUpdate()
{
@@ -585,7 +594,7 @@ namespace Tgstation.Server.Tests.Live
do
{
await Task.Delay(TimeSpan.FromSeconds(10));
serverInformation = await node1Client.ServerInformation(cancellationToken);
serverInformation = await node1Client.RestClient.ServerInformation(cancellationToken);
}
while (serverInformation.SwarmServers.Count == 1);
}
@@ -618,13 +627,13 @@ namespace Tgstation.Server.Tests.Live
WaitForSwarmServerUpdate(),
Task.Delay(TimeSpan.FromMinutes(4), cancellationToken));
var node2Info = await node2Client.ServerInformation(cancellationToken);
var node1Info = await node1Client.ServerInformation(cancellationToken);
var node2Info = await node2Client.RestClient.ServerInformation(cancellationToken);
var node1Info = await node1Client.RestClient.ServerInformation(cancellationToken);
CheckInfo(node1Info);
CheckInfo(node2Info);
// check user info is shared
var newUser = await node2Client.Users.Create(new UserCreateRequest
var newUser = await node2Client.RestClient.Users.Create(new UserCreateRequest
{
Name = "asdf",
Password = "asdfasdfasdfasdf",
@@ -635,20 +644,20 @@ namespace Tgstation.Server.Tests.Live
}
}, cancellationToken);
var node1User = await node1Client.Users.GetId(newUser, cancellationToken);
var node1User = await node1Client.RestClient.Users.GetId(newUser, cancellationToken);
Assert.AreEqual(newUser.Name, node1User.Name);
Assert.AreEqual(newUser.Enabled, node1User.Enabled);
await using var controllerUserClient = await clientFactory.CreateFromLogin(
await using var controllerUserClient = await restClientFactory.CreateFromLogin(
controllerAddress,
newUser.Name,
"asdfasdfasdfasdf");
await using var node1TokenCopiedClient = clientFactory.CreateFromToken(node1.RootUrl, controllerUserClient.Token);
await using var node1TokenCopiedClient = restClientFactory.CreateFromToken(node1.RootUrl, controllerUserClient.Token);
await node1TokenCopiedClient.Administration.Read(false, cancellationToken);
// check instance info is not shared
var controllerInstance = await controllerClient.Instances.CreateOrAttach(
var controllerInstance = await controllerClient.RestClient.Instances.CreateOrAttach(
new InstanceCreateRequest
{
Name = "ControllerInstance",
@@ -656,27 +665,27 @@ namespace Tgstation.Server.Tests.Live
},
cancellationToken);
var node2Instance = await node2Client.Instances.CreateOrAttach(
var node2Instance = await node2Client.RestClient.Instances.CreateOrAttach(
new InstanceCreateRequest
{
Name = "Node2Instance",
Path = Path.Combine(node2.Directory, "Node2Instance")
},
cancellationToken);
var node2InstanceList = await node2Client.Instances.List(null, cancellationToken);
var node2InstanceList = await node2Client.RestClient.Instances.List(null, cancellationToken);
Assert.AreEqual(1, node2InstanceList.Count);
Assert.AreEqual(node2Instance.Id, node2InstanceList[0].Id);
Assert.IsNotNull(await node2Client.Instances.GetId(node2Instance, cancellationToken));
var controllerInstanceList = await controllerClient.Instances.List(null, cancellationToken);
Assert.IsNotNull(await node2Client.RestClient.Instances.GetId(node2Instance, cancellationToken));
var controllerInstanceList = await controllerClient.RestClient.Instances.List(null, cancellationToken);
Assert.AreEqual(1, controllerInstanceList.Count);
Assert.AreEqual(controllerInstance.Id, controllerInstanceList[0].Id);
Assert.IsNotNull(await controllerClient.Instances.GetId(controllerInstance, cancellationToken));
Assert.IsNotNull(await controllerClient.RestClient.Instances.GetId(controllerInstance, cancellationToken));
await ApiAssert.ThrowsException<ConflictException, InstanceResponse>(() => controllerClient.Instances.GetId(node2Instance, cancellationToken), Api.Models.ErrorCode.ResourceNotPresent);
await ApiAssert.ThrowsException<ConflictException, InstanceResponse>(() => node1Client.Instances.GetId(controllerInstance, cancellationToken), Api.Models.ErrorCode.ResourceNotPresent);
await ApiAssert.ThrowsException<ConflictException, InstanceResponse>(() => controllerClient.RestClient.Instances.GetId(node2Instance, cancellationToken), Api.Models.ErrorCode.ResourceNotPresent);
await ApiAssert.ThrowsException<ConflictException, InstanceResponse>(() => node1Client.RestClient.Instances.GetId(controllerInstance, cancellationToken), Api.Models.ErrorCode.ResourceNotPresent);
// test update
await node1Client.Administration.Update(
await node1Client.RestClient.Administration.Update(
new ServerUpdateRequest
{
NewVersion = TestUpdateVersion
@@ -717,7 +726,7 @@ namespace Tgstation.Server.Tests.Live
await using var controllerClient2 = await CreateAdminClient(controller.ApiUrl, cancellationToken);
await using var node1Client2 = await CreateAdminClient(node1.ApiUrl, cancellationToken);
await ApiAssert.ThrowsException<ApiConflictException, ServerUpdateResponse>(() => controllerClient2.Administration.Update(
await ApiAssert.ThrowsException<ApiConflictException, ServerUpdateResponse>(() => controllerClient2.RestClient.Administration.Update(
new ServerUpdateRequest
{
NewVersion = TestUpdateVersion
@@ -738,7 +747,7 @@ namespace Tgstation.Server.Tests.Live
do
{
await Task.Delay(TimeSpan.FromSeconds(10));
serverInformation = await node2Client2.ServerInformation(cancellationToken);
serverInformation = await node2Client2.RestClient.ServerInformation(cancellationToken);
}
while (serverInformation.SwarmServers.Count == 1);
}
@@ -747,8 +756,8 @@ namespace Tgstation.Server.Tests.Live
WaitForSwarmServerUpdate2(),
Task.Delay(TimeSpan.FromMinutes(4), cancellationToken));
var node2Info2 = await node2Client2.ServerInformation(cancellationToken);
var node1Info2 = await node1Client2.ServerInformation(cancellationToken);
var node2Info2 = await node2Client2.RestClient.ServerInformation(cancellationToken);
var node1Info2 = await node1Client2.RestClient.ServerInformation(cancellationToken);
CheckInfo(node1Info2);
CheckInfo(node2Info2);
@@ -765,7 +774,7 @@ namespace Tgstation.Server.Tests.Live
gitHubToken);
var downloadStream = await download.GetResult(cancellationToken);
var responseModel = await controllerClient2.Administration.Update(
var responseModel = await controllerClient2.RestClient.Administration.Update(
new ServerUpdateRequest
{
NewVersion = TestUpdateVersion,
@@ -848,7 +857,7 @@ namespace Tgstation.Server.Tests.Live
await using var node1Client = await CreateAdminClient(node1.ApiUrl, cancellationToken);
await using var node2Client = await CreateAdminClient(node2.ApiUrl, cancellationToken);
var controllerInfo = await controllerClient.ServerInformation(cancellationToken);
var controllerInfo = await controllerClient.RestClient.ServerInformation(cancellationToken);
async Task WaitForSwarmServerUpdate(IRestServerClient client, int currentServerCount)
{
@@ -889,11 +898,11 @@ namespace Tgstation.Server.Tests.Live
// wait a few minutes for the updated server list to dispatch
await Task.WhenAny(
WaitForSwarmServerUpdate(node1Client, 1),
WaitForSwarmServerUpdate(node1Client.RestClient, 1),
Task.Delay(TimeSpan.FromMinutes(4), cancellationToken));
var node2Info = await node2Client.ServerInformation(cancellationToken);
var node1Info = await node1Client.ServerInformation(cancellationToken);
var node2Info = await node2Client.RestClient.ServerInformation(cancellationToken);
var node1Info = await node1Client.RestClient.ServerInformation(cancellationToken);
CheckInfo(node1Info);
CheckInfo(node2Info);
@@ -905,21 +914,21 @@ namespace Tgstation.Server.Tests.Live
Assert.IsTrue(node1Task.IsCompleted);
// it should unregister
controllerInfo = await controllerClient.ServerInformation(cancellationToken);
controllerInfo = await controllerClient.RestClient.ServerInformation(cancellationToken);
Assert.AreEqual(2, controllerInfo.SwarmServers.Count);
Assert.IsFalse(controllerInfo.SwarmServers.Any(x => x.Identifier == "node1"));
// wait a few minutes for the updated server list to dispatch
await Task.WhenAny(
WaitForSwarmServerUpdate(node2Client, 3),
WaitForSwarmServerUpdate(node2Client.RestClient, 3),
Task.Delay(TimeSpan.FromMinutes(4), cancellationToken));
node2Info = await node2Client.ServerInformation(cancellationToken);
node2Info = await node2Client.RestClient.ServerInformation(cancellationToken);
Assert.AreEqual(2, node2Info.SwarmServers.Count);
Assert.IsFalse(node2Info.SwarmServers.Any(x => x.Identifier == "node1"));
// restart the controller
await controllerClient.Administration.Restart(cancellationToken);
await controllerClient.RestClient.Administration.Restart(cancellationToken);
await Task.WhenAny(
controllerTask,
Task.Delay(TimeSpan.FromMinutes(1), cancellationToken));
@@ -930,10 +939,10 @@ namespace Tgstation.Server.Tests.Live
// node 2 should reconnect once it's health check triggers
await Task.WhenAny(
WaitForSwarmServerUpdate(controllerClient2, 1),
WaitForSwarmServerUpdate(controllerClient2.RestClient, 1),
Task.Delay(TimeSpan.FromMinutes(5), cancellationToken));
controllerInfo = await controllerClient2.ServerInformation(cancellationToken);
controllerInfo = await controllerClient2.RestClient.ServerInformation(cancellationToken);
Assert.AreEqual(2, controllerInfo.SwarmServers.Count);
Assert.IsNotNull(controllerInfo.SwarmServers.SingleOrDefault(x => x.Identifier == "node2"));
@@ -941,20 +950,20 @@ namespace Tgstation.Server.Tests.Live
await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken);
// restart node2
await node2Client.Administration.Restart(cancellationToken);
await node2Client.RestClient.Administration.Restart(cancellationToken);
await Task.WhenAny(
node2Task,
Task.Delay(TimeSpan.FromMinutes(1)));
Assert.IsTrue(node1Task.IsCompleted);
// should have unregistered
controllerInfo = await controllerClient2.ServerInformation(cancellationToken);
controllerInfo = await controllerClient2.RestClient.ServerInformation(cancellationToken);
Assert.AreEqual(1, controllerInfo.SwarmServers.Count);
Assert.IsNull(controllerInfo.SwarmServers.SingleOrDefault(x => x.Identifier == "node2"));
// update should fail
await ApiAssert.ThrowsException<ApiConflictException, ServerUpdateResponse>(
() => controllerClient2.Administration.Update(new ServerUpdateRequest
() => controllerClient2.RestClient.Administration.Update(new ServerUpdateRequest
{
NewVersion = TestUpdateVersion
},
@@ -967,10 +976,10 @@ namespace Tgstation.Server.Tests.Live
// should re-register
await Task.WhenAny(
WaitForSwarmServerUpdate(node2Client2, 1),
WaitForSwarmServerUpdate(node2Client2.RestClient, 1),
Task.Delay(TimeSpan.FromMinutes(4), cancellationToken));
node2Info = await node2Client2.ServerInformation(cancellationToken);
node2Info = await node2Client2.RestClient.ServerInformation(cancellationToken);
Assert.AreEqual(2, node2Info.SwarmServers.Count);
Assert.IsNotNull(node2Info.SwarmServers.SingleOrDefault(x => x.Identifier == "controller"));
}
@@ -1021,10 +1030,10 @@ namespace Tgstation.Server.Tests.Live
try
{
await using var adminClient = await CreateAdminClient(server.ApiUrl, cancellationToken);
var instanceManagerTest = new InstanceManagerTest(adminClient, server.Directory);
var restAdminClient = adminClient.RestClient;
var instanceManagerTest = new InstanceManagerTest(restAdminClient, server.Directory);
var instance = await instanceManagerTest.CreateTestInstance("TgTestInstance", cancellationToken);
var instanceClient = adminClient.Instances.CreateClient(instance);
var instanceClient = restAdminClient.Instances.CreateClient(instance);
var ddUpdateTask = instanceClient.DreamDaemon.Update(new DreamDaemonRequest
@@ -1327,34 +1336,48 @@ namespace Tgstation.Server.Tests.Live
var serverTask = server.Run(cancellationToken).AsTask();
var fileDownloader = ((Host.Server)server.RealServer).Host.Services.GetRequiredService<Host.IO.IFileDownloader>();
var graphQLClientFactory = new GraphQLServerClientFactory(restClientFactory);
try
{
Api.Models.Instance instance;
long initialStaged, initialActive, initialSessionId;
await using var firstAdminClient = await CreateAdminClient(server.ApiUrl, cancellationToken);
await using (var tokenOnlyClient = clientFactory.CreateFromToken(server.RootUrl, firstAdminClient.Token))
await using var firstAdminMultiClient = await CreateAdminClient(server.ApiUrl, cancellationToken);
var firstAdminRestClient = firstAdminMultiClient.RestClient;
await using (var tokenOnlyRestClient = restClientFactory.CreateFromToken(server.RootUrl, firstAdminRestClient.Token))
{
// regression test for password change issue
var currentUser = await tokenOnlyClient.Users.Read(cancellationToken);
var updatedUser = await tokenOnlyClient.Users.Update(new UserUpdateRequest
var currentUser = await tokenOnlyRestClient.Users.Read(cancellationToken);
var updatedUser = await tokenOnlyRestClient.Users.Update(new UserUpdateRequest
{
Id = currentUser.Id,
Password = DefaultCredentials.DefaultAdminUserPassword,
}, cancellationToken);
await ApiAssert.ThrowsException<UnauthorizedException, UserResponse>(() => tokenOnlyClient.Users.Read(cancellationToken), null);
await ApiAssert.ThrowsException<UnauthorizedException, UserResponse>(() => tokenOnlyRestClient.Users.Read(cancellationToken), null);
}
await using (var tokenOnlyGraphQLClient = graphQLClientFactory.CreateFromToken(server.RootUrl, firstAdminRestClient.Token.Bearer))
{
// just testing auth works the same here
var result = await tokenOnlyGraphQLClient.RunOperation(client => client.ServerVersion.ExecuteAsync(cancellationToken), cancellationToken);
Assert.IsTrue(result.IsSuccessResult());
}
// basic graphql test, to be used everywhere eventually
await using (var graphQLClient = new GraphQLServerClientFactory(clientFactory).CreateUnauthenticated(server.RootUrl))
await using (var unauthenticatedGraphQLClient = graphQLClientFactory.CreateUnauthenticated(server.RootUrl))
{
// test getting server info
var multiClient = new MultiServerClient(firstAdminClient, graphQLClient);
// check auth works as expected
var result = await unauthenticatedGraphQLClient.RunOperation(client => client.ServerVersion.ExecuteAsync(cancellationToken), cancellationToken);
Assert.IsTrue(result.IsErrorResult());
await multiClient.ExecuteReadOnlyConfirmEquivalence(
// test getting server info
var unAuthedMultiClient = new MultiServerClient(firstAdminRestClient, unauthenticatedGraphQLClient);
await unAuthedMultiClient.ExecuteReadOnlyConfirmEquivalence(
restClient => restClient.ServerInformation(cancellationToken),
async gqlClient => (await gqlClient.UnauthenticatedServerInformation.ExecuteAsync(cancellationToken)).Data,
gqlClient => gqlClient.UnauthenticatedServerInformation.ExecuteAsync(cancellationToken),
(restServerInfo, gqlServerInfo) => restServerInfo.ApiVersion.Major == gqlServerInfo.Swarm.CurrentNode.Gateway.Information.MajorApiVersion
&& (restServerInfo.OAuthProviderInfos == gqlServerInfo.Swarm.CurrentNode.Gateway.Information.OAuthProviderInfos
|| restServerInfo.OAuthProviderInfos.All(kvp =>
@@ -1364,7 +1387,8 @@ namespace Tgstation.Server.Tests.Live
&& info.Value.ServerUrl == kvp.Value.ServerUrl
&& info.Value.ClientId == kvp.Value.ClientId
&& info.Value.RedirectUri == kvp.Value.RedirectUri;
})));
})),
cancellationToken);
}
async ValueTask<IRestServerClient> CreateUserWithNoInstancePerms()
@@ -1380,13 +1404,13 @@ namespace Tgstation.Server.Tests.Live
}
};
var user = await firstAdminClient.Users.Create(createRequest, cancellationToken);
var user = await firstAdminRestClient.Users.Create(createRequest, cancellationToken);
Assert.IsTrue(user.Enabled);
return await clientFactory.CreateFromLogin(server.RootUrl, createRequest.Name, createRequest.Password, cancellationToken: cancellationToken);
return await restClientFactory.CreateFromLogin(server.RootUrl, createRequest.Name, createRequest.Password, cancellationToken: cancellationToken);
}
var jobsHubTest = new JobsHubTests(firstAdminClient, await CreateUserWithNoInstancePerms());
var jobsHubTest = new JobsHubTests(firstAdminRestClient, await CreateUserWithNoInstancePerms());
Task jobsHubTestTask;
{
if (server.DumpOpenApiSpecpath)
@@ -1422,11 +1446,11 @@ namespace Tgstation.Server.Tests.Live
if (!openDreamOnly)
{
jobsHubTestTask = FailFast(await jobsHubTest.Run(cancellationToken)); // returns Task<Task>
var rootTest = FailFast(RawRequestTests.Run(clientFactory, firstAdminClient, cancellationToken));
var adminTest = FailFast(new AdministrationTest(firstAdminClient.Administration).Run(cancellationToken));
var usersTest = FailFast(new UsersTest(firstAdminClient).Run(cancellationToken));
var rootTest = FailFast(RawRequestTests.Run(restClientFactory, firstAdminRestClient, cancellationToken));
var adminTest = FailFast(new AdministrationTest(firstAdminRestClient.Administration).Run(cancellationToken));
var usersTest = FailFast(new UsersTest(firstAdminRestClient).Run(cancellationToken));
var instanceManagerTest = new InstanceManagerTest(firstAdminClient, server.Directory);
var instanceManagerTest = new InstanceManagerTest(firstAdminRestClient, server.Directory);
var compatInstanceTask = instanceManagerTest.CreateTestInstance("CompatTestsInstance", cancellationToken);
var odInstanceTask = instanceManagerTest.CreateTestInstance("OdTestsInstance", cancellationToken);
var byondApiCompatInstanceTask = instanceManagerTest.CreateTestInstance("BCAPITestsInstance", cancellationToken);
@@ -1436,7 +1460,7 @@ namespace Tgstation.Server.Tests.Live
var byondApiCompatInstance = await byondApiCompatInstanceTask;
var instancesTest = FailFast(instanceManagerTest.RunPreTest(cancellationToken));
Assert.IsTrue(Directory.Exists(instance.Path));
instanceClient = firstAdminClient.Instances.CreateClient(instance);
instanceClient = firstAdminRestClient.Instances.CreateClient(instance);
Assert.IsTrue(Directory.Exists(instanceClient.Metadata.Path));
nonInstanceTests = Task.WhenAll(instancesTest, adminTest, rootTest, usersTest);
@@ -1447,13 +1471,13 @@ namespace Tgstation.Server.Tests.Live
nonInstanceTests = Task.CompletedTask;
jobsHubTestTask = null;
instance = null;
var instanceManagerTest = new InstanceManagerTest(firstAdminClient, server.Directory);
var instanceManagerTest = new InstanceManagerTest(firstAdminRestClient, server.Directory);
var odInstanceTask = instanceManagerTest.CreateTestInstance("OdTestsInstance", cancellationToken);
odInstance = await odInstanceTask;
}
var instanceTest = new InstanceTest(
firstAdminClient.Instances,
firstAdminRestClient.Instances,
fileDownloader,
GetInstanceManager(),
(ushort)server.ApiUrl.Port);
@@ -1482,7 +1506,7 @@ namespace Tgstation.Server.Tests.Live
.RunCompatTests(
await edgeODVersionTask,
server.OpenDreamUrl,
firstAdminClient.Instances.CreateClient(odInstance),
firstAdminRestClient.Instances.CreateClient(odInstance),
odDMPort.Value,
odDDPort.Value,
server.HighPriorityDreamDaemon,
@@ -1509,7 +1533,7 @@ namespace Tgstation.Server.Tests.Live
: new Version(512, 1451) // http://www.byond.com/forum/?forum=5&command=search&scope=local&text=resolved%3a512.1451
},
server.OpenDreamUrl,
firstAdminClient.Instances.CreateClient(compatInstance),
firstAdminRestClient.Instances.CreateClient(compatInstance),
compatDMPort.Value,
compatDDPort.Value,
server.HighPriorityDreamDaemon,
@@ -1550,7 +1574,7 @@ namespace Tgstation.Server.Tests.Live
initialSessionId = dd.SessionId.Value;
jobsHubTest.ExpectShutdown();
await firstAdminClient.Administration.Restart(cancellationToken);
await firstAdminRestClient.Administration.Restart(cancellationToken);
}
await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken));
@@ -1599,8 +1623,10 @@ namespace Tgstation.Server.Tests.Live
// chat bot start and DD reattach test
serverTask = server.Run(cancellationToken).AsTask();
await using (var adminClient = await CreateAdminClient(server.ApiUrl, cancellationToken))
await using (var multiClient = await CreateAdminClient(server.ApiUrl, cancellationToken))
{
var adminClient = multiClient.RestClient;
await jobsHubTest.WaitForReconnect(cancellationToken);
var instanceClient = adminClient.Instances.CreateClient(instance);
@@ -1704,8 +1730,9 @@ namespace Tgstation.Server.Tests.Live
var edgeVersion = await EngineTest.GetEdgeVersion(EngineType.Byond, fileDownloader, cancellationToken);
await using (var adminClient = await CreateAdminClient(server.ApiUrl, cancellationToken))
{
var restAdminClient = adminClient.RestClient;
await jobsHubTest.WaitForReconnect(cancellationToken);
var instanceClient = adminClient.Instances.CreateClient(instance);
var instanceClient = restAdminClient.Instances.CreateClient(instance);
await WaitForInitialJobs(instanceClient);
var dd = await instanceClient.DreamDaemon.Read(cancellationToken);
@@ -1737,7 +1764,7 @@ namespace Tgstation.Server.Tests.Live
expectedStaged = compileJob.Id.Value;
jobsHubTest.ExpectShutdown();
await adminClient.Administration.Restart(cancellationToken);
await restAdminClient.Administration.Restart(cancellationToken);
}
await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken));
@@ -1747,8 +1774,9 @@ namespace Tgstation.Server.Tests.Live
serverTask = server.Run(cancellationToken).AsTask();
await using (var adminClient = await CreateAdminClient(server.ApiUrl, cancellationToken))
{
var restAdminClient = adminClient.RestClient;
await jobsHubTest.WaitForReconnect(cancellationToken);
var instanceClient = adminClient.Instances.CreateClient(instance);
var instanceClient = restAdminClient.Instances.CreateClient(instance);
await WaitForInitialJobs(instanceClient);
var currentDD = await instanceClient.DreamDaemon.Read(cancellationToken);
@@ -1763,7 +1791,7 @@ namespace Tgstation.Server.Tests.Live
await using var repoTestObj = new RepositoryTest(instanceClient.Repository, instanceClient.Jobs);
var repoTest = repoTestObj.RunPostTest(cancellationToken);
await using var chatTestObj = new ChatTest(instanceClient.ChatBots, adminClient.Instances, instanceClient.Jobs, instance);
await using var chatTestObj = new ChatTest(instanceClient.ChatBots, restAdminClient.Instances, instanceClient.Jobs, instance);
await chatTestObj.RunPostTest(cancellationToken);
await repoTest;
@@ -1772,7 +1800,7 @@ namespace Tgstation.Server.Tests.Live
jobsHubTest.CompleteNow();
await jobsHubTestTask;
await new InstanceManagerTest(adminClient, server.Directory).RunPostTest(instance, cancellationToken);
await new InstanceManagerTest(restAdminClient, server.Directory).RunPostTest(instance, cancellationToken);
}
}
catch (ApiException ex)
@@ -1807,20 +1835,64 @@ namespace Tgstation.Server.Tests.Live
await serverTask;
}
async Task<IRestServerClient> CreateAdminClient(Uri url, CancellationToken cancellationToken)
async ValueTask<MultiServerClient> CreateAdminClient(Uri url, CancellationToken cancellationToken)
{
url = new Uri(url.ToString().Replace(Routes.ApiRoot, String.Empty));
var giveUpAt = DateTimeOffset.UtcNow.AddMinutes(2);
for (var I = 1; ; ++I)
{
ValueTask<IRestServerClient> restClientTask;
ValueTask<IAuthenticatedGraphQLServerClient> graphQLClientTask;
try
{
System.Console.WriteLine($"TEST: CreateAdminClient attempt {I}...");
return await clientFactory.CreateFromLogin(
Console.WriteLine($"TEST: CreateAdminClient attempt {I}...");
restClientTask = restClientFactory.CreateFromLogin(
url,
DefaultCredentials.AdminUserName,
DefaultCredentials.DefaultAdminUserPassword,
cancellationToken: cancellationToken);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
graphQLClientTask = graphQLClientFactory.CreateFromLogin(
url,
DefaultCredentials.AdminUserName,
DefaultCredentials.DefaultAdminUserPassword,
cancellationToken: cts.Token);
IRestServerClient restClient;
try
{
restClient = await restClientTask;
}
catch (Exception restException) when (restException is not HttpRequestException && restException is not ServiceUnavailableException)
{
cts.Cancel();
try
{
await (await graphQLClientTask).DisposeAsync();
}
catch (OperationCanceledException)
{
}
catch (Exception graphQLException)
{
throw new AggregateException(restException, graphQLException);
}
throw;
}
try
{
return new MultiServerClient(
restClient,
await graphQLClientTask);
}
catch
{
await restClient.DisposeAsync();
throw;
}
}
catch (HttpRequestException)
{