diff --git a/src/Tgstation.Server.Client.GraphQL/.graphqlrc.json b/src/Tgstation.Server.Client.GraphQL/.graphqlrc.json index 0b651ad84f..a6148380bc 100644 --- a/src/Tgstation.Server.Client.GraphQL/.graphqlrc.json +++ b/src/Tgstation.Server.Client.GraphQL/.graphqlrc.json @@ -13,7 +13,7 @@ "transportProfiles": [ { "default": "Http", - "subscription": "WebSocket" + "subscription": "Http" } ] } diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Subscriptions/SessionInvalidation.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Subscriptions/SessionInvalidation.graphql new file mode 100644 index 0000000000..e7e5baf3de --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Subscriptions/SessionInvalidation.graphql @@ -0,0 +1,3 @@ +subscription SessionInvalidation { + sessionInvalidated +} diff --git a/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs b/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs index 178c44e713..0f46608df1 100644 --- a/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs +++ b/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs @@ -124,19 +124,47 @@ namespace Tgstation.Server.Client.GraphQL public virtual ValueTask DisposeAsync() => serviceProvider.DisposeAsync(); /// - public ValueTask> RunOperationAsync(Func>> queryExector, CancellationToken cancellationToken) + public ValueTask> RunOperationAsync(Func>> operationExecutor, CancellationToken cancellationToken) where TResultData : class { - ArgumentNullException.ThrowIfNull(queryExector); - return WrapAuthentication(queryExector, cancellationToken); + ArgumentNullException.ThrowIfNull(operationExecutor); + return WrapAuthentication(operationExecutor, cancellationToken); } /// - public ValueTask> RunOperation(Func>> queryExector, CancellationToken cancellationToken) + public ValueTask> RunOperation(Func>> operationExecutor, CancellationToken cancellationToken) where TResultData : class { - ArgumentNullException.ThrowIfNull(queryExector); - return WrapAuthentication(async localClient => await queryExector(localClient), cancellationToken); + ArgumentNullException.ThrowIfNull(operationExecutor); + return WrapAuthentication(async localClient => await operationExecutor(localClient), cancellationToken); + } + + /// + public async ValueTask Subscribe(Func>> operationExecutor, IObserver> observer, CancellationToken cancellationToken) + where TResultData : class + { + ArgumentNullException.ThrowIfNull(operationExecutor); + ArgumentNullException.ThrowIfNull(observer); + + var observable = operationExecutor(graphQLClient); + + if (Authenticated) + { + var tuple = await bearerCredentialsTask.ConfigureAwait(false); + if (!tuple.HasValue) + ThrowOtherCallerFailedAuthException(); + + var (currentAuthHeader, expires) = tuple.Value; + if (expires <= DateTimeOffset.UtcNow) + currentAuthHeader = await Reauthenticate(currentAuthHeader, cancellationToken).ConfigureAwait(false); + + setAuthenticationHeader(currentAuthHeader); + } + + // maybe make this handle reauthentication one day + // but would need to check if lost auth results in complete events being sent + // if so, it can't be done + return observable.Subscribe(observer); } /// @@ -167,59 +195,6 @@ namespace Tgstation.Server.Client.GraphQL if (!tuple.HasValue) ThrowOtherCallerFailedAuthException(); - async ValueTask 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); @@ -238,6 +213,65 @@ namespace Tgstation.Server.Client.GraphQL return operationResult; } + /// + /// Attempt to reauthenticate. + /// + /// The current for the bearer token. + /// The for the operation. + /// A resulting in the updated to use. + async ValueTask 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; + } + } + /// /// Attempt to create the for . /// diff --git a/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClient.cs b/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClient.cs index c20a606fa0..bddfeae618 100644 --- a/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClient.cs +++ b/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClient.cs @@ -32,5 +32,16 @@ namespace Tgstation.Server.Client.GraphQL /// Thrown when automatic reauthentication fails. ValueTask> RunOperation(Func>> operationExecutor, CancellationToken cancellationToken) where TResultData : class; + + /// + /// Subcribes to the GraphQL subscription indicated by . + /// + /// The of the 's . + /// A which initiates a single subscription on a given and returns a resulting in the . + /// The for s. + /// The for the operation. + /// A resulting in the representing the lifetime of the subscription. + ValueTask Subscribe(Func>> operationExecutor, IObserver> observer, CancellationToken cancellationToken) + where TResultData : class; } } diff --git a/tests/Tgstation.Server.Tests/Live/HoldLastObserver.cs b/tests/Tgstation.Server.Tests/Live/HoldLastObserver.cs new file mode 100644 index 0000000000..e5ee2b32d8 --- /dev/null +++ b/tests/Tgstation.Server.Tests/Live/HoldLastObserver.cs @@ -0,0 +1,34 @@ +using System; + +namespace Tgstation.Server.Tests.Live +{ + sealed class HoldLastObserver : IObserver + { + public bool Completed { get; private set; } + + public Exception LastError { get; private set; } + + public T LastValue { get; private set; } + + public ulong ErrorCount { get; private set; } + + public ulong ResultCount { get; private set; } + + public void OnCompleted() + { + Completed = true; + } + + public void OnError(Exception error) + { + ++ErrorCount; + LastError = error; + } + + public void OnNext(T value) + { + ++ResultCount; + LastValue = value; + } + } +} diff --git a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs index 93776764e6..6071e51d48 100644 --- a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs +++ b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs @@ -1395,6 +1395,19 @@ namespace Tgstation.Server.Tests.Live return result; }, cancellationToken); + + var testObserver = new HoldLastObserver>(); + using var subscription = await unauthenticatedGraphQLClient.Subscribe( + gql => gql.SessionInvalidation.Watch(), + testObserver, + cancellationToken); + + await Task.Delay(1000, cancellationToken); + + Assert.AreEqual(0U, testObserver.ErrorCount); + Assert.AreEqual(1U, testObserver.ResultCount); + Assert.IsTrue(testObserver.LastValue.IsAuthenticationError()); + Assert.IsTrue(testObserver.Completed); } async ValueTask CreateUserWithNoInstancePerms() @@ -1416,6 +1429,8 @@ namespace Tgstation.Server.Tests.Live return await CreateClient(server.RootUrl, createRequest.Name, createRequest.Password, false, cancellationToken); } + var restartObserver = new HoldLastObserver>(); + IDisposable restartSubscription; var jobsHubTest = new JobsHubTests(firstAdminMultiClient, await CreateUserWithNoInstancePerms()); Task jobsHubTestTask; { @@ -1579,12 +1594,45 @@ namespace Tgstation.Server.Tests.Live initialStaged = dd.StagedCompileJob.Id.Value; initialSessionId = dd.SessionId.Value; - jobsHubTest.ExpectShutdown(); - await firstAdminRestClient.Administration.Restart(cancellationToken); + // force a session refresh if necessary + await firstAdminMultiClient.GraphQLClient.RunQueryEnsureNoErrors( + gql => gql.ReadCurrentUser.ExecuteAsync(cancellationToken), + cancellationToken); + + restartSubscription = await firstAdminMultiClient.GraphQLClient.Subscribe( + gql => gql.SessionInvalidation.Watch(), + restartObserver, + cancellationToken); + + try + { + await Task.Delay(1000, cancellationToken); + + jobsHubTest.ExpectShutdown(); + await firstAdminRestClient.Administration.Restart(cancellationToken); + } + catch + { + restartSubscription.Dispose(); + throw; + } } - await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)); - Assert.IsTrue(serverTask.IsCompleted); + try + { + await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)); + Assert.IsTrue(serverTask.IsCompleted); + + Assert.AreEqual(0U, restartObserver.ErrorCount); + Assert.AreEqual(1U, restartObserver.ResultCount); + restartObserver.LastValue.EnsureNoErrors(); + Assert.IsTrue(restartObserver.Completed); + Assert.AreEqual(SessionInvalidationReason.ServerShutdown, restartObserver.LastValue.Data.SessionInvalidated); + } + finally + { + restartSubscription.Dispose(); + } // test the reattach message queueing // for the code coverage really...