feat(presentation): the companion's own sign-in for several servers; a failed browser start no longer logs the sign-in address (#53)
This commit was merged in pull request #53.
This commit is contained in:
34 files changed
+5197
-36
No files matched your search
@@ -2,6 +2,62 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
A failed browser start no longer writes the sign-in address to the companion log or the dialog (2.0). When Windows
|
||||
could not start the default browser for a browser sign-in, the companion's log entry and the dialog's "Browser
|
||||
sign-in not completed" message repeated Windows' own error text, and that text names what it could not start: the
|
||||
whole sign-in address, with that attempt's state, nonce and code challenge. When the browser could not be started
|
||||
for the identity provider's sign-out, the log entry named the provider's sign-out address the same way, with its ID
|
||||
token hint. Both now say only that the default browser could not be opened, with the Windows error number, and what
|
||||
to check: that a default browser is set in Windows Settings under Apps, Default apps. After a sign-out whose
|
||||
provider page could not be opened, the dialog used to say that the provider's sign-out "was opened in your browser
|
||||
but did not confirm"; it now says that the page could not be opened, that the provider's session may therefore
|
||||
still be there, and what to check. Sign-in and sign-out are otherwise unchanged. Every release from 0.2.0-rc.1 to
|
||||
1.2.1 has the sign-in case (0.1.x has no browser sign-in). The sign-out case is in no release. Both cases are in the
|
||||
development snapshot `snapshot-2.0.0-rc.1`, which was not installed anywhere, and on the unreleased `release/1.2.2`
|
||||
branch; 2.0 builds after that snapshot have neither ([audit](docs/AUDIT-2026-11-10.md), U22).
|
||||
|
||||
An unexpected failure of the browser callback inbox no longer leaves the connection dialog's browser sign-in as an
|
||||
unhandled error (2.0). The inbox gives an argument error when the process that sent a callback has already exited,
|
||||
which the dialog's flow did not expect. It is now reported as "Browser sign-in not completed", with the kind of
|
||||
failure as the reason.
|
||||
|
||||
Sign-in in the companion, rebuilt for several servers (2.0; not used by the window yet). Three types were added to
|
||||
the presentation assembly for the connection wizard and the per-server Connection page of
|
||||
[ADR-0005](docs/adr/0005-connection-wizard-and-server-tree.md). Nothing the operator sees changes through them in
|
||||
this build: the connection dialog still runs on the existing flow, whose only changes are the entries above.
|
||||
|
||||
- `CompanionSignIn` is the part of signing in that needs a session and runs as the signed-in Windows user. It reads
|
||||
the server's sign-in description, signs in by password or through the browser, and returns the session credential
|
||||
to its caller. It hands nothing to the service, enrolls nothing and keeps no certificate setting: the exception to
|
||||
read under is given with each read. It offers only the methods that the service's check and its own read both
|
||||
found, and logs a difference. It stops when the description names another sign-in provider than the check
|
||||
reported. When this Windows account refuses a certificate the service accepted, or the sign-in provider's
|
||||
certificate, it says which of the two it was and keeps the refused certificate for the wizard to show. A browser
|
||||
sign-in that is cancelled or times out before its callback has been handed to the session keeps the session, so
|
||||
the next attempt needs no new read. Once the callback has been handed over the server may complete the sign-in,
|
||||
so an attempt that is cancelled, times out or is stopped after that discards the session.
|
||||
- `BrowserCallbackDispatcher` owns the one browser callback inbox. A sign-in or a provider sign-out registers the
|
||||
OAuth state it sent and waits for the callback that carries it, so two servers can be signing in at once. A
|
||||
callback that carries no state, more than one, or a state nobody waits for is dropped, and so is one the session
|
||||
would refuse to read (an entry without a value, a repeated name, a control character, a fragment); the log says
|
||||
only that one was dropped. An inbox is open exactly while something waits. When the inbox fails, everything that
|
||||
waits fails once and the next registration opens a new inbox. Whatever the inbox fails with, each sign-in or
|
||||
sign-out that waits ends as a failure it reports: a failure of a kind they do not expect (the inbox gives an
|
||||
argument error when the process that sent a callback has already exited) reaches them as one that names only its
|
||||
kind. A log that cannot be written to does not stop the dispatcher.
|
||||
- `ProviderSignOut` is the identity provider's half of a sign-out: it opens the provider's sign-out page and waits
|
||||
up to two minutes for the browser to return with that sign-out's own state. Its result says how it ended:
|
||||
confirmed, not opened (the browser or the callback inbox could not be opened, so the operator saw no page), not
|
||||
confirmed (the page was opened and the provider did not confirm), or cancelled by its caller.
|
||||
- When Windows cannot start the browser, its failure text names what it could not start, which is the whole sign-in
|
||||
or sign-out address. The new types report only the kind of failure, so no state and no token hint can reach the
|
||||
log or the screen that way.
|
||||
- The sign-in session seen by the presentation assembly (`ISignInSession`) now also gives the certificate it refused
|
||||
last and the origin that presented it.
|
||||
- The presentation log (`IPresentationLog`) can be written to with a message template and its values, which the new
|
||||
types use for every entry. A host that implements only the four text methods, as the companion does, gets the
|
||||
finished text.
|
||||
|
||||
In-app update, service controls and the pipe's identity check (2.0). The installed product behaves as before. This
|
||||
change does not alter the Windows Installer command line, the files, owners and access lists that are checked or
|
||||
the order they are checked in, what an installer exit code means, the calls made to the service control manager,
|
||||
|
||||
@@ -269,6 +269,12 @@ Found by the retrospective reviews of 2026-11-10 and by the review of the printi
|
||||
| W80 | Nit | When the update session's folder fails its second check, the one made after the folder is created, the empty folder is left under the updates folder (`MsiUpdateService.cs:186-187`): nothing was downloaded into it, and removing it is refused by the same check. `NothingIsDownloadedIntoAFolderThatCannotBeTrusted` pins this. An administrator finds an empty folder with a random name (found by the author of plan step 8 and confirmed by its review) | Open |
|
||||
| W81 | Nit | The link check of the recorded outcome before it is written (`MsiUpdateSteps.Apply.cs:201`, on `last-result.json`) has no refusing test: with the check removed every test passes. The path is a file, so showing the refusal needs a file symbolic link, which Windows lets a test create only with a privilege or in developer mode; the other link checks are shown with junctions, which are folders (review of plan step 8) | Open |
|
||||
|
||||
Found while building the companion's sign-in for the connection wizard (2026-11-10), by its author:
|
||||
|
||||
| ID | Severity | Finding | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| U22 | Not rated | A browser that cannot be started puts the address it was asked to open into the companion's log and, at sign-in, into the dialog. The companion starts the browser through the Windows shell, and when that fails .NET's failure text names what could not be started: "An error occurred trying to start process '<the whole address>' ..." (seen on .NET 10.0.14 with a shell start of a file that does not exist; no browser was involved). At a browser sign-in the address is the provider's authorization address, with that attempt's `state`, `nonce` and `code_challenge`; the text was logged at error level and shown as the reason of "Browser sign-in not completed", and the whole failure was logged again as debug detail (`SignInFlow.Browser.cs:98-103`, started at `:152`, read at `ebea861`). At a provider sign-out the address is the provider's end-session address, with the `id_token_hint` (the provider's ID token) and the sign-out's `state`; the text was logged as a warning and not shown (`SignInFlow.SignOut.cs:121-125`, started at `:114`). When this was found (at `ebea861`) the log file's own redaction removed the value of `state` from such a line in 2.0 and nothing else of it: `id_token_hint=` passed unchanged, which was the gap W40 recorded, and W40's note that no log line can carry it did not hold for this one. The redaction fixes that closed W40 now remove the ID token hint as well; a sign-in address still keeps its `nonce` and `code_challenge` (run against 2.0 at `4e01519`, with the text Windows gives for a failed start). The dialog's message is not redacted at all. It takes a failed browser start to happen: no default browser, or a broken handler for web addresses. Tags (from `git tag --contains` and the code at each tag): the sign-in case is in the eleven version tags from `v0.2.0-rc.1` to `v1.2.1` and in `snapshot-2.0.0-rc.1`; the four 0.1.x tags have no browser sign-in. It was introduced by `2c1c5f1` (2026-10-31); in `v1.2.1` the start is `src/Qqq.PrintAgent/MainWindow.Authentication.cs:339` and the catch that logs and shows the text is at `:294-298`, and 1.2.1's log has no rule for `state`, so installed 1.2.x systems write and show the address whole. The sign-out case came with `5d4b7f4` (2026-11-09) and is in one tag, `snapshot-2.0.0-rc.1`, and in no version tag. That snapshot (commit `6f0237d`) has both cases unguarded (`SignInFlow.Browser.cs:152`, `SignInFlow.SignOut.cs:114`); `docs/SESSION-STATE.md` records it as a Gitea prerelease published on 2026-11-10 as a development snapshot and not installed anywhere. The unreleased `release/1.2.2` branch, which is in no tag, has both cases too (`MainWindow.Authentication.cs:303`, `:311` for the sign-out; `:395`, `:354` for the sign-in). Read from the code in each case, not run | Closed for 2.0 builds after `snapshot-2.0.0-rc.1` by the change that adds this row: every browser start in the presentation assembly goes through `BrowserLaunch`, which replaces the failure with fixed text (that the default browser could not be opened, the kind of failure or the Windows error number, and what to check), in the dialog's flow and in the new `CompanionSignIn` and `ProviderSignOut`. The snapshot itself has both cases and stays as published. Open for 1.2.x: `release/1.2.2` still has both cases, and 1.2.1 as installed has the sign-in case |
|
||||
|
||||
### Repository
|
||||
|
||||
| ID | Requirement | Finding | Status |
|
||||
|
||||
@@ -8,7 +8,9 @@ namespace Qqq.PrintAgent.Presentation;
|
||||
/// <summary>
|
||||
/// Where presentation logic writes its activity entries. The companion forwards them to the shared activity log with its own
|
||||
/// prefix; tests record them. Keeping this to four levels means the presentation assembly needs no logging package and no
|
||||
/// log file, and callers must never pass credentials, enrollment codes or document content to it.
|
||||
/// log file, and callers must never pass credentials, enrollment codes or document content to it. New code writes an
|
||||
/// entry as a message template and its values (the second set of methods), never as a string it put together itself;
|
||||
/// a host that keeps structured entries implements those methods, and one that does not gets the entry as text.
|
||||
/// </summary>
|
||||
public interface IPresentationLog
|
||||
{
|
||||
@@ -29,4 +31,24 @@ public interface IPresentationLog
|
||||
|
||||
/// <summary>Records request-level detail; the host writes it only while debug logging is switched on.</summary>
|
||||
void Debug(string message);
|
||||
|
||||
|
||||
|
||||
/// <summary>Records one step of normal operation from a message template and the values of its holes (see <see cref="PresentationLogTemplate"/>).</summary>
|
||||
void Info(string template, params object?[] values) => Info(PresentationLogTemplate.Render(template, values));
|
||||
|
||||
|
||||
|
||||
/// <summary>Records something the operator should notice, from a message template and the values of its holes.</summary>
|
||||
void Warning(string template, params object?[] values) => Warning(PresentationLogTemplate.Render(template, values));
|
||||
|
||||
|
||||
|
||||
/// <summary>Records a failure the operator has to act on, from a message template and the values of its holes.</summary>
|
||||
void Error(string template, params object?[] values) => Error(PresentationLogTemplate.Render(template, values));
|
||||
|
||||
|
||||
|
||||
/// <summary>Records request-level detail from a message template and the values of its holes.</summary>
|
||||
void Debug(string template, params object?[] values) => Debug(PresentationLogTemplate.Render(template, values));
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace Qqq.PrintAgent.Presentation;
|
||||
|
||||
/// <summary>
|
||||
/// Turns a message template and its values into the text of a log entry, for a host whose log keeps text. A template
|
||||
/// is fixed text with named holes, "Opened {Server} after {Seconds} s", and the values fill the holes in the order
|
||||
/// they are given. Writing entries this way keeps what was said apart from what it was said about, so the sentence
|
||||
/// is the same for every server and a host can keep the values as fields.
|
||||
/// </summary>
|
||||
public static class PresentationLogTemplate
|
||||
{
|
||||
/// <summary>
|
||||
/// Fills the holes of a template, first hole with the first value. Each value is written in the invariant culture,
|
||||
/// so an entry reads the same on every computer, and a missing value (null) is written as nothing. A hole that has
|
||||
/// no value left stays as it was written, which shows in the log that a value was forgotten; a value that has no
|
||||
/// hole is left out. Only the template is searched for holes, so a brace inside a value is never taken for one.
|
||||
/// </summary>
|
||||
public static string Render(string template, IReadOnlyList<object?> values)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(template);
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
StringBuilder text = new(template.Length);
|
||||
int position = 0;
|
||||
foreach(object? value in values)
|
||||
{
|
||||
int open = template.IndexOf('{', position);
|
||||
int close = open < 0 ? -1 : template.IndexOf('}', open);
|
||||
if(close < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
text.Append(template, position, open - position).Append(Convert.ToString(value, CultureInfo.InvariantCulture));
|
||||
position = close + 1;
|
||||
}
|
||||
return text.Append(template, position, template.Length - position).ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Presentation.SignIn;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the one browser callback inbox. Windows gives each user one redirect address and one inbox, so browser
|
||||
/// exchanges cannot each have their own: a sign-in or a provider sign-out registers the OAuth state it sent and gets a
|
||||
/// ticket to await, and this hands each callback to the ticket whose state it carries. Two servers can then be signing
|
||||
/// in at once, and a late callback for an exchange that has ended is dropped instead of failing another. An inbox is
|
||||
/// open exactly while at least one ticket waits. Each inbox takes one callback, so after every callback it is closed and,
|
||||
/// when tickets still wait, a new one is opened. A callback that carries no state, more than one, a state nobody
|
||||
/// waits for, or a query the session would refuse to read is dropped, and the log says only that one was dropped:
|
||||
/// nothing of a callback, its state or its code is ever logged, and a registered state is held only while its
|
||||
/// ticket waits. When the inbox itself fails, which
|
||||
/// includes an inbox that refuses what it received as no callback at all, every waiting ticket fails and the next
|
||||
/// registration opens a new inbox. The tickets are given the inbox's failure when it is of a kind every exchange
|
||||
/// reports, and otherwise one that names only its kind (see <see cref="BrowserCallbackFailure"/>), so whatever an
|
||||
/// inbox fails with ends each exchange as a failure it reports and never as an exception nobody handles. Tickets
|
||||
/// are registered from the window's thread and callbacks arrive on others, so the waiting set is guarded, and this
|
||||
/// never continues on a caller's thread. A log that fails does not stop any of it.
|
||||
/// </summary>
|
||||
public sealed class BrowserCallbackDispatcher
|
||||
{
|
||||
/// <summary>The longest query a callback may have, in characters; the session refuses a longer one.</summary>
|
||||
internal const int LongestQuery = 16_384;
|
||||
|
||||
/// <summary>Where an inbox is opened.</summary>
|
||||
private readonly IBrowserCallbackSource _callbacks;
|
||||
|
||||
/// <summary>The log; written to only through <see cref="Record"/>, so that it cannot break the dispatcher.</summary>
|
||||
private readonly IPresentationLog _log;
|
||||
|
||||
/// <summary>Guards the waiting set and the open inbox, which the window's thread and the threads callbacks arrive on both change.</summary>
|
||||
private readonly Lock _gate = new();
|
||||
|
||||
/// <summary>The tickets that wait, by the state each registered. A state is compared exactly: it is somebody's only when it is the very one that was sent.</summary>
|
||||
private readonly Dictionary<string, BrowserCallbackTicket> _waiting = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>The inbox that is open; null while no ticket waits. A wait that ends on an inbox that is no longer this one has nothing to do.</summary>
|
||||
private IBrowserCallbackInbox? _inbox;
|
||||
|
||||
/// <summary>Ends the wait on the open inbox when the inbox is closed; null while none is open.</summary>
|
||||
private CancellationTokenSource? _listening;
|
||||
|
||||
|
||||
|
||||
/// <summary>Wires the dispatcher to where callbacks arrive and to the log.</summary>
|
||||
public BrowserCallbackDispatcher(IBrowserCallbackSource callbacks, IPresentationLog log)
|
||||
{
|
||||
_callbacks = callbacks ?? throw new ArgumentNullException(nameof(callbacks));
|
||||
_log = log ?? throw new ArgumentNullException(nameof(log));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Registers the state a browser exchange sent and returns the ticket to await. The inbox is open when this
|
||||
/// returns, so the caller opens the browser afterwards and a fast redirect is not lost. A state that is already
|
||||
/// waiting is refused, because two exchanges could not be told apart. When the inbox cannot be opened the failure is
|
||||
/// thrown to the caller and nothing is registered; a failure of a kind the exchanges do not report is thrown as
|
||||
/// one that names only its kind, as it is for a ticket. The caller disposes the ticket when its exchange ends,
|
||||
/// however it ends. Only the presentation assembly's own exchanges register: a ticket's task carries a callback
|
||||
/// with its one-time code and state, so neither this nor the ticket is offered to code outside the assembly.
|
||||
/// </summary>
|
||||
internal BrowserCallbackTicket Register(string state)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(state);
|
||||
BrowserCallbackTicket ticket = new(this, state);
|
||||
lock(_gate)
|
||||
{
|
||||
if(!_waiting.TryAdd(state, ticket))
|
||||
{
|
||||
throw new InvalidOperationException("A browser exchange with the same state is already waiting for its callback.");
|
||||
}
|
||||
if(_inbox is null)
|
||||
{
|
||||
try
|
||||
{
|
||||
(IBrowserCallbackInbox inbox, CancellationToken closed) = Open();
|
||||
_ = ReceiveAsync(inbox, closed);
|
||||
}
|
||||
catch(Exception exception) when(!BrowserCallbackFailure.Listed(exception))
|
||||
{
|
||||
_waiting.Remove(state);
|
||||
throw BrowserCallbackFailure.Reported(exception);
|
||||
}
|
||||
catch(Exception)
|
||||
{
|
||||
_waiting.Remove(state);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ticket;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reads the one <c>state</c> parameter of an address's query: the state a sign-in sent, from the authorization
|
||||
/// address, or the state a callback returns. The query is read by the rules the session applies to a callback
|
||||
/// before it accepts it (<c>QqqSessionClient</c>, in Core): the address is absolute and has no fragment and no
|
||||
/// user information, the query is there and no longer than a callback's may be, every entry is a name and a
|
||||
/// value, no name is repeated, and neither a name nor a value holds a control character. An address that breaks
|
||||
/// one of them has no state here. A callback like that is therefore dropped as nobody's, where a more lenient
|
||||
/// reading would hand it to the exchange whose state it seems to carry, only for the session to refuse it and
|
||||
/// end that sign-in. There is no state either when the parameter is missing or empty. A repeated name is refused
|
||||
/// because two readers could disagree about which exchange a callback belongs to.
|
||||
/// </summary>
|
||||
internal static string? ReadState(Uri address)
|
||||
{
|
||||
if(!address.IsAbsoluteUri || address.Fragment.Length != 0 || address.UserInfo.Length != 0 || address.Query.Length is < 2 or > LongestQuery)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
Dictionary<string, string> values = new(StringComparer.Ordinal);
|
||||
foreach(string entry in address.Query[1..].Split('&'))
|
||||
{
|
||||
string[] pair = entry.Split('=', 2);
|
||||
if(pair.Length != 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
string name = Uri.UnescapeDataString(pair[0].Replace('+', ' '));
|
||||
string value = Uri.UnescapeDataString(pair[1].Replace('+', ' '));
|
||||
if(name.Any(char.IsControl) || value.Any(char.IsControl) || !values.TryAdd(name, value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return values.TryGetValue("state", out string? state) && state.Length != 0 ? state : null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Ends a ticket's claim. Only the ticket that is waiting under its state is forgotten, so disposing a ticket that already has its callback cannot remove a later one, and the inbox is closed when nothing waits any more.</summary>
|
||||
internal void Release(BrowserCallbackTicket ticket)
|
||||
{
|
||||
lock(_gate)
|
||||
{
|
||||
bool waited = ((ICollection<KeyValuePair<string, BrowserCallbackTicket>>)_waiting).Remove(new KeyValuePair<string, BrowserCallbackTicket>(ticket.State, ticket));
|
||||
if(waited && _waiting.Count == 0)
|
||||
{
|
||||
Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Opens an inbox, makes it the open one, and returns it with the token that ends the wait on it when it is
|
||||
/// closed. The caller holds the gate, and waits on the inbox next: a registration starts the wait before it lets
|
||||
/// go of the gate, and the wait that has just routed a callback goes on with the inbox it is given. A failure to
|
||||
/// open is thrown, and no inbox is then open.
|
||||
/// </summary>
|
||||
private (IBrowserCallbackInbox Inbox, CancellationToken Closed) Open()
|
||||
{
|
||||
IBrowserCallbackInbox inbox = _callbacks.Open();
|
||||
CancellationTokenSource listening = new();
|
||||
_inbox = inbox;
|
||||
_listening = listening;
|
||||
Record(static log => log.Info("Browser callback inbox open."));
|
||||
return (inbox, listening.Token);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Writes a log entry in a way that cannot break the dispatcher. Its entries are written between steps that
|
||||
/// belong together: after an inbox was opened and before anyone waits on it, after an inbox was closed and before
|
||||
/// the next one is opened, before the tickets of a broken inbox are failed. A log that threw there would leave
|
||||
/// an inbox nobody reads, or tickets waiting for nothing until their own time limits ran out. So a failure of
|
||||
/// the log stays with the log: the entry is lost, and the dispatcher goes on.
|
||||
/// </summary>
|
||||
private void Record(Action<IPresentationLog> entry)
|
||||
{
|
||||
try
|
||||
{
|
||||
entry(_log);
|
||||
}
|
||||
catch(Exception)
|
||||
{
|
||||
// A log that cannot be written to must not cost an operator the sign-in that is waiting.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Closes the open inbox. The caller holds the gate. The fields are cleared before the wait is cancelled, because
|
||||
/// cancelling can run the end of that wait at once, on this thread, and it must then find that its inbox is no
|
||||
/// longer the open one and do nothing.
|
||||
/// </summary>
|
||||
private void Close()
|
||||
{
|
||||
IBrowserCallbackInbox inbox = _inbox!;
|
||||
CancellationTokenSource listening = _listening!;
|
||||
_inbox = null;
|
||||
_listening = null;
|
||||
listening.Cancel();
|
||||
inbox.Dispose();
|
||||
listening.Dispose();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the one callback of an inbox, routes it, and goes on with the inbox that was opened for the tickets
|
||||
/// that still wait, until none is. It is one loop for a whole run of inboxes, not a call per inbox: an inbox
|
||||
/// whose callback is already there when the wait starts is dealt with at once, and a call per inbox would then
|
||||
/// go one level deeper for every such callback. Any failure of the wait, whatever its kind, is the inbox
|
||||
/// failing, and has to reach the tickets that wait: a failure left here would leave every one of them waiting for
|
||||
/// its own time limit. The wait that ends because the inbox was closed is told apart inside
|
||||
/// <see cref="Fail"/>, by the inbox no longer being the open one.
|
||||
/// </summary>
|
||||
private async Task ReceiveAsync(IBrowserCallbackInbox inbox, CancellationToken closed)
|
||||
{
|
||||
IBrowserCallbackInbox? open = inbox;
|
||||
while(open is not null)
|
||||
{
|
||||
Uri callback;
|
||||
try
|
||||
{
|
||||
callback = await open.ReceiveAsync(closed).ConfigureAwait(false);
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
Fail(open, exception);
|
||||
return;
|
||||
}
|
||||
open = Route(open, callback, out closed);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Hands a received callback to the ticket whose state it carries, or drops it. The inbox that delivered it is
|
||||
/// closed and, when tickets still wait, the next one is opened in the same step, so an inbox is open exactly while
|
||||
/// a ticket waits and the gap between two inboxes is as short as it can be. That next inbox is returned, with the
|
||||
/// token that ends the wait on it, for the caller to wait on; null means nothing is left to wait for, or another
|
||||
/// wait already has the open inbox. When the next inbox cannot be opened, the tickets that wait cannot receive
|
||||
/// anything and are failed. A callback from an inbox that was closed meanwhile (its last ticket ended as the
|
||||
/// callback arrived) is dropped. The log line for a drop is fixed text, written before the next inbox opens so
|
||||
/// the log reads in the order things happened. The ticket is completed last, outside the gate.
|
||||
/// </summary>
|
||||
private IBrowserCallbackInbox? Route(IBrowserCallbackInbox inbox, Uri callback, out CancellationToken closed)
|
||||
{
|
||||
BrowserCallbackTicket? ticket = null;
|
||||
BrowserCallbackTicket[] stranded = [];
|
||||
Exception? failure = null;
|
||||
IBrowserCallbackInbox? next = null;
|
||||
closed = CancellationToken.None;
|
||||
lock(_gate)
|
||||
{
|
||||
if(ReferenceEquals(_inbox, inbox))
|
||||
{
|
||||
Close();
|
||||
if(ReadState(callback) is { } state)
|
||||
{
|
||||
_waiting.Remove(state, out ticket);
|
||||
}
|
||||
}
|
||||
if(ticket is null)
|
||||
{
|
||||
Record(static log => log.Warning("A browser callback that belongs to no waiting sign-in or sign-out was dropped. Nothing of it is logged."));
|
||||
}
|
||||
if(_inbox is null && _waiting.Count != 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
(next, closed) = Open();
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
stranded = TakeWaiting();
|
||||
failure = exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(failure is not null)
|
||||
{
|
||||
End(stranded, failure);
|
||||
}
|
||||
ticket?.Complete(callback);
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Handles a wait that ended without a callback. When the inbox is still the open one it failed: it is closed and every waiting ticket fails. Otherwise it was closed on purpose and nothing waits on it.</summary>
|
||||
private void Fail(IBrowserCallbackInbox inbox, Exception failure)
|
||||
{
|
||||
BrowserCallbackTicket[] stranded;
|
||||
lock(_gate)
|
||||
{
|
||||
if(!ReferenceEquals(_inbox, inbox))
|
||||
{
|
||||
return;
|
||||
}
|
||||
Close();
|
||||
stranded = TakeWaiting();
|
||||
}
|
||||
End(stranded, failure);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Removes and returns every waiting ticket. The caller holds the gate.</summary>
|
||||
private BrowserCallbackTicket[] TakeWaiting()
|
||||
{
|
||||
BrowserCallbackTicket[] waiting = [.. _waiting.Values];
|
||||
_waiting.Clear();
|
||||
return waiting;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Fails the tickets a broken inbox left waiting, each once, and logs the kind of failure and how many it ended.
|
||||
/// The tickets get the failure itself when it is of a kind every exchange reports, whose text is then left to
|
||||
/// each exchange to report, and otherwise one that names only its kind. Nothing an inbox put in a message is
|
||||
/// logged from here.
|
||||
/// </summary>
|
||||
private void End(BrowserCallbackTicket[] stranded, Exception failure)
|
||||
{
|
||||
Record(log => log.Error("The browser callback inbox failed ({Failure}); {Waiting} waiting browser exchange(s) were ended and have to be started again.", failure.GetType().Name, stranded.Length));
|
||||
Exception reported = BrowserCallbackFailure.Reported(failure);
|
||||
foreach(BrowserCallbackTicket ticket in stranded)
|
||||
{
|
||||
ticket.Fail(reported);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace Qqq.PrintAgent.Presentation.SignIn;
|
||||
|
||||
/// <summary>
|
||||
/// What an exchange that waits for a browser callback is told when the callback inbox fails. Each exchange reports a
|
||||
/// fixed list of failures as "not completed" and lets anything else through, as it would a programming error. The
|
||||
/// inbox is fed by other processes, so it can fail outside that list: the real one fails with an argument error when
|
||||
/// the process that sent a callback has already exited, which any process in the user's session can bring about. Such
|
||||
/// a failure has to end a sign-in as a failure the operator is told about, in every exchange that waits, and not as
|
||||
/// an exception nobody handles. So a failure outside the list is replaced by one inside it that names only the kind
|
||||
/// of failure. Its own text is not passed on: nothing is known about what an unexpected failure says.
|
||||
/// </summary>
|
||||
internal static class BrowserCallbackFailure
|
||||
{
|
||||
/// <summary>True for the kinds of inbox failure every exchange reports itself, with the failure's own text: a pipe that broke or was refused, a callback the inbox refused, and a wait that was ended.</summary>
|
||||
internal static bool Listed(Exception failure)
|
||||
=> failure is IOException or InvalidDataException or UnauthorizedAccessException or Win32Exception or InvalidOperationException or OperationCanceledException;
|
||||
|
||||
|
||||
|
||||
/// <summary>The failure to hand to an exchange: the inbox's own when it is of a listed kind, and otherwise one with fixed text and the kind of failure.</summary>
|
||||
internal static Exception Reported(Exception failure)
|
||||
=> Listed(failure) ? failure : new IOException("The browser callback inbox failed (" + failure.GetType().Name + "), so no callback could be received. Try again.");
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Presentation.SignIn;
|
||||
|
||||
/// <summary>
|
||||
/// One browser exchange's claim on the callback inbox: the sign-in or provider sign-out that registered an OAuth state
|
||||
/// with the <see cref="BrowserCallbackDispatcher"/> waits on this for the callback carrying that state. Disposing it
|
||||
/// ends the claim, after which a callback with that state is dropped. The state is held only while the ticket waits
|
||||
/// and is never shown. The ticket's task carries the callback, with its one-time code and state, so the ticket is
|
||||
/// internal to the presentation assembly, whose sign-in and sign-out are the only ones to wait on it.
|
||||
/// </summary>
|
||||
internal sealed class BrowserCallbackTicket : IDisposable
|
||||
{
|
||||
/// <summary>What the exchange waits on. Its continuations run asynchronously, so whoever awaits the ticket never continues inside the dispatcher, on the thread a callback arrived on.</summary>
|
||||
private readonly TaskCompletionSource<Uri> _callback = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>The dispatcher the ticket is registered with, which is told when the claim ends.</summary>
|
||||
private readonly BrowserCallbackDispatcher _dispatcher;
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates the ticket for a state; only the dispatcher does, when the state is registered.</summary>
|
||||
internal BrowserCallbackTicket(BrowserCallbackDispatcher dispatcher, string state)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
State = state;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The state this ticket waits for, which is how the dispatcher finds it.</summary>
|
||||
internal string State { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The callback carrying this ticket's state. It completes with the callback address when the dispatcher receives
|
||||
/// it, fails with the inbox's failure when the inbox failed while this ticket waited, and is cancelled when the
|
||||
/// ticket is disposed first. Whoever awaits it continues on its own thread, never inside the dispatcher.
|
||||
/// </summary>
|
||||
internal Task<Uri> Callback => _callback.Task;
|
||||
|
||||
|
||||
|
||||
/// <summary>Hands the ticket its callback.</summary>
|
||||
internal void Complete(Uri callback) => _callback.TrySetResult(callback);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Ends the wait with the inbox's failure, and marks the failure as seen. An exchange whose own wait had already
|
||||
/// ended (its token was cancelled) disposes the ticket without ever looking at the task, and the runtime reports
|
||||
/// a failed task nobody looked at as an unobserved error once it is collected. Whoever does await the ticket is
|
||||
/// given the failure all the same.
|
||||
/// </summary>
|
||||
internal void Fail(Exception failure)
|
||||
{
|
||||
if(_callback.TrySetException(failure))
|
||||
{
|
||||
_ = _callback.Task.Exception;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Ends the claim: the dispatcher forgets the state, closes the inbox when no other ticket waits, and a wait that has no result yet is cancelled. Disposing a ticket that already has its callback changes nothing.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_dispatcher.Release(this);
|
||||
_callback.TrySetCanceled();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
|
||||
namespace Qqq.PrintAgent.Presentation.SignIn;
|
||||
|
||||
/// <summary>
|
||||
/// Opens an address in the browser for a sign-in or a provider sign-out, and makes a failure to do so safe to report.
|
||||
/// The address carries a sign-in state or the provider's ID token hint. When Windows cannot start the browser, its
|
||||
/// failure names the thing it could not start, which is that whole address, so the failure's own text must never
|
||||
/// reach a log line or the screen.
|
||||
/// </summary>
|
||||
internal static class BrowserLaunch
|
||||
{
|
||||
/// <summary>What an operator can do about a browser that could not be opened; it follows the kind of failure in the failure's text, which is shown as the reason a sign-in was not completed, and it ends what the dialog says about a provider sign-out whose page could not be opened.</summary>
|
||||
internal const string Remedy = "Check that a default browser is set in Windows Settings under Apps, Default apps, then try again.";
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Opens the address. Any failure is replaced by one with fixed text: that the default browser could not be
|
||||
/// opened, the kind of failure (for a Windows error, its number) and what to do about it. The new failure is
|
||||
/// thrown after the original has been let go, so the original is not attached as its cause: a report of the new
|
||||
/// failure, whichever way it is written out, then cannot contain the address.
|
||||
/// </summary>
|
||||
internal static void Open(IBrowserLauncher browser, Uri address)
|
||||
{
|
||||
string kind;
|
||||
try
|
||||
{
|
||||
browser.Open(address);
|
||||
return;
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
kind = exception is Win32Exception windows ? "Windows error " + windows.NativeErrorCode.ToString(CultureInfo.InvariantCulture) : exception.GetType().Name;
|
||||
}
|
||||
throw new IOException("The default browser could not be opened (" + kind + "). " + Remedy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.ComponentModel;
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
namespace Qqq.PrintAgent.Presentation.SignIn;
|
||||
|
||||
/// <summary>
|
||||
/// Browser sign-in: the exchange with the identity provider through the default browser, its time limit and
|
||||
/// cancellation, and what is recorded when the browser never comes back. The callback is awaited through the one
|
||||
/// <see cref="BrowserCallbackDispatcher"/>, by the state this attempt sent, so another server's sign-in can be waiting
|
||||
/// at the same time and a callback that is not this attempt's never reaches it.
|
||||
/// <para>
|
||||
/// When an attempt keeps its session. An attempt that failed keeps it, whatever the failure and whenever it
|
||||
/// happened. An attempt whose wait ended (the operator cancelled, the three minutes ran out, or the caller stopped
|
||||
/// waiting) keeps it only when that happened before the callback was handed to the session: the session was given
|
||||
/// nothing then, and the next attempt can use it without a new read. From the moment the callback is handed over,
|
||||
/// the server may complete the sign-in on that session whatever happens here. An attempt that then ends without a
|
||||
/// result discards the session, so that nothing here holds a signed-in session behind an attempt that was reported
|
||||
/// as cancelled, and the next attempt needs a new read: <see cref="HasSession"/> is false and <see cref="Offer"/>
|
||||
/// is null, while the state still says how the attempt ended (cancelled, timed out, or not prepared for a caller
|
||||
/// that stopped waiting).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed partial class CompanionSignIn
|
||||
{
|
||||
/// <summary>How long a browser sign-in waits before it is given up.</summary>
|
||||
public static readonly TimeSpan BrowserSignInLimit = TimeSpan.FromMinutes(3);
|
||||
|
||||
/// <summary>How far back the protocol handler's trace is read when a browser sign-in ends without a result.</summary>
|
||||
private static readonly TimeSpan s_traceWindow = TimeSpan.FromMinutes(10);
|
||||
|
||||
/// <summary>What the Cancel button cancels: set while a browser exchange runs, and cleared before the exchange's cancellation sources are released.</summary>
|
||||
private CancellationTokenSource? _browserCancel;
|
||||
|
||||
/// <summary>True from the moment the running browser attempt hands its callback to the session until the attempt has ended. An attempt that ends without a result while this is set discards the session.</summary>
|
||||
private bool _handedOver;
|
||||
|
||||
/// <summary>What the running browser attempt has not got past on its way to opening the provider's page, in words for the log; null once the page is open. An attempt that ends while this is set never involved a browser.</summary>
|
||||
private string? _unopened;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Signs in through the browser, with a three minute limit, and returns the session credential. Nothing happens
|
||||
/// without a session that offers the browser. The redirect address is the one this application's installer
|
||||
/// registered, and it is used only when the server also lists it, so a server cannot steer the callback elsewhere.
|
||||
/// A cancelled, timed-out or failed attempt is reported by state. A failed attempt keeps the session, and so does
|
||||
/// one that was cancelled or timed out before its callback was handed to the session, so the operator can try
|
||||
/// again: a late callback of the abandoned attempt carries a state nobody waits for and is dropped by the
|
||||
/// dispatcher. One that was cancelled or timed out after the callback was handed over discards the session (the
|
||||
/// type's summary says when a session is kept). It returns null when no credential was obtained.
|
||||
/// </summary>
|
||||
public async Task<QqqSessionCredential?> SignInWithBrowserAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if(_session is not { } session || _offer is not { Browser: true } || !TryBegin("Browser sign-in"))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return await RunAsync("Browser sign-in", CompanionSignInState.WaitingForBrowser, token => TryBrowserSignInAsync(session, token), cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Cancels the browser sign-in that is running, if one is. It has no effect once the exchange with the browser has ended, however it ended.</summary>
|
||||
public void CancelBrowserSignIn() => _browserCancel?.Cancel();
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The exchange under its limit, and what its ending means. The Cancel button reaches the exchange through a
|
||||
/// field, which is cleared before the exchange's cancellation sources are released, so a Cancel that arrives while
|
||||
/// a failed exchange is still being reported finds nothing to cancel. An exchange that the operator cancelled or
|
||||
/// that ran out of time is said to be exactly that, and loses its session when the callback had already been
|
||||
/// handed to it. A wait that the caller or disposal ended is left to the runner, which does the same as it
|
||||
/// releases the sign-in. Announcing the result is outside the protected part, as it is for a password sign-in.
|
||||
/// </summary>
|
||||
private async Task<QqqSessionCredential?> TryBrowserSignInAsync(ISignInSession session, CancellationToken token)
|
||||
{
|
||||
QqqCertificateRejection? known = session.LastCertificateRejection;
|
||||
using CancellationTokenSource limit = new(BrowserSignInLimit, _time);
|
||||
using CancellationTokenSource cancel = new();
|
||||
using var exchange = CancellationTokenSource.CreateLinkedTokenSource(limit.Token, cancel.Token, token);
|
||||
QqqSessionCredential credential;
|
||||
_handedOver = false;
|
||||
_browserCancel = cancel;
|
||||
try
|
||||
{
|
||||
await ExchangeWithBrowserAsync(session, exchange.Token);
|
||||
exchange.Token.ThrowIfCancellationRequested();
|
||||
credential = session.ExportCredential();
|
||||
}
|
||||
catch(OperationCanceledException) when(!token.IsCancellationRequested && (cancel.IsCancellationRequested || limit.IsCancellationRequested))
|
||||
{
|
||||
_log.Info(cancel.IsCancellationRequested ? "Browser sign-in to {Server} was cancelled by the operator." : "Browser sign-in to {Server} was given up after the three minute limit.", QqqOrigin.ShownAuthority(session.Origin));
|
||||
ReportCallbackTrace();
|
||||
DropHandedOverSession();
|
||||
Settle(cancel.IsCancellationRequested ? CompanionSignInState.BrowserCanceled : CompanionSignInState.BrowserTimedOut);
|
||||
return null;
|
||||
}
|
||||
catch(Exception exception) when(Reportable(exception, token) && exception is IOException or InvalidOperationException or HttpRequestException or UnauthorizedAccessException or Win32Exception or InvalidDataException or OperationCanceledException)
|
||||
{
|
||||
ReportCallbackTrace();
|
||||
Fail("Browser sign-in not completed", CompanionSignInState.BrowserFailed, session, known, exception);
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_browserCancel = null;
|
||||
}
|
||||
return SignedIn(session, credential);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The browser exchange itself: begin the sign-in on the session, read the state it sent from the address it
|
||||
/// returned, register that state with the dispatcher (which has an inbox open before the browser is, so a fast
|
||||
/// redirect is not lost), open the provider's page in the default browser, wait for the callback with that state,
|
||||
/// and complete the sign-in on the session that started it. The registration ends when the exchange does. Until
|
||||
/// the page is open, the exchange notes what it is at, so an attempt that ends before any browser was involved can
|
||||
/// say that, and why (see <see cref="ReportCallbackTrace"/>). Only the provider's host is logged, never the
|
||||
/// address, its state or the callback. The token is looked at again after each wait: once the sign-in was begun,
|
||||
/// so a browser is not opened for a wait that has already ended, and once the callback is there, so the session
|
||||
/// is not asked to complete for one. That second look is needed because a callback that has already arrived is
|
||||
/// handed over whatever the token says, and a session is not bound to refuse: the check is made here and not left
|
||||
/// to it. Completing takes the same token, so a wait that ends while the session completes ends the completion
|
||||
/// too.
|
||||
/// </summary>
|
||||
private async Task ExchangeWithBrowserAsync(ISignInSession session, CancellationToken cancellationToken)
|
||||
{
|
||||
_log.Info("Browser sign-in requested for {Server}; this application's callback is {Callback}.", QqqOrigin.ShownAuthority(session.Origin), _callbacks.RedirectUri);
|
||||
_unopened = "the server does not list this application's callback";
|
||||
if(!_redirects.Contains(_callbacks.RedirectUri, StringComparer.Ordinal))
|
||||
{
|
||||
_log.Warning("The server's native grant does not list this application's callback {Callback}; it lists {Count} other address(es). Add the callback to the QQQ native session grant.", _callbacks.RedirectUri, _redirects.Count);
|
||||
throw new InvalidOperationException("No approved native browser callback is configured.");
|
||||
}
|
||||
_unopened = "the session had not prepared a usable sign-in address";
|
||||
Uri authorization = await session.BeginBrowserSignInAsync(new Uri(_callbacks.RedirectUri), cancellationToken);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string state = BrowserCallbackDispatcher.ReadState(authorization) ?? throw new InvalidOperationException("The sign-in address the session prepared carries no state of its own, so its callback could not be told from another.");
|
||||
_unopened = "the attempt was not yet waiting for its callback";
|
||||
using BrowserCallbackTicket ticket = _dispatcher.Register(state);
|
||||
_log.Info("Opening the identity provider's sign-in page in the default browser ({Provider}).", QqqOrigin.ShownAuthority(authorization));
|
||||
_unopened = "the default browser was not started";
|
||||
BrowserLaunch.Open(_browser, authorization);
|
||||
_unopened = null;
|
||||
Uri callback = await ticket.Callback.WaitAsync(cancellationToken);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
_log.Info("Browser callback received; completing the sign-in.");
|
||||
_handedOver = true;
|
||||
await session.CompleteBrowserSignInAsync(callback, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Discards the session when the browser attempt that is ending had handed its callback to it. The server may
|
||||
/// have completed that sign-in, so a session kept here could be signed in behind an attempt that was reported as
|
||||
/// cancelled, timed out or stopped. An attempt that ended before the hand-over gave the session nothing, and
|
||||
/// keeps it.
|
||||
/// </summary>
|
||||
private void DropHandedOverSession()
|
||||
{
|
||||
if(!_handedOver)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_handedOver = false;
|
||||
_log.Info("The browser sign-in ended without a result after its callback had been handed to the session, so the session is discarded: the server may have signed it in, and nothing here waits for that. The next attempt reads the sign-in description again.");
|
||||
DropSession();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Says what is known about the hand-off from the browser when an attempt ends without a result. When the
|
||||
/// provider's page was opened, it copies the protocol handler's recent trace into the activity log, or states
|
||||
/// that the browser never launched the handler at all, so a hand-off that stops outside the companion is still
|
||||
/// visible in one place. The handler writes that trace without the query of any callback. When the attempt ended
|
||||
/// before a page was opened, none of that applies: no browser was started, so nothing could have redirected or
|
||||
/// been blocked, and a trace from the last ten minutes would be some other attempt's. The entry then says that
|
||||
/// no page was opened and what the attempt had not got past: the server not listing this application's callback,
|
||||
/// the session not having prepared an address, the wait for the callback not having started, or the browser not
|
||||
/// having started.
|
||||
/// </summary>
|
||||
private void ReportCallbackTrace()
|
||||
{
|
||||
if(_unopened is { } reason)
|
||||
{
|
||||
_log.Info("This attempt ended before a browser page was opened ({Reason}), so the sign-in protocol handler was not involved and its trace is not copied here.", reason);
|
||||
return;
|
||||
}
|
||||
IReadOnlyList<string> lines = _callbacks.RecentTrace(s_traceWindow);
|
||||
if(lines.Count == 0)
|
||||
{
|
||||
_log.Warning("The browser never launched the sign-in protocol handler in the last 10 minutes (no entries in {TracePath}). Either the identity provider did not redirect to {Callback} (check the provider's allowed redirect URIs and any error page it showed), or the browser blocked opening the QQQ Print Agent sign-in link.", _callbacks.TracePath, _callbacks.RedirectUri);
|
||||
return;
|
||||
}
|
||||
_log.Warning("Protocol handler trace from {TracePath}:{NewLine}{Trace}", _callbacks.TracePath, Environment.NewLine, string.Join(Environment.NewLine, lines));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
using Qqq.PrintAgent.Windows.Management;
|
||||
|
||||
namespace Qqq.PrintAgent.Presentation.SignIn;
|
||||
|
||||
/// <summary>
|
||||
/// The part of signing in that needs a session, and runs in the companion as the signed-in Windows user: read the
|
||||
/// server's sign-in description, sign in by password or through the browser, and return the session credential to the
|
||||
/// caller. That is all it does. It hands nothing to the service, enrolls nothing and holds no trust setting: the
|
||||
/// certificate exception to read under is given with each preparation, and what happens to the credential is the
|
||||
/// caller's business. A password is passed straight to the session and never kept, and the credential is only ever a
|
||||
/// return value, never a property. One operation runs at a time; a second one is refused and logged. Everything it
|
||||
/// announces is raised on the thread that called it. Disposing it only cancels: the running operation ends itself,
|
||||
/// discards the session on its way out, and from then on nothing is announced, neither that a property changed nor
|
||||
/// that one is about to.
|
||||
/// </summary>
|
||||
public sealed partial class CompanionSignIn : ObservableObject, IDisposable
|
||||
{
|
||||
/// <summary>Creates the session a read of the sign-in description runs on.</summary>
|
||||
private readonly ISignInSessionFactory _sessions;
|
||||
|
||||
/// <summary>Opens the provider's page; used only through <see cref="BrowserLaunch"/>.</summary>
|
||||
private readonly IBrowserLauncher _browser;
|
||||
|
||||
/// <summary>Names this application's registered callback and reads the protocol handler's trace. The inbox itself belongs to the dispatcher.</summary>
|
||||
private readonly IBrowserCallbackSource _callbacks;
|
||||
|
||||
/// <summary>The one dispatcher a browser sign-in waits for its callback through.</summary>
|
||||
private readonly BrowserCallbackDispatcher _dispatcher;
|
||||
|
||||
/// <summary>The log. No password, credential, code, state or address with a query is ever passed to it.</summary>
|
||||
private readonly IPresentationLog _log;
|
||||
|
||||
/// <summary>The clock the browser sign-in's time limit runs on.</summary>
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
/// <summary>Cancelled when the sign-in is disposed, which ends the operation that is running.</summary>
|
||||
private readonly CancellationTokenSource _lifetime = new();
|
||||
|
||||
/// <summary>The session that is held, on which a sign-in can be made; null when none is.</summary>
|
||||
private ISignInSession? _session;
|
||||
|
||||
/// <summary>The callbacks the held session's description lists for a browser sign-in. They are compared with this application's own and never logged.</summary>
|
||||
private IReadOnlyList<string> _redirects = [];
|
||||
|
||||
/// <summary>The wait of the operation that is running, from its start until it begins to be released; null otherwise. While it is set, the operation can still be cancelled and will discard the session on its way out.</summary>
|
||||
private CancellationTokenSource? _operation;
|
||||
|
||||
/// <summary>Backs <see cref="State"/>.</summary>
|
||||
private CompanionSignInState _state;
|
||||
|
||||
/// <summary>Backs <see cref="Offer"/>.</summary>
|
||||
private SignInOffer? _offer;
|
||||
|
||||
/// <summary>Backs <see cref="Rejection"/>.</summary>
|
||||
private QqqCertificateRejection? _rejection;
|
||||
|
||||
/// <summary>Backs <see cref="Failure"/>.</summary>
|
||||
private string? _failure;
|
||||
|
||||
/// <summary>True from the moment an operation claims the sign-in until it has been released, announcements included; it backs <see cref="IsBusy"/>.</summary>
|
||||
private bool _operating;
|
||||
|
||||
/// <summary>True when a discard was asked for while an operation ran: the operation discards the session as it ends.</summary>
|
||||
private bool _discarding;
|
||||
|
||||
/// <summary>True once the sign-in was disposed; nothing is started or announced after that.</summary>
|
||||
private bool _disposed;
|
||||
|
||||
|
||||
|
||||
/// <summary>Wires the sign-in to the things it cannot do itself: create a session, open the browser, name this application's callback, and wait for a callback through the one dispatcher. The time provider drives the browser time limit.</summary>
|
||||
public CompanionSignIn(ISignInSessionFactory sessions, IBrowserLauncher browser, IBrowserCallbackSource callbacks, BrowserCallbackDispatcher dispatcher, IPresentationLog log, TimeProvider? time = null)
|
||||
{
|
||||
_sessions = sessions ?? throw new ArgumentNullException(nameof(sessions));
|
||||
_browser = browser ?? throw new ArgumentNullException(nameof(browser));
|
||||
_callbacks = callbacks ?? throw new ArgumentNullException(nameof(callbacks));
|
||||
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||
_log = log ?? throw new ArgumentNullException(nameof(log));
|
||||
_time = time ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Where the sign-in is, or how its last step ended.</summary>
|
||||
public CompanionSignInState State
|
||||
{
|
||||
get => _state;
|
||||
private set => SetProperty(ref _state, value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The methods that can be used on the session that is held; null while none is held.</summary>
|
||||
public SignInOffer? Offer
|
||||
{
|
||||
get => _offer;
|
||||
private set => SetProperty(ref _offer, value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The certificate this Windows account refused in the last step, with the origin that presented it; null when the last step did not end over a certificate. It is public certificate data.</summary>
|
||||
public QqqCertificateRejection? Rejection
|
||||
{
|
||||
get => _rejection;
|
||||
private set => SetProperty(ref _rejection, value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Why the last step was not completed, in words, when it failed for a reason other than a certificate; null otherwise. It is the session's own explanation, which never says which part of a sign-in was wrong.</summary>
|
||||
public string? Failure
|
||||
{
|
||||
get => _failure;
|
||||
private set => SetProperty(ref _failure, value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>True while one of the operations is running; any other is refused until it ends.</summary>
|
||||
public bool IsBusy => _operating;
|
||||
|
||||
|
||||
|
||||
/// <summary>True while a session is held, which is what a sign-in needs.</summary>
|
||||
public bool HasSession => _session is not null;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reads the server's sign-in description as the signed-in Windows user and says which methods can be used. Any
|
||||
/// session held from before is discarded first. The session is created under the certificate exception the caller
|
||||
/// gives, which is the one the operator accepted or the one stored with the server, and under no other. The methods
|
||||
/// offered are those the service's check and this read both found; a difference is logged. When the description
|
||||
/// names another sign-in provider than the check reported, or this account refuses the server's certificate, or the
|
||||
/// description cannot be read, the state says which and no session is kept. It returns the offer, or null when
|
||||
/// nothing can be signed in with, when the caller stopped waiting, or when another operation is running. An offer
|
||||
/// is returned only while the session it was made on is the one that is held: when a subscriber discarded the
|
||||
/// session while the result was being announced, the read returns null, as <see cref="Offer"/> then is, so a
|
||||
/// caller is never handed an offer it cannot sign in with.
|
||||
/// </summary>
|
||||
public async Task<SignInOffer?> PrepareAsync(Uri server, ServerCheckResult check, QqqCertificateExceptionOrigins certificateException, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(server);
|
||||
ArgumentNullException.ThrowIfNull(check);
|
||||
ArgumentNullException.ThrowIfNull(certificateException);
|
||||
if(!TryBegin("Reading the sign-in description"))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
SignInOffer? offer = await RunAsync("Reading the sign-in description", CompanionSignInState.Preparing, token => ReadDescriptionAsync(server, check, certificateException, token), cancellationToken);
|
||||
if(offer is not null && !ReferenceEquals(offer, _offer))
|
||||
{
|
||||
_log.Debug("The sign-in description was read, and its session was discarded before the read returned, so the read offers nothing.");
|
||||
return null;
|
||||
}
|
||||
return offer;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The read itself. The session it creates becomes the held session only when a method is offered; in every other
|
||||
/// case, a failure and a cancellation included, it is discarded before this returns or throws. Taking the session
|
||||
/// over and announcing it are outside the protected part, so a subscriber that throws while the result is
|
||||
/// announced has not made the read fail.
|
||||
/// </summary>
|
||||
private async Task<SignInOffer?> ReadDescriptionAsync(Uri server, ServerCheckResult check, QqqCertificateExceptionOrigins certificateException, CancellationToken token)
|
||||
{
|
||||
DropSession();
|
||||
ISignInSession? session = null;
|
||||
QqqAuthenticationMetadata? description = null;
|
||||
SignInOffer? offer = null;
|
||||
try
|
||||
{
|
||||
_log.Info("Reading the sign-in description of {Server} as this Windows account; an untrusted certificate is accepted from {Exception}.", QqqOrigin.ShownAuthority(server), certificateException);
|
||||
session = _sessions.Create(server, certificateException);
|
||||
description = await session.DiscoverAsync(token);
|
||||
token.ThrowIfCancellationRequested();
|
||||
offer = Judge(session, description, check);
|
||||
}
|
||||
catch(Exception exception) when(Reportable(exception, token) && exception is IOException or ArgumentException or InvalidOperationException or HttpRequestException or OperationCanceledException)
|
||||
{
|
||||
Fail("The sign-in description could not be read", CompanionSignInState.PrepareFailed, session, null, exception);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(offer is null)
|
||||
{
|
||||
session?.Dispose();
|
||||
}
|
||||
}
|
||||
return offer is null ? null : Adopt(session!, description!, offer);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Judges a description that was read and returns what is offered, or null when nobody can sign in with it, having
|
||||
/// said why. The provider it names is compared with the one the service's check reported, by the rule the check
|
||||
/// applies (a table-based server names none), and a difference stops the sign-in: a certificate exception the
|
||||
/// operator accepted covers the provider that was shown, not another. Then the methods are settled: those the
|
||||
/// check and this read both found, with a difference logged.
|
||||
/// </summary>
|
||||
private SignInOffer? Judge(ISignInSession session, QqqAuthenticationMetadata description, ServerCheckResult check)
|
||||
{
|
||||
string server = QqqOrigin.ShownAuthority(session.Origin);
|
||||
var read = SignInOffer.Read(description);
|
||||
Uri? provider = read.Password ? null : description.ProviderOrigin;
|
||||
Uri? reported = Uri.TryCreate(check.ProviderOrigin, UriKind.Absolute, out Uri? named) ? QqqOrigin.Of(named) : null;
|
||||
if(!Equals(provider, reported))
|
||||
{
|
||||
_log.Warning("The sign-in provider in the sign-in description of {Server} is {Read}, and the service's check reported {Checked}. Nobody is signed in until the server has been checked again.", server, Shown(provider), Shown(reported));
|
||||
Settle(CompanionSignInState.ProviderChanged);
|
||||
return null;
|
||||
}
|
||||
var found = SignInOffer.Checked(check);
|
||||
SignInOffer offer = found.Common(read);
|
||||
if(found == read)
|
||||
{
|
||||
_log.Info("Sign-in methods for {Server}: {Offer}.", server, offer);
|
||||
}
|
||||
else
|
||||
{
|
||||
_log.Warning("Sign-in methods for {Server}: the service's check found {Checked} and this account's read found {Read}, so {Offer} is offered.", server, found, read, offer);
|
||||
}
|
||||
if(!offer.Any)
|
||||
{
|
||||
Settle(CompanionSignInState.NothingOffered);
|
||||
return null;
|
||||
}
|
||||
return offer;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Takes over the session a method is offered on, with the callbacks its description lists for a browser sign-in, and announces that a sign-in can start.</summary>
|
||||
private SignInOffer Adopt(ISignInSession session, QqqAuthenticationMetadata description, SignInOffer offer)
|
||||
{
|
||||
_session = session;
|
||||
_redirects = description.Values?.NativeSessionLogin?.RedirectUris ?? [];
|
||||
OnPropertyChanged(nameof(HasSession));
|
||||
Offer = offer;
|
||||
Settle(CompanionSignInState.Ready);
|
||||
return offer;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Signs in with a user name and password and returns the session credential. Nothing happens without a session
|
||||
/// that offers a password. The password is passed straight to the session and never kept or logged. A failure is
|
||||
/// reported by state, with the session's own reason, which does not say which part (name, password, permission)
|
||||
/// was wrong, and the session stays for another attempt. It returns null when no credential was obtained: the
|
||||
/// sign-in failed, the caller stopped waiting, or nothing was attempted.
|
||||
/// </summary>
|
||||
public async Task<QqqSessionCredential?> SignInWithPasswordAsync(string? username, string password, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if(_session is not { } session || _offer is not { Password: true } || !TryBegin("Password sign-in"))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return await RunAsync("Password sign-in", CompanionSignInState.SigningIn, token => TrySignInWithPasswordAsync(session, (username ?? string.Empty).Trim(), password, token), cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The server's part of a password sign-in. A failure of it, the server's own time limit included, is reported as
|
||||
/// not completed. A wait that the caller or disposal ended is not a failure and is left to the runner, and no
|
||||
/// credential is taken once the wait has ended. Announcing the result is outside the protected part: once the
|
||||
/// server has accepted the sign-in, a subscriber that throws while it is announced has not made the sign-in fail.
|
||||
/// </summary>
|
||||
private async Task<QqqSessionCredential?> TrySignInWithPasswordAsync(ISignInSession session, string username, string password, CancellationToken token)
|
||||
{
|
||||
QqqCertificateRejection? known = session.LastCertificateRejection;
|
||||
QqqSessionCredential credential;
|
||||
try
|
||||
{
|
||||
_log.Info("Password sign-in submitted to {Server}.", QqqOrigin.ShownAuthority(session.Origin));
|
||||
await session.SignInWithPasswordAsync(username, password, token);
|
||||
token.ThrowIfCancellationRequested();
|
||||
credential = session.ExportCredential();
|
||||
}
|
||||
catch(Exception exception) when(Reportable(exception, token) && exception is IOException or ArgumentException or HttpRequestException or OperationCanceledException or UnauthorizedAccessException)
|
||||
{
|
||||
Fail("Password sign-in not completed", CompanionSignInState.PasswordFailed, session, known, exception);
|
||||
return null;
|
||||
}
|
||||
return SignedIn(session, credential);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Discards the session, if any, so a later sign-in starts with a new preparation. While an operation is running
|
||||
/// the session is in use, so this cancels the operation instead, and the operation discards the session as it
|
||||
/// ends: discarding a session under a call that is still using it makes that call fail in ways that look like
|
||||
/// errors. Either way no session is held once the running operation, if any, has ended. "Running" is decided by
|
||||
/// the operation's own wait, which exists exactly while the operation can still discard the session for us, and
|
||||
/// not by <see cref="IsBusy"/>, which stays true a little longer: an operation that is being released announces
|
||||
/// how it ended before it lets go, and a subscriber that calls this from that announcement finds no call using
|
||||
/// the session any more, so the session is discarded at once. Leaving it to the operation then would leave it
|
||||
/// to nobody.
|
||||
/// </summary>
|
||||
public void Discard()
|
||||
{
|
||||
if(_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(_operation is { } running)
|
||||
{
|
||||
_discarding = true;
|
||||
running.Cancel();
|
||||
return;
|
||||
}
|
||||
DropSession();
|
||||
Settle(CompanionSignInState.NotPrepared);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Ends the sign-in. It only cancels: an operation that is still running sees the cancellation, ends quietly and
|
||||
/// discards the session itself. When nothing is running the session is discarded here, and "running" is decided
|
||||
/// as it is in <see cref="Discard"/>, by the operation's own wait: a subscriber that disposes the sign-in while
|
||||
/// an operation that has ended is being released finds no call using the session, and nothing left that would
|
||||
/// discard it later. After this nothing is announced.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if(_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_disposed = true;
|
||||
_lifetime.Cancel();
|
||||
if(_operation is null)
|
||||
{
|
||||
DropSession();
|
||||
}
|
||||
_lifetime.Dispose();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Announces a change, unless the sign-in was disposed: a view that has closed must not be called back by an operation that was still finishing.</summary>
|
||||
protected override void OnPropertyChanged(PropertyChangedEventArgs e)
|
||||
{
|
||||
if(!_disposed)
|
||||
{
|
||||
base.OnPropertyChanged(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Announces that a property is about to change, unless the sign-in was disposed, for the same reason: "nothing is announced after disposal" holds for both of the events a view can subscribe to.</summary>
|
||||
protected override void OnPropertyChanging(PropertyChangingEventArgs e)
|
||||
{
|
||||
if(!_disposed)
|
||||
{
|
||||
base.OnPropertyChanging(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Records that the server accepted the user and returns the credential to hand to the caller.</summary>
|
||||
private QqqSessionCredential SignedIn(ISignInSession session, QqqSessionCredential credential)
|
||||
{
|
||||
_log.Info("Sign-in accepted by {Server}; the session credential goes to the caller and is not kept here.", QqqOrigin.ShownAuthority(session.Origin));
|
||||
Settle(CompanionSignInState.SignedIn);
|
||||
return credential;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Claims the sign-in for one operation. After it was disposed it refuses silently. While another operation is
|
||||
/// running it refuses and logs that, so a request that did nothing is never silent in the log; the caller sees the
|
||||
/// state of the operation that is running.
|
||||
/// </summary>
|
||||
private bool TryBegin(string operation)
|
||||
{
|
||||
if(_disposed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if(_operating)
|
||||
{
|
||||
_log.Warning("{Operation} was not started: another sign-in operation is still running.", operation);
|
||||
return false;
|
||||
}
|
||||
_operating = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Runs one claimed operation and always releases the sign-in afterwards, whatever the operation or a subscriber
|
||||
/// does. The operation's token ends when the caller's does, when the session is discarded and when the sign-in is
|
||||
/// disposed. An operation that ended for one of those reasons is not a failure: it is noted as debug detail and
|
||||
/// null is returned.
|
||||
/// </summary>
|
||||
private async Task<T?> RunAsync<T>(string operation, CompanionSignInState running, Func<CancellationToken, Task<T?>> body, CancellationToken cancellationToken)
|
||||
where T : class
|
||||
{
|
||||
CancellationTokenSource? wait = null;
|
||||
try
|
||||
{
|
||||
wait = CancellationTokenSource.CreateLinkedTokenSource(_lifetime.Token, cancellationToken);
|
||||
_operation = wait;
|
||||
OnPropertyChanged(nameof(IsBusy));
|
||||
Settle(running);
|
||||
wait.Token.ThrowIfCancellationRequested();
|
||||
return await body(wait.Token);
|
||||
}
|
||||
catch(Exception exception) when(_disposed || exception is OperationCanceledException && wait is { IsCancellationRequested: true })
|
||||
{
|
||||
_log.Debug("{Operation} ended early because {Reason} ({Failure}).", operation, _disposed ? "the sign-in was closed" : _discarding ? "the session was discarded" : "its caller stopped waiting", exception.GetType().Name);
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
End(wait);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Releases the sign-in after an operation. A sign-in that was disposed, or whose session was asked to be
|
||||
/// discarded, while the operation ran discards the session here, now that no call is using it. An operation that
|
||||
/// ended without an outcome of its own (its caller stopped waiting, or it threw something unexpected) leaves the
|
||||
/// state that matches what is held; when it was a browser sign-in that had already handed its callback to the
|
||||
/// session, the session is discarded first, as it is for one the operator cancelled. The state is settled first
|
||||
/// and the sign-in released after it, so a subscriber that starts the next operation when the sign-in becomes
|
||||
/// free does not have its state overwritten, and the release happens even when a subscriber throws.
|
||||
/// </summary>
|
||||
private void End(CancellationTokenSource? wait)
|
||||
{
|
||||
try
|
||||
{
|
||||
_operation = null;
|
||||
if(_disposed || _discarding)
|
||||
{
|
||||
_discarding = false;
|
||||
DropSession();
|
||||
Settle(CompanionSignInState.NotPrepared);
|
||||
}
|
||||
else if(_state is CompanionSignInState.Preparing or CompanionSignInState.SigningIn or CompanionSignInState.WaitingForBrowser)
|
||||
{
|
||||
DropHandedOverSession();
|
||||
Settle(_session is null ? CompanionSignInState.NotPrepared : CompanionSignInState.Ready);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_handedOver = false;
|
||||
wait?.Dispose();
|
||||
_operating = false;
|
||||
OnPropertyChanged(nameof(IsBusy));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Whether a failure is one to report. A failure seen after the sign-in was disposed never is, and neither is the
|
||||
/// cancellation that the operation's own token asked for. A cancellation nobody here asked for, such as the
|
||||
/// server's own time limit, is a failure like any other.
|
||||
/// </summary>
|
||||
private bool Reportable(Exception exception, CancellationToken token)
|
||||
=> !_disposed && !(exception is OperationCanceledException && token.IsCancellationRequested);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reports a step that failed. The failure is logged by type and message, with the full exception as debug
|
||||
/// detail. When the session refused a certificate during the step (its last refusal is not the one known before
|
||||
/// the step), the certificate is why: the state says whose it was, the server's own or the sign-in provider's,
|
||||
/// and the refusal is kept for the caller to show. Any other failure settles in the given state with its reason.
|
||||
/// </summary>
|
||||
private void Fail(string context, CompanionSignInState state, ISignInSession? session, QqqCertificateRejection? known, Exception exception)
|
||||
{
|
||||
_log.Error("{Context}: {Failure}", context, QqqSessionDiagnostics.Describe(exception));
|
||||
_log.Debug("{Context} detail: {Exception}", context, exception);
|
||||
if(session?.LastCertificateRejection is not { } rejection || ReferenceEquals(rejection, known))
|
||||
{
|
||||
Settle(state, QqqSessionDiagnostics.Explain(exception));
|
||||
return;
|
||||
}
|
||||
bool own = Equals(rejection.Origin, QqqOrigin.Of(session.Origin));
|
||||
_log.Warning(own
|
||||
? "This Windows account refused the certificate presented by the server ({Presenter}; {Errors}). The service checks certificates under its own account, so it can accept one that this account does not."
|
||||
: "This Windows account refused the certificate presented by the sign-in provider ({Presenter}; {Errors}).", Shown(rejection.Origin), rejection.Errors);
|
||||
Settle(own ? CompanionSignInState.AccountRefusedCertificate : CompanionSignInState.ProviderCertificateRefused, rejection: rejection);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Sets how things stand: the reason and the certificate first, the state last, so a subscriber that reacts to the state finds what goes with it.</summary>
|
||||
private void Settle(CompanionSignInState state, string? failure = null, QqqCertificateRejection? rejection = null)
|
||||
{
|
||||
Failure = failure;
|
||||
Rejection = rejection;
|
||||
State = state;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Discards the session, if any, and withdraws what it offered, so no half-finished attempt can be continued and nothing is offered that cannot work.</summary>
|
||||
private void DropSession()
|
||||
{
|
||||
_session?.Dispose();
|
||||
_session = null;
|
||||
_redirects = [];
|
||||
Offer = null;
|
||||
OnPropertyChanged(nameof(HasSession));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>An origin as a log line names it: its host and port, which an origin holds in plain ASCII, or words when there is none.</summary>
|
||||
private static string Shown(Uri? origin) => origin is null ? "none" : origin.Authority;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Presentation.SignIn;
|
||||
|
||||
/// <summary>
|
||||
/// Where a <see cref="CompanionSignIn"/> is, or how its last step ended. It moves between these values and nothing
|
||||
/// else, so a wizard step can show the right thing for each and a test can assert the path taken. Whether a session
|
||||
/// is held, which is what another attempt needs, is said by <see cref="CompanionSignIn.HasSession"/>.
|
||||
/// </summary>
|
||||
public enum CompanionSignInState
|
||||
{
|
||||
/// <summary>No session is held: nothing was prepared yet, or the session was discarded.</summary>
|
||||
NotPrepared,
|
||||
|
||||
/// <summary>The server's sign-in description is being read as the signed-in Windows user.</summary>
|
||||
Preparing,
|
||||
|
||||
/// <summary>The description was read and a session is held; <see cref="CompanionSignIn.Offer"/> says which methods can be used.</summary>
|
||||
Ready,
|
||||
|
||||
/// <summary>The description was read, and no method is one that both the service's check and this read found. No session is kept.</summary>
|
||||
NothingOffered,
|
||||
|
||||
/// <summary>The description names another sign-in provider than the service's check reported, so the server has to be checked again before anyone signs in. No session is kept.</summary>
|
||||
ProviderChanged,
|
||||
|
||||
/// <summary>This Windows account refused the certificate the server presented, which the service's check had accepted; <see cref="CompanionSignIn.Rejection"/> holds the certificate and who presented it.</summary>
|
||||
AccountRefusedCertificate,
|
||||
|
||||
/// <summary>The description could not be read; <see cref="CompanionSignIn.Failure"/> says why. No session is kept.</summary>
|
||||
PrepareFailed,
|
||||
|
||||
/// <summary>A user name and password are being checked by the server.</summary>
|
||||
SigningIn,
|
||||
|
||||
/// <summary>A browser sign-in is running: the provider's page is open, or about to be, and its callback is awaited.</summary>
|
||||
WaitingForBrowser,
|
||||
|
||||
/// <summary>The server accepted the user and the session credential was returned to the caller. The session is held until it is discarded.</summary>
|
||||
SignedIn,
|
||||
|
||||
/// <summary>The password sign-in was not completed; <see cref="CompanionSignIn.Failure"/> gives the reason, which never says which part was wrong. The session is kept for another attempt.</summary>
|
||||
PasswordFailed,
|
||||
|
||||
/// <summary>The operator cancelled the browser sign-in. The session is kept for another attempt when the callback had not yet been handed to it, and discarded otherwise.</summary>
|
||||
BrowserCanceled,
|
||||
|
||||
/// <summary>The browser sign-in was given up after its time limit. The session is kept for another attempt when the callback had not yet been handed to it, and discarded otherwise.</summary>
|
||||
BrowserTimedOut,
|
||||
|
||||
/// <summary>The browser sign-in was not completed; <see cref="CompanionSignIn.Failure"/> gives the reason. The session is kept for another attempt.</summary>
|
||||
BrowserFailed,
|
||||
|
||||
/// <summary>This Windows account refused the certificate the sign-in provider presented; <see cref="CompanionSignIn.Rejection"/> holds the certificate and who presented it.</summary>
|
||||
ProviderCertificateRefused,
|
||||
}
|
||||
@@ -8,6 +8,6 @@ namespace Qqq.PrintAgent.Presentation.SignIn;
|
||||
/// <summary>Opens an address in the user's default browser. The address can carry a sign-in state or a token hint, so implementations hand it straight to the browser and never log it.</summary>
|
||||
public interface IBrowserLauncher
|
||||
{
|
||||
/// <summary>Opens the address; a failure to start the browser is thrown to the caller.</summary>
|
||||
/// <summary>Opens the address; a failure to start the browser is thrown to the caller. On Windows that failure's text names what could not be started, which is the whole address, so presentation code opens a browser only through <see cref="BrowserLaunch"/>, which replaces the failure with fixed text before anything can log or show it.</summary>
|
||||
void Open(Uri address);
|
||||
}
|
||||
@@ -41,4 +41,14 @@ public interface ISignInSession : IDisposable
|
||||
|
||||
/// <summary>Returns the session credential, the only secret handed to the service; it fails when no sign-in completed.</summary>
|
||||
QqqSessionCredential ExportCredential();
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The certificate this session refused most recently, with the origin that presented it, or null when it has
|
||||
/// refused none. The session runs as the signed-in Windows user, whose certificate store can differ from the
|
||||
/// service's, and it talks to its server and to the sign-in provider that server names, so this is how a caller
|
||||
/// learns that a step failed over a certificate and which of the two presented it. It is public certificate data.
|
||||
/// </summary>
|
||||
QqqCertificateRejection? LastCertificateRejection { get; }
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.ComponentModel;
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
namespace Qqq.PrintAgent.Presentation.SignIn;
|
||||
|
||||
/// <summary>
|
||||
/// The identity provider's half of a sign-out. The provider is meant to be invisible, so when a server's sign-out
|
||||
/// comes with the provider's own, that is opened in the browser too: staying signed in to the provider would let the
|
||||
/// next sign-in skip the credential prompt. This opens the provider's page and waits for the browser to come back
|
||||
/// with this sign-out's own state, through the one <see cref="BrowserCallbackDispatcher"/>, so it can run while a
|
||||
/// sign-in to another server waits for its callback. Ending the session in the service is the caller's part. The
|
||||
/// sign-out request carries the provider's ID token hint: it goes to the browser and nowhere else, and neither it
|
||||
/// nor its state is logged or kept. The result says how it ended (<see cref="ProviderSignOutResult"/>), so the page
|
||||
/// that shows it can tell a page that was never opened from a provider that did not confirm.
|
||||
/// </summary>
|
||||
public sealed class ProviderSignOut
|
||||
{
|
||||
/// <summary>How long the provider is given to confirm its sign-out.</summary>
|
||||
public static readonly TimeSpan Limit = TimeSpan.FromMinutes(2);
|
||||
|
||||
/// <summary>Opens the provider's page; used only through <see cref="BrowserLaunch"/>.</summary>
|
||||
private readonly IBrowserLauncher _browser;
|
||||
|
||||
/// <summary>The one dispatcher the sign-out waits for its callback through.</summary>
|
||||
private readonly BrowserCallbackDispatcher _dispatcher;
|
||||
|
||||
/// <summary>The log. Neither the token hint nor the state nor the address is ever passed to it.</summary>
|
||||
private readonly IPresentationLog _log;
|
||||
|
||||
/// <summary>The clock the two minute limit runs on.</summary>
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
|
||||
|
||||
/// <summary>Wires the sign-out to the browser, the one callback dispatcher and the log; the time provider drives the limit.</summary>
|
||||
public ProviderSignOut(IBrowserLauncher browser, BrowserCallbackDispatcher dispatcher, IPresentationLog log, TimeProvider? time = null)
|
||||
{
|
||||
_browser = browser ?? throw new ArgumentNullException(nameof(browser));
|
||||
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||
_log = log ?? throw new ArgumentNullException(nameof(log));
|
||||
_time = time ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Opens the provider's end-session page in the default browser and waits up to two minutes for the browser to
|
||||
/// return to this application's registered callback with this sign-out's own state. The state is registered
|
||||
/// first, which opens the inbox, so a fast redirect is not lost, and no page is opened for a caller whose token
|
||||
/// has already ended. It says how the sign-out ended. Confirmed: the browser came back, on the registered
|
||||
/// address, with this state. Not opened: the inbox or the browser could not be opened, so the operator saw no
|
||||
/// page. Not confirmed: the page was opened, and the provider did not come back in time, came back with this
|
||||
/// state on another address, or the inbox failed meanwhile. Cancelled: the caller's token ended the sign-out.
|
||||
/// Only the provider's host is logged. A failure is logged as a warning with its kind and text; running out of
|
||||
/// time is logged as a warning in fixed words; the caller's own cancellation is a deliberate stop, and is logged
|
||||
/// as information in fixed words.
|
||||
/// </summary>
|
||||
public async Task<ProviderSignOutResult> CompleteAsync(QqqProviderLogout logout, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(logout);
|
||||
bool opened = false;
|
||||
using CancellationTokenSource limit = new(Limit, _time);
|
||||
using var wait = CancellationTokenSource.CreateLinkedTokenSource(limit.Token, cancellationToken);
|
||||
try
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
using BrowserCallbackTicket ticket = _dispatcher.Register(logout.State);
|
||||
_log.Info("Opening the identity provider's sign-out page in the default browser ({Provider}).", QqqOrigin.ShownAuthority(logout.EndSessionUri));
|
||||
BrowserLaunch.Open(_browser, logout.EndSessionUri);
|
||||
opened = true;
|
||||
Uri callback = await ticket.Callback.WaitAsync(wait.Token);
|
||||
bool matched = logout.Matches(callback);
|
||||
_log.Info(matched ? "Identity provider sign-out callback matched." : "Identity provider sign-out callback did not match this sign-out.");
|
||||
return matched ? ProviderSignOutResult.Confirmed : ProviderSignOutResult.NotConfirmed;
|
||||
}
|
||||
catch(OperationCanceledException) when(cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_log.Info("Identity provider sign-out was stopped by its caller before the provider confirmed.");
|
||||
return ProviderSignOutResult.Cancelled;
|
||||
}
|
||||
catch(OperationCanceledException) when(limit.IsCancellationRequested)
|
||||
{
|
||||
_log.Warning("Identity provider sign-out was not confirmed: the provider did not return within the two minute limit.");
|
||||
return ProviderSignOutResult.NotConfirmed;
|
||||
}
|
||||
catch(Exception exception) when(exception is OperationCanceledException or IOException or InvalidDataException or UnauthorizedAccessException or Win32Exception or InvalidOperationException or ArgumentException)
|
||||
{
|
||||
_log.Warning(opened ? "Identity provider sign-out was not confirmed: {Failure}" : "The identity provider's sign-out page was not opened: {Failure}", QqqSessionDiagnostics.Describe(exception));
|
||||
return opened ? ProviderSignOutResult.NotConfirmed : ProviderSignOutResult.NotOpened;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Presentation.SignIn;
|
||||
|
||||
/// <summary>
|
||||
/// How the identity provider's half of a sign-out ended (see <see cref="ProviderSignOut"/>). A page that shows the
|
||||
/// result has to tell these apart, because what the operator is told to do differs: nothing, look at the browser
|
||||
/// setting, look at the page that is still open, or nothing because it was stopped on purpose. The log has the
|
||||
/// kind of failure behind the middle two.
|
||||
/// </summary>
|
||||
public enum ProviderSignOutResult
|
||||
{
|
||||
/// <summary>The provider's page was opened and the browser came back to this application's registered callback with this sign-out's own state: the provider's session has ended.</summary>
|
||||
Confirmed,
|
||||
|
||||
/// <summary>The provider's page was not opened, so the operator saw nothing and the provider's session is as it was: the default browser could not be started, or the callback inbox could not be opened, in which case no browser was started either.</summary>
|
||||
NotOpened,
|
||||
|
||||
/// <summary>The provider's page was opened and the provider did not confirm: the browser did not come back within the limit, it came back with this sign-out's state on another address, or the callback inbox failed while the sign-out waited. The page may still be open, and the provider's session may or may not have ended.</summary>
|
||||
NotConfirmed,
|
||||
|
||||
/// <summary>The caller's token ended the sign-out before the provider confirmed. When the token had already ended as the sign-out was started, nothing was opened; otherwise the provider's page was opened and may still be open.</summary>
|
||||
Cancelled,
|
||||
}
|
||||
@@ -137,8 +137,11 @@ public sealed partial class SignInFlow
|
||||
/// <summary>
|
||||
/// The browser exchange itself: open the callback inbox first (so a fast redirect is not lost), begin the login, open the
|
||||
/// provider's page in the default browser, wait for the forwarded callback, and complete the login on the session
|
||||
/// that started it. The inbox is disposed when the exchange ends. Only the provider's host is logged. A callback that is
|
||||
/// that started it. The inbox is disposed when the exchange ends. Only the provider's host is logged, and a browser that
|
||||
/// cannot be started is reported by the kind of failure only, because Windows' own text for it names the whole address
|
||||
/// with its state (see <see cref="BrowserLaunch"/>). A callback that is
|
||||
/// malformed or too large is refused by the inbox as invalid data, which the caller reports like any other failed exchange.
|
||||
/// An inbox failure of a kind the caller does not report is turned into one it does (see <see cref="ReceiveCallbackAsync"/>).
|
||||
/// The token is looked at again after each wait, so a browser is not opened and a login is not completed for a wait
|
||||
/// that has already ended.
|
||||
/// </summary>
|
||||
@@ -149,8 +152,8 @@ public sealed partial class SignInFlow
|
||||
Uri authorization = await session.BeginBrowserSignInAsync(redirect, cancellationToken);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
_log.Info("Opening the identity provider's sign-in page in the default browser (" + QqqOrigin.ShownAuthority(authorization) + ").");
|
||||
_browser.Open(authorization);
|
||||
Uri callback = await inbox.ReceiveAsync(cancellationToken);
|
||||
BrowserLaunch.Open(_browser, authorization);
|
||||
Uri callback = await ReceiveCallbackAsync(inbox, cancellationToken);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
_callbackArrived = true;
|
||||
_log.Info("Browser callback received; completing the sign-in.");
|
||||
@@ -159,6 +162,27 @@ public sealed partial class SignInFlow
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the callback. The inbox is fed by other processes and can fail in ways the exchange does not list as
|
||||
/// failures to report: the real one fails with an argument error when the process that sent a callback has already
|
||||
/// exited. Such a failure is replaced by one the exchange reports, with fixed text and the kind of failure (see
|
||||
/// <see cref="BrowserCallbackFailure"/>), so it ends the sign-in as "not completed" instead of leaving the flow as an
|
||||
/// exception nobody handles.
|
||||
/// </summary>
|
||||
private static async Task<Uri> ReceiveCallbackAsync(IBrowserCallbackInbox inbox, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await inbox.ReceiveAsync(cancellationToken);
|
||||
}
|
||||
catch(Exception exception) when(!BrowserCallbackFailure.Listed(exception))
|
||||
{
|
||||
throw BrowserCallbackFailure.Reported(exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Handles a hand-over that never left this computer; every other outcome was already reported by the hand-over. When
|
||||
/// the service did not accept a connection in time, nothing was handed over, so the attempt ends like a cancelled
|
||||
|
||||
@@ -99,11 +99,15 @@ public sealed partial class SignInFlow
|
||||
/// <summary>
|
||||
/// Opens the identity provider's end-session page in the default browser and waits up to two minutes for the browser to return
|
||||
/// to this application's registered callback with this sign-out's own state. The inbox is opened first so a fast redirect is not lost.
|
||||
/// The request carries a sensitive token hint, so only its host is logged. Returns whether the provider confirmed; the wait
|
||||
/// The request carries a sensitive token hint, so only its host is logged, and a browser that cannot be started is logged by
|
||||
/// the kind of failure only, because Windows' own text for it names the whole request (see <see cref="BrowserLaunch"/>).
|
||||
/// Returns whether the provider confirmed, did not confirm, or was never asked because the browser could not be
|
||||
/// started, which the operator is told as what it is (an inbox that could not be opened is still returned as not
|
||||
/// confirmed, as it was before); the wait
|
||||
/// also ends when the caller's token does. When the flow is disposed the wait ends without a result, and that is left
|
||||
/// to the runner as a quiet end instead of being reported as an unconfirmed sign-out.
|
||||
/// </summary>
|
||||
private async Task<bool> CompleteProviderSignOutAsync(QqqProviderLogout logout, CancellationToken cancellationToken)
|
||||
private async Task<ProviderSignOutResult> CompleteProviderSignOutAsync(QqqProviderLogout logout, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -111,17 +115,25 @@ public sealed partial class SignInFlow
|
||||
using CancellationTokenSource limit = new(ProviderSignOutLimit, _time);
|
||||
using var wait = CancellationTokenSource.CreateLinkedTokenSource(limit.Token, cancellationToken);
|
||||
_log.Info("Opening the identity provider's sign-out page in the default browser (" + QqqOrigin.ShownAuthority(logout.EndSessionUri) + ").");
|
||||
_browser.Open(logout.EndSessionUri);
|
||||
try
|
||||
{
|
||||
BrowserLaunch.Open(_browser, logout.EndSessionUri);
|
||||
}
|
||||
catch(IOException exception) when(!_disposed)
|
||||
{
|
||||
_log.Warning("Identity provider sign-out was not confirmed: " + QqqSessionDiagnostics.Describe(exception));
|
||||
return ProviderSignOutResult.NotOpened;
|
||||
}
|
||||
Uri callback = await inbox.ReceiveAsync(wait.Token);
|
||||
ThrowIfDisposed();
|
||||
bool matched = logout.Matches(callback);
|
||||
_log.Info("Identity provider sign-out callback " + (matched ? "matched." : "did not match this sign-out."));
|
||||
return matched;
|
||||
return matched ? ProviderSignOutResult.Confirmed : ProviderSignOutResult.NotConfirmed;
|
||||
}
|
||||
catch(Exception exception) when(!_disposed && exception is OperationCanceledException or IOException or InvalidDataException or UnauthorizedAccessException or Win32Exception)
|
||||
{
|
||||
_log.Warning("Identity provider sign-out was not confirmed: " + QqqSessionDiagnostics.Describe(exception));
|
||||
return false;
|
||||
return ProviderSignOutResult.NotConfirmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,10 +148,18 @@ public static class SignInMessages
|
||||
|
||||
|
||||
|
||||
/// <summary>Whether the identity provider confirmed its sign-out, and what to do when it did not.</summary>
|
||||
public static FeedbackMessage ProviderSignedOut(string message, bool confirmed) => confirmed
|
||||
? new("Account sign-out", message + " The identity provider session ended too, so the next sign-in asks for credentials.", FeedbackSeverity.Success)
|
||||
: new("Account sign-out", message + " The identity provider sign-out was opened in your browser but did not confirm; close that page and sign out there if it is still open.", FeedbackSeverity.Warning);
|
||||
/// <summary>
|
||||
/// How the identity provider's sign-out ended, and what to do when it was not confirmed. A page that was never
|
||||
/// opened, because the default browser could not be started, is said to be exactly that, with what to check: the
|
||||
/// operator has no page to close, and the provider's session, if there is one, is still there. Every other
|
||||
/// unconfirmed ending is told as before.
|
||||
/// </summary>
|
||||
public static FeedbackMessage ProviderSignedOut(string message, ProviderSignOutResult result) => result switch
|
||||
{
|
||||
ProviderSignOutResult.Confirmed => new("Account sign-out", message + " The identity provider session ended too, so the next sign-in asks for credentials.", FeedbackSeverity.Success),
|
||||
ProviderSignOutResult.NotOpened => new("Account sign-out", message + " The identity provider's sign-out page could not be opened in your browser, so you may still be signed in to the identity provider and the next sign-in may not ask for credentials. " + BrowserLaunch.Remedy, FeedbackSeverity.Warning),
|
||||
_ => new("Account sign-out", message + " The identity provider sign-out was opened in your browser but did not confirm; close that page and sign out there if it is still open.", FeedbackSeverity.Warning),
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
using Qqq.PrintAgent.Windows.Management;
|
||||
|
||||
namespace Qqq.PrintAgent.Presentation.SignIn;
|
||||
|
||||
/// <summary>
|
||||
/// The sign-in methods on offer for one server. The service checks a server under its own account and the companion
|
||||
/// reads the server's sign-in description as the signed-in user; a method is offered only when both found it, so a
|
||||
/// button is never shown for a method that one of the two could not use.
|
||||
/// </summary>
|
||||
public sealed record SignInOffer(bool Password, bool Browser)
|
||||
{
|
||||
/// <summary>True when at least one method is offered.</summary>
|
||||
public bool Any => Password || Browser;
|
||||
|
||||
|
||||
|
||||
/// <summary>The methods the service's check found. A method name this companion does not know is ignored, as the contract asks of a client.</summary>
|
||||
public static SignInOffer Checked(ServerCheckResult check)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(check);
|
||||
return new SignInOffer(check.SignInMethods.Contains(ServerCheckResult.Password, StringComparer.Ordinal), check.SignInMethods.Contains(ServerCheckResult.Browser, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The methods a sign-in description offers, by the rule the service's check applies: a table-based server takes
|
||||
/// a user name and password and nothing else, and any other server offers the browser when it publishes the native
|
||||
/// session grant. Whether that grant lists this application's callback is checked when the browser sign-in starts.
|
||||
/// </summary>
|
||||
public static SignInOffer Read(QqqAuthenticationMetadata description)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(description);
|
||||
bool password = string.Equals(description.Type, "TABLE_BASED", StringComparison.Ordinal);
|
||||
return new SignInOffer(password, !password && description.Values?.NativeSessionLogin is not null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The methods both this offer and another one hold.</summary>
|
||||
public SignInOffer Common(SignInOffer other)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(other);
|
||||
return new SignInOffer(Password && other.Password, Browser && other.Browser);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The offer in words, for a log line: the names the contract gives the methods, or "no method".</summary>
|
||||
public override string ToString() => (Password, Browser) switch
|
||||
{
|
||||
(true, true) => ServerCheckResult.Password + " and " + ServerCheckResult.Browser,
|
||||
(true, false) => ServerCheckResult.Password,
|
||||
(false, true) => ServerCheckResult.Browser,
|
||||
_ => "no method",
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,6 @@ namespace Qqq.PrintAgent;
|
||||
/// <summary>Opens an address in the user's default browser through the Windows shell. The address can carry a sign-in state or a token hint, so it is handed straight to the shell and never logged here.</summary>
|
||||
internal sealed class DefaultBrowserLauncher : IBrowserLauncher
|
||||
{
|
||||
/// <summary>Starts the default browser on the address; a failure to start it is thrown to the flow, which reports it.</summary>
|
||||
/// <summary>Starts the default browser on the address; a failure to start it is thrown to the presentation assembly. Windows' text for that failure names the whole address, so nothing here logs it, and the presentation assembly reports only the kind of failure.</summary>
|
||||
public void Open(Uri address) => _ = Process.Start(new ProcessStartInfo(address.AbsoluteUri) { UseShellExecute = true });
|
||||
}
|
||||
@@ -58,6 +58,11 @@ internal sealed class QqqSignInSession(QqqSessionClient client) : ISignInSession
|
||||
|
||||
|
||||
|
||||
/// <summary>The certificate the session client refused most recently, with the origin that presented it; null when it refused none.</summary>
|
||||
public QqqCertificateRejection? LastCertificateRejection => client.LastCertificateRejection;
|
||||
|
||||
|
||||
|
||||
/// <summary>Releases the session client.</summary>
|
||||
public void Dispose() => client.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
|
||||
namespace Qqq.PrintAgent.Presentation.Tests.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// A browser that cannot be started, failing the way Windows fails: the failure's own text names what could not be
|
||||
/// started, which is the whole address the browser was asked to open. The addresses here carry marked synthetic values
|
||||
/// in the places a real sign-in address and a real sign-out address carry their sensitive ones (state, nonce, code
|
||||
/// challenge, ID token hint), so a test can show that none of them, and no query at all, reaches a log line, a message
|
||||
/// or a property a view binds to.
|
||||
/// </summary>
|
||||
internal static class BrowserStartFailure
|
||||
{
|
||||
/// <summary>The marked state of both addresses.</summary>
|
||||
internal const string State = "synthetic-state-canary";
|
||||
|
||||
/// <summary>The marked nonce of the sign-in address.</summary>
|
||||
internal const string Nonce = "synthetic-nonce-canary";
|
||||
|
||||
/// <summary>The marked code challenge of the sign-in address.</summary>
|
||||
internal const string Challenge = "synthetic-challenge-canary";
|
||||
|
||||
/// <summary>The marked ID token hint of the sign-out address.</summary>
|
||||
internal const string Hint = "synthetic-id-token-hint-canary";
|
||||
|
||||
/// <summary>What an operator is told to do, which ends every report of a browser that could not be opened.</summary>
|
||||
internal const string Remedy = "Check that a default browser is set in Windows Settings under Apps, Default apps, then try again.";
|
||||
|
||||
/// <summary>The whole report of a browser Windows could not start for want of the file (error 2).</summary>
|
||||
internal const string WindowsReport = "The default browser could not be opened (Windows error 2). " + Remedy;
|
||||
|
||||
/// <summary>The whole report of a launcher that failed in some other way.</summary>
|
||||
internal const string OtherReport = "The default browser could not be opened (InvalidOperationException). " + Remedy;
|
||||
|
||||
|
||||
|
||||
/// <summary>A provider's authorization address as the session client builds one, with the marked state, nonce and code challenge.</summary>
|
||||
internal static Uri SignInAddress { get; } = new("https://id.example.com/authorize?client_id=synthetic-client&response_type=code&redirect_uri=io.qrun.qqq-print-agent%3A%2Foauth%2Fcallback&scope=openid&state=" + State
|
||||
+ "&nonce=" + Nonce + "&prompt=login&max_age=0&code_challenge_method=S256&code_challenge=" + Challenge);
|
||||
|
||||
|
||||
|
||||
/// <summary>A provider's end-session address as the service returns one, with the marked ID token hint and state.</summary>
|
||||
internal static Uri SignOutAddress { get; } = new("https://id.example.com/end-session?id_token_hint=" + Hint + "&post_logout_redirect_uri=io.qrun.qqq-print-agent%3A%2Foauth%2Fcallback&state=" + State);
|
||||
|
||||
|
||||
|
||||
/// <summary>The failure .NET throws when Windows cannot start what it was asked to open: error 2, with the text .NET gives it, which names the address in full.</summary>
|
||||
internal static Win32Exception AsWindows(Uri address)
|
||||
=> new(2, "An error occurred trying to start process '" + address.AbsoluteUri + "' with working directory 'C:/Program Files/Synthetic'. The system cannot find the file specified.");
|
||||
|
||||
|
||||
|
||||
/// <summary>A launcher failure of another kind whose text also names the address.</summary>
|
||||
internal static InvalidOperationException AsAnotherKind(Uri address) => new("The launcher could not open " + address.AbsoluteUri + ".");
|
||||
|
||||
|
||||
|
||||
/// <summary>True when a text holds anything of either address beyond the provider's host: one of the marked values, a query, a parameter name or the path.</summary>
|
||||
internal static bool Leaks(string? text)
|
||||
=> text is not null && new[] { State, Nonce, Challenge, Hint, "?", "client_id", "id_token_hint", "redirect_uri", "/authorize", "/end-session" }.Any(part => text.Contains(part, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
|
||||
|
||||
/// <summary>Every public property of a view model as the text a view would show for it, which is what a binding can reach.</summary>
|
||||
internal static List<string?> Bound(object viewModel)
|
||||
=> [.. viewModel.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public).Select(property => Convert.ToString(property.GetValue(viewModel), CultureInfo.InvariantCulture))];
|
||||
}
|
||||
@@ -11,11 +11,19 @@ namespace Qqq.PrintAgent.Presentation.Tests.Fakes;
|
||||
/// A callback inbox a test delivers to by hand. Waiting ends when a callback is delivered, when a failure is injected, or
|
||||
/// when the flow's token is cancelled, exactly like the real pipe inbox. Like the real one it finishes on whatever thread
|
||||
/// delivered the callback, not on the caller's, so a flow that forgot to return to its own thread is caught by a test.
|
||||
/// An inbox that answers inline is the exception a test asks for when it has to know that a delivery was dealt with
|
||||
/// before it does the next thing: whoever waits on it, with no synchronization context on the delivering thread,
|
||||
/// continues inside the call that delivers.
|
||||
/// </summary>
|
||||
internal sealed class FakeInbox : IBrowserCallbackInbox
|
||||
internal sealed class FakeInbox(bool answersInline = false) : IBrowserCallbackInbox
|
||||
{
|
||||
private readonly TaskCompletionSource<Uri> _callback = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
/// <summary>What the wait ends with: the callback a test delivers, or the failure it injects.</summary>
|
||||
private readonly TaskCompletionSource<Uri> _callback = new(answersInline ? TaskCreationOptions.None : TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>Completed when the flow starts waiting.</summary>
|
||||
private readonly TaskCompletionSource _listening = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>The flow's wait, once it has started.</summary>
|
||||
private Task<Uri>? _wait;
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ namespace Qqq.PrintAgent.Presentation.Tests.Fakes;
|
||||
/// step succeeds unless a failure is set for it, can be held open until the test releases it, and records what the flow
|
||||
/// passed in. Disposing it behaves like the real client too: a call that is still in flight fails with "disposed" when
|
||||
/// it finishes, whatever else happened to it, and every later call is refused. A held call resumes on another thread, as
|
||||
/// the real client's network calls do. It never touches a network.
|
||||
/// the real client's network calls do. A test can also name the certificate the session refuses when a step fails,
|
||||
/// which is how the real client reports that a certificate was why. It never touches a network.
|
||||
/// </summary>
|
||||
internal sealed class FakeSession(Uri origin, QqqCertificateExceptionOrigins certificateException) : ISignInSession
|
||||
{
|
||||
@@ -65,6 +66,11 @@ internal sealed class FakeSession(Uri origin, QqqCertificateExceptionOrigins cer
|
||||
|
||||
|
||||
|
||||
/// <summary>When set, it runs at the moment a step is about to answer with success. A test uses it to end the caller's wait at the last moment, which is how a real answer can arrive for a wait that has just ended.</summary>
|
||||
internal Action? BeforeAnswer { get; set; }
|
||||
|
||||
|
||||
|
||||
/// <summary>When set, discovery fails with this.</summary>
|
||||
internal Exception? DiscoverFailure { get; set; }
|
||||
|
||||
@@ -105,23 +111,48 @@ internal sealed class FakeSession(Uri origin, QqqCertificateExceptionOrigins cer
|
||||
|
||||
|
||||
|
||||
/// <summary>The token the flow gave the completion of a browser sign-in, which is how a test sees that ending the flow's wait ends the completion too.</summary>
|
||||
internal CancellationToken? CompleteToken { get; private set; }
|
||||
|
||||
|
||||
|
||||
/// <summary>True once the flow discarded this session.</summary>
|
||||
internal bool Disposed { get; private set; }
|
||||
|
||||
|
||||
|
||||
/// <summary>When set, a step that fails records this as the certificate the session refused, as the real client does when a certificate is why a request failed. A step that fails while this is null leaves the last refusal as it was.</summary>
|
||||
internal QqqCertificateRejection? RefusedCertificate { get; set; }
|
||||
|
||||
|
||||
|
||||
/// <summary>The certificate this session refused most recently; null until a step failed while <see cref="RefusedCertificate"/> was set.</summary>
|
||||
public QqqCertificateRejection? LastCertificateRejection { get; private set; }
|
||||
|
||||
|
||||
|
||||
/// <summary>What every browser attempt's state begins with, followed by the attempt's number. Two sessions that sign in at the same time are given different stems, as two real sessions have different random states.</summary>
|
||||
internal string StateStem { get; set; } = "secret-state-";
|
||||
|
||||
|
||||
|
||||
/// <summary>The host and port of the provider page, as the server's provider wrote them; a test sets a name outside ASCII to see how it is shown.</summary>
|
||||
internal string ProviderAuthority { get; set; } = "id.example.com";
|
||||
|
||||
|
||||
|
||||
/// <summary>When set, beginning a browser sign-in returns this address in place of the provider page with the attempt's state, as a session that prepared an unusable address would.</summary>
|
||||
internal Uri? AuthorizationOverride { get; set; }
|
||||
|
||||
|
||||
|
||||
/// <summary>The provider page of the latest browser attempt; its query stands for that attempt's secret state.</summary>
|
||||
internal Uri Authorization => new("https://" + ProviderAuthority + "/authorize?state=secret-state-" + _attempts);
|
||||
internal Uri Authorization => AuthorizationOverride ?? new("https://" + ProviderAuthority + "/authorize?state=" + StateStem + _attempts);
|
||||
|
||||
|
||||
|
||||
/// <summary>The callback the provider would send the browser to for the latest attempt, carrying that attempt's state and a one-time code.</summary>
|
||||
internal Uri CallbackForLatestAttempt => new(SignInFixture.Callback + "?code=one-time-code&state=secret-state-" + _attempts);
|
||||
internal Uri CallbackForLatestAttempt => new(SignInFixture.Callback + "?code=one-time-code&state=" + StateStem + _attempts);
|
||||
|
||||
|
||||
|
||||
@@ -133,9 +164,11 @@ internal sealed class FakeSession(Uri origin, QqqCertificateExceptionOrigins cer
|
||||
{
|
||||
if(DiscoverFailure is not null)
|
||||
{
|
||||
throw DiscoverFailure;
|
||||
throw Refusing(DiscoverFailure);
|
||||
}
|
||||
return PendingDiscovery is null ? Metadata : await PendingDiscovery.Task.WaitAsync(cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
|
||||
QqqAuthenticationMetadata metadata = PendingDiscovery is null ? Metadata : await PendingDiscovery.Task.WaitAsync(cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
|
||||
BeforeAnswer?.Invoke();
|
||||
return metadata;
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -154,13 +187,14 @@ internal sealed class FakeSession(Uri origin, QqqCertificateExceptionOrigins cer
|
||||
{
|
||||
if(PasswordFailure is not null)
|
||||
{
|
||||
throw PasswordFailure;
|
||||
throw Refusing(PasswordFailure);
|
||||
}
|
||||
if(PendingPassword is not null)
|
||||
{
|
||||
await PendingPassword.Task.WaitAsync(cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
|
||||
}
|
||||
_signedIn = true;
|
||||
BeforeAnswer?.Invoke();
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -179,7 +213,7 @@ internal sealed class FakeSession(Uri origin, QqqCertificateExceptionOrigins cer
|
||||
{
|
||||
if(BeginFailure is not null)
|
||||
{
|
||||
throw BeginFailure;
|
||||
throw Refusing(BeginFailure);
|
||||
}
|
||||
if(PendingBegin is not null)
|
||||
{
|
||||
@@ -187,6 +221,7 @@ internal sealed class FakeSession(Uri origin, QqqCertificateExceptionOrigins cer
|
||||
}
|
||||
_attempts++;
|
||||
_attemptOpen = true;
|
||||
BeforeAnswer?.Invoke();
|
||||
return Authorization;
|
||||
}
|
||||
finally
|
||||
@@ -202,6 +237,7 @@ internal sealed class FakeSession(Uri origin, QqqCertificateExceptionOrigins cer
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(Disposed, this);
|
||||
Callback = callback;
|
||||
CompleteToken = cancellationToken;
|
||||
_completeEntered.TrySetResult();
|
||||
try
|
||||
{
|
||||
@@ -212,9 +248,9 @@ internal sealed class FakeSession(Uri origin, QqqCertificateExceptionOrigins cer
|
||||
_attemptOpen = false;
|
||||
if(CompleteFailure is not null)
|
||||
{
|
||||
throw CompleteFailure;
|
||||
throw Refusing(CompleteFailure);
|
||||
}
|
||||
if(!callback.Query.EndsWith("state=secret-state-" + _attempts, StringComparison.Ordinal))
|
||||
if(!callback.Query.EndsWith("state=" + StateStem + _attempts, StringComparison.Ordinal))
|
||||
{
|
||||
throw new QqqSessionException(QqqSessionException.FailureKind.InvalidResponse, "The callback does not belong to this sign-in attempt.");
|
||||
}
|
||||
@@ -223,6 +259,7 @@ internal sealed class FakeSession(Uri origin, QqqCertificateExceptionOrigins cer
|
||||
await PendingComplete.Task.WaitAsync(cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
|
||||
}
|
||||
_signedIn = true;
|
||||
BeforeAnswer?.Invoke();
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -245,6 +282,15 @@ internal sealed class FakeSession(Uri origin, QqqCertificateExceptionOrigins cer
|
||||
|
||||
|
||||
|
||||
/// <summary>Records the certificate a failing step refused, when the test named one, and returns the failure to throw.</summary>
|
||||
private Exception Refusing(Exception failure)
|
||||
{
|
||||
LastCertificateRejection = RefusedCertificate ?? LastCertificateRejection;
|
||||
return failure;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Marks the session discarded. From then on a call in flight fails as it finishes and a new call is refused, which is what disposing the real client under a running call does.</summary>
|
||||
public void Dispose() => Disposed = true;
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Presentation.Tests.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Removes the test runner's synchronization context from the calling thread until it is disposed. With a context
|
||||
/// present, .NET never continues an awaiting method inside the call that completes what it awaits; without one it
|
||||
/// does, which is what happens on the thread-pool threads the callback dispatcher works on. A test uses this to make
|
||||
/// that order of events certain: when the call that delivers something returns, whoever awaited it has dealt with it.
|
||||
/// </summary>
|
||||
internal sealed class NoContext : IDisposable
|
||||
{
|
||||
/// <summary>The context the thread had, to put back.</summary>
|
||||
private readonly SynchronizationContext? _before = SynchronizationContext.Current;
|
||||
|
||||
|
||||
|
||||
/// <summary>Removes the context.</summary>
|
||||
internal NoContext() => SynchronizationContext.SetSynchronizationContext(null);
|
||||
|
||||
|
||||
|
||||
/// <summary>Puts the context back.</summary>
|
||||
public void Dispose() => SynchronizationContext.SetSynchronizationContext(_before);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
using Qqq.PrintAgent.Presentation.SignIn;
|
||||
using Qqq.PrintAgent.Windows.Management;
|
||||
|
||||
namespace Qqq.PrintAgent.Presentation.Tests.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Everything a companion sign-in touches, as hand-written fakes around the one real callback dispatcher: scripted
|
||||
/// sessions, a browser that only records what it was asked to open, callback inboxes a test delivers to by hand, a
|
||||
/// manual clock and a recording log. Any number of sign-ins and provider sign-outs can be built over one world, which
|
||||
/// is how a test puts two of them on the one inbox at the same time. Each session gets its own state stem, as two
|
||||
/// real sessions have different random states. Every value in it is synthetic.
|
||||
/// </summary>
|
||||
internal sealed class SignInWorld : ISignInSessionFactory, IBrowserLauncher, IBrowserCallbackSource
|
||||
{
|
||||
/// <summary>The address tests use for the first server.</summary>
|
||||
internal const string Server = "https://print.example.com";
|
||||
|
||||
/// <summary>The address tests use for a second server.</summary>
|
||||
internal const string OtherServer = "https://labels.example.net";
|
||||
|
||||
/// <summary>The origin of the sign-in provider the fake sessions send the browser to.</summary>
|
||||
internal const string Provider = "https://id.example.com";
|
||||
|
||||
/// <summary>The callback the fake installation has registered.</summary>
|
||||
internal const string Callback = "io.qrun.qqq-print-agent:/oauth/callback";
|
||||
|
||||
/// <summary>The marked text of an inbox failure of a kind no exchange lists, so a test can show that nothing of it is logged or shown.</summary>
|
||||
internal const string UnlistedFailureText = "synthetic-inbox-failure-text";
|
||||
|
||||
/// <summary>Completed with the next inbox that is opened, for a test that waits for it (see <see cref="NextInboxAsync"/>).</summary>
|
||||
private volatile TaskCompletionSource<FakeInbox>? _awaitedInbox;
|
||||
|
||||
|
||||
|
||||
/// <summary>Builds the world with its dispatcher over the fake inboxes.</summary>
|
||||
internal SignInWorld()
|
||||
{
|
||||
Dispatcher = new BrowserCallbackDispatcher(this, Log);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The one dispatcher every sign-in and sign-out of this world shares.</summary>
|
||||
internal BrowserCallbackDispatcher Dispatcher { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Everything that was logged.</summary>
|
||||
internal RecordingLog Log { get; } = new();
|
||||
|
||||
|
||||
|
||||
/// <summary>The clock that drives the time limits.</summary>
|
||||
internal ManualTime Time { get; } = new();
|
||||
|
||||
|
||||
|
||||
/// <summary>Every session that was created, oldest first.</summary>
|
||||
internal List<FakeSession> Sessions { get; } = [];
|
||||
|
||||
|
||||
|
||||
/// <summary>Applied to each new session before it is used, to script what the server offers or how it fails.</summary>
|
||||
internal Action<FakeSession>? ConfigureSession { get; set; }
|
||||
|
||||
|
||||
|
||||
/// <summary>When set, creating a session fails with this, as the real client does for an address it refuses.</summary>
|
||||
internal Exception? CreateFailure { get; set; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Every address the browser was asked to open.</summary>
|
||||
internal List<Uri> Opened { get; } = [];
|
||||
|
||||
|
||||
|
||||
/// <summary>How many inboxes were open and waiting each time the browser was asked to open an address, which shows that the inbox was open first.</summary>
|
||||
internal List<int> ListeningAtLaunch { get; } = [];
|
||||
|
||||
|
||||
|
||||
/// <summary>When set, opening the browser fails with this.</summary>
|
||||
internal Exception? BrowserFailure { get; set; }
|
||||
|
||||
|
||||
|
||||
/// <summary>When set, it runs once the browser has been asked to open an address, on the thread that asked and before that call returns. A test uses it to make something happen between the provider's page opening and the sign-in starting to wait for its callback.</summary>
|
||||
internal Action<Uri>? Launched { get; set; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Every inbox that was opened, oldest first.</summary>
|
||||
internal List<FakeInbox> Inboxes { get; } = [];
|
||||
|
||||
|
||||
|
||||
/// <summary>When set, opening an inbox fails with this.</summary>
|
||||
internal Exception? InboxFailure { get; set; }
|
||||
|
||||
|
||||
|
||||
/// <summary>When set, the inboxes opened from then on answer inline (see <see cref="FakeInbox"/>).</summary>
|
||||
internal bool InboxesAnswerInline { get; set; }
|
||||
|
||||
|
||||
|
||||
/// <summary>The protocol handler trace; empty means the browser never launched the handler.</summary>
|
||||
internal List<string> Trace { get; } = [];
|
||||
|
||||
|
||||
|
||||
/// <summary>The window each read of the trace asked for.</summary>
|
||||
internal List<TimeSpan> TraceWindows { get; } = [];
|
||||
|
||||
|
||||
|
||||
/// <summary>The registered callback.</summary>
|
||||
public string RedirectUri => Callback;
|
||||
|
||||
|
||||
|
||||
/// <summary>Where the fake protocol handler writes its trace.</summary>
|
||||
public string TracePath => "trace/auth-callback.log";
|
||||
|
||||
|
||||
|
||||
/// <summary>What the service's check reports for a server that takes a user name and password: that method, and no sign-in provider.</summary>
|
||||
internal static ServerCheckResult PasswordCheck { get; } = new([], [ServerCheckResult.Password]);
|
||||
|
||||
|
||||
|
||||
/// <summary>What the service's check reports for a server that signs in through the browser at the fake provider.</summary>
|
||||
internal static ServerCheckResult BrowserCheck { get; } = new([], [ServerCheckResult.Browser], Provider + "/");
|
||||
|
||||
|
||||
|
||||
/// <summary>A sign-in description for a server that signs in through the browser: the native grant with the given issuer and redirect addresses.</summary>
|
||||
internal static QqqAuthenticationMetadata BrowserDescription(string issuer = Provider, params string[] redirects) => new()
|
||||
{
|
||||
Type = "OAUTH2",
|
||||
Values = new QqqAuthenticationMetadata.ProviderValues { NativeSessionLogin = new QqqAuthenticationMetadata.NativeGrant { Issuer = issuer, RedirectUris = redirects.Length == 0 ? [Callback] : redirects } },
|
||||
};
|
||||
|
||||
|
||||
|
||||
/// <summary>A certificate refusal as the session client records one: an untrusted authority, presented by the given origin.</summary>
|
||||
internal static QqqCertificateRejection Refusal(string origin) => new(System.Net.Security.SslPolicyErrors.RemoteCertificateChainErrors, "CN=synthetic.example", "CN=Synthetic Test Authority",
|
||||
new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), new DateTime(2027, 1, 1, 0, 0, 0, DateTimeKind.Utc), "00112233445566778899AABBCCDDEEFF00112233", new Uri(origin), new DateTimeOffset(2026, 11, 10, 10, 0, 0, TimeSpan.Zero));
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// An inbox failure of a kind no exchange lists as one to report, by the name of its type: the argument error the
|
||||
/// real inbox gives when the process that sent a callback has already exited, an arithmetic overflow, or a
|
||||
/// time-out. Its text is the marked one.
|
||||
/// </summary>
|
||||
internal static Exception UnlistedInboxFailure(string kind) => kind switch
|
||||
{
|
||||
nameof(ArgumentException) => new ArgumentException(UnlistedFailureText, nameof(kind)),
|
||||
nameof(OverflowException) => new OverflowException(UnlistedFailureText),
|
||||
_ => new TimeoutException(UnlistedFailureText),
|
||||
};
|
||||
|
||||
|
||||
|
||||
/// <summary>What an exchange is told, and reports, when the inbox failed in such a way: fixed text and the kind of failure.</summary>
|
||||
internal static string InboxFailedReport(string kind) => "The browser callback inbox failed (" + kind + "), so no callback could be received. Try again.";
|
||||
|
||||
|
||||
|
||||
/// <summary>A new sign-in over this world.</summary>
|
||||
internal CompanionSignIn NewSignIn() => new(this, this, this, Dispatcher, Log, Time);
|
||||
|
||||
|
||||
|
||||
/// <summary>A new sign-in that has read a password server's description and holds its session, with the log cleared so a test asserts only what its own step wrote.</summary>
|
||||
internal async Task<CompanionSignIn> PasswordReadyAsync()
|
||||
{
|
||||
CompanionSignIn signIn = NewSignIn();
|
||||
Assert.NotNull(await signIn.PrepareAsync(new Uri(Server), PasswordCheck, QqqCertificateExceptionOrigins.None));
|
||||
Log.Entries.Clear();
|
||||
return signIn;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A new sign-in that has read a browser server's description and holds its session, with the log cleared. Each call creates the next session, so two calls give two sign-ins with different states.</summary>
|
||||
internal async Task<CompanionSignIn> BrowserReadyAsync(string server = Server)
|
||||
{
|
||||
ConfigureSession ??= static session => session.Metadata = BrowserDescription();
|
||||
CompanionSignIn signIn = NewSignIn();
|
||||
Assert.NotNull(await signIn.PrepareAsync(new Uri(server), BrowserCheck, QqqCertificateExceptionOrigins.None));
|
||||
Log.Entries.Clear();
|
||||
return signIn;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Records what a sign-in announces from now on, as "Property=value" in order, which is what a view would have been told.</summary>
|
||||
internal static List<string> Watch(CompanionSignIn signIn)
|
||||
{
|
||||
List<string> changes = [];
|
||||
signIn.PropertyChanged += (_, change) => changes.Add(change.PropertyName + "=" + (change.PropertyName switch
|
||||
{
|
||||
nameof(CompanionSignIn.State) => signIn.State.ToString(),
|
||||
nameof(CompanionSignIn.IsBusy) => signIn.IsBusy.ToString(),
|
||||
nameof(CompanionSignIn.HasSession) => signIn.HasSession.ToString(),
|
||||
nameof(CompanionSignIn.Offer) => signIn.Offer?.ToString() ?? "null",
|
||||
nameof(CompanionSignIn.Failure) => signIn.Failure ?? "null",
|
||||
_ => signIn.Rejection is null ? "null" : "set",
|
||||
}));
|
||||
return changes;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Records the name of every property a sign-in says is about to change from now on, in order: the other event a view can subscribe to.</summary>
|
||||
internal static List<string> WatchChanging(CompanionSignIn signIn)
|
||||
{
|
||||
List<string> changing = [];
|
||||
signIn.PropertyChanging += (_, change) => changing.Add(change.PropertyName!);
|
||||
return changing;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Awaits something that has to end by itself and fails the test, instead of hanging the run, when it does not. The
|
||||
/// limit is real time, but it only ever runs out when the code under test is broken; a passing test never waits for it.
|
||||
/// </summary>
|
||||
internal static async Task<T> EndsAsync<T>(Task<T> operation) => await operation.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Completes with the next inbox that is opened, once the dispatcher has started waiting on it. The dispatcher
|
||||
/// opens the inbox after a callback on another thread's schedule, so a test uses this to hold its next callback
|
||||
/// until the new inbox is really open, without sleeping. It must be called before that inbox is opened.
|
||||
/// </summary>
|
||||
internal async Task<FakeInbox> NextInboxAsync()
|
||||
{
|
||||
_awaitedInbox = new TaskCompletionSource<FakeInbox>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
FakeInbox inbox = await _awaitedInbox.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
|
||||
await inbox.Listening.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
|
||||
return inbox;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates a scripted session with its own state stem, or fails.</summary>
|
||||
public ISignInSession Create(Uri origin, QqqCertificateExceptionOrigins certificateException)
|
||||
{
|
||||
if(CreateFailure is not null)
|
||||
{
|
||||
throw CreateFailure;
|
||||
}
|
||||
FakeSession session = new(origin, certificateException) { StateStem = "synthetic-state-" + (char)('a' + Sessions.Count) + "-" };
|
||||
ConfigureSession?.Invoke(session);
|
||||
Sessions.Add(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Records the address instead of opening a browser, then runs what the test asked for at that moment; or fails.</summary>
|
||||
public void Open(Uri address)
|
||||
{
|
||||
if(BrowserFailure is not null)
|
||||
{
|
||||
throw BrowserFailure;
|
||||
}
|
||||
ListeningAtLaunch.Add(Inboxes.Count(inbox => inbox.Waiting && !inbox.Disposed));
|
||||
Opened.Add(address);
|
||||
Launched?.Invoke(address);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Opens a new inbox the test can deliver to, or fails.</summary>
|
||||
IBrowserCallbackInbox IBrowserCallbackSource.Open()
|
||||
{
|
||||
if(InboxFailure is not null)
|
||||
{
|
||||
throw InboxFailure;
|
||||
}
|
||||
FakeInbox inbox = new(InboxesAnswerInline);
|
||||
Inboxes.Add(inbox);
|
||||
_awaitedInbox?.TrySetResult(inbox);
|
||||
return inbox;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns the scripted trace and records the window that was asked for.</summary>
|
||||
public IReadOnlyList<string> RecentTrace(TimeSpan window)
|
||||
{
|
||||
TraceWindows.Add(window);
|
||||
return Trace;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Globalization;
|
||||
using Qqq.PrintAgent.Presentation.Tests.Fakes;
|
||||
|
||||
namespace Qqq.PrintAgent.Presentation.Tests;
|
||||
|
||||
/// <summary>Log entries written as a message template and its values: how a template becomes text, and that each level's template method writes at that level for a host that keeps text.</summary>
|
||||
public sealed class PresentationLogTests
|
||||
{
|
||||
/// <summary>The holes of a template are filled in the order the values are given, whatever the holes are called, and the text around them is kept as written.</summary>
|
||||
[Fact]
|
||||
public void Template_HolesAreFilledInOrder()
|
||||
{
|
||||
Assert.Equal("Opened print.example.com after 3 attempts.", PresentationLogTemplate.Render("Opened {Server} after {Attempts} attempts.", ["print.example.com", 3]));
|
||||
Assert.Equal("first second", PresentationLogTemplate.Render("{A} {B}", ["first", "second"]));
|
||||
Assert.Equal("No holes here.", PresentationLogTemplate.Render("No holes here.", []));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A value is written the same on every computer, whatever language Windows is set to, and a missing value is written as nothing.</summary>
|
||||
[Fact]
|
||||
public void Template_ValuesAreWrittenInTheInvariantCulture_AndNullAsNothing()
|
||||
{
|
||||
CultureInfo before = CultureInfo.CurrentCulture;
|
||||
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("de-DE");
|
||||
try
|
||||
{
|
||||
Assert.Equal("Took 1.5 s on 11/10/2026 10:00:00; reason: .", PresentationLogTemplate.Render("Took {Seconds} s on {When}; reason: {Reason}.", [1.5, new DateTime(2026, 11, 10, 10, 0, 0, DateTimeKind.Utc), null]));
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = before;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A hole that was given no value stays as it was written, so a forgotten value shows in the log; a value that has no hole is left out; and a brace inside a value is never taken for a hole.</summary>
|
||||
[Fact]
|
||||
public void Template_MissingAndSurplusValues_AndBracesInValues()
|
||||
{
|
||||
Assert.Equal("Opened print.example.com after {Attempts} attempts.", PresentationLogTemplate.Render("Opened {Server} after {Attempts} attempts.", ["print.example.com"]));
|
||||
Assert.Equal("Opened print.example.com.", PresentationLogTemplate.Render("Opened {Server}.", ["print.example.com", "surplus"]));
|
||||
Assert.Equal("Said {B} and then last.", PresentationLogTemplate.Render("Said {A} and then {B}.", ["{B}", "last"]));
|
||||
Assert.Equal("An open brace { is text.", PresentationLogTemplate.Render("An open brace { is text.", ["surplus"]));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A template and its values are both needed.</summary>
|
||||
[Fact]
|
||||
public void Template_NeedsATemplateAndValues()
|
||||
{
|
||||
Assert.Equal("template", Assert.Throws<ArgumentNullException>(() => PresentationLogTemplate.Render(null!, [])).ParamName);
|
||||
Assert.Equal("values", Assert.Throws<ArgumentNullException>(() => PresentationLogTemplate.Render("text", null!)).ParamName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A host that keeps text and implements only the four text methods gets each templated entry as finished text at the level it was written at.</summary>
|
||||
[Fact]
|
||||
public void TemplateMethods_WriteTheFinishedTextAtTheirLevel()
|
||||
{
|
||||
RecordingLog recording = new();
|
||||
IPresentationLog log = recording;
|
||||
|
||||
log.Info("Opened {Server}.", "print.example.com");
|
||||
log.Warning("Slow answer from {Server} ({Seconds} s).", "print.example.com", 12);
|
||||
log.Error("Could not reach {Server}.", "print.example.com");
|
||||
log.Debug("Request {Number} to {Server}.", 7, "print.example.com");
|
||||
|
||||
Assert.Equal(["INFO Opened print.example.com.", "WARN Slow answer from print.example.com (12 s).", "ERROR Could not reach print.example.com.", "DEBUG Request 7 to print.example.com."], recording.Entries);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,836 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
using Qqq.PrintAgent.Presentation.SignIn;
|
||||
using Qqq.PrintAgent.Presentation.Tests.Fakes;
|
||||
|
||||
namespace Qqq.PrintAgent.Presentation.Tests.SignIn;
|
||||
|
||||
/// <summary>
|
||||
/// The one callback inbox shared by every browser exchange: who gets which callback, what is dropped, when an inbox is
|
||||
/// open, and what happens when it fails. The dispatcher is the only concurrent piece of the sign-in, so each test
|
||||
/// decides the order of events itself: a callback is delivered by hand, and the next one is held until the inbox it
|
||||
/// goes to is known to be open.
|
||||
/// </summary>
|
||||
public sealed class BrowserCallbackDispatcherTests
|
||||
{
|
||||
/// <summary>The whole of what the log says about a callback that was dropped.</summary>
|
||||
private const string Dropped = "WARN A browser callback that belongs to no waiting sign-in or sign-out was dropped. Nothing of it is logged.";
|
||||
|
||||
/// <summary>What the log says each time an inbox is opened.</summary>
|
||||
private const string InboxOpen = "INFO Browser callback inbox open.";
|
||||
|
||||
|
||||
|
||||
/// <summary>A callback on the registered address carrying the given state and a one-time code, as a provider sends it.</summary>
|
||||
private static Uri CallbackFor(string state) => new(SignInWorld.Callback + "?code=synthetic-code&state=" + state);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Two servers sign in through the browser at the same time. Both wait on the one inbox, which was open before
|
||||
/// either browser page was. The second sign-in's callback arrives first and completes the second sign-in only; the
|
||||
/// inbox is closed and a new one opened for the first, whose callback then completes it. Each session was handed
|
||||
/// its own callback, and nothing of either reached the log.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Dispatcher_TwoSignIns_EachGetsItsOwnCallback()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn first = await world.BrowserReadyAsync();
|
||||
using CompanionSignIn second = await world.BrowserReadyAsync(SignInWorld.OtherServer);
|
||||
FakeSession firstSession = world.Sessions[0];
|
||||
FakeSession secondSession = world.Sessions[1];
|
||||
|
||||
Task<QqqSessionCredential?> firstRunning = first.SignInWithBrowserAsync();
|
||||
Task<QqqSessionCredential?> secondRunning = second.SignInWithBrowserAsync();
|
||||
FakeInbox inbox = Assert.Single(world.Inboxes);
|
||||
Assert.Equal([1, 1], world.ListeningAtLaunch);
|
||||
Assert.Equal([firstSession.Authorization, secondSession.Authorization], world.Opened);
|
||||
Assert.NotEqual(firstSession.Authorization, secondSession.Authorization);
|
||||
Task<FakeInbox> reopened = world.NextInboxAsync();
|
||||
inbox.Deliver(secondSession.CallbackForLatestAttempt);
|
||||
QqqSessionCredential? secondCredential = await SignInWorld.EndsAsync(secondRunning);
|
||||
FakeInbox next = await reopened;
|
||||
|
||||
Assert.Equal(new Uri(SignInWorld.OtherServer), secondCredential?.Origin);
|
||||
Assert.Equal(CompanionSignInState.SignedIn, second.State);
|
||||
Assert.False(firstRunning.IsCompleted);
|
||||
Assert.Equal(CompanionSignInState.WaitingForBrowser, first.State);
|
||||
Assert.Null(firstSession.Callback);
|
||||
Assert.True(inbox.Disposed);
|
||||
Assert.False(next.Disposed);
|
||||
|
||||
next.Deliver(firstSession.CallbackForLatestAttempt);
|
||||
QqqSessionCredential? firstCredential = await SignInWorld.EndsAsync(firstRunning);
|
||||
|
||||
Assert.Equal(new Uri(SignInWorld.Server), firstCredential?.Origin);
|
||||
Assert.Equal(CompanionSignInState.SignedIn, first.State);
|
||||
Assert.Equal(firstSession.CallbackForLatestAttempt, firstSession.Callback);
|
||||
Assert.Equal(secondSession.CallbackForLatestAttempt, secondSession.Callback);
|
||||
Assert.True(next.Disposed);
|
||||
Assert.Equal(2, world.Inboxes.Count);
|
||||
Assert.DoesNotContain(Dropped, world.Log.Entries, StringComparer.Ordinal);
|
||||
Assert.DoesNotContain(world.Log.Entries, entry => entry.Contains("synthetic-state", StringComparison.Ordinal) || entry.Contains("one-time-code", StringComparison.Ordinal) || entry.Contains("synthetic-session-token", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// One of two sign-ins is cancelled, and its browser page comes back afterwards. That late callback belongs to
|
||||
/// nobody: it is dropped, the log says only that one was dropped, the session that started it never sees it, and
|
||||
/// the other sign-in is neither completed nor failed by it. The other sign-in's own callback, held until the new
|
||||
/// inbox is open, then completes it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Dispatcher_LateCallbackForAnEndedSignIn_IsDropped_TheOtherCompletes()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn ended = await world.BrowserReadyAsync();
|
||||
using CompanionSignIn other = await world.BrowserReadyAsync(SignInWorld.OtherServer);
|
||||
FakeSession endedSession = world.Sessions[0];
|
||||
FakeSession otherSession = world.Sessions[1];
|
||||
Task<QqqSessionCredential?> endedRunning = ended.SignInWithBrowserAsync();
|
||||
Task<QqqSessionCredential?> otherRunning = other.SignInWithBrowserAsync();
|
||||
FakeInbox inbox = Assert.Single(world.Inboxes);
|
||||
ended.CancelBrowserSignIn();
|
||||
Assert.Null(await SignInWorld.EndsAsync(endedRunning));
|
||||
Assert.False(inbox.Disposed);
|
||||
world.Log.Entries.Clear();
|
||||
|
||||
Task<FakeInbox> reopened = world.NextInboxAsync();
|
||||
inbox.Deliver(endedSession.CallbackForLatestAttempt);
|
||||
FakeInbox next = await reopened;
|
||||
|
||||
Assert.Equal([Dropped, InboxOpen], world.Log.Entries);
|
||||
Assert.Null(endedSession.Callback);
|
||||
Assert.Equal(CompanionSignInState.BrowserCanceled, ended.State);
|
||||
Assert.False(otherRunning.IsCompleted);
|
||||
Assert.Equal(CompanionSignInState.WaitingForBrowser, other.State);
|
||||
Assert.True(inbox.Disposed);
|
||||
|
||||
next.Deliver(otherSession.CallbackForLatestAttempt);
|
||||
QqqSessionCredential? credential = await SignInWorld.EndsAsync(otherRunning);
|
||||
|
||||
Assert.Equal(new Uri(SignInWorld.OtherServer), credential?.Origin);
|
||||
Assert.Equal(otherSession.CallbackForLatestAttempt, otherSession.Callback);
|
||||
Assert.True(next.Disposed);
|
||||
Assert.Equal(2, world.Inboxes.Count);
|
||||
Assert.Single(world.Log.Entries, entry => string.Equals(entry, Dropped, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A callback that cannot be given to anyone is dropped: one with no state, with the state twice, with an empty
|
||||
/// state, with a parameter whose name only looks like "state", or with a state nobody waits for. A state is
|
||||
/// somebody's only when it is exactly the one that was registered: the same letters in another case, the
|
||||
/// beginning of it, more than it, or it with a space after it are all nobody's. The ticket that waits is
|
||||
/// untouched, a new inbox is opened for it, and the log holds the fixed sentence and nothing of the callback.
|
||||
/// The ticket's own callback, sent to the new inbox, still reaches it. A callback the session would refuse to
|
||||
/// read is nobody's too, even with the right state in it: a query that starts with a second question mark, an
|
||||
/// entry without a value, a repeated name, a control character, or a fragment. Handed to the exchange, each of
|
||||
/// these would have ended its sign-in; dropped, it leaves the exchange waiting for the real one.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("?code=synthetic-code")]
|
||||
[InlineData("?state=synthetic-state-x&state=synthetic-state-x")]
|
||||
[InlineData("?code=synthetic-code&state=")]
|
||||
[InlineData("?STATE=synthetic-state-x")]
|
||||
[InlineData("?state")]
|
||||
[InlineData("?state=synthetic-state-unknown&code=synthetic-code")]
|
||||
[InlineData("?code=synthetic-code&state=SYNTHETIC-STATE-X")]
|
||||
[InlineData("?code=synthetic-code&state=synthetic-state-")]
|
||||
[InlineData("?code=synthetic-code&state=synthetic-state-xy")]
|
||||
[InlineData("?code=synthetic-code&state=synthetic-state-x%20")]
|
||||
[InlineData("??state=synthetic-state-x")]
|
||||
[InlineData("?state=synthetic-state-x&synthetic-entry-without-a-value")]
|
||||
[InlineData("?code=synthetic-code&code=synthetic-code&state=synthetic-state-x")]
|
||||
[InlineData("?state=synthetic-state-x&code=synthetic%00code")]
|
||||
[InlineData("?state=synthetic-state-x#synthetic-fragment")]
|
||||
public async Task Dispatcher_CallbackWithNoStateOrTwo_IsDropped_AndNotLogged(string query)
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using BrowserCallbackTicket ticket = world.Dispatcher.Register("synthetic-state-x");
|
||||
FakeInbox inbox = Assert.Single(world.Inboxes);
|
||||
|
||||
Task<FakeInbox> reopened = world.NextInboxAsync();
|
||||
inbox.Deliver(new Uri(SignInWorld.Callback + query));
|
||||
FakeInbox next = await reopened;
|
||||
|
||||
Assert.False(ticket.Callback.IsCompleted);
|
||||
Assert.True(inbox.Disposed);
|
||||
Assert.False(next.Disposed);
|
||||
Assert.Equal([InboxOpen, Dropped, InboxOpen], world.Log.Entries);
|
||||
Assert.DoesNotContain(world.Log.Entries, entry => entry.Contains("synthetic", StringComparison.Ordinal) || entry.Contains("oauth", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
next.Deliver(CallbackFor("synthetic-state-x"));
|
||||
|
||||
Assert.Equal(CallbackFor("synthetic-state-x"), await SignInWorld.EndsAsync(ticket.Callback));
|
||||
Assert.True(next.Disposed);
|
||||
Assert.Equal(2, world.Inboxes.Count);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// No inbox exists until something waits. The first ticket opens one and the dispatcher is waiting on it when the
|
||||
/// registration returns; a second ticket shares it; it stays open while either waits and is closed with the last,
|
||||
/// without that being logged as a failure. A ticket that ended can be disposed again, and that does not take away
|
||||
/// a later ticket that waits under the same state. The next registration opens a new inbox. The test runs without
|
||||
/// a synchronization context, as the dispatcher's own threads do: closing an inbox then ends the dispatcher's wait
|
||||
/// on it inside the very call that closes it, which is the order of events the closing has to get right.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Dispatcher_InboxOpensWithTheFirstTicket_ClosesWithTheLast()
|
||||
{
|
||||
using NoContext none = new();
|
||||
SignInWorld world = new();
|
||||
Assert.Empty(world.Inboxes);
|
||||
|
||||
BrowserCallbackTicket first = world.Dispatcher.Register("synthetic-state-a");
|
||||
FakeInbox inbox = Assert.Single(world.Inboxes);
|
||||
Assert.True(inbox.Waiting);
|
||||
BrowserCallbackTicket second = world.Dispatcher.Register("synthetic-state-b");
|
||||
Assert.Single(world.Inboxes);
|
||||
|
||||
first.Dispose();
|
||||
Assert.False(inbox.Disposed);
|
||||
Assert.True(first.Callback.IsCanceled);
|
||||
Assert.False(second.Callback.IsCompleted);
|
||||
second.Dispose();
|
||||
Assert.True(inbox.Disposed);
|
||||
Assert.True(second.Callback.IsCanceled);
|
||||
Assert.Equal([InboxOpen], world.Log.Entries);
|
||||
|
||||
BrowserCallbackTicket later = world.Dispatcher.Register("synthetic-state-a");
|
||||
Assert.Equal(2, world.Inboxes.Count);
|
||||
Assert.True(world.Inboxes[1].Waiting);
|
||||
first.Dispose();
|
||||
Assert.False(world.Inboxes[1].Disposed);
|
||||
Assert.False(later.Callback.IsCompleted);
|
||||
later.Dispose();
|
||||
Assert.True(world.Inboxes[1].Disposed);
|
||||
Assert.Equal([InboxOpen, InboxOpen], world.Log.Entries);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The inbox breaks while two exchanges wait. Both waits fail with that failure, once: the log has one line for
|
||||
/// it, which names the kind of failure and how many it ended and nothing of the failure's own text, and the broken
|
||||
/// inbox is closed. The failed tickets are no longer registered, so disposing them changes nothing. The next
|
||||
/// registration opens a new inbox, which works.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Dispatcher_InboxFails_EveryWaiterFailsOnce_TheNextOpensANewOne()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using BrowserCallbackTicket first = world.Dispatcher.Register("synthetic-state-a");
|
||||
using BrowserCallbackTicket second = world.Dispatcher.Register("synthetic-state-b");
|
||||
FakeInbox inbox = Assert.Single(world.Inboxes);
|
||||
IOException failure = new("synthetic pipe failure");
|
||||
|
||||
inbox.Fail(failure);
|
||||
|
||||
Assert.Same(failure, await Assert.ThrowsAsync<IOException>(() => SignInWorld.EndsAsync(first.Callback)));
|
||||
Assert.Same(failure, await Assert.ThrowsAsync<IOException>(() => SignInWorld.EndsAsync(second.Callback)));
|
||||
Assert.True(inbox.Disposed);
|
||||
Assert.Equal([InboxOpen, "ERROR The browser callback inbox failed (IOException); 2 waiting browser exchange(s) were ended and have to be started again."], world.Log.Entries);
|
||||
|
||||
first.Dispose();
|
||||
second.Dispose();
|
||||
Assert.Single(world.Inboxes);
|
||||
using BrowserCallbackTicket next = world.Dispatcher.Register("synthetic-state-a");
|
||||
Assert.Equal(2, world.Inboxes.Count);
|
||||
Assert.False(next.Callback.IsCompleted);
|
||||
world.Inboxes[1].Deliver(CallbackFor("synthetic-state-a"));
|
||||
|
||||
Assert.Equal(CallbackFor("synthetic-state-a"), await SignInWorld.EndsAsync(next.Callback));
|
||||
Assert.Single(world.Log.Entries, entry => entry.StartsWith("ERROR", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The inbox is fed by other processes and can fail in a way no exchange lists as a failure to report: the real
|
||||
/// one gives an argument error when the process that sent a callback has already exited. Left as it is, such a
|
||||
/// failure would leave every waiting exchange as an exception nobody handles. Both waiting tickets get one
|
||||
/// failure of a kind every exchange reports instead: its text is fixed and names the kind of failure, the
|
||||
/// original is not attached to it, and nothing of the original's own text reaches the log.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(nameof(ArgumentException))]
|
||||
[InlineData(nameof(OverflowException))]
|
||||
[InlineData(nameof(TimeoutException))]
|
||||
public async Task Dispatcher_InboxFailsInAWayNoExchangeLists_EveryWaiterGetsAFailureItReports(string kind)
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using BrowserCallbackTicket first = world.Dispatcher.Register("synthetic-state-a");
|
||||
using BrowserCallbackTicket second = world.Dispatcher.Register("synthetic-state-b");
|
||||
|
||||
world.Inboxes[0].Fail(SignInWorld.UnlistedInboxFailure(kind));
|
||||
|
||||
IOException reported = await Assert.ThrowsAsync<IOException>(() => SignInWorld.EndsAsync(first.Callback));
|
||||
Assert.Same(reported, await Assert.ThrowsAsync<IOException>(() => SignInWorld.EndsAsync(second.Callback)));
|
||||
Assert.Equal(SignInWorld.InboxFailedReport(kind), reported.Message);
|
||||
Assert.Null(reported.InnerException);
|
||||
Assert.DoesNotContain(SignInWorld.UnlistedFailureText, reported.ToString(), StringComparison.Ordinal);
|
||||
Assert.True(world.Inboxes[0].Disposed);
|
||||
Assert.Equal([InboxOpen, $"ERROR The browser callback inbox failed ({kind}); 2 waiting browser exchange(s) were ended and have to be started again."], world.Log.Entries);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The same holds when the inbox cannot be opened: a failure of a kind no exchange lists is thrown to whoever
|
||||
/// registered as one that names only its kind, nothing of its text is kept, and nothing is left registered.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InboxThatCannotBeOpenedInAWayNoExchangeLists_FailsTheRegistrationWithAFailureItReports()
|
||||
{
|
||||
SignInWorld world = new() { InboxFailure = SignInWorld.UnlistedInboxFailure(nameof(ArgumentException)) };
|
||||
|
||||
IOException reported = Assert.Throws<IOException>(() => world.Dispatcher.Register("synthetic-state-a"));
|
||||
|
||||
Assert.Equal(SignInWorld.InboxFailedReport(nameof(ArgumentException)), reported.Message);
|
||||
Assert.Null(reported.InnerException);
|
||||
Assert.DoesNotContain(SignInWorld.UnlistedFailureText, reported.ToString(), StringComparison.Ordinal);
|
||||
Assert.Empty(world.Inboxes);
|
||||
Assert.Empty(world.Log.Entries);
|
||||
|
||||
world.InboxFailure = null;
|
||||
using BrowserCallbackTicket ticket = world.Dispatcher.Register("synthetic-state-a");
|
||||
Assert.Single(world.Inboxes).Deliver(CallbackFor("synthetic-state-a"));
|
||||
Assert.Equal(CallbackFor("synthetic-state-a"), await SignInWorld.EndsAsync(ticket.Callback));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>When the inbox cannot be opened for the first ticket, the failure goes to whoever registered and nothing is left registered: the same state can be registered again once the inbox can be opened.</summary>
|
||||
[Fact]
|
||||
public async Task InboxThatCannotBeOpened_FailsTheRegistration_AndLeavesNothingBehind()
|
||||
{
|
||||
SignInWorld world = new() { InboxFailure = new UnauthorizedAccessException("synthetic open failure") };
|
||||
|
||||
Assert.Same(world.InboxFailure, Assert.Throws<UnauthorizedAccessException>(() => world.Dispatcher.Register("synthetic-state-a")));
|
||||
Assert.Empty(world.Inboxes);
|
||||
Assert.Empty(world.Log.Entries);
|
||||
|
||||
world.InboxFailure = null;
|
||||
using BrowserCallbackTicket ticket = world.Dispatcher.Register("synthetic-state-a");
|
||||
Assert.Single(world.Inboxes).Deliver(CallbackFor("synthetic-state-a"));
|
||||
|
||||
Assert.Equal(CallbackFor("synthetic-state-a"), await SignInWorld.EndsAsync(ticket.Callback));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// After a callback the inbox has to be opened again for the tickets that still wait. When that fails, the callback
|
||||
/// that did arrive still completes its own ticket, and the ticket left waiting fails with the failure, because
|
||||
/// nothing could reach it any more. The next registration tries a new inbox.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InboxThatCannotBeReopened_CompletesTheCallbackItHas_AndFailsWhoStillWaits()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using BrowserCallbackTicket served = world.Dispatcher.Register("synthetic-state-a");
|
||||
using BrowserCallbackTicket stranded = world.Dispatcher.Register("synthetic-state-b");
|
||||
UnauthorizedAccessException failure = new("synthetic open failure");
|
||||
world.InboxFailure = failure;
|
||||
|
||||
world.Inboxes[0].Deliver(CallbackFor("synthetic-state-a"));
|
||||
|
||||
Assert.Equal(CallbackFor("synthetic-state-a"), await SignInWorld.EndsAsync(served.Callback));
|
||||
Assert.Same(failure, await Assert.ThrowsAsync<UnauthorizedAccessException>(() => SignInWorld.EndsAsync(stranded.Callback)));
|
||||
Assert.True(world.Inboxes[0].Disposed);
|
||||
Assert.Equal([InboxOpen, "ERROR The browser callback inbox failed (UnauthorizedAccessException); 1 waiting browser exchange(s) were ended and have to be started again."], world.Log.Entries);
|
||||
|
||||
world.InboxFailure = null;
|
||||
using BrowserCallbackTicket next = world.Dispatcher.Register("synthetic-state-b");
|
||||
Assert.Equal(2, world.Inboxes.Count);
|
||||
Assert.False(next.Callback.IsCompleted);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A callback, or a failure, can come out of an inbox at the very moment its last ticket ends and the inbox is
|
||||
/// closed. The inboxes here ignore being closed and answer when told to, which is that moment made certain, and by
|
||||
/// then another exchange has registered and a new inbox is open. The late callback is dropped like any other that
|
||||
/// reached nobody, even though it carries the state that now waits, and the late failure is not reported at all,
|
||||
/// because the closing caused it. Either way the exchange that waits on the new inbox is untouched, and its own
|
||||
/// callback completes it. The test runs without a synchronization context, as the dispatcher's own threads do, so
|
||||
/// the dispatcher deals with each answer inside the call that gives it and the test reads the result straight after.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(true, new[] { InboxOpen, InboxOpen, Dropped })]
|
||||
[InlineData(false, new[] { InboxOpen, InboxOpen })]
|
||||
public void AnswerFromAnInboxThatWasClosedMeanwhile_DoesNotTouchTheNextExchange(bool callback, string[] entries)
|
||||
{
|
||||
using NoContext none = new();
|
||||
RecordingLog log = new();
|
||||
LateInboxes source = new();
|
||||
BrowserCallbackDispatcher dispatcher = new(source, log);
|
||||
BrowserCallbackTicket ended = dispatcher.Register("synthetic-state-a");
|
||||
ended.Dispose();
|
||||
Assert.True(source.Inboxes[0].Disposed);
|
||||
using BrowserCallbackTicket waiting = dispatcher.Register("synthetic-state-a");
|
||||
|
||||
if(callback)
|
||||
{
|
||||
source.Inboxes[0].Answer.SetResult(CallbackFor("synthetic-state-a"));
|
||||
}
|
||||
else
|
||||
{
|
||||
source.Inboxes[0].Answer.SetException(new IOException("synthetic pipe failure"));
|
||||
}
|
||||
|
||||
Assert.Equal(entries, log.Entries, StringComparer.Ordinal);
|
||||
Assert.True(ended.Callback.IsCanceled);
|
||||
Assert.False(waiting.Callback.IsCompleted);
|
||||
Assert.Equal(2, source.Inboxes.Count);
|
||||
Assert.False(source.Inboxes[1].Disposed);
|
||||
|
||||
source.Inboxes[1].Answer.SetResult(CallbackFor("synthetic-state-a"));
|
||||
|
||||
Assert.True(waiting.Callback.IsCompletedSuccessfully);
|
||||
Assert.True(source.Inboxes[1].Disposed);
|
||||
Assert.Equal(2, source.Inboxes.Count);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A log that cannot be written to does not stop the dispatcher. Every entry it writes fails here, and all the
|
||||
/// same: a registration returns its ticket with the inbox open and being read; a callback for nobody is dropped
|
||||
/// and the next inbox is opened and read; the ticket's own callback reaches it; and when an inbox breaks, the
|
||||
/// tickets that wait are failed and not left waiting. Each of the three entries was attempted, so the test is
|
||||
/// about entries that failed and not about entries that were never written.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task LogThatThrows_DoesNotBreakTheDispatcher()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
ThrowingLog log = new();
|
||||
BrowserCallbackDispatcher dispatcher = new(world, log);
|
||||
|
||||
using BrowserCallbackTicket ticket = dispatcher.Register("synthetic-state-a");
|
||||
FakeInbox inbox = Assert.Single(world.Inboxes);
|
||||
Assert.True(inbox.Waiting);
|
||||
Task<FakeInbox> reopened = world.NextInboxAsync();
|
||||
inbox.Deliver(CallbackFor("synthetic-state-of-nobody"));
|
||||
FakeInbox next = await reopened;
|
||||
Assert.True(inbox.Disposed);
|
||||
Assert.False(ticket.Callback.IsCompleted);
|
||||
next.Deliver(CallbackFor("synthetic-state-a"));
|
||||
Assert.Equal(CallbackFor("synthetic-state-a"), await SignInWorld.EndsAsync(ticket.Callback));
|
||||
|
||||
using BrowserCallbackTicket first = dispatcher.Register("synthetic-state-b");
|
||||
using BrowserCallbackTicket second = dispatcher.Register("synthetic-state-c");
|
||||
IOException failure = new("synthetic pipe failure");
|
||||
world.Inboxes[2].Fail(failure);
|
||||
|
||||
Assert.Same(failure, await Assert.ThrowsAsync<IOException>(() => SignInWorld.EndsAsync(first.Callback)));
|
||||
Assert.Same(failure, await Assert.ThrowsAsync<IOException>(() => SignInWorld.EndsAsync(second.Callback)));
|
||||
Assert.All(world.Inboxes, opened => Assert.True(opened.Disposed));
|
||||
Assert.Equal(["INFO", "WARN", "INFO", "INFO", "ERROR"], log.Attempts);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// An inbox can have its callback ready the moment it is opened, and the next one too. Two hundred inboxes in a
|
||||
/// row answer at once here, each with a callback for nobody. The dispatcher drops each and opens the next in one
|
||||
/// loop: the stack is no deeper when the last inbox is opened than when the second was, where a call per inbox
|
||||
/// would have gone deeper with every callback. The ticket is still waiting afterwards, on the first inbox that
|
||||
/// did not answer at once, and its own callback reaches it there.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InboxesThatAnswerAtOnce_AreWorkedThroughInALoop()
|
||||
{
|
||||
EagerInboxes source = new(200);
|
||||
RecordingLog log = new();
|
||||
BrowserCallbackDispatcher dispatcher = new(source, log);
|
||||
|
||||
using BrowserCallbackTicket ticket = dispatcher.Register("synthetic-state-a");
|
||||
|
||||
Assert.Equal(201, source.Depths.Count);
|
||||
Assert.Equal(200, log.Entries.Count(entry => string.Equals(entry, Dropped, StringComparison.Ordinal)));
|
||||
Assert.Equal(201, log.Entries.Count(entry => string.Equals(entry, InboxOpen, StringComparison.Ordinal)));
|
||||
Assert.InRange(source.Depths.Skip(1).Max() - source.Depths[1], 0, 8);
|
||||
Assert.False(ticket.Callback.IsCompleted);
|
||||
|
||||
source.Waiting.SetResult(CallbackFor("synthetic-state-a"));
|
||||
|
||||
Assert.Equal(CallbackFor("synthetic-state-a"), await SignInWorld.EndsAsync(ticket.Callback));
|
||||
Assert.Equal(201, source.Depths.Count);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Two exchanges cannot wait under one state, because a callback could not be told to belong to one of them. The
|
||||
/// second registration is refused without naming the state, and the first is left as it was. A state of nothing is
|
||||
/// refused before anything is opened.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task StateThatIsAlreadyWaiting_OrEmpty_IsRefused()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
Assert.Throws<ArgumentException>(() => world.Dispatcher.Register(""));
|
||||
Assert.Empty(world.Inboxes);
|
||||
using BrowserCallbackTicket ticket = world.Dispatcher.Register("synthetic-state-a");
|
||||
|
||||
InvalidOperationException refused = Assert.Throws<InvalidOperationException>(() => world.Dispatcher.Register("synthetic-state-a"));
|
||||
|
||||
Assert.Equal("A browser exchange with the same state is already waiting for its callback.", refused.Message);
|
||||
Assert.Single(world.Inboxes).Deliver(CallbackFor("synthetic-state-a"));
|
||||
Assert.Equal(CallbackFor("synthetic-state-a"), await SignInWorld.EndsAsync(ticket.Callback));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The state of an address is its one "state" parameter, decoded: found wherever it stands in the query, with a
|
||||
/// plus sign and a percent escape read as the provider wrote them, in the name as well as in the value. There is
|
||||
/// none when the parameter is missing, empty, repeated or named differently, when the address has no query, and
|
||||
/// when it is not an absolute address. There is none either when the session would refuse the query: it starts
|
||||
/// with a second question mark, an entry has no value, any name is repeated, a name or a value holds a control
|
||||
/// character, or the address has a fragment or user information.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("io.qrun.qqq-print-agent:/oauth/callback?%73tate=synthetic-state-a", "synthetic-state-a")]
|
||||
[InlineData("io.qrun.qqq-print-agent:/oauth/callback?state=synthetic-state-a&State=synthetic-state-b", "synthetic-state-a")]
|
||||
[InlineData("io.qrun.qqq-print-agent:/oauth/callback??state=synthetic-state-a", null)]
|
||||
[InlineData("io.qrun.qqq-print-agent:/oauth/callback?state=synthetic-state-a&synthetic", null)]
|
||||
[InlineData("io.qrun.qqq-print-agent:/oauth/callback?code=a&code=b&state=synthetic-state-a", null)]
|
||||
[InlineData("io.qrun.qqq-print-agent:/oauth/callback?state=synthetic-state-a%00", null)]
|
||||
[InlineData("io.qrun.qqq-print-agent:/oauth/callback?synthetic%0Aname=a&state=synthetic-state-a", null)]
|
||||
[InlineData("io.qrun.qqq-print-agent:/oauth/callback?state=synthetic-state-a#state=synthetic-state-b", null)]
|
||||
[InlineData("https://synthetic-user@id.example.com/authorize?state=synthetic-state-a", null)]
|
||||
[InlineData("io.qrun.qqq-print-agent:/oauth/callback?", null)]
|
||||
[InlineData("https://id.example.com/authorize?client_id=synthetic&state=synthetic-state-a&nonce=synthetic", "synthetic-state-a")]
|
||||
[InlineData("io.qrun.qqq-print-agent:/oauth/callback?state=synthetic%2Dstate+a", "synthetic-state a")]
|
||||
[InlineData("io.qrun.qqq-print-agent:/oauth/callback?state=synthetic=state", "synthetic=state")]
|
||||
[InlineData("io.qrun.qqq-print-agent:/oauth/callback?state=a&state=b", null)]
|
||||
[InlineData("io.qrun.qqq-print-agent:/oauth/callback?state=", null)]
|
||||
[InlineData("io.qrun.qqq-print-agent:/oauth/callback?state", null)]
|
||||
[InlineData("io.qrun.qqq-print-agent:/oauth/callback?mystate=synthetic", null)]
|
||||
[InlineData("io.qrun.qqq-print-agent:/oauth/callback", null)]
|
||||
[InlineData("/oauth/callback?state=synthetic-state-a", null)]
|
||||
public void ReadState_IsTheOneStateParameter(string address, string? state)
|
||||
{
|
||||
Assert.Equal(state, BrowserCallbackDispatcher.ReadState(new Uri(address, UriKind.RelativeOrAbsolute)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Whoever waits on a ticket continues on a thread of its own, never inside the dispatcher, which completes
|
||||
/// tickets from the thread a callback arrived on. The waiter here asks to be continued inside the very call that
|
||||
/// ends the ticket, which is what an awaiting method without a synchronization context does, and it is not: when
|
||||
/// it runs, the call that gave the ticket its callback, or its failure, is not running on that thread.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task TicketsWaiter_IsNotContinuedInsideTheCallThatEndsTheTicket(bool callback)
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using BrowserCallbackTicket ticket = world.Dispatcher.Register("synthetic-state-a");
|
||||
int ending = Environment.CurrentManagedThreadId;
|
||||
bool inside = false;
|
||||
bool? continuedInside = null;
|
||||
Task waiter = ticket.Callback.ContinueWith(_ => continuedInside = inside && Environment.CurrentManagedThreadId == ending, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
|
||||
|
||||
inside = true;
|
||||
if(callback)
|
||||
{
|
||||
ticket.Complete(CallbackFor("synthetic-state-a"));
|
||||
}
|
||||
else
|
||||
{
|
||||
ticket.Fail(new IOException("synthetic pipe failure"));
|
||||
}
|
||||
inside = false;
|
||||
|
||||
await waiter.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
Assert.False(continuedInside);
|
||||
Assert.Equal(callback, ticket.Callback.IsCompletedSuccessfully);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// An inbox can fail after an exchange has stopped waiting and before it has disposed its ticket. The ticket is
|
||||
/// then failed and never looked at. The runtime reports a failed task nobody looked at as an unobserved error
|
||||
/// when the task is collected, and a ticket must not be the cause of one. The test fails a ticket, lets it go
|
||||
/// without looking at it, and has everything collected; beside it, as the control, an ordinary task is failed
|
||||
/// and let go the same way. The control is reported, which shows that the collection did what it had to, and
|
||||
/// the ticket is not. Each failure is told from any other test's by its own text, and nothing here waits: the
|
||||
/// collections run to completion before the counts are read.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TicketFailedAndNeverLookedAt_LeavesNoUnobservedFailure()
|
||||
{
|
||||
const string ticketText = "synthetic failure of a ticket nobody looked at";
|
||||
const string controlText = "synthetic failure of a task nobody looked at";
|
||||
int tickets = 0;
|
||||
int controls = 0;
|
||||
void Count(object? sender, UnobservedTaskExceptionEventArgs unobserved)
|
||||
{
|
||||
tickets += unobserved.Exception.InnerExceptions.Count(inner => string.Equals(inner.Message, ticketText, StringComparison.Ordinal));
|
||||
controls += unobserved.Exception.InnerExceptions.Count(inner => string.Equals(inner.Message, controlText, StringComparison.Ordinal));
|
||||
}
|
||||
TaskScheduler.UnobservedTaskException += Count;
|
||||
try
|
||||
{
|
||||
FailATicketAndLetItGo(ticketText);
|
||||
FailATaskAndLetItGo(controlText);
|
||||
for(int round = 0; round < 3; round++)
|
||||
{
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
TaskScheduler.UnobservedTaskException -= Count;
|
||||
}
|
||||
|
||||
Assert.Equal(1, controls);
|
||||
Assert.Equal(0, tickets);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Registers a ticket, breaks its inbox so the ticket is failed inside that call, disposes the ticket without looking at its task, and returns with nothing left that refers to it. It is not inlined, so the caller's frame holds nothing of it either.</summary>
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private static void FailATicketAndLetItGo(string text)
|
||||
{
|
||||
using NoContext none = new();
|
||||
LateInboxes source = new();
|
||||
BrowserCallbackDispatcher dispatcher = new(source, new RecordingLog());
|
||||
BrowserCallbackTicket ticket = dispatcher.Register("synthetic-state-a");
|
||||
source.Inboxes[0].Answer.SetException(new IOException(text));
|
||||
Assert.True(ticket.Callback.IsFaulted);
|
||||
ticket.Dispose();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The control: fails an ordinary task and returns with nothing left that refers to it.</summary>
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private static void FailATaskAndLetItGo(string text)
|
||||
{
|
||||
TaskCompletionSource<Uri> source = new();
|
||||
source.SetException(new IOException(text));
|
||||
Assert.True(source.Task.IsFaulted);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A ticket's task carries a callback with its one-time code and state, so code outside the presentation assembly
|
||||
/// can neither see a ticket nor register for one: the ticket is not a public type and offers no public property,
|
||||
/// and the dispatcher's public surface is its constructor alone.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TicketAndRegistration_AreNotOfferedOutsideTheAssembly()
|
||||
{
|
||||
const BindingFlags declaredPublic = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.DeclaredOnly;
|
||||
|
||||
Assert.False(typeof(BrowserCallbackTicket).IsVisible);
|
||||
Assert.Empty(typeof(BrowserCallbackTicket).GetProperties(declaredPublic));
|
||||
Assert.True(typeof(BrowserCallbackDispatcher).IsVisible);
|
||||
Assert.Empty(typeof(BrowserCallbackDispatcher).GetMethods(declaredPublic));
|
||||
Assert.Empty(typeof(BrowserCallbackDispatcher).GetProperties(declaredPublic));
|
||||
Assert.Single(typeof(BrowserCallbackDispatcher).GetConstructors(declaredPublic));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A query is read up to the length the session accepts for a callback and not beyond: one character more, and the address has no state.</summary>
|
||||
[Fact]
|
||||
public void ReadState_QueryLongerThanACallbacksMayBe_HasNoState()
|
||||
{
|
||||
const string prefix = "?state=synthetic-state-a&synthetic=";
|
||||
string longest = prefix + new string('a', BrowserCallbackDispatcher.LongestQuery - prefix.Length);
|
||||
|
||||
Assert.Equal(16_384, BrowserCallbackDispatcher.LongestQuery);
|
||||
Assert.Equal("synthetic-state-a", BrowserCallbackDispatcher.ReadState(new Uri(SignInWorld.Callback + longest)));
|
||||
Assert.Null(BrowserCallbackDispatcher.ReadState(new Uri(SignInWorld.Callback + longest + "a")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The dispatcher cannot work without a place callbacks arrive and a log, and says which is missing.</summary>
|
||||
[Fact]
|
||||
public void MissingCollaborator_IsRefused()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
|
||||
Assert.Equal("callbacks", Assert.Throws<ArgumentNullException>(() => new BrowserCallbackDispatcher(null!, world.Log)).ParamName);
|
||||
Assert.Equal("log", Assert.Throws<ArgumentNullException>(() => new BrowserCallbackDispatcher(world, null!)).ParamName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A log that fails on every entry, as a log whose file cannot be written would, and notes the level of each entry that was attempted.</summary>
|
||||
private sealed class ThrowingLog : IPresentationLog
|
||||
{
|
||||
/// <summary>The level of every entry that was attempted, oldest first.</summary>
|
||||
internal List<string> Attempts { get; } = [];
|
||||
|
||||
|
||||
|
||||
/// <summary>Fails.</summary>
|
||||
public void Info(string message) => Fail("INFO");
|
||||
|
||||
|
||||
|
||||
/// <summary>Fails.</summary>
|
||||
public void Warning(string message) => Fail("WARN");
|
||||
|
||||
|
||||
|
||||
/// <summary>Fails.</summary>
|
||||
public void Error(string message) => Fail("ERROR");
|
||||
|
||||
|
||||
|
||||
/// <summary>Fails.</summary>
|
||||
public void Debug(string message) => Fail("DEBUG");
|
||||
|
||||
|
||||
|
||||
/// <summary>Notes the attempt and fails.</summary>
|
||||
private void Fail(string level)
|
||||
{
|
||||
lock(Attempts)
|
||||
{
|
||||
Attempts.Add(level);
|
||||
}
|
||||
throw new IOException("synthetic log failure");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A callback source whose first inboxes answer the moment they are waited on, each with a callback for nobody, and whose next inbox waits for the test. It notes how deep the stack is each time an inbox is opened.</summary>
|
||||
private sealed class EagerInboxes(int eager) : IBrowserCallbackSource
|
||||
{
|
||||
/// <summary>The depth of the stack at each opening of an inbox, oldest first.</summary>
|
||||
internal List<int> Depths { get; } = [];
|
||||
|
||||
|
||||
|
||||
/// <summary>What the first inbox that does not answer at once waits for.</summary>
|
||||
internal TaskCompletionSource<Uri> Waiting { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
|
||||
|
||||
/// <summary>The registered callback.</summary>
|
||||
public string RedirectUri => SignInWorld.Callback;
|
||||
|
||||
|
||||
|
||||
/// <summary>Not read by the dispatcher.</summary>
|
||||
public string TracePath => "trace/auth-callback.log";
|
||||
|
||||
|
||||
|
||||
/// <summary>Notes the depth of the stack and opens the next inbox.</summary>
|
||||
public IBrowserCallbackInbox Open()
|
||||
{
|
||||
Depths.Add(new System.Diagnostics.StackTrace().FrameCount);
|
||||
return new EagerInbox(Depths.Count <= eager ? Task.FromResult(CallbackFor("synthetic-state-of-nobody")) : Waiting.Task);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Not read by the dispatcher.</summary>
|
||||
public IReadOnlyList<string> RecentTrace(TimeSpan window) => [];
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>An inbox whose wait is the task it was given: one that is already complete, or one the test completes.</summary>
|
||||
private sealed class EagerInbox(Task<Uri> answer) : IBrowserCallbackInbox
|
||||
{
|
||||
/// <summary>Returns the answer, which may already be there.</summary>
|
||||
public Task<Uri> ReceiveAsync(CancellationToken cancellationToken) => answer;
|
||||
|
||||
|
||||
|
||||
/// <summary>Nothing to close.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A callback source whose inboxes do not notice being closed and answer when the test says so, on the test's own thread.</summary>
|
||||
private sealed class LateInboxes : IBrowserCallbackSource
|
||||
{
|
||||
/// <summary>Every inbox that was opened, oldest first.</summary>
|
||||
internal List<LateInbox> Inboxes { get; } = [];
|
||||
|
||||
|
||||
|
||||
/// <summary>The registered callback.</summary>
|
||||
public string RedirectUri => SignInWorld.Callback;
|
||||
|
||||
|
||||
|
||||
/// <summary>Not read by the dispatcher.</summary>
|
||||
public string TracePath => "trace/auth-callback.log";
|
||||
|
||||
|
||||
|
||||
/// <summary>Opens a new inbox.</summary>
|
||||
public IBrowserCallbackInbox Open()
|
||||
{
|
||||
LateInbox inbox = new();
|
||||
Inboxes.Add(inbox);
|
||||
return inbox;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Not read by the dispatcher.</summary>
|
||||
public IReadOnlyList<string> RecentTrace(TimeSpan window) => [];
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>An inbox whose wait ends only when the test completes <see cref="Answer"/>, whatever happens to the token or the inbox meanwhile. The wait's continuation runs inside that completion, so the test continues after the dispatcher has dealt with the answer.</summary>
|
||||
private sealed class LateInbox : IBrowserCallbackInbox
|
||||
{
|
||||
/// <summary>What the wait ends with; completing it runs the dispatcher's handling at once.</summary>
|
||||
internal TaskCompletionSource<Uri> Answer { get; } = new();
|
||||
|
||||
|
||||
|
||||
/// <summary>True once the dispatcher closed the inbox.</summary>
|
||||
internal bool Disposed { get; private set; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Waits for the answer and ignores the token.</summary>
|
||||
public Task<Uri> ReceiveAsync(CancellationToken cancellationToken) => Answer.Task;
|
||||
|
||||
|
||||
|
||||
/// <summary>Marks the inbox closed.</summary>
|
||||
public void Dispose() => Disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.ComponentModel;
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
using Qqq.PrintAgent.Presentation.SignIn;
|
||||
using Qqq.PrintAgent.Presentation.Tests.Fakes;
|
||||
|
||||
namespace Qqq.PrintAgent.Presentation.Tests.SignIn;
|
||||
|
||||
/// <summary>The companion's browser sign-in: the exchange through the default browser and the one callback dispatcher, its time limit and cancellation, what is reported when it does not complete, and the thread everything is announced on.</summary>
|
||||
public sealed class CompanionSignInBrowserTests
|
||||
{
|
||||
/// <summary>The first entry of every browser sign-in to the first server.</summary>
|
||||
private const string Requested = "INFO Browser sign-in requested for print.example.com; this application's callback is io.qrun.qqq-print-agent:/oauth/callback.";
|
||||
|
||||
/// <summary>What the log says when an attempt that ended without a result had already handed its callback to the session, which is then discarded.</summary>
|
||||
private const string Discarded = "INFO The browser sign-in ended without a result after its callback had been handed to the session, so the session is discarded: the server may have signed it in, and nothing here waits for that. The next attempt reads the sign-in description again.";
|
||||
|
||||
/// <summary>What the log says when a page was opened and the protocol handler recorded nothing.</summary>
|
||||
private const string NeverLaunched = "WARN The browser never launched the sign-in protocol handler in the last 10 minutes (no entries in trace/auth-callback.log). Either the identity provider did not redirect to io.qrun.qqq-print-agent:/oauth/callback (check the provider's allowed redirect URIs and any error page it showed), or the browser blocked opening the QQQ Print Agent sign-in link.";
|
||||
|
||||
|
||||
|
||||
/// <summary>What the log says, in place of anything about the protocol handler, when an attempt ended before a browser page was opened, with what the attempt had not got past.</summary>
|
||||
private static string NotOpened(string reason) => "INFO This attempt ended before a browser page was opened (" + reason + "), so the sign-in protocol handler was not involved and its trace is not copied here.";
|
||||
|
||||
|
||||
|
||||
/// <summary>True when an entry holds something that must never be logged: a sign-in state, a one-time code or a session token.</summary>
|
||||
private static bool Secret(string entry)
|
||||
=> entry.Contains("synthetic-state", StringComparison.Ordinal) || entry.Contains("one-time-code", StringComparison.Ordinal) || entry.Contains("synthetic-session-token", StringComparison.Ordinal);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The exchange begins the sign-in on the session with this application's registered callback, has an inbox open
|
||||
/// before the provider's page is opened, waits under a three minute limit, completes the sign-in with the callback
|
||||
/// that carries this attempt's state, closes the inbox and returns the credential. The session stays. Only the
|
||||
/// provider's host is logged: no state, no code and no token.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Browser_CompletedExchange_ReturnsTheCredential_AndLogsNoSecrets()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn signIn = await world.BrowserReadyAsync();
|
||||
FakeSession session = world.Sessions[0];
|
||||
List<string> changes = SignInWorld.Watch(signIn);
|
||||
|
||||
Task<QqqSessionCredential?> running = signIn.SignInWithBrowserAsync();
|
||||
Assert.Equal(CompanionSignInState.WaitingForBrowser, signIn.State);
|
||||
Assert.True(signIn.IsBusy);
|
||||
FakeInbox inbox = Assert.Single(world.Inboxes);
|
||||
Assert.True(inbox.Waiting);
|
||||
Assert.Equal([1], world.ListeningAtLaunch);
|
||||
Assert.Equal(session.Authorization, Assert.Single(world.Opened));
|
||||
Assert.Equal(new Uri(SignInWorld.Callback), session.Redirect);
|
||||
inbox.Deliver(session.CallbackForLatestAttempt);
|
||||
QqqSessionCredential? credential = await SignInWorld.EndsAsync(running);
|
||||
|
||||
Assert.Equal(new Uri(SignInWorld.Server), credential?.Origin);
|
||||
Assert.Equal("synthetic-session-token", credential?.SessionToken);
|
||||
Assert.Equal(session.CallbackForLatestAttempt, session.Callback);
|
||||
Assert.True(inbox.Disposed);
|
||||
Assert.Equal(CompanionSignInState.SignedIn, signIn.State);
|
||||
Assert.True(signIn.HasSession);
|
||||
Assert.False(session.Disposed);
|
||||
Assert.Equal(["IsBusy=True", "State=WaitingForBrowser", "State=SignedIn", "IsBusy=False"], changes);
|
||||
Assert.Equal(TimeSpan.FromMinutes(3), Assert.Single(world.Time.RequestedDelays));
|
||||
Assert.Equal(TimeSpan.FromMinutes(3), CompanionSignIn.BrowserSignInLimit);
|
||||
Assert.Equal([Requested,
|
||||
"INFO Browser callback inbox open.",
|
||||
"INFO Opening the identity provider's sign-in page in the default browser (id.example.com).",
|
||||
"INFO Browser callback received; completing the sign-in.",
|
||||
"INFO Sign-in accepted by print.example.com; the session credential goes to the caller and is not kept here."], world.Log.Entries);
|
||||
Assert.DoesNotContain(world.Log.Entries, Secret);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The provider's page is at an address the server's provider wrote. The log names its host by the ASCII name the browser will really go to, so a look-alike letter reads as the "xn--" name, and the browser is given the address exactly as the session returned it.</summary>
|
||||
[Fact]
|
||||
public async Task Browser_ProviderPageWithALookAlikeHost_IsLoggedByItsAsciiName()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn signIn = await world.BrowserReadyAsync();
|
||||
world.Sessions[0].ProviderAuthority = "l\u043Egin.example.com:9443";
|
||||
|
||||
Task<QqqSessionCredential?> running = signIn.SignInWithBrowserAsync();
|
||||
world.Inboxes[0].Deliver(world.Sessions[0].CallbackForLatestAttempt);
|
||||
|
||||
Assert.NotNull(await SignInWorld.EndsAsync(running));
|
||||
Assert.Equal(new Uri("https://l\u043Egin.example.com:9443/authorize?state=synthetic-state-a-1"), Assert.Single(world.Opened));
|
||||
Assert.Contains("INFO Opening the identity provider's sign-in page in the default browser (xn--lgin-55d.example.com:9443).", world.Log.Entries, StringComparer.Ordinal);
|
||||
Assert.DoesNotContain(world.Log.Entries, entry => entry.Contains('\u043E', StringComparison.Ordinal) || Secret(entry));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A browser sign-in waits, with the inbox open, until the operator cancels it or three minutes pass, and not a
|
||||
/// second less. Either ending is said to be exactly what it was, the inbox is closed, and the session and its
|
||||
/// offer stay for another attempt. Cancel has no effect once the exchange has ended. The next attempt on the same
|
||||
/// session works.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(false, CompanionSignInState.BrowserCanceled, "INFO Browser sign-in to print.example.com was cancelled by the operator.")]
|
||||
[InlineData(true, CompanionSignInState.BrowserTimedOut, "INFO Browser sign-in to print.example.com was given up after the three minute limit.")]
|
||||
public async Task Browser_WaitsCanBeCancelledAndGivesUpAfterThreeMinutes(bool byLimit, CompanionSignInState ending, string entry)
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn signIn = await world.BrowserReadyAsync();
|
||||
FakeSession session = world.Sessions[0];
|
||||
|
||||
Task<QqqSessionCredential?> running = signIn.SignInWithBrowserAsync();
|
||||
Assert.Equal(CompanionSignInState.WaitingForBrowser, signIn.State);
|
||||
Assert.True(world.Inboxes[0].Waiting);
|
||||
world.Time.Advance(TimeSpan.FromMinutes(3) - TimeSpan.FromSeconds(1));
|
||||
Assert.False(running.IsCompleted);
|
||||
Assert.Equal(CompanionSignInState.WaitingForBrowser, signIn.State);
|
||||
if(byLimit)
|
||||
{
|
||||
world.Time.Advance(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
else
|
||||
{
|
||||
signIn.CancelBrowserSignIn();
|
||||
}
|
||||
|
||||
Assert.Null(await SignInWorld.EndsAsync(running));
|
||||
Assert.Equal(ending, signIn.State);
|
||||
Assert.Null(signIn.Failure);
|
||||
Assert.Null(signIn.Rejection);
|
||||
Assert.True(signIn.HasSession);
|
||||
Assert.Equal(new SignInOffer(false, true), signIn.Offer);
|
||||
Assert.False(session.Disposed);
|
||||
Assert.Null(session.Callback);
|
||||
Assert.True(world.Inboxes[0].Disposed);
|
||||
Assert.False(signIn.IsBusy);
|
||||
string[] last = [.. world.Log.Entries.TakeLast(2)];
|
||||
Assert.Equal([entry, NeverLaunched], last);
|
||||
Assert.DoesNotContain(world.Log.Entries, line => line.StartsWith("ERROR", StringComparison.Ordinal) || Secret(line));
|
||||
|
||||
signIn.CancelBrowserSignIn();
|
||||
running = signIn.SignInWithBrowserAsync();
|
||||
world.Inboxes[1].Deliver(session.CallbackForLatestAttempt);
|
||||
|
||||
Assert.NotNull(await SignInWorld.EndsAsync(running));
|
||||
Assert.Equal(CompanionSignInState.SignedIn, signIn.State);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// An abandoned attempt leaves a browser page behind, and that page can still come back. Its callback carries the
|
||||
/// abandoned attempt's state, which nobody waits for: it is dropped while the next attempt on the same session
|
||||
/// waits, and that attempt is completed by its own callback, the only one the session is ever given.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Browser_AbandonedAttemptsCallback_IsDroppedWhileTheNextAttemptWaits()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn signIn = await world.BrowserReadyAsync();
|
||||
FakeSession session = world.Sessions[0];
|
||||
Task<QqqSessionCredential?> abandoned = signIn.SignInWithBrowserAsync();
|
||||
Uri late = session.CallbackForLatestAttempt;
|
||||
signIn.CancelBrowserSignIn();
|
||||
Assert.Null(await SignInWorld.EndsAsync(abandoned));
|
||||
|
||||
Task<QqqSessionCredential?> running = signIn.SignInWithBrowserAsync();
|
||||
Task<FakeInbox> reopened = world.NextInboxAsync();
|
||||
world.Inboxes[1].Deliver(late);
|
||||
FakeInbox next = await reopened;
|
||||
Assert.False(running.IsCompleted);
|
||||
Assert.Null(session.Callback);
|
||||
Assert.NotEqual(late, session.CallbackForLatestAttempt);
|
||||
next.Deliver(session.CallbackForLatestAttempt);
|
||||
|
||||
Assert.NotNull(await SignInWorld.EndsAsync(running));
|
||||
Assert.Equal(session.CallbackForLatestAttempt, session.Callback);
|
||||
Assert.Equal(CompanionSignInState.SignedIn, signIn.State);
|
||||
Assert.Single(world.Log.Entries, entry => entry.StartsWith("WARN A browser callback that belongs to no waiting sign-in or sign-out was dropped.", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(world.Log.Entries, Secret);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Only the callback this application's installer registered is used, and only when the server lists exactly that
|
||||
/// address, so a server cannot steer the callback elsewhere. A server that lists other addresses, one that
|
||||
/// differs only by letter case among them, is refused before the session is asked to begin: no browser is opened
|
||||
/// and no inbox. The log says how many addresses the server lists and repeats none of them.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Browser_CallbackTheServerDoesNotList_IsRefused()
|
||||
{
|
||||
SignInWorld world = new() { ConfigureSession = session => session.Metadata = SignInWorld.BrowserDescription(SignInWorld.Provider, "https://print.example.com/synthetic-other-callback", "io.qrun.qqq-print-agent:/oauth/Callback") };
|
||||
using CompanionSignIn signIn = await world.BrowserReadyAsync();
|
||||
|
||||
QqqSessionCredential? credential = await SignInWorld.EndsAsync(signIn.SignInWithBrowserAsync());
|
||||
|
||||
Assert.Null(credential);
|
||||
Assert.Equal(CompanionSignInState.BrowserFailed, signIn.State);
|
||||
Assert.Equal("No approved native browser callback is configured.", signIn.Failure);
|
||||
Assert.Empty(world.Opened);
|
||||
Assert.Empty(world.Inboxes);
|
||||
Assert.Null(world.Sessions[0].Redirect);
|
||||
Assert.True(signIn.HasSession);
|
||||
Assert.False(signIn.IsBusy);
|
||||
Assert.Equal("WARN The server's native grant does not list this application's callback io.qrun.qqq-print-agent:/oauth/callback; it lists 2 other address(es). Add the callback to the QQQ native session grant.", world.Log.Entries[1]);
|
||||
Assert.Equal(NotOpened("the server does not list this application's callback"), world.Log.Entries[2]);
|
||||
Assert.DoesNotContain(NeverLaunched, world.Log.Entries, StringComparer.Ordinal);
|
||||
Assert.Empty(world.TraceWindows);
|
||||
Assert.DoesNotContain(world.Log.Entries, entry => entry.Contains("synthetic-other-callback", StringComparison.Ordinal) || entry.Contains("/oauth/Callback", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The sign-in provider presents a certificate this Windows account does not trust, so the session cannot read the
|
||||
/// provider's own description and the sign-in cannot begin. That is reported as the provider's certificate being
|
||||
/// refused, with the certificate and the origin that presented it, and not as a plain failure; the browser is not
|
||||
/// opened and the session stays. A later attempt that fails for another reason is not blamed on that certificate.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Browser_ProvidersCertificateRefused_IsReportedWithTheCertificate()
|
||||
{
|
||||
QqqCertificateRejection refusal = SignInWorld.Refusal(SignInWorld.Provider);
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn signIn = await world.BrowserReadyAsync();
|
||||
world.Sessions[0].BeginFailure = new HttpRequestException("The SSL connection could not be established, see inner exception.");
|
||||
world.Sessions[0].RefusedCertificate = refusal;
|
||||
|
||||
QqqSessionCredential? credential = await SignInWorld.EndsAsync(signIn.SignInWithBrowserAsync());
|
||||
|
||||
Assert.Null(credential);
|
||||
Assert.Equal(CompanionSignInState.ProviderCertificateRefused, signIn.State);
|
||||
Assert.Same(refusal, signIn.Rejection);
|
||||
Assert.Equal(new Uri(SignInWorld.Provider), signIn.Rejection?.Origin);
|
||||
Assert.Null(signIn.Failure);
|
||||
Assert.True(signIn.HasSession);
|
||||
Assert.Empty(world.Opened);
|
||||
Assert.Empty(world.Inboxes);
|
||||
Assert.Equal("WARN This Windows account refused the certificate presented by the sign-in provider (id.example.com; RemoteCertificateChainErrors).", world.Log.Entries[^1]);
|
||||
|
||||
world.Sessions[0].RefusedCertificate = null;
|
||||
world.Sessions[0].BeginFailure = new IOException("synthetic network failure");
|
||||
Assert.Null(await SignInWorld.EndsAsync(signIn.SignInWithBrowserAsync()));
|
||||
Assert.Equal(CompanionSignInState.BrowserFailed, signIn.State);
|
||||
Assert.Null(signIn.Rejection);
|
||||
Assert.Equal("synthetic network failure", signIn.Failure);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A failure at any step of the exchange ends it as "not completed" with the reason, keeps the session, closes
|
||||
/// any inbox that was opened and releases the sign-in: the session cannot begin, it prepares an address without a
|
||||
/// state, the inbox cannot be opened, the browser cannot be opened, the inbox breaks while waiting, the session
|
||||
/// refuses the callback, no credential can be taken, or the server's own time limit ends the completion. When
|
||||
/// the browser cannot be opened the failure Windows gives names the whole sign-in address; the reason shown and
|
||||
/// logged names only the kind of failure. No state reaches the log or the reason in any of them. What the log
|
||||
/// adds about the hand-off from the browser is true for where the attempt stopped: before a page was opened it
|
||||
/// says that, with what the attempt had not got past, and reads no handler trace; after, it says that the
|
||||
/// browser never launched the protocol handler.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("begin", "the session had not prepared a usable sign-in address", "The server's sign-in configuration is not usable.", "QqqSessionException: The server's sign-in configuration is not usable.")]
|
||||
[InlineData("no state", "the session had not prepared a usable sign-in address", "The sign-in address the session prepared carries no state of its own, so its callback could not be told from another.", "InvalidOperationException: The sign-in address the session prepared carries no state of its own, so its callback could not be told from another.")]
|
||||
[InlineData("no inbox", "the attempt was not yet waiting for its callback", "synthetic open failure", "IOException: synthetic open failure")]
|
||||
[InlineData("browser", "the default browser was not started", BrowserStartFailure.WindowsReport, "IOException: " + BrowserStartFailure.WindowsReport)]
|
||||
[InlineData("launcher", "the default browser was not started", BrowserStartFailure.OtherReport, "IOException: " + BrowserStartFailure.OtherReport)]
|
||||
[InlineData("inbox", null, "Invalid QQQ browser callback.", "InvalidDataException: Invalid QQQ browser callback.")]
|
||||
[InlineData("complete", null, "The server's answer was not usable.", "QqqSessionException: The server's answer was not usable.")]
|
||||
[InlineData("export", null, "Sign in before exporting the session.", "QqqSessionException: Sign in before exporting the session.")]
|
||||
[InlineData("time limit", null, "The server did not answer in time.", "TaskCanceledException: The server did not answer in time.")]
|
||||
public async Task Browser_FailureDuringTheExchange_IsReportedWithItsReason(string step, string? unopened, string reason, string logged)
|
||||
{
|
||||
const string address = "https://id.example.com/authorize?state=synthetic-state-a-1";
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn signIn = await world.BrowserReadyAsync();
|
||||
FakeSession session = world.Sessions[0];
|
||||
session.BeginFailure = step is "begin" ? new QqqSessionException(QqqSessionException.FailureKind.ConfigurationRequired, "The server's sign-in configuration is not usable.") : null;
|
||||
session.AuthorizationOverride = step is "no state" ? new Uri("https://id.example.com/authorize?client_id=synthetic-client") : null;
|
||||
world.InboxFailure = step is "no inbox" ? new IOException("synthetic open failure") : null;
|
||||
world.BrowserFailure = step switch
|
||||
{
|
||||
"browser" => new Win32Exception(2, "An error occurred trying to start process '" + address + "' with working directory 'C:\\synthetic'. The system cannot find the file specified."),
|
||||
"launcher" => new InvalidOperationException("synthetic launcher failure for " + address),
|
||||
_ => null,
|
||||
};
|
||||
session.CompleteFailure = step switch
|
||||
{
|
||||
"complete" => new QqqSessionException(QqqSessionException.FailureKind.InvalidResponse, "The server's answer was not usable."),
|
||||
"time limit" => new TaskCanceledException("The server did not answer in time."),
|
||||
_ => null,
|
||||
};
|
||||
session.ExportFailure = step is "export" ? new QqqSessionException(QqqSessionException.FailureKind.AuthenticationRequired, "Sign in before exporting the session.") : null;
|
||||
|
||||
Task<QqqSessionCredential?> running = signIn.SignInWithBrowserAsync();
|
||||
if(step is "inbox")
|
||||
{
|
||||
world.Inboxes[0].Fail(new InvalidDataException("Invalid QQQ browser callback."));
|
||||
}
|
||||
else if(step is "complete" or "export" or "time limit")
|
||||
{
|
||||
world.Inboxes[0].Deliver(session.CallbackForLatestAttempt);
|
||||
}
|
||||
|
||||
Assert.Null(await SignInWorld.EndsAsync(running));
|
||||
Assert.Equal(CompanionSignInState.BrowserFailed, signIn.State);
|
||||
Assert.Equal(reason, signIn.Failure);
|
||||
Assert.Null(signIn.Rejection);
|
||||
Assert.True(signIn.HasSession);
|
||||
Assert.False(session.Disposed);
|
||||
Assert.False(signIn.IsBusy);
|
||||
Assert.Equal(unopened is null ? 1 : 0, world.Opened.Count);
|
||||
Assert.All(world.Inboxes, inbox => Assert.True(inbox.Disposed));
|
||||
Assert.Single(world.Log.Entries, entry => string.Equals(entry, unopened is null ? NeverLaunched : NotOpened(unopened), StringComparison.Ordinal));
|
||||
Assert.Equal(unopened is null ? 1 : 0, world.TraceWindows.Count);
|
||||
Assert.DoesNotContain(world.Log.Entries, entry => entry.Contains(unopened is null ? "before a browser page was opened" : "never launched", StringComparison.Ordinal));
|
||||
Assert.Contains("ERROR Browser sign-in not completed: " + logged, world.Log.Entries, StringComparer.Ordinal);
|
||||
Assert.DoesNotContain(world.Log.Entries, Secret);
|
||||
Assert.False(Secret(signIn.Failure!));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Two servers are signing in through the browser when the inbox fails in a way no sign-in lists as a failure to
|
||||
/// report: the argument error the real inbox gives when the process that sent a callback has already exited, an
|
||||
/// overflow, a time-out. Nothing escapes either sign-in. Each ends as "not completed" with a reason that names
|
||||
/// the kind of failure in fixed text, keeps its session and is free again, and nothing of the failure's own text
|
||||
/// reaches the log or a property a view binds to. One failure ends both sign-ins in the same moment, so the test
|
||||
/// runs them on a single-threaded context, as a window does: each continues on that one thread, one after the
|
||||
/// other, and the two never write to the log at the same time.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(nameof(ArgumentException))]
|
||||
[InlineData(nameof(OverflowException))]
|
||||
[InlineData(nameof(TimeoutException))]
|
||||
public void Browser_InboxFailsInAWayNoSignInLists_EveryWaitingSignInReportsItAndNothingEscapes(string kind)
|
||||
{
|
||||
using PumpContext pump = new();
|
||||
|
||||
pump.Run(() => TwoSignInsLoseTheirInboxAsync(kind));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Two browser sign-ins wait on the one inbox, the inbox fails with a failure of the named kind, and both must end as reported failures.</summary>
|
||||
private static async Task TwoSignInsLoseTheirInboxAsync(string kind)
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn first = await world.BrowserReadyAsync();
|
||||
using CompanionSignIn second = await world.BrowserReadyAsync(SignInWorld.OtherServer);
|
||||
Task<QqqSessionCredential?> firstRunning = first.SignInWithBrowserAsync();
|
||||
Task<QqqSessionCredential?> secondRunning = second.SignInWithBrowserAsync();
|
||||
|
||||
Assert.Single(world.Inboxes).Fail(SignInWorld.UnlistedInboxFailure(kind));
|
||||
|
||||
Assert.Null(await SignInWorld.EndsAsync(firstRunning));
|
||||
Assert.Null(await SignInWorld.EndsAsync(secondRunning));
|
||||
Assert.All([first, second], signIn =>
|
||||
{
|
||||
Assert.Equal(CompanionSignInState.BrowserFailed, signIn.State);
|
||||
Assert.Equal(SignInWorld.InboxFailedReport(kind), signIn.Failure);
|
||||
Assert.True(signIn.HasSession);
|
||||
Assert.False(signIn.IsBusy);
|
||||
Assert.DoesNotContain(BrowserStartFailure.Bound(signIn), text => text is not null && text.Contains(SignInWorld.UnlistedFailureText, StringComparison.Ordinal));
|
||||
});
|
||||
Assert.Equal(2, world.Log.Entries.Count(entry => string.Equals(entry, "ERROR Browser sign-in not completed: IOException: " + SignInWorld.InboxFailedReport(kind), StringComparison.Ordinal)));
|
||||
Assert.Contains($"ERROR The browser callback inbox failed ({kind}); 2 waiting browser exchange(s) were ended and have to be started again.", world.Log.Entries, StringComparer.Ordinal);
|
||||
Assert.DoesNotContain(world.Log.Entries, entry => entry.Contains(SignInWorld.UnlistedFailureText, StringComparison.Ordinal) || Secret(entry));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// When Windows cannot start the browser, the failure it gives names what it could not start: the whole sign-in
|
||||
/// address, with the attempt's state, nonce and code challenge. None of that is logged or kept. The reason the
|
||||
/// wizard is given says that the default browser could not be opened and what to check, the log says the same
|
||||
/// with the kind of failure, and no log line and no property a view binds to holds one of those values or any
|
||||
/// query at all. The same holds for a launcher that fails in another way and names the address in its text.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(true, BrowserStartFailure.WindowsReport)]
|
||||
[InlineData(false, BrowserStartFailure.OtherReport)]
|
||||
public async Task Browser_ThatCannotBeStarted_IsReportedWithoutAnythingOfTheSignInAddress(bool asWindows, string report)
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn signIn = await world.BrowserReadyAsync();
|
||||
world.Sessions[0].AuthorizationOverride = BrowserStartFailure.SignInAddress;
|
||||
world.BrowserFailure = asWindows ? BrowserStartFailure.AsWindows(BrowserStartFailure.SignInAddress) : BrowserStartFailure.AsAnotherKind(BrowserStartFailure.SignInAddress);
|
||||
Assert.True(BrowserStartFailure.Leaks(world.BrowserFailure.Message));
|
||||
|
||||
Assert.Null(await SignInWorld.EndsAsync(signIn.SignInWithBrowserAsync()));
|
||||
|
||||
Assert.Equal(CompanionSignInState.BrowserFailed, signIn.State);
|
||||
Assert.Equal(report, signIn.Failure);
|
||||
Assert.Contains("ERROR Browser sign-in not completed: IOException: " + report, world.Log.Entries, StringComparer.Ordinal);
|
||||
Assert.Contains(world.Log.Entries, entry => entry.StartsWith("DEBUG Browser sign-in not completed detail: System.IO.IOException: " + report, StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(world.Log.Entries, BrowserStartFailure.Leaks);
|
||||
Assert.DoesNotContain(BrowserStartFailure.Bound(signIn), BrowserStartFailure.Leaks);
|
||||
Assert.Contains(NotOpened("the default browser was not started"), world.Log.Entries, StringComparer.Ordinal);
|
||||
Assert.DoesNotContain(NeverLaunched, world.Log.Entries, StringComparer.Ordinal);
|
||||
Assert.Empty(world.Opened);
|
||||
Assert.True(Assert.Single(world.Inboxes).Disposed);
|
||||
Assert.True(signIn.HasSession);
|
||||
Assert.False(signIn.IsBusy);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The operator cancels, or the three minutes run out, while the session is still beginning the sign-in. No
|
||||
/// browser page was opened, so the log does not say that the browser never launched the protocol handler or that
|
||||
/// the provider did not redirect: it says the attempt ended before a page was opened and what it had not got
|
||||
/// past. The session stays.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(false, CompanionSignInState.BrowserCanceled, "INFO Browser sign-in to print.example.com was cancelled by the operator.")]
|
||||
[InlineData(true, CompanionSignInState.BrowserTimedOut, "INFO Browser sign-in to print.example.com was given up after the three minute limit.")]
|
||||
public async Task Browser_WaitEndsWhileTheSessionBegins_SaysNoPageWasOpened(bool byLimit, CompanionSignInState ending, string entry)
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn signIn = await world.BrowserReadyAsync();
|
||||
world.Sessions[0].PendingBegin = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
Task<QqqSessionCredential?> running = signIn.SignInWithBrowserAsync();
|
||||
if(byLimit)
|
||||
{
|
||||
world.Time.Advance(CompanionSignIn.BrowserSignInLimit);
|
||||
}
|
||||
else
|
||||
{
|
||||
signIn.CancelBrowserSignIn();
|
||||
}
|
||||
|
||||
Assert.Null(await SignInWorld.EndsAsync(running));
|
||||
Assert.Equal(ending, signIn.State);
|
||||
Assert.Equal([Requested, entry, NotOpened("the session had not prepared a usable sign-in address")], world.Log.Entries);
|
||||
Assert.Empty(world.Opened);
|
||||
Assert.Empty(world.Inboxes);
|
||||
Assert.Empty(world.TraceWindows);
|
||||
Assert.True(signIn.HasSession);
|
||||
Assert.False(world.Sessions[0].Disposed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// When a browser sign-in is not completed, what the protocol handler recorded is copied into the log, so a
|
||||
/// hand-off that stopped outside the companion can be seen in one place; when it recorded nothing in the last ten
|
||||
/// minutes, the log says the browser never launched the handler and what that usually means.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task Browser_NotCompleted_CopiesTheHandlersTrace_OrSaysItNeverRan(bool traced)
|
||||
{
|
||||
string[] trace = ["2026-11-10T10:00:00Z Protocol handler started by the browser (process 4242, session 1).", "2026-11-10T10:00:05Z Callback NOT delivered: TimeoutException: synthetic."];
|
||||
SignInWorld world = new();
|
||||
world.Trace.AddRange(traced ? trace : []);
|
||||
using CompanionSignIn signIn = await world.BrowserReadyAsync();
|
||||
|
||||
Task<QqqSessionCredential?> running = signIn.SignInWithBrowserAsync();
|
||||
world.Time.Advance(TimeSpan.FromMinutes(3));
|
||||
|
||||
Assert.Null(await SignInWorld.EndsAsync(running));
|
||||
Assert.Equal(traced ? "WARN Protocol handler trace from trace/auth-callback.log:" + Environment.NewLine + trace[0] + Environment.NewLine + trace[1] : NeverLaunched, world.Log.Entries[^1]);
|
||||
Assert.Equal(TimeSpan.FromMinutes(10), Assert.Single(world.TraceWindows));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A browser sign-in whose caller stops waiting ends quietly: nothing is reported as cancelled or failed, the inbox
|
||||
/// is closed, and the session stays. When the wait ends at the very moment the session has begun the sign-in, the
|
||||
/// browser is not opened for it and no inbox is.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(true, 1, "TaskCanceledException")]
|
||||
[InlineData(false, 0, "OperationCanceledException")]
|
||||
public async Task Browser_CallerStopsWaiting_EndsQuietlyAndKeepsTheSession(bool whileWaiting, int opened, string cancellation)
|
||||
{
|
||||
using CancellationTokenSource caller = new();
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn signIn = await world.BrowserReadyAsync();
|
||||
world.Sessions[0].BeforeAnswer = whileWaiting ? null : caller.Cancel;
|
||||
|
||||
Task<QqqSessionCredential?> running = signIn.SignInWithBrowserAsync(caller.Token);
|
||||
if(whileWaiting)
|
||||
{
|
||||
await caller.CancelAsync();
|
||||
}
|
||||
|
||||
Assert.Null(await SignInWorld.EndsAsync(running));
|
||||
Assert.Equal(CompanionSignInState.Ready, signIn.State);
|
||||
Assert.True(signIn.HasSession);
|
||||
Assert.False(world.Sessions[0].Disposed);
|
||||
Assert.False(signIn.IsBusy);
|
||||
Assert.Equal(opened, world.Opened.Count);
|
||||
Assert.Equal(opened, world.Inboxes.Count);
|
||||
Assert.All(world.Inboxes, inbox => Assert.True(inbox.Disposed));
|
||||
Assert.Equal($"DEBUG Browser sign-in ended early because its caller stopped waiting ({cancellation}).", world.Log.Entries[^1]);
|
||||
Assert.DoesNotContain(world.Log.Entries, entry => entry.StartsWith("WARN", StringComparison.Ordinal) || entry.StartsWith("ERROR", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The wait ends at the very moment the session completes the sign-in: the operator cancels, the three minutes
|
||||
/// run out, or the caller stops waiting. No credential is taken, and the attempt ends as what ended it. The
|
||||
/// session, though, was handed the callback and is signed in on the server by now, so it is not kept behind an
|
||||
/// attempt that was reported as cancelled: it is discarded, what it offered is withdrawn before the ending is
|
||||
/// announced, the log says why, and a sign-in then needs a new read, which works.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("cancel", CompanionSignInState.BrowserCanceled)]
|
||||
[InlineData("limit", CompanionSignInState.BrowserTimedOut)]
|
||||
[InlineData("caller", CompanionSignInState.NotPrepared)]
|
||||
public async Task Browser_WaitEndsAfterTheCallbackWasHandedOver_TakesNoCredentialAndDiscardsTheSession(string by, CompanionSignInState ending)
|
||||
{
|
||||
using CancellationTokenSource caller = new();
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn signIn = await world.BrowserReadyAsync();
|
||||
FakeSession session = world.Sessions[0];
|
||||
session.BeforeAnswer = () =>
|
||||
{
|
||||
if(session.Callback is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
switch(by)
|
||||
{
|
||||
case "cancel":
|
||||
signIn.CancelBrowserSignIn();
|
||||
break;
|
||||
case "limit":
|
||||
world.Time.Advance(CompanionSignIn.BrowserSignInLimit);
|
||||
break;
|
||||
default:
|
||||
caller.Cancel();
|
||||
break;
|
||||
}
|
||||
};
|
||||
List<string> changes = SignInWorld.Watch(signIn);
|
||||
|
||||
Task<QqqSessionCredential?> running = signIn.SignInWithBrowserAsync(caller.Token);
|
||||
world.Inboxes[0].Deliver(session.CallbackForLatestAttempt);
|
||||
|
||||
Assert.Null(await SignInWorld.EndsAsync(running));
|
||||
Assert.Equal(ending, signIn.State);
|
||||
Assert.NotNull(session.Callback);
|
||||
Assert.NotNull(session.ExportCredential());
|
||||
Assert.True(session.Disposed);
|
||||
Assert.False(signIn.HasSession);
|
||||
Assert.Null(signIn.Offer);
|
||||
Assert.False(signIn.IsBusy);
|
||||
Assert.Equal(["IsBusy=True", "State=WaitingForBrowser", "Offer=null", "HasSession=False", "State=" + ending, "IsBusy=False"], changes);
|
||||
Assert.Single(world.Log.Entries, entry => string.Equals(entry, Discarded, StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(world.Log.Entries, entry => entry.StartsWith("ERROR", StringComparison.Ordinal) || Secret(entry));
|
||||
Assert.Null(await signIn.SignInWithBrowserAsync());
|
||||
|
||||
Assert.NotNull(await signIn.PrepareAsync(new Uri(SignInWorld.Server), SignInWorld.BrowserCheck, QqqCertificateExceptionOrigins.None));
|
||||
Assert.True(signIn.HasSession);
|
||||
Assert.False(world.Sessions[1].Disposed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The callback is already there, and the wait has already ended, when the sign-in starts waiting for the
|
||||
/// callback: the browser came back at once and, in the same moment, the operator pressed Cancel or the three
|
||||
/// minutes ran out. Waiting for something that has already arrived does not look at the token, so the sign-in
|
||||
/// looks itself. The session is never asked to complete and nothing says a callback was received; the attempt
|
||||
/// ends as cancelled or timed out, and the session, which was handed nothing, stays for another attempt. The
|
||||
/// inbox answers inline here, so the callback has reached its ticket before the wait is ended, which the test
|
||||
/// checks: it was not dropped, and its inbox was closed, inside the call that delivered it.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(false, CompanionSignInState.BrowserCanceled)]
|
||||
[InlineData(true, CompanionSignInState.BrowserTimedOut)]
|
||||
public async Task Browser_WaitEndedAsTheCallbackArrived_NeverAsksTheSessionToComplete(bool byLimit, CompanionSignInState ending)
|
||||
{
|
||||
SignInWorld world = new() { InboxesAnswerInline = true };
|
||||
using CompanionSignIn signIn = await world.BrowserReadyAsync();
|
||||
FakeSession session = world.Sessions[0];
|
||||
bool routed = false;
|
||||
world.Launched = _ =>
|
||||
{
|
||||
using NoContext none = new();
|
||||
world.Inboxes[0].Deliver(session.CallbackForLatestAttempt);
|
||||
routed = world.Inboxes[0].Disposed;
|
||||
if(byLimit)
|
||||
{
|
||||
world.Time.Advance(CompanionSignIn.BrowserSignInLimit);
|
||||
}
|
||||
else
|
||||
{
|
||||
signIn.CancelBrowserSignIn();
|
||||
}
|
||||
};
|
||||
|
||||
QqqSessionCredential? credential = await SignInWorld.EndsAsync(signIn.SignInWithBrowserAsync());
|
||||
|
||||
Assert.True(routed);
|
||||
Assert.DoesNotContain(world.Log.Entries, entry => entry.Contains("was dropped", StringComparison.Ordinal));
|
||||
Assert.Null(credential);
|
||||
Assert.Null(session.Callback);
|
||||
Assert.Null(session.CompleteToken);
|
||||
Assert.DoesNotContain("INFO Browser callback received; completing the sign-in.", world.Log.Entries, StringComparer.Ordinal);
|
||||
Assert.Equal(ending, signIn.State);
|
||||
Assert.True(signIn.HasSession);
|
||||
Assert.Equal(new SignInOffer(false, true), signIn.Offer);
|
||||
Assert.False(session.Disposed);
|
||||
Assert.False(signIn.IsBusy);
|
||||
Assert.Single(world.Opened);
|
||||
Assert.DoesNotContain(Discarded, world.Log.Entries, StringComparer.Ordinal);
|
||||
Assert.DoesNotContain(world.Log.Entries, Secret);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// An attempt that failed keeps its session even when the callback had been handed over: here the server refuses
|
||||
/// the completion. That the callback was handed over is forgotten when the attempt ends, so a later operation
|
||||
/// that is stopped is not taken for a browser sign-in whose session has to go: a read whose caller had already
|
||||
/// stopped waiting leaves the session where it was and logs nothing about discarding it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Browser_FailedAfterTheCallbackWasHandedOver_KeepsTheSession_AlsoThroughALaterStoppedRead()
|
||||
{
|
||||
using CancellationTokenSource caller = new();
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn signIn = await world.BrowserReadyAsync();
|
||||
FakeSession session = world.Sessions[0];
|
||||
session.CompleteFailure = new QqqSessionException(QqqSessionException.FailureKind.InvalidResponse, "The server's answer was not usable.");
|
||||
Task<QqqSessionCredential?> running = signIn.SignInWithBrowserAsync();
|
||||
world.Inboxes[0].Deliver(session.CallbackForLatestAttempt);
|
||||
Assert.Null(await SignInWorld.EndsAsync(running));
|
||||
Assert.Equal(CompanionSignInState.BrowserFailed, signIn.State);
|
||||
Assert.NotNull(session.Callback);
|
||||
Assert.True(signIn.HasSession);
|
||||
|
||||
await caller.CancelAsync();
|
||||
Assert.Null(await signIn.PrepareAsync(new Uri(SignInWorld.Server), SignInWorld.BrowserCheck, QqqCertificateExceptionOrigins.None, caller.Token));
|
||||
|
||||
Assert.Equal(CompanionSignInState.Ready, signIn.State);
|
||||
Assert.True(signIn.HasSession);
|
||||
Assert.False(session.Disposed);
|
||||
Assert.Single(world.Sessions);
|
||||
Assert.DoesNotContain(Discarded, world.Log.Entries, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The session is asked to complete with the exchange's own token, not with one that never ends: while the session
|
||||
/// is completing, the token it was given is still live, and Cancel ends it, so the completion stops with the
|
||||
/// attempt instead of running on behind an attempt that was reported as cancelled. The session had been handed
|
||||
/// the callback by then, so it is discarded with the attempt.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Browser_CancelledWhileTheSessionCompletes_EndsTheCompletionToo()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn signIn = await world.BrowserReadyAsync();
|
||||
FakeSession session = world.Sessions[0];
|
||||
session.PendingComplete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
Task<QqqSessionCredential?> running = signIn.SignInWithBrowserAsync();
|
||||
world.Inboxes[0].Deliver(session.CallbackForLatestAttempt);
|
||||
await session.CompleteEntered.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
Assert.True(session.CompleteToken is { CanBeCanceled: true, IsCancellationRequested: false });
|
||||
|
||||
signIn.CancelBrowserSignIn();
|
||||
|
||||
Assert.True(session.CompleteToken is { IsCancellationRequested: true });
|
||||
Assert.Null(await SignInWorld.EndsAsync(running));
|
||||
Assert.Equal(CompanionSignInState.BrowserCanceled, signIn.State);
|
||||
Assert.False(signIn.IsBusy);
|
||||
Assert.False(signIn.HasSession);
|
||||
Assert.True(session.Disposed);
|
||||
Assert.Contains(Discarded, world.Log.Entries, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Nothing is attempted without a session, or on a session that does not offer the browser: no credential, no inbox, no browser, nothing announced or logged.</summary>
|
||||
[Fact]
|
||||
public async Task Browser_WithoutASessionThatOffersIt_NothingIsAttempted()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn unprepared = world.NewSignIn();
|
||||
using CompanionSignIn passwordOnly = await world.PasswordReadyAsync();
|
||||
List<string> changes = SignInWorld.Watch(passwordOnly);
|
||||
|
||||
Assert.Null(await unprepared.SignInWithBrowserAsync());
|
||||
Assert.Null(await SignInWorld.EndsAsync(passwordOnly.SignInWithBrowserAsync()));
|
||||
|
||||
Assert.Equal(CompanionSignInState.NotPrepared, unprepared.State);
|
||||
Assert.Equal(CompanionSignInState.Ready, passwordOnly.State);
|
||||
Assert.Empty(world.Inboxes);
|
||||
Assert.Empty(world.Opened);
|
||||
Assert.Null(world.Sessions[0].Redirect);
|
||||
Assert.Empty(changes);
|
||||
Assert.Empty(world.Log.Entries);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Discarding, or disposing, while a browser sign-in waits ends the wait quietly: the inbox is closed, the session
|
||||
/// is discarded once the exchange has ended, and nothing is reported as cancelled or failed. After a discard the
|
||||
/// sign-in says so and can be used again; after disposal it announces nothing.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(false, "DEBUG Browser sign-in ended early because the session was discarded (TaskCanceledException).")]
|
||||
[InlineData(true, "DEBUG Browser sign-in ended early because the sign-in was closed (TaskCanceledException).")]
|
||||
public async Task Browser_DiscardedOrDisposedWhileWaiting_EndsQuietly(bool dispose, string entry)
|
||||
{
|
||||
SignInWorld world = new();
|
||||
CompanionSignIn signIn = await world.BrowserReadyAsync();
|
||||
Task<QqqSessionCredential?> running = signIn.SignInWithBrowserAsync();
|
||||
List<string> changes = SignInWorld.Watch(signIn);
|
||||
|
||||
if(dispose)
|
||||
{
|
||||
signIn.Dispose();
|
||||
}
|
||||
else
|
||||
{
|
||||
signIn.Discard();
|
||||
}
|
||||
|
||||
Assert.Null(await SignInWorld.EndsAsync(running));
|
||||
Assert.True(world.Inboxes[0].Disposed);
|
||||
Assert.True(world.Sessions[0].Disposed);
|
||||
Assert.False(signIn.HasSession);
|
||||
Assert.False(signIn.IsBusy);
|
||||
Assert.Equal(entry, world.Log.Entries[^1]);
|
||||
Assert.DoesNotContain(world.Log.Entries, line => line.StartsWith("WARN", StringComparison.Ordinal) || line.StartsWith("ERROR", StringComparison.Ordinal));
|
||||
string[] announced = dispose ? [] : ["Offer=null", "HasSession=False", "State=NotPrepared", "IsBusy=False"];
|
||||
Assert.Equal(announced, changes, StringComparer.Ordinal);
|
||||
signIn.Dispose();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Runs every operation on a single-threaded context, like a window's, while each thing the sign-in waits for is
|
||||
/// completed from a thread-pool thread: the description, the password check, the start of the browser sign-in, the
|
||||
/// callback (which the dispatcher receives on a thread of its own) and its completion. Every announcement must
|
||||
/// arrive on the calling thread with that context current. A view updates its controls in these handlers, so one
|
||||
/// "do not return to the caller's thread" inside the sign-in fails this test instead of crashing the companion.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Announcements_AreRaisedOnTheCallingThread()
|
||||
{
|
||||
using PumpContext pump = new();
|
||||
int caller = Environment.CurrentManagedThreadId;
|
||||
List<string> offThread = [];
|
||||
List<string> announced = [];
|
||||
SignInWorld world = new() { ConfigureSession = Held(new QqqAuthenticationMetadata { Type = "TABLE_BASED" }) };
|
||||
using CompanionSignIn signIn = world.NewSignIn();
|
||||
signIn.PropertyChanged += (_, change) =>
|
||||
{
|
||||
announced.Add(change.PropertyName!);
|
||||
if(Environment.CurrentManagedThreadId != caller || !ReferenceEquals(SynchronizationContext.Current, pump))
|
||||
{
|
||||
offThread.Add(change.PropertyName!);
|
||||
}
|
||||
};
|
||||
|
||||
pump.Run(() => RunEveryOperationAsync(world, signIn));
|
||||
|
||||
Assert.Empty(offThread);
|
||||
Assert.Contains(nameof(CompanionSignIn.State), announced, StringComparer.Ordinal);
|
||||
Assert.Contains(nameof(CompanionSignIn.IsBusy), announced, StringComparer.Ordinal);
|
||||
Assert.Contains(nameof(CompanionSignIn.Offer), announced, StringComparer.Ordinal);
|
||||
Assert.Contains(nameof(CompanionSignIn.HasSession), announced, StringComparer.Ordinal);
|
||||
Assert.Contains(nameof(CompanionSignIn.Failure), announced, StringComparer.Ordinal);
|
||||
Assert.Equal(CompanionSignInState.SignedIn, signIn.State);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A session script in which every step waits for the test, so each can be completed from another thread.</summary>
|
||||
private static Action<FakeSession> Held(QqqAuthenticationMetadata description) => session =>
|
||||
{
|
||||
session.Metadata = description;
|
||||
session.PendingDiscovery = new TaskCompletionSource<QqqAuthenticationMetadata>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
session.PendingPassword = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
session.PendingBegin = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
session.PendingComplete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
};
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads a password server's description, fails a password sign-in, discards, reads a browser server's description and signs in through the browser, completing every wait from another thread.</summary>
|
||||
private static async Task RunEveryOperationAsync(SignInWorld world, CompanionSignIn signIn)
|
||||
{
|
||||
Task<SignInOffer?> prepare = signIn.PrepareAsync(new Uri(SignInWorld.Server), SignInWorld.PasswordCheck, QqqCertificateExceptionOrigins.None);
|
||||
await Task.Run(() => world.Sessions[0].PendingDiscovery!.SetResult(world.Sessions[0].Metadata));
|
||||
Assert.NotNull(await prepare);
|
||||
Task<QqqSessionCredential?> password = signIn.SignInWithPasswordAsync("synthetic-operator", "synthetic-password");
|
||||
await Task.Run(() => world.Sessions[0].PendingPassword!.SetException(new IOException("synthetic network failure")));
|
||||
Assert.Null(await password);
|
||||
signIn.Discard();
|
||||
|
||||
world.ConfigureSession = Held(SignInWorld.BrowserDescription());
|
||||
prepare = signIn.PrepareAsync(new Uri(SignInWorld.Server), SignInWorld.BrowserCheck, QqqCertificateExceptionOrigins.None);
|
||||
await Task.Run(() => world.Sessions[1].PendingDiscovery!.SetResult(world.Sessions[1].Metadata));
|
||||
Assert.NotNull(await prepare);
|
||||
Task<FakeInbox> opened = world.NextInboxAsync();
|
||||
Task<QqqSessionCredential?> browser = signIn.SignInWithBrowserAsync();
|
||||
await Task.Run(() => world.Sessions[1].PendingBegin!.SetResult());
|
||||
FakeInbox inbox = await opened;
|
||||
await Task.Run(() => inbox.Deliver(world.Sessions[1].CallbackForLatestAttempt));
|
||||
await world.Sessions[1].CompleteEntered;
|
||||
await Task.Run(() => world.Sessions[1].PendingComplete!.SetResult());
|
||||
Assert.NotNull(await browser);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,856 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Reflection;
|
||||
using System.Security.Authentication;
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
using Qqq.PrintAgent.Presentation.SignIn;
|
||||
using Qqq.PrintAgent.Presentation.Tests.Fakes;
|
||||
using Qqq.PrintAgent.Windows.Management;
|
||||
|
||||
namespace Qqq.PrintAgent.Presentation.Tests.SignIn;
|
||||
|
||||
/// <summary>The companion's own sign-in: reading a server's sign-in description as the signed-in user, signing in by password, and what it holds, announces and logs. Browser sign-in has its own file.</summary>
|
||||
public sealed class CompanionSignInTests
|
||||
{
|
||||
/// <summary>The first entry of every read of the first server's description under no certificate exception.</summary>
|
||||
private const string Reading = "INFO Reading the sign-in description of print.example.com as this Windows account; an untrusted certificate is accepted from none.";
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The methods offered are those the service's check found and this account's read of the description offers too.
|
||||
/// When the two agree that is logged as information, and when they differ as a warning naming both. A method name
|
||||
/// the companion does not know is ignored. The session is created for the server that was asked for, under exactly
|
||||
/// the certificate exception the caller gave, and kept; the view is told in the order things became true.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("password", true, true, false, "INFO Sign-in methods for print.example.com: password.")]
|
||||
[InlineData("password,passkey", true, true, false, "INFO Sign-in methods for print.example.com: password.")]
|
||||
[InlineData("browser", false, false, true, "INFO Sign-in methods for print.example.com: browser.")]
|
||||
[InlineData("password,browser", false, false, true, "WARN Sign-in methods for print.example.com: the service's check found password and browser and this account's read found browser, so browser is offered.")]
|
||||
[InlineData("password,browser", true, true, false, "WARN Sign-in methods for print.example.com: the service's check found password and browser and this account's read found password, so password is offered.")]
|
||||
public async Task Prepare_OffersOnlyWhatTheCheckAndThisReadBothFound(string found, bool tableBased, bool password, bool browser, string entry)
|
||||
{
|
||||
SignInWorld world = new() { ConfigureSession = session => session.Metadata = tableBased ? new QqqAuthenticationMetadata { Type = "TABLE_BASED" } : SignInWorld.BrowserDescription() };
|
||||
ServerCheckResult check = new([], found.Split(','), tableBased ? null : SignInWorld.Provider);
|
||||
var exception = QqqCertificateExceptionOrigins.ForServer(new Uri(SignInWorld.Server), new Uri(SignInWorld.Provider));
|
||||
using CompanionSignIn signIn = world.NewSignIn();
|
||||
List<string> changes = SignInWorld.Watch(signIn);
|
||||
|
||||
SignInOffer? offer = await signIn.PrepareAsync(new Uri(SignInWorld.Server), check, exception);
|
||||
|
||||
Assert.Equal(new SignInOffer(password, browser), offer);
|
||||
Assert.Same(offer, signIn.Offer);
|
||||
Assert.Equal(CompanionSignInState.Ready, signIn.State);
|
||||
Assert.True(signIn.HasSession);
|
||||
Assert.False(signIn.IsBusy);
|
||||
Assert.Null(signIn.Failure);
|
||||
Assert.Null(signIn.Rejection);
|
||||
FakeSession session = Assert.Single(world.Sessions);
|
||||
Assert.False(session.Disposed);
|
||||
Assert.Equal(new Uri(SignInWorld.Server), session.Origin);
|
||||
Assert.Same(exception, session.CertificateException);
|
||||
Assert.Equal(["INFO Reading the sign-in description of print.example.com as this Windows account; an untrusted certificate is accepted from https://print.example.com, https://id.example.com.", entry], world.Log.Entries);
|
||||
Assert.Equal(["IsBusy=True", "State=Preparing", "HasSession=False", "HasSession=True", "Offer=" + offer, "State=Ready", "IsBusy=False"], changes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>When no method is one that both the check and this read found, nothing is offered: the state says so, the difference is logged, and the session that was created is discarded, because nobody could sign in on it.</summary>
|
||||
[Fact]
|
||||
public async Task Prepare_NoMethodBothFound_OffersNothingAndKeepsNoSession()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn signIn = world.NewSignIn();
|
||||
|
||||
SignInOffer? offer = await signIn.PrepareAsync(new Uri(SignInWorld.Server), new ServerCheckResult([], [ServerCheckResult.Browser]), QqqCertificateExceptionOrigins.None);
|
||||
|
||||
Assert.Null(offer);
|
||||
Assert.Null(signIn.Offer);
|
||||
Assert.Equal(CompanionSignInState.NothingOffered, signIn.State);
|
||||
Assert.False(signIn.HasSession);
|
||||
Assert.True(Assert.Single(world.Sessions).Disposed);
|
||||
Assert.Equal([Reading, "WARN Sign-in methods for print.example.com: the service's check found browser and this account's read found password, so no method is offered."], world.Log.Entries);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The service's check accepted the server's certificate under its own account, and this Windows account refuses
|
||||
/// it. That is reported as exactly that, with the certificate the session refused and the origin that presented
|
||||
/// it, which is the server's own. It is not reported as an unreadable description, no reason text is given beside
|
||||
/// the certificate, and no session is kept.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Prepare_ThisAccountRefusesTheCertificate_ReportsItAndWhoPresentedIt()
|
||||
{
|
||||
QqqCertificateRejection refusal = SignInWorld.Refusal(SignInWorld.Server);
|
||||
SignInWorld world = new()
|
||||
{
|
||||
ConfigureSession = session =>
|
||||
{
|
||||
session.DiscoverFailure = new HttpRequestException("The SSL connection could not be established, see inner exception.", new AuthenticationException("The remote certificate was rejected by the provided RemoteCertificateValidationCallback."));
|
||||
session.RefusedCertificate = refusal;
|
||||
},
|
||||
};
|
||||
using CompanionSignIn signIn = world.NewSignIn();
|
||||
|
||||
SignInOffer? offer = await signIn.PrepareAsync(new Uri(SignInWorld.Server), SignInWorld.PasswordCheck, QqqCertificateExceptionOrigins.None);
|
||||
|
||||
Assert.Null(offer);
|
||||
Assert.Equal(CompanionSignInState.AccountRefusedCertificate, signIn.State);
|
||||
Assert.Same(refusal, signIn.Rejection);
|
||||
Assert.Equal(new Uri(SignInWorld.Server), signIn.Rejection?.Origin);
|
||||
Assert.Null(signIn.Failure);
|
||||
Assert.False(signIn.HasSession);
|
||||
Assert.Null(signIn.Offer);
|
||||
Assert.True(Assert.Single(world.Sessions).Disposed);
|
||||
Assert.Equal("ERROR The sign-in description could not be read: HttpRequestException: The SSL connection could not be established, see inner exception. <- AuthenticationException: The remote certificate was rejected by the provided RemoteCertificateValidationCallback.", world.Log.Entries[1]);
|
||||
Assert.Equal("WARN This Windows account refused the certificate presented by the server (print.example.com; RemoteCertificateChainErrors). The service checks certificates under its own account, so it can accept one that this account does not.", world.Log.Entries[^1]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The description this account reads names another sign-in provider than the service's check reported: a
|
||||
/// different one, one where the check reported none, none where the check reported one, or a look-alike of it.
|
||||
/// The sign-in stops there, because a certificate exception the operator accepted covers the provider that was
|
||||
/// shown and not another. Both are named in the log by the ASCII name a connection would really go to, and no
|
||||
/// session is kept.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("https://id.example.com", "https://login.example.org/realms/synthetic", "login.example.org", "id.example.com")]
|
||||
[InlineData(null, "https://id.example.com", "id.example.com", "none")]
|
||||
[InlineData("https://id.example.com", "", "none", "id.example.com")]
|
||||
[InlineData("not an address", "https://id.example.com", "id.example.com", "none")]
|
||||
[InlineData("https://login.example.com", "https://l\u043Egin.example.com", "xn--lgin-55d.example.com", "login.example.com")]
|
||||
public async Task Prepare_AnotherProviderThanTheOneChecked_IsRefused(string? reported, string issuer, string read, string shown)
|
||||
{
|
||||
SignInWorld world = new() { ConfigureSession = session => session.Metadata = SignInWorld.BrowserDescription(issuer) };
|
||||
using CompanionSignIn signIn = world.NewSignIn();
|
||||
|
||||
SignInOffer? offer = await signIn.PrepareAsync(new Uri(SignInWorld.Server), new ServerCheckResult([], [ServerCheckResult.Browser], reported), QqqCertificateExceptionOrigins.None);
|
||||
|
||||
Assert.Null(offer);
|
||||
Assert.Equal(CompanionSignInState.ProviderChanged, signIn.State);
|
||||
Assert.False(signIn.HasSession);
|
||||
Assert.Null(signIn.Offer);
|
||||
Assert.Null(signIn.Failure);
|
||||
Assert.True(Assert.Single(world.Sessions).Disposed);
|
||||
Assert.Equal([Reading, $"WARN The sign-in provider in the sign-in description of print.example.com is {read}, and the service's check reported {shown}. Nobody is signed in until the server has been checked again."], world.Log.Entries);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A table-based server names no sign-in provider, by the rule the service's check applies: it takes a user name
|
||||
/// and password and nothing else. Its description can still carry a native session grant with an issuer, which
|
||||
/// is not a provider anybody is sent to. The check reported none for it, and the read agrees: the grant's issuer
|
||||
/// is not taken for a provider that changed, and the password is offered.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Prepare_TableBasedServerWithANativeGrant_NamesNoProvider()
|
||||
{
|
||||
QqqAuthenticationMetadata description = new()
|
||||
{
|
||||
Type = "TABLE_BASED",
|
||||
Values = new QqqAuthenticationMetadata.ProviderValues { NativeSessionLogin = new QqqAuthenticationMetadata.NativeGrant { Issuer = SignInWorld.Provider, RedirectUris = [SignInWorld.Callback] } },
|
||||
};
|
||||
SignInWorld world = new() { ConfigureSession = session => session.Metadata = description };
|
||||
using CompanionSignIn signIn = world.NewSignIn();
|
||||
Assert.Equal(new Uri(SignInWorld.Provider), description.ProviderOrigin);
|
||||
|
||||
SignInOffer? offer = await signIn.PrepareAsync(new Uri(SignInWorld.Server), SignInWorld.PasswordCheck, QqqCertificateExceptionOrigins.None);
|
||||
|
||||
Assert.Equal(new SignInOffer(true, false), offer);
|
||||
Assert.Equal(CompanionSignInState.Ready, signIn.State);
|
||||
Assert.True(signIn.HasSession);
|
||||
Assert.Equal([Reading, "INFO Sign-in methods for print.example.com: password."], world.Log.Entries);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The provider is compared as an origin. The check's address and the description's issuer are the same provider
|
||||
/// when their scheme, host and port are: whatever the letter case, an explicit default port or a path on either
|
||||
/// side, and whether a name outside ASCII is written in Unicode or in its "xn--" form.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("https://ID.example.com:443/", "https://id.example.com/realms/synthetic")]
|
||||
[InlineData("https://id.example.com/realms/other", "https://id.example.com")]
|
||||
[InlineData("https://l\u043Egin.example.com", "https://xn--lgin-55d.example.com")]
|
||||
public async Task Prepare_SameProviderWrittenDifferently_IsTheSameProvider(string reported, string issuer)
|
||||
{
|
||||
SignInWorld world = new() { ConfigureSession = session => session.Metadata = SignInWorld.BrowserDescription(issuer) };
|
||||
using CompanionSignIn signIn = world.NewSignIn();
|
||||
|
||||
SignInOffer? offer = await signIn.PrepareAsync(new Uri(SignInWorld.Server), new ServerCheckResult([], [ServerCheckResult.Browser], reported), QqqCertificateExceptionOrigins.None);
|
||||
|
||||
Assert.Equal(new SignInOffer(false, true), offer);
|
||||
Assert.Equal(CompanionSignInState.Ready, signIn.State);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A description that cannot be read is reported with the session's own reason, and no session is kept: a server
|
||||
/// that does not answer, an address the session client refuses, and the server's own time limit, which is a
|
||||
/// cancellation nobody here asked for and therefore a failure like any other.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("unreachable", "No connection could be made because the target machine actively refused it.", "HttpRequestException: No connection could be made because the target machine actively refused it.")]
|
||||
[InlineData("refused address", "Use an HTTPS server origin, or explicitly enabled loopback HTTP. (Parameter 'kind')", "ArgumentException: Use an HTTPS server origin, or explicitly enabled loopback HTTP. (Parameter 'kind')")]
|
||||
[InlineData("time limit", "The server did not answer in time.", "TaskCanceledException: The server did not answer in time.")]
|
||||
public async Task Prepare_DescriptionThatCannotBeRead_IsReportedWithItsReason(string kind, string reason, string logged)
|
||||
{
|
||||
SignInWorld world = new()
|
||||
{
|
||||
CreateFailure = string.Equals(kind, "refused address", StringComparison.Ordinal) ? new ArgumentException("Use an HTTPS server origin, or explicitly enabled loopback HTTP.", nameof(kind)) : null,
|
||||
ConfigureSession = session => session.DiscoverFailure = string.Equals(kind, "unreachable", StringComparison.Ordinal)
|
||||
? new HttpRequestException("No connection could be made because the target machine actively refused it.")
|
||||
: new TaskCanceledException("The server did not answer in time."),
|
||||
};
|
||||
using CompanionSignIn signIn = world.NewSignIn();
|
||||
|
||||
SignInOffer? offer = await signIn.PrepareAsync(new Uri(SignInWorld.Server), SignInWorld.PasswordCheck, QqqCertificateExceptionOrigins.None);
|
||||
|
||||
Assert.Null(offer);
|
||||
Assert.Equal(CompanionSignInState.PrepareFailed, signIn.State);
|
||||
Assert.Equal(reason, signIn.Failure);
|
||||
Assert.Null(signIn.Rejection);
|
||||
Assert.False(signIn.HasSession);
|
||||
Assert.False(signIn.IsBusy);
|
||||
Assert.All(world.Sessions, session => Assert.True(session.Disposed));
|
||||
Assert.Equal("ERROR The sign-in description could not be read: " + logged, world.Log.Entries[1]);
|
||||
Assert.StartsWith("DEBUG The sign-in description could not be read detail: System.", world.Log.Entries[2], StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A read whose caller stops waiting ends quietly: nothing is reported as failed, the session it created is
|
||||
/// discarded once its call has ended, and only a debug entry says what happened. The same holds when the answer
|
||||
/// arrives at the very moment the wait ends, and a read asked for with a wait that has already ended creates
|
||||
/// no session at all.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("while reading", 1, "TaskCanceledException")]
|
||||
[InlineData("as the answer arrives", 1, "OperationCanceledException")]
|
||||
[InlineData("before it starts", 0, "OperationCanceledException")]
|
||||
public async Task Prepare_CallerStopsWaiting_EndsQuietlyWithoutASession(string moment, int sessions, string cancellation)
|
||||
{
|
||||
using CancellationTokenSource caller = new();
|
||||
SignInWorld world = new()
|
||||
{
|
||||
ConfigureSession = session =>
|
||||
{
|
||||
session.PendingDiscovery = string.Equals(moment, "while reading", StringComparison.Ordinal) ? new TaskCompletionSource<QqqAuthenticationMetadata>(TaskCreationOptions.RunContinuationsAsynchronously) : null;
|
||||
session.BeforeAnswer = caller.Cancel;
|
||||
},
|
||||
};
|
||||
using CompanionSignIn signIn = world.NewSignIn();
|
||||
if(string.Equals(moment, "before it starts", StringComparison.Ordinal))
|
||||
{
|
||||
await caller.CancelAsync();
|
||||
}
|
||||
|
||||
Task<SignInOffer?> running = signIn.PrepareAsync(new Uri(SignInWorld.Server), SignInWorld.PasswordCheck, QqqCertificateExceptionOrigins.None, caller.Token);
|
||||
if(string.Equals(moment, "while reading", StringComparison.Ordinal))
|
||||
{
|
||||
Assert.Equal(CompanionSignInState.Preparing, signIn.State);
|
||||
Assert.True(signIn.IsBusy);
|
||||
Assert.False(signIn.HasSession);
|
||||
await caller.CancelAsync();
|
||||
}
|
||||
|
||||
Assert.Null(await SignInWorld.EndsAsync(running));
|
||||
Assert.Equal(CompanionSignInState.NotPrepared, signIn.State);
|
||||
Assert.False(signIn.HasSession);
|
||||
Assert.False(signIn.IsBusy);
|
||||
Assert.Null(signIn.Failure);
|
||||
Assert.Equal(sessions, world.Sessions.Count);
|
||||
Assert.All(world.Sessions, session => Assert.True(session.Disposed));
|
||||
Assert.Equal($"DEBUG Reading the sign-in description ended early because its caller stopped waiting ({cancellation}).", world.Log.Entries[^1]);
|
||||
Assert.DoesNotContain(world.Log.Entries, entry => entry.StartsWith("ERROR", StringComparison.Ordinal) || entry.StartsWith("WARN", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reading again starts clean: the session held from the earlier read is discarded before the new one is created, and the new one is the one that is held.</summary>
|
||||
[Fact]
|
||||
public async Task Prepare_Again_DiscardsTheEarlierSession()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn signIn = await world.PasswordReadyAsync();
|
||||
|
||||
Assert.NotNull(await signIn.PrepareAsync(new Uri(SignInWorld.Server), SignInWorld.PasswordCheck, QqqCertificateExceptionOrigins.None));
|
||||
|
||||
Assert.Equal(2, world.Sessions.Count);
|
||||
Assert.True(world.Sessions[0].Disposed);
|
||||
Assert.False(world.Sessions[1].Disposed);
|
||||
Assert.True(signIn.HasSession);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A password sign-in passes the trimmed user name and the password to the session once and returns the session
|
||||
/// credential to the caller. The sign-in keeps neither: no field of it holds the password, the user name or the
|
||||
/// credential, it has no property that could show a credential, and nothing of them is logged. The session stays,
|
||||
/// so the caller decides when it is discarded.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Password_ReturnsTheCredential_KeepsNoPassword()
|
||||
{
|
||||
const string password = "synthetic-password-canary";
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn signIn = await world.PasswordReadyAsync();
|
||||
List<string> changes = SignInWorld.Watch(signIn);
|
||||
|
||||
QqqSessionCredential? credential = await signIn.SignInWithPasswordAsync(" synthetic-operator ", password);
|
||||
|
||||
Assert.Equal(new Uri(SignInWorld.Server), credential?.Origin);
|
||||
Assert.Equal("synthetic-session-token", credential?.SessionToken);
|
||||
Assert.Equal(("synthetic-operator", password), world.Sessions[0].PasswordAttempt);
|
||||
Assert.Equal(CompanionSignInState.SignedIn, signIn.State);
|
||||
Assert.True(signIn.HasSession);
|
||||
Assert.False(world.Sessions[0].Disposed);
|
||||
Assert.Null(signIn.Failure);
|
||||
Assert.Equal(["IsBusy=True", "State=SigningIn", "State=SignedIn", "IsBusy=False"], changes);
|
||||
Assert.Equal(["INFO Password sign-in submitted to print.example.com.", "INFO Sign-in accepted by print.example.com; the session credential goes to the caller and is not kept here."], world.Log.Entries);
|
||||
object?[] held = [.. typeof(CompanionSignIn).GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Select(field => field.GetValue(signIn))];
|
||||
Assert.NotEmpty(held);
|
||||
Assert.DoesNotContain(held, value => value is QqqSessionCredential || value is string text && (text.Contains(password, StringComparison.Ordinal) || text.Contains("synthetic-operator", StringComparison.Ordinal)));
|
||||
Assert.DoesNotContain(typeof(CompanionSignIn).GetProperties(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic), property => property.PropertyType == typeof(QqqSessionCredential));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A refused password sign-in ends the same way whatever the server objected to, the credentials or the permission:
|
||||
/// the same state and the session's own sentence, to which nothing is added that tells the cases apart. Neither
|
||||
/// the user name nor the password is logged, and the session and its offer stay, so another attempt needs no new
|
||||
/// read; that attempt succeeds and the reason is gone.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(QqqSessionException.FailureKind.AuthenticationRequired)]
|
||||
[InlineData(QqqSessionException.FailureKind.PermissionDenied)]
|
||||
public async Task Password_Failure_DoesNotSayWhichPartWasWrong(QqqSessionException.FailureKind kind)
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn signIn = await world.PasswordReadyAsync();
|
||||
world.Sessions[0].PasswordFailure = new QqqSessionException(kind, "The server did not accept this sign-in.");
|
||||
|
||||
QqqSessionCredential? credential = await signIn.SignInWithPasswordAsync("synthetic-operator", "synthetic-wrong-password");
|
||||
|
||||
Assert.Null(credential);
|
||||
Assert.Equal(CompanionSignInState.PasswordFailed, signIn.State);
|
||||
Assert.Equal("The server did not accept this sign-in.", signIn.Failure);
|
||||
Assert.Null(signIn.Rejection);
|
||||
Assert.True(signIn.HasSession);
|
||||
Assert.Equal(new SignInOffer(true, false), signIn.Offer);
|
||||
Assert.False(world.Sessions[0].Disposed);
|
||||
Assert.Equal("ERROR Password sign-in not completed: QqqSessionException: The server did not accept this sign-in.", world.Log.Entries[1]);
|
||||
Assert.DoesNotContain(world.Log.Entries, entry => entry.Contains("synthetic-wrong-password", StringComparison.Ordinal) || entry.Contains("synthetic-operator", StringComparison.Ordinal) || entry.Contains(kind.ToString(), StringComparison.Ordinal));
|
||||
|
||||
world.Sessions[0].PasswordFailure = null;
|
||||
Assert.NotNull(await signIn.SignInWithPasswordAsync("synthetic-operator", "synthetic-password"));
|
||||
Assert.Equal(CompanionSignInState.SignedIn, signIn.State);
|
||||
Assert.Null(signIn.Failure);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The server's own time limit is a cancellation nobody here asked for, so it is a failed sign-in with its reason, not a quiet end.</summary>
|
||||
[Fact]
|
||||
public async Task Password_ServerTimeLimit_IsAFailedSignIn()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn signIn = await world.PasswordReadyAsync();
|
||||
world.Sessions[0].PasswordFailure = new TaskCanceledException("The server did not answer in time.");
|
||||
|
||||
Assert.Null(await signIn.SignInWithPasswordAsync("synthetic-operator", "synthetic-password"));
|
||||
|
||||
Assert.Equal(CompanionSignInState.PasswordFailed, signIn.State);
|
||||
Assert.Equal("The server did not answer in time.", signIn.Failure);
|
||||
Assert.True(signIn.HasSession);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A certificate this account refuses while a password is being checked is why that step failed: the state says
|
||||
/// whose certificate it was and the refusal is kept. A later step that fails for another reason is not blamed on
|
||||
/// the certificate the session refused before.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Password_CertificateRefusedDuringTheStep_IsReported_AndAnOlderRefusalIsNot()
|
||||
{
|
||||
QqqCertificateRejection refusal = SignInWorld.Refusal(SignInWorld.Server);
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn signIn = await world.PasswordReadyAsync();
|
||||
world.Sessions[0].PasswordFailure = new HttpRequestException("The SSL connection could not be established, see inner exception.");
|
||||
world.Sessions[0].RefusedCertificate = refusal;
|
||||
|
||||
Assert.Null(await signIn.SignInWithPasswordAsync("synthetic-operator", "synthetic-password"));
|
||||
Assert.Equal(CompanionSignInState.AccountRefusedCertificate, signIn.State);
|
||||
Assert.Same(refusal, signIn.Rejection);
|
||||
Assert.Null(signIn.Failure);
|
||||
Assert.True(signIn.HasSession);
|
||||
|
||||
world.Sessions[0].RefusedCertificate = null;
|
||||
world.Sessions[0].PasswordFailure = new IOException("synthetic network failure");
|
||||
Assert.Null(await signIn.SignInWithPasswordAsync("synthetic-operator", "synthetic-password"));
|
||||
Assert.Equal(CompanionSignInState.PasswordFailed, signIn.State);
|
||||
Assert.Null(signIn.Rejection);
|
||||
Assert.Equal("synthetic network failure", signIn.Failure);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A password sign-in whose caller stops waiting ends quietly and keeps the session for another attempt; nothing
|
||||
/// is reported as failed. When the server accepts the sign-in at the very moment the wait ends, no credential is
|
||||
/// taken either.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(true, "TaskCanceledException")]
|
||||
[InlineData(false, "OperationCanceledException")]
|
||||
public async Task Password_CallerStopsWaiting_EndsQuietlyAndKeepsTheSession(bool whileChecking, string cancellation)
|
||||
{
|
||||
using CancellationTokenSource caller = new();
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn signIn = await world.PasswordReadyAsync();
|
||||
world.Sessions[0].PendingPassword = whileChecking ? new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) : null;
|
||||
world.Sessions[0].BeforeAnswer = caller.Cancel;
|
||||
|
||||
Task<QqqSessionCredential?> running = signIn.SignInWithPasswordAsync("synthetic-operator", "synthetic-password", caller.Token);
|
||||
if(whileChecking)
|
||||
{
|
||||
Assert.Equal(CompanionSignInState.SigningIn, signIn.State);
|
||||
await caller.CancelAsync();
|
||||
}
|
||||
|
||||
Assert.Null(await SignInWorld.EndsAsync(running));
|
||||
Assert.Equal(CompanionSignInState.Ready, signIn.State);
|
||||
Assert.True(signIn.HasSession);
|
||||
Assert.False(world.Sessions[0].Disposed);
|
||||
Assert.False(signIn.IsBusy);
|
||||
Assert.Null(signIn.Failure);
|
||||
Assert.Equal(["INFO Password sign-in submitted to print.example.com.", $"DEBUG Password sign-in ended early because its caller stopped waiting ({cancellation})."], world.Log.Entries);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Nothing is attempted without a session, or on a session that does not offer a password: the caller gets no credential, the session is not asked, and nothing is announced or logged.</summary>
|
||||
[Fact]
|
||||
public async Task Password_WithoutASessionThatOffersIt_NothingIsAttempted()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn unprepared = world.NewSignIn();
|
||||
using CompanionSignIn browserOnly = await world.BrowserReadyAsync();
|
||||
List<string> changes = SignInWorld.Watch(browserOnly);
|
||||
|
||||
Assert.Null(await unprepared.SignInWithPasswordAsync("synthetic-operator", "synthetic-password"));
|
||||
Assert.Null(await browserOnly.SignInWithPasswordAsync("synthetic-operator", "synthetic-password"));
|
||||
|
||||
Assert.Equal(CompanionSignInState.NotPrepared, unprepared.State);
|
||||
Assert.Equal(CompanionSignInState.Ready, browserOnly.State);
|
||||
Assert.Null(Assert.Single(world.Sessions).PasswordAttempt);
|
||||
Assert.Empty(changes);
|
||||
Assert.Empty(world.Log.Entries);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// One operation runs at a time. While a password is being checked, another read and another password sign-in are
|
||||
/// refused: each returns nothing, each refusal is logged, and the running operation and its state are not
|
||||
/// disturbed. When it ends the sign-in is free again.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SecondOperationWhileOneRuns_IsRefusedAndLogged()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn signIn = await world.PasswordReadyAsync();
|
||||
TaskCompletionSource answer = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
world.Sessions[0].PendingPassword = answer;
|
||||
Task<QqqSessionCredential?> running = signIn.SignInWithPasswordAsync("synthetic-operator", "synthetic-password");
|
||||
List<string> changes = SignInWorld.Watch(signIn);
|
||||
|
||||
Assert.Null(await signIn.PrepareAsync(new Uri(SignInWorld.Server), SignInWorld.PasswordCheck, QqqCertificateExceptionOrigins.None));
|
||||
Assert.Null(await signIn.SignInWithPasswordAsync("synthetic-operator", "synthetic-other-password"));
|
||||
|
||||
Assert.Equal(["INFO Password sign-in submitted to print.example.com.",
|
||||
"WARN Reading the sign-in description was not started: another sign-in operation is still running.",
|
||||
"WARN Password sign-in was not started: another sign-in operation is still running."], world.Log.Entries);
|
||||
Assert.Empty(changes);
|
||||
Assert.Equal(CompanionSignInState.SigningIn, signIn.State);
|
||||
Assert.True(signIn.IsBusy);
|
||||
Assert.Single(world.Sessions);
|
||||
Assert.Equal("synthetic-password", world.Sessions[0].PasswordAttempt?.Password);
|
||||
|
||||
answer.SetResult();
|
||||
Assert.NotNull(await SignInWorld.EndsAsync(running));
|
||||
Assert.False(signIn.IsBusy);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Discarding when nothing runs lets the session go at once and withdraws what it offered; a sign-in then needs a new read, and until one is made nothing is attempted.</summary>
|
||||
[Fact]
|
||||
public async Task Discard_WhenNothingRuns_LetsTheSessionGo()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn signIn = await world.PasswordReadyAsync();
|
||||
Assert.NotNull(await signIn.SignInWithPasswordAsync("synthetic-operator", "synthetic-password"));
|
||||
List<string> changes = SignInWorld.Watch(signIn);
|
||||
|
||||
signIn.Discard();
|
||||
|
||||
Assert.True(world.Sessions[0].Disposed);
|
||||
Assert.False(signIn.HasSession);
|
||||
Assert.Null(signIn.Offer);
|
||||
Assert.Equal(CompanionSignInState.NotPrepared, signIn.State);
|
||||
Assert.Equal(["Offer=null", "HasSession=False", "State=NotPrepared"], changes);
|
||||
Assert.Null(await signIn.SignInWithPasswordAsync("synthetic-operator", "synthetic-password"));
|
||||
Assert.Equal(CompanionSignInState.NotPrepared, signIn.State);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Discarding while a password is being checked does not pull the session out from under that call: a session
|
||||
/// that is discarded under a running call makes the call end with "disposed", and here it ends with the
|
||||
/// cancellation that was asked for. It ends quietly, the session is discarded once it has ended, and nothing is
|
||||
/// reported as failed. Afterwards the sign-in can read a description again and keeps that session.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Discard_WhileAnOperationRuns_CancelsIt_AndTheSessionGoesWhenItEnds()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn signIn = await world.PasswordReadyAsync();
|
||||
world.Sessions[0].PendingPassword = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
Task<QqqSessionCredential?> running = signIn.SignInWithPasswordAsync("synthetic-operator", "synthetic-password");
|
||||
|
||||
signIn.Discard();
|
||||
|
||||
Assert.Null(await SignInWorld.EndsAsync(running));
|
||||
Assert.True(world.Sessions[0].Disposed);
|
||||
Assert.False(signIn.HasSession);
|
||||
Assert.Null(signIn.Offer);
|
||||
Assert.Equal(CompanionSignInState.NotPrepared, signIn.State);
|
||||
Assert.False(signIn.IsBusy);
|
||||
Assert.Equal(["INFO Password sign-in submitted to print.example.com.", "DEBUG Password sign-in ended early because the session was discarded (TaskCanceledException)."], world.Log.Entries);
|
||||
|
||||
Assert.NotNull(await signIn.PrepareAsync(new Uri(SignInWorld.Server), SignInWorld.PasswordCheck, QqqCertificateExceptionOrigins.None));
|
||||
Assert.True(signIn.HasSession);
|
||||
Assert.False(world.Sessions[1].Disposed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// An operation that is being released says how it ended before it lets go of the sign-in, so for the length of
|
||||
/// that announcement the sign-in is still busy while no call uses the session any more. A subscriber that
|
||||
/// discards from that announcement gets the session discarded at once: left to the operation, as a discard
|
||||
/// during a running call is, it would have been left to nobody, and every later read would have lost its
|
||||
/// session as it ended. Here a password sign-in whose caller stopped waiting ends ready, a subscriber discards
|
||||
/// at that moment, and afterwards the next read keeps its session and a sign-in on it works.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Discard_FromASubscriberWhileAnOperationIsBeingReleased_DiscardsAtOnce()
|
||||
{
|
||||
using CancellationTokenSource caller = new();
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn signIn = await world.PasswordReadyAsync();
|
||||
world.Sessions[0].PendingPassword = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
bool? busyWhenDiscarded = null;
|
||||
signIn.PropertyChanged += (_, change) =>
|
||||
{
|
||||
if(busyWhenDiscarded is null && string.Equals(change.PropertyName, nameof(CompanionSignIn.State), StringComparison.Ordinal) && signIn.State == CompanionSignInState.Ready)
|
||||
{
|
||||
busyWhenDiscarded = signIn.IsBusy;
|
||||
signIn.Discard();
|
||||
}
|
||||
};
|
||||
Task<QqqSessionCredential?> running = signIn.SignInWithPasswordAsync("synthetic-operator", "synthetic-password", caller.Token);
|
||||
await caller.CancelAsync();
|
||||
|
||||
Assert.Null(await SignInWorld.EndsAsync(running));
|
||||
Assert.True(busyWhenDiscarded);
|
||||
Assert.True(world.Sessions[0].Disposed);
|
||||
Assert.False(signIn.HasSession);
|
||||
Assert.Null(signIn.Offer);
|
||||
Assert.Equal(CompanionSignInState.NotPrepared, signIn.State);
|
||||
Assert.False(signIn.IsBusy);
|
||||
|
||||
SignInOffer? offer = await signIn.PrepareAsync(new Uri(SignInWorld.Server), SignInWorld.PasswordCheck, QqqCertificateExceptionOrigins.None);
|
||||
|
||||
Assert.Equal(new SignInOffer(true, false), offer);
|
||||
Assert.Same(offer, signIn.Offer);
|
||||
Assert.Equal(CompanionSignInState.Ready, signIn.State);
|
||||
Assert.True(signIn.HasSession);
|
||||
Assert.False(world.Sessions[1].Disposed);
|
||||
Assert.NotNull(await signIn.SignInWithPasswordAsync("synthetic-operator", "synthetic-password"));
|
||||
Assert.Equal(CompanionSignInState.SignedIn, signIn.State);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The same moment, with the sign-in disposed instead of discarded: a view that closes when it hears how an
|
||||
/// operation ended. The operation has ended and is only being released, so nothing is left that would discard
|
||||
/// the session later. Disposing discards it there and then, and nothing more is announced.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Dispose_FromASubscriberWhileAnOperationIsBeingReleased_DiscardsTheSession()
|
||||
{
|
||||
using CancellationTokenSource caller = new();
|
||||
SignInWorld world = new();
|
||||
CompanionSignIn signIn = await world.PasswordReadyAsync();
|
||||
world.Sessions[0].PendingPassword = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
List<string> changes = SignInWorld.Watch(signIn);
|
||||
signIn.PropertyChanged += (_, change) =>
|
||||
{
|
||||
if(string.Equals(change.PropertyName, nameof(CompanionSignIn.State), StringComparison.Ordinal) && signIn.State == CompanionSignInState.Ready)
|
||||
{
|
||||
signIn.Dispose();
|
||||
}
|
||||
};
|
||||
Task<QqqSessionCredential?> running = signIn.SignInWithPasswordAsync("synthetic-operator", "synthetic-password", caller.Token);
|
||||
await caller.CancelAsync();
|
||||
|
||||
Assert.Null(await SignInWorld.EndsAsync(running));
|
||||
Assert.True(world.Sessions[0].Disposed);
|
||||
Assert.False(signIn.HasSession);
|
||||
Assert.Null(signIn.Offer);
|
||||
Assert.False(signIn.IsBusy);
|
||||
Assert.Equal(["IsBusy=True", "State=SigningIn", "State=Ready"], changes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A read never returns an offer whose session is gone. A subscriber that discards while the read announces that
|
||||
/// it is ready ends the read's session with it: the read returns nothing, exactly as the sign-in then offers
|
||||
/// nothing and holds no session, and a debug entry says why. The sign-in is free and can read again.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Prepare_SessionDiscardedWhileItsResultIsAnnounced_ReturnsNoOffer()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn signIn = world.NewSignIn();
|
||||
bool discard = true;
|
||||
signIn.PropertyChanged += (_, change) =>
|
||||
{
|
||||
if(discard && string.Equals(change.PropertyName, nameof(CompanionSignIn.State), StringComparison.Ordinal) && signIn.State == CompanionSignInState.Ready)
|
||||
{
|
||||
signIn.Discard();
|
||||
}
|
||||
};
|
||||
|
||||
SignInOffer? offer = await signIn.PrepareAsync(new Uri(SignInWorld.Server), SignInWorld.PasswordCheck, QqqCertificateExceptionOrigins.None);
|
||||
|
||||
Assert.Null(offer);
|
||||
Assert.Null(signIn.Offer);
|
||||
Assert.False(signIn.HasSession);
|
||||
Assert.True(Assert.Single(world.Sessions).Disposed);
|
||||
Assert.Equal(CompanionSignInState.NotPrepared, signIn.State);
|
||||
Assert.False(signIn.IsBusy);
|
||||
Assert.Equal("DEBUG The sign-in description was read, and its session was discarded before the read returned, so the read offers nothing.", world.Log.Entries[^1]);
|
||||
Assert.DoesNotContain(world.Log.Entries, entry => entry.StartsWith("ERROR", StringComparison.Ordinal));
|
||||
|
||||
discard = false;
|
||||
Assert.NotNull(await signIn.PrepareAsync(new Uri(SignInWorld.Server), SignInWorld.PasswordCheck, QqqCertificateExceptionOrigins.None));
|
||||
Assert.True(signIn.HasSession);
|
||||
Assert.False(world.Sessions[1].Disposed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Disposing while the server checks a password only cancels. The session is not discarded under the running
|
||||
/// call, which would make the call end with "disposed": it ends with the cancellation, quietly, the session is
|
||||
/// discarded then, and from the moment of disposal nothing more is announced: a view that has closed is not
|
||||
/// called back, neither with a change nor with the notice that one is coming, although the session's offer is
|
||||
/// withdrawn and the state settles as the operation ends. Only a debug entry records the end.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Dispose_OnlyCancels_NothingIsAnnouncedAfterwards()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
CompanionSignIn signIn = await world.PasswordReadyAsync();
|
||||
world.Sessions[0].PendingPassword = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
List<string> changes = SignInWorld.Watch(signIn);
|
||||
List<string> changing = SignInWorld.WatchChanging(signIn);
|
||||
Task<QqqSessionCredential?> running = signIn.SignInWithPasswordAsync("synthetic-operator", "synthetic-password");
|
||||
Assert.Equal(["IsBusy=True", "State=SigningIn"], changes);
|
||||
Assert.Equal([nameof(CompanionSignIn.State)], changing);
|
||||
|
||||
signIn.Dispose();
|
||||
|
||||
Assert.Null(await SignInWorld.EndsAsync(running));
|
||||
Assert.True(world.Sessions[0].Disposed);
|
||||
Assert.Equal(["IsBusy=True", "State=SigningIn"], changes);
|
||||
Assert.Equal([nameof(CompanionSignIn.State)], changing);
|
||||
Assert.Null(signIn.Offer);
|
||||
Assert.Equal(CompanionSignInState.NotPrepared, signIn.State);
|
||||
Assert.False(signIn.HasSession);
|
||||
Assert.False(signIn.IsBusy);
|
||||
Assert.Equal(["INFO Password sign-in submitted to print.example.com.", "DEBUG Password sign-in ended early because the sign-in was closed (TaskCanceledException)."], world.Log.Entries);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Disposing when nothing runs discards the session at once and announces nothing. After that every request does
|
||||
/// nothing: no read, no sign-in, no discard, and disposing again is harmless.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Dispose_WhenNothingRuns_DiscardsTheSession_AndEverythingAfterwardsDoesNothing()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
CompanionSignIn signIn = await world.PasswordReadyAsync();
|
||||
List<string> changes = SignInWorld.Watch(signIn);
|
||||
List<string> changing = SignInWorld.WatchChanging(signIn);
|
||||
|
||||
signIn.Dispose();
|
||||
signIn.Dispose();
|
||||
signIn.Discard();
|
||||
signIn.CancelBrowserSignIn();
|
||||
|
||||
Assert.True(world.Sessions[0].Disposed);
|
||||
Assert.Null(signIn.Offer);
|
||||
Assert.Empty(changing);
|
||||
Assert.Null(await signIn.PrepareAsync(new Uri(SignInWorld.Server), SignInWorld.PasswordCheck, QqqCertificateExceptionOrigins.None));
|
||||
Assert.Null(await signIn.SignInWithPasswordAsync("synthetic-operator", "synthetic-password"));
|
||||
Assert.Null(await signIn.SignInWithBrowserAsync());
|
||||
Assert.Single(world.Sessions);
|
||||
Assert.Empty(changes);
|
||||
Assert.Empty(world.Log.Entries);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A subscriber that throws while the sign-in announces that it is busy does not leave it claimed forever: the
|
||||
/// failure reaches the caller, the sign-in is free again, and the next read works.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SubscriberThatThrowsAtTheStart_DoesNotLeaveTheSignInClaimed()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn signIn = world.NewSignIn();
|
||||
bool thrown = false;
|
||||
signIn.PropertyChanged += (_, change) =>
|
||||
{
|
||||
if(!thrown && string.Equals(change.PropertyName, nameof(CompanionSignIn.IsBusy), StringComparison.Ordinal))
|
||||
{
|
||||
thrown = true;
|
||||
throw new InvalidOperationException("synthetic subscriber failure");
|
||||
}
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => signIn.PrepareAsync(new Uri(SignInWorld.Server), SignInWorld.PasswordCheck, QqqCertificateExceptionOrigins.None));
|
||||
|
||||
Assert.False(signIn.IsBusy);
|
||||
Assert.Equal(CompanionSignInState.NotPrepared, signIn.State);
|
||||
Assert.NotNull(await signIn.PrepareAsync(new Uri(SignInWorld.Server), SignInWorld.PasswordCheck, QqqCertificateExceptionOrigins.None));
|
||||
Assert.Equal(CompanionSignInState.Ready, signIn.State);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A subscriber that throws while a result is announced has not made the step fail. The read still ended ready
|
||||
/// with its session, and the password sign-in still ended signed in; neither is reported as not completed, the
|
||||
/// subscriber's failure reaches the caller, and the sign-in is free again.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(CompanionSignInState.Ready)]
|
||||
[InlineData(CompanionSignInState.SignedIn)]
|
||||
public async Task SubscriberThatThrowsWhileAResultIsAnnounced_HasNotMadeTheStepFail(CompanionSignInState announced)
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn signIn = world.NewSignIn();
|
||||
signIn.PropertyChanged += (_, change) =>
|
||||
{
|
||||
if(string.Equals(change.PropertyName, nameof(CompanionSignIn.State), StringComparison.Ordinal) && signIn.State == announced)
|
||||
{
|
||||
throw new IOException("synthetic subscriber failure");
|
||||
}
|
||||
};
|
||||
|
||||
Task step = announced == CompanionSignInState.Ready
|
||||
? signIn.PrepareAsync(new Uri(SignInWorld.Server), SignInWorld.PasswordCheck, QqqCertificateExceptionOrigins.None)
|
||||
: PrepareThenSignInAsync(signIn);
|
||||
|
||||
await Assert.ThrowsAsync<IOException>(() => step);
|
||||
Assert.Equal(announced, signIn.State);
|
||||
Assert.True(signIn.HasSession);
|
||||
Assert.False(Assert.Single(world.Sessions).Disposed);
|
||||
Assert.Null(signIn.Failure);
|
||||
Assert.False(signIn.IsBusy);
|
||||
Assert.DoesNotContain(world.Log.Entries, entry => entry.StartsWith("ERROR", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads a password server's description and then signs in by password.</summary>
|
||||
private static async Task PrepareThenSignInAsync(CompanionSignIn signIn)
|
||||
{
|
||||
Assert.NotNull(await signIn.PrepareAsync(new Uri(SignInWorld.Server), SignInWorld.PasswordCheck, QqqCertificateExceptionOrigins.None));
|
||||
_ = await signIn.SignInWithPasswordAsync("synthetic-operator", "synthetic-password");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A failure nobody expects (a programming error in a session, say) is not dressed up as a failed sign-in: it
|
||||
/// reaches the caller. The sign-in is released all the same and its state matches what it holds: no session after
|
||||
/// a read that threw, and the session still there after a sign-in that threw.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task UnexpectedFailure_ReachesTheCaller_AndTheStateMatchesWhatIsHeld()
|
||||
{
|
||||
SignInWorld world = new() { ConfigureSession = session => session.DiscoverFailure = new NotSupportedException("synthetic unexpected failure") };
|
||||
using CompanionSignIn signIn = world.NewSignIn();
|
||||
|
||||
await Assert.ThrowsAsync<NotSupportedException>(() => signIn.PrepareAsync(new Uri(SignInWorld.Server), SignInWorld.PasswordCheck, QqqCertificateExceptionOrigins.None));
|
||||
Assert.Equal(CompanionSignInState.NotPrepared, signIn.State);
|
||||
Assert.False(signIn.HasSession);
|
||||
Assert.False(signIn.IsBusy);
|
||||
Assert.True(world.Sessions[0].Disposed);
|
||||
|
||||
world.ConfigureSession = session => session.PasswordFailure = new NotSupportedException("synthetic unexpected failure");
|
||||
Assert.NotNull(await signIn.PrepareAsync(new Uri(SignInWorld.Server), SignInWorld.PasswordCheck, QqqCertificateExceptionOrigins.None));
|
||||
await Assert.ThrowsAsync<NotSupportedException>(() => signIn.SignInWithPasswordAsync("synthetic-operator", "synthetic-password"));
|
||||
Assert.Equal(CompanionSignInState.Ready, signIn.State);
|
||||
Assert.True(signIn.HasSession);
|
||||
Assert.False(signIn.IsBusy);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A sign-in cannot work without its collaborators and says which is missing; a read needs a server, the check's result and a certificate exception, which may be the empty one but not nothing.</summary>
|
||||
[Fact]
|
||||
public async Task MissingCollaboratorOrArgument_IsRefused()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using CompanionSignIn signIn = world.NewSignIn();
|
||||
|
||||
Assert.Equal("sessions", Assert.Throws<ArgumentNullException>(() => new CompanionSignIn(null!, world, world, world.Dispatcher, world.Log)).ParamName);
|
||||
Assert.Equal("browser", Assert.Throws<ArgumentNullException>(() => new CompanionSignIn(world, null!, world, world.Dispatcher, world.Log)).ParamName);
|
||||
Assert.Equal("callbacks", Assert.Throws<ArgumentNullException>(() => new CompanionSignIn(world, world, null!, world.Dispatcher, world.Log)).ParamName);
|
||||
Assert.Equal("dispatcher", Assert.Throws<ArgumentNullException>(() => new CompanionSignIn(world, world, world, null!, world.Log)).ParamName);
|
||||
Assert.Equal("log", Assert.Throws<ArgumentNullException>(() => new CompanionSignIn(world, world, world, world.Dispatcher, null!)).ParamName);
|
||||
Assert.Equal("server", (await Assert.ThrowsAsync<ArgumentNullException>(() => signIn.PrepareAsync(null!, SignInWorld.PasswordCheck, QqqCertificateExceptionOrigins.None))).ParamName);
|
||||
Assert.Equal("check", (await Assert.ThrowsAsync<ArgumentNullException>(() => signIn.PrepareAsync(new Uri(SignInWorld.Server), null!, QqqCertificateExceptionOrigins.None))).ParamName);
|
||||
Assert.Equal("certificateException", (await Assert.ThrowsAsync<ArgumentNullException>(() => signIn.PrepareAsync(new Uri(SignInWorld.Server), SignInWorld.PasswordCheck, null!))).ParamName);
|
||||
Assert.False(signIn.IsBusy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.ComponentModel;
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
using Qqq.PrintAgent.Presentation.SignIn;
|
||||
using Qqq.PrintAgent.Presentation.Tests.Fakes;
|
||||
|
||||
namespace Qqq.PrintAgent.Presentation.Tests.SignIn;
|
||||
|
||||
/// <summary>The identity provider's half of a sign-out: its page is opened, its return is recognized by this sign-out's own state within two minutes, the result says how it ended, and nothing of the request reaches the log.</summary>
|
||||
public sealed class ProviderSignOutTests
|
||||
{
|
||||
/// <summary>The marked ID token hint of the synthetic sign-out address, which must reach no log line.</summary>
|
||||
private const string Hint = "synthetic-id-token-hint";
|
||||
|
||||
/// <summary>The state of the synthetic sign-out, which must reach no log line.</summary>
|
||||
private const string State = "synthetic-sign-out-state";
|
||||
|
||||
|
||||
|
||||
/// <summary>A provider sign-out as the service returns one: the end-session address with its token hint, the registered callback and this sign-out's state.</summary>
|
||||
private static QqqProviderLogout Logout(string provider = "id.example.com") => new(new Uri("https://" + provider + "/end-session?id_token_hint=" + Hint + "&state=" + State), new Uri(SignInWorld.Callback), State);
|
||||
|
||||
|
||||
|
||||
/// <summary>A provider sign-out over the world's dispatcher, browser, log and clock.</summary>
|
||||
private static ProviderSignOut Over(SignInWorld world) => new(world, world.Dispatcher, world.Log, world.Time);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The inbox is open before the provider's page is, the page is the address the service returned, and the wait has
|
||||
/// a two minute limit. A callback carrying another exchange's state is not this sign-out's: it is dropped and the
|
||||
/// wait goes on. The callback with this sign-out's state, on the registered address, confirms it. The log names
|
||||
/// the provider's host and nothing else of the request: no token hint and no state.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ProviderSignOut_MatchesItsOwnState_WithinTwoMinutes()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
QqqProviderLogout logout = Logout();
|
||||
|
||||
Task<ProviderSignOutResult> running = Over(world).CompleteAsync(logout);
|
||||
FakeInbox inbox = Assert.Single(world.Inboxes);
|
||||
Assert.Equal([1], world.ListeningAtLaunch);
|
||||
Assert.Equal(logout.EndSessionUri, Assert.Single(world.Opened));
|
||||
Assert.Equal(TimeSpan.FromMinutes(2), Assert.Single(world.Time.RequestedDelays));
|
||||
Assert.Equal(TimeSpan.FromMinutes(2), ProviderSignOut.Limit);
|
||||
Task<FakeInbox> reopened = world.NextInboxAsync();
|
||||
inbox.Deliver(new Uri(SignInWorld.Callback + "?state=synthetic-state-of-another-exchange"));
|
||||
FakeInbox next = await reopened;
|
||||
Assert.False(running.IsCompleted);
|
||||
next.Deliver(new Uri(SignInWorld.Callback + "?state=" + State));
|
||||
|
||||
Assert.Equal(ProviderSignOutResult.Confirmed, await SignInWorld.EndsAsync(running));
|
||||
Assert.True(next.Disposed);
|
||||
Assert.Equal(["INFO Browser callback inbox open.",
|
||||
"INFO Opening the identity provider's sign-out page in the default browser (id.example.com).",
|
||||
"WARN A browser callback that belongs to no waiting sign-in or sign-out was dropped. Nothing of it is logged.",
|
||||
"INFO Browser callback inbox open.",
|
||||
"INFO Identity provider sign-out callback matched."], world.Log.Entries);
|
||||
Assert.DoesNotContain(world.Log.Entries, entry => entry.Contains("synthetic", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The provider's page is at an address the server's provider wrote, so the log names its host by the ASCII name the browser will really go to: a look-alike letter reads as the "xn--" name. The browser is given the address unchanged.</summary>
|
||||
[Fact]
|
||||
public async Task ProviderPageWithALookAlikeHost_IsLoggedByItsAsciiName()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
QqqProviderLogout logout = Logout("l\u043Egin.example.com:8443");
|
||||
|
||||
Task<ProviderSignOutResult> running = Over(world).CompleteAsync(logout);
|
||||
world.Inboxes[0].Deliver(new Uri(SignInWorld.Callback + "?state=" + State));
|
||||
|
||||
Assert.Equal(ProviderSignOutResult.Confirmed, await SignInWorld.EndsAsync(running));
|
||||
Assert.Same(logout.EndSessionUri, Assert.Single(world.Opened));
|
||||
Assert.Contains("INFO Opening the identity provider's sign-out page in the default browser (xn--lgin-55d.example.com:8443).", world.Log.Entries, StringComparer.Ordinal);
|
||||
Assert.DoesNotContain(world.Log.Entries, entry => entry.Contains('\u043E', StringComparison.Ordinal) || entry.Contains("synthetic", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A callback that carries this sign-out's state but is not on the registered address is handed to the sign-out, which does not take it as the provider finishing: the page was opened, and the provider did not confirm.</summary>
|
||||
[Fact]
|
||||
public async Task CallbackWithItsStateOnAnotherAddress_DoesNotConfirm()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
|
||||
Task<ProviderSignOutResult> running = Over(world).CompleteAsync(Logout());
|
||||
world.Inboxes[0].Deliver(new Uri("io.qrun.qqq-print-agent:/oauth/other?state=" + State));
|
||||
|
||||
Assert.Equal(ProviderSignOutResult.NotConfirmed, await SignInWorld.EndsAsync(running));
|
||||
Assert.Equal("INFO Identity provider sign-out callback did not match this sign-out.", world.Log.Entries[^1]);
|
||||
Assert.True(world.Inboxes[0].Disposed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A sign-out that is not confirmed says which way it went, because the operator is told something different for
|
||||
/// each. The page was never opened when the browser or the inbox could not be opened. The page was opened and the
|
||||
/// provider did not confirm when two minutes passed, when the inbox broke meanwhile, or when the inbox's own wait
|
||||
/// was ended by something other than this sign-out. The caller stopped it when its token ended the wait. A
|
||||
/// failure is logged as a warning with its kind, running out of time as a warning in fixed words, and the
|
||||
/// caller's own cancellation as information in fixed words, without the text of the cancellation. Windows names
|
||||
/// what it could not start in its failure, which is the whole sign-out address with the token hint; none of that
|
||||
/// reaches the log.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("limit", ProviderSignOutResult.NotConfirmed, 1, "WARN Identity provider sign-out was not confirmed: the provider did not return within the two minute limit.")]
|
||||
[InlineData("token", ProviderSignOutResult.Cancelled, 1, "INFO Identity provider sign-out was stopped by its caller before the provider confirmed.")]
|
||||
[InlineData("browser", ProviderSignOutResult.NotOpened, 0, "WARN The identity provider's sign-out page was not opened: IOException: " + BrowserStartFailure.WindowsReport)]
|
||||
[InlineData("no inbox", ProviderSignOutResult.NotOpened, 0, "WARN The identity provider's sign-out page was not opened: UnauthorizedAccessException: synthetic open failure")]
|
||||
[InlineData("inbox", ProviderSignOutResult.NotConfirmed, 1, "WARN Identity provider sign-out was not confirmed: IOException: synthetic pipe failure")]
|
||||
[InlineData("inbox wait ended", ProviderSignOutResult.NotConfirmed, 1, "WARN Identity provider sign-out was not confirmed: OperationCanceledException: synthetic end of the inbox's wait")]
|
||||
public async Task ProviderThatDoesNotConfirm_SaysWhetherItsPageWasOpened(string ending, ProviderSignOutResult result, int opened, string entry)
|
||||
{
|
||||
SignInWorld world = new()
|
||||
{
|
||||
BrowserFailure = string.Equals(ending, "browser", StringComparison.Ordinal) ? new Win32Exception(2, "An error occurred trying to start process '" + Logout().EndSessionUri.AbsoluteUri + "' with working directory 'C:\\synthetic'. The system cannot find the file specified.") : null,
|
||||
InboxFailure = string.Equals(ending, "no inbox", StringComparison.Ordinal) ? new UnauthorizedAccessException("synthetic open failure") : null,
|
||||
};
|
||||
using CancellationTokenSource cancellation = new();
|
||||
|
||||
Task<ProviderSignOutResult> running = Over(world).CompleteAsync(Logout(), cancellation.Token);
|
||||
switch(ending)
|
||||
{
|
||||
case "limit":
|
||||
world.Time.Advance(ProviderSignOut.Limit - TimeSpan.FromSeconds(1));
|
||||
Assert.False(running.IsCompleted);
|
||||
world.Time.Advance(TimeSpan.FromSeconds(1));
|
||||
break;
|
||||
case "token":
|
||||
await cancellation.CancelAsync();
|
||||
break;
|
||||
case "inbox":
|
||||
world.Inboxes[0].Fail(new IOException("synthetic pipe failure"));
|
||||
break;
|
||||
case "inbox wait ended":
|
||||
world.Inboxes[0].Fail(new OperationCanceledException("synthetic end of the inbox's wait"));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
Assert.Equal(result, await SignInWorld.EndsAsync(running));
|
||||
Assert.Equal(entry, world.Log.Entries[^1]);
|
||||
Assert.Equal(string.Equals(ending, "token", StringComparison.Ordinal), !world.Log.Entries.Exists(line => line.StartsWith("WARN", StringComparison.Ordinal)));
|
||||
Assert.All(world.Inboxes, inbox => Assert.True(inbox.Disposed));
|
||||
Assert.Equal(opened, world.Opened.Count);
|
||||
Assert.DoesNotContain(world.Log.Entries, line => line.Contains(Hint, StringComparison.Ordinal) || line.Contains(State, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A sign-out whose caller had already stopped waiting when it was started opens nothing: no inbox and no browser page. It ends as cancelled, and says so as information.</summary>
|
||||
[Fact]
|
||||
public async Task CallerThatHadAlreadyStopped_OpensNothing()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
using CancellationTokenSource cancellation = new();
|
||||
await cancellation.CancelAsync();
|
||||
|
||||
Assert.Equal(ProviderSignOutResult.Cancelled, await SignInWorld.EndsAsync(Over(world).CompleteAsync(Logout(), cancellation.Token)));
|
||||
|
||||
Assert.Empty(world.Inboxes);
|
||||
Assert.Empty(world.Opened);
|
||||
Assert.Equal(["INFO Identity provider sign-out was stopped by its caller before the provider confirmed."], world.Log.Entries);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The inbox fails, while the sign-out waits, in a way the sign-out does not list as a failure to report: the
|
||||
/// argument error the real inbox gives when the process that sent a callback has already exited, an overflow, a
|
||||
/// time-out. Nothing escapes: the sign-out is not confirmed, and the log names the kind of failure in fixed text
|
||||
/// and holds nothing of the failure's own text.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(nameof(ArgumentException))]
|
||||
[InlineData(nameof(OverflowException))]
|
||||
[InlineData(nameof(TimeoutException))]
|
||||
public async Task InboxFailsInAWayTheSignOutDoesNotList_IsNotConfirmedAndNothingEscapes(string kind)
|
||||
{
|
||||
SignInWorld world = new();
|
||||
|
||||
Task<ProviderSignOutResult> running = Over(world).CompleteAsync(Logout());
|
||||
Assert.Single(world.Inboxes).Fail(SignInWorld.UnlistedInboxFailure(kind));
|
||||
|
||||
Assert.Equal(ProviderSignOutResult.NotConfirmed, await SignInWorld.EndsAsync(running));
|
||||
Assert.Equal("WARN Identity provider sign-out was not confirmed: IOException: " + SignInWorld.InboxFailedReport(kind), world.Log.Entries[^1]);
|
||||
Assert.True(world.Inboxes[0].Disposed);
|
||||
Assert.DoesNotContain(world.Log.Entries, entry => entry.Contains(SignInWorld.UnlistedFailureText, StringComparison.Ordinal) || entry.Contains(Hint, StringComparison.Ordinal) || entry.Contains(State, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// When Windows cannot start the browser, the failure it gives names what it could not start: the whole
|
||||
/// end-session address, with the provider's ID token hint and this sign-out's state. The sign-out is reported as
|
||||
/// not opened, the log says that the default browser could not be opened, with the kind of failure and what to
|
||||
/// check, and no log line holds one of those values or any query at all. The same holds for a launcher that fails
|
||||
/// in another way and names the address in its text.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(true, BrowserStartFailure.WindowsReport)]
|
||||
[InlineData(false, BrowserStartFailure.OtherReport)]
|
||||
public async Task BrowserThatCannotBeStarted_IsReportedWithoutAnythingOfTheSignOutAddress(bool asWindows, string report)
|
||||
{
|
||||
QqqProviderLogout logout = new(BrowserStartFailure.SignOutAddress, new Uri(SignInWorld.Callback), BrowserStartFailure.State);
|
||||
SignInWorld world = new() { BrowserFailure = asWindows ? BrowserStartFailure.AsWindows(BrowserStartFailure.SignOutAddress) : BrowserStartFailure.AsAnotherKind(BrowserStartFailure.SignOutAddress) };
|
||||
Assert.True(BrowserStartFailure.Leaks(world.BrowserFailure.Message));
|
||||
|
||||
Assert.Equal(ProviderSignOutResult.NotOpened, await SignInWorld.EndsAsync(Over(world).CompleteAsync(logout)));
|
||||
|
||||
Assert.Equal(["INFO Browser callback inbox open.",
|
||||
"INFO Opening the identity provider's sign-out page in the default browser (id.example.com).",
|
||||
"WARN The identity provider's sign-out page was not opened: IOException: " + report], world.Log.Entries);
|
||||
Assert.DoesNotContain(world.Log.Entries, BrowserStartFailure.Leaks);
|
||||
Assert.Empty(world.Opened);
|
||||
Assert.True(Assert.Single(world.Inboxes).Disposed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A provider sign-out cannot work without a browser, the dispatcher and a log, and says which is missing; and it needs a sign-out to complete.</summary>
|
||||
[Fact]
|
||||
public async Task MissingCollaborator_IsRefused()
|
||||
{
|
||||
SignInWorld world = new();
|
||||
|
||||
Assert.Equal("browser", Assert.Throws<ArgumentNullException>(() => new ProviderSignOut(null!, world.Dispatcher, world.Log)).ParamName);
|
||||
Assert.Equal("dispatcher", Assert.Throws<ArgumentNullException>(() => new ProviderSignOut(world, null!, world.Log)).ParamName);
|
||||
Assert.Equal("log", Assert.Throws<ArgumentNullException>(() => new ProviderSignOut(world, world.Dispatcher, null!)).ParamName);
|
||||
Assert.Equal("logout", (await Assert.ThrowsAsync<ArgumentNullException>(() => new ProviderSignOut(world, world.Dispatcher, world.Log).CompleteAsync(null!))).ParamName);
|
||||
}
|
||||
}
|
||||
@@ -309,7 +309,8 @@ public sealed class SignInFlowBrowserTests
|
||||
/// <summary>
|
||||
/// A failure anywhere in the exchange (the inbox cannot be opened, the server refuses to begin, the browser cannot be started,
|
||||
/// the inbox breaks, a malformed or oversize callback is written to the inbox, the server rejects the callback) is reported as
|
||||
/// not completed with its reason and never escapes the flow; the session stays for an explicit retry.
|
||||
/// not completed with its reason and never escapes the flow; the session stays for an explicit retry. The reason is the
|
||||
/// failure's own text, except for a browser that cannot be started, whose text is replaced by the kind of failure.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("inbox")]
|
||||
@@ -346,8 +347,10 @@ public sealed class SignInFlowBrowserTests
|
||||
}
|
||||
await running;
|
||||
|
||||
Assert.Equal(new FeedbackMessage("Browser sign-in not completed", "No validated account session was received. Reason: " + failure.Message + " Check the server's advertised native sign-in grant and installed callback configuration, then try again explicitly. The activity log has the full detail.", FeedbackSeverity.Warning), fixture.Shown[^1]);
|
||||
Assert.Contains("ERROR Browser sign-in not completed: " + failure.GetType().Name + ": " + failure.Message, fixture.Log.Entries, StringComparer.Ordinal);
|
||||
string reason = step is "browser" ? BrowserStartFailure.WindowsReport : failure.Message;
|
||||
string logged = step is "browser" ? "IOException: " + reason : failure.GetType().Name + ": " + reason;
|
||||
Assert.Equal(new FeedbackMessage("Browser sign-in not completed", "No validated account session was received. Reason: " + reason + " Check the server's advertised native sign-in grant and installed callback configuration, then try again explicitly. The activity log has the full detail.", FeedbackSeverity.Warning), fixture.Shown[^1]);
|
||||
Assert.Contains("ERROR Browser sign-in not completed: " + logged, fixture.Log.Entries, StringComparer.Ordinal);
|
||||
Assert.Empty(fixture.Requests);
|
||||
Assert.True(fixture.Flow.HasSession);
|
||||
Assert.True(fixture.Flow.BrowserOffered);
|
||||
@@ -359,6 +362,74 @@ public sealed class SignInFlowBrowserTests
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The inbox fails, while the dialog's sign-in waits, in a way the flow does not list as a failure to report: the
|
||||
/// argument error the real inbox gives when the process that sent a callback has already exited, an overflow, a
|
||||
/// time-out. It does not escape the flow: the sign-in is reported as not completed with a reason that names the
|
||||
/// kind of failure in fixed text, the session stays for an explicit retry, and nothing of the failure's own text
|
||||
/// is logged or shown.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(nameof(ArgumentException))]
|
||||
[InlineData(nameof(OverflowException))]
|
||||
[InlineData(nameof(TimeoutException))]
|
||||
public async Task InboxFailsInAWayTheFlowDoesNotList_IsReportedByItsKindAndDoesNotEscape(string kind)
|
||||
{
|
||||
SignInFixture fixture = await DiscoveredAsync();
|
||||
string report = SignInWorld.InboxFailedReport(kind);
|
||||
|
||||
Task<bool> running = fixture.Flow.SignInWithBrowserAsync(PairingChoice.KeepCurrent);
|
||||
fixture.Inboxes[0].Fail(SignInWorld.UnlistedInboxFailure(kind));
|
||||
|
||||
Assert.True(await SignInFixture.EndsAsync(running));
|
||||
Assert.Equal(new FeedbackMessage("Browser sign-in not completed", "No validated account session was received. Reason: " + report + " Check the server's advertised native sign-in grant and installed callback configuration, then try again explicitly. The activity log has the full detail.", FeedbackSeverity.Warning), fixture.Shown[^1]);
|
||||
Assert.Contains("ERROR Browser sign-in not completed: IOException: " + report, fixture.Log.Entries, StringComparer.Ordinal);
|
||||
Assert.DoesNotContain(fixture.Log.Entries, entry => entry.Contains(SignInWorld.UnlistedFailureText, StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(fixture.Shown, message => message.Message.Contains(SignInWorld.UnlistedFailureText, StringComparison.Ordinal));
|
||||
Assert.Empty(fixture.Requests);
|
||||
Assert.Equal(SignInStage.Ready, fixture.Flow.Stage);
|
||||
Assert.True(fixture.Flow.HasSession);
|
||||
Assert.False(fixture.Flow.IsBusy);
|
||||
Assert.True(fixture.Inboxes[0].Disposed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// When Windows cannot start the browser, the failure it gives names what it could not start: the whole sign-in
|
||||
/// address, with the attempt's state, nonce and code challenge. None of that is logged or shown. The operator is
|
||||
/// told that the default browser could not be opened and what to check, the log says the same with the kind of
|
||||
/// failure, and no log line, message or property a view binds to holds one of those values or any query at all.
|
||||
/// The same holds for a launcher that fails in another way and names the address in its text.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(true, BrowserStartFailure.WindowsReport)]
|
||||
[InlineData(false, BrowserStartFailure.OtherReport)]
|
||||
public async Task BrowserThatCannotBeStarted_IsReportedWithoutAnythingOfTheSignInAddress(bool asWindows, string report)
|
||||
{
|
||||
SignInFixture fixture = await DiscoveredAsync();
|
||||
fixture.Sessions[0].AuthorizationOverride = BrowserStartFailure.SignInAddress;
|
||||
fixture.BrowserFailure = asWindows ? BrowserStartFailure.AsWindows(BrowserStartFailure.SignInAddress) : BrowserStartFailure.AsAnotherKind(BrowserStartFailure.SignInAddress);
|
||||
Assert.True(BrowserStartFailure.Leaks(fixture.BrowserFailure.Message));
|
||||
|
||||
await fixture.Flow.SignInWithBrowserAsync(PairingChoice.KeepCurrent);
|
||||
|
||||
Assert.Equal(new FeedbackMessage("Browser sign-in not completed", "No validated account session was received. Reason: " + report + " Check the server's advertised native sign-in grant and installed callback configuration, then try again explicitly. The activity log has the full detail.", FeedbackSeverity.Warning), fixture.Shown[^1]);
|
||||
Assert.Contains("ERROR Browser sign-in not completed: IOException: " + report, fixture.Log.Entries, StringComparer.Ordinal);
|
||||
Assert.Contains(fixture.Log.Entries, entry => entry.StartsWith("DEBUG Browser sign-in not completed detail: System.IO.IOException: " + report, StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(fixture.Log.Entries, BrowserStartFailure.Leaks);
|
||||
Assert.DoesNotContain(fixture.Shown, message => BrowserStartFailure.Leaks(message.Title) || BrowserStartFailure.Leaks(message.Message));
|
||||
Assert.DoesNotContain(BrowserStartFailure.Bound(fixture.Flow), BrowserStartFailure.Leaks);
|
||||
Assert.Empty(fixture.Opened);
|
||||
Assert.Empty(fixture.Requests);
|
||||
Assert.Equal(SignInStage.Ready, fixture.Flow.Stage);
|
||||
Assert.True(fixture.Flow.HasSession);
|
||||
Assert.False(fixture.Flow.IsBusy);
|
||||
Assert.All(fixture.Inboxes, inbox => Assert.True(inbox.Disposed));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>After a failed attempt the session can be used for a second one, which begins a new login of its own; a callback left over from the first attempt is refused by the server, and the callback of the second attempt signs in.</summary>
|
||||
[Fact]
|
||||
public async Task SecondAttemptOnTheSameSession_RefusesTheFirstAttemptsCallback()
|
||||
|
||||
@@ -310,14 +310,56 @@ public sealed class SignInFlowSignOutTests
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// When the provider does not come back within two minutes, the browser cannot be opened, or the caller cancels, the
|
||||
/// wait ends, the sign-out still completes locally, and the operator is warned that the provider did not confirm.
|
||||
/// When Windows cannot start the browser for the provider's sign-out, the failure it gives names what it could not
|
||||
/// start: the whole end-session address, with the provider's ID token hint and this sign-out's state. None of that
|
||||
/// is logged or shown. The log says that the default browser could not be opened, with the kind of failure and what
|
||||
/// to check. The sign-out still completes on this computer, and the operator is told what is true: the provider's
|
||||
/// page could not be opened, so the provider's session may still be there, and what to check. It is not told that a
|
||||
/// page "was opened in your browser", because none was. No log line, message or property a view binds to holds one
|
||||
/// of those values or any query at all. The same holds for a launcher that fails in another way and names the
|
||||
/// address in its text.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("limit", "TaskCanceledException")]
|
||||
[InlineData("browser", "Win32Exception: The system cannot find the file specified.")]
|
||||
[InlineData("token", "TaskCanceledException")]
|
||||
public async Task ProviderThatDoesNotReturn_IsReportedAsUnconfirmed(string ending, string cause)
|
||||
[InlineData(true, BrowserStartFailure.WindowsReport)]
|
||||
[InlineData(false, BrowserStartFailure.OtherReport)]
|
||||
public async Task ProviderSignOutPageThatCannotBeStarted_IsReportedWithoutAnythingOfTheSignOutAddress(bool asWindows, string report)
|
||||
{
|
||||
QqqProviderLogout logout = new(BrowserStartFailure.SignOutAddress, new Uri(SignInFixture.Callback), BrowserStartFailure.State);
|
||||
SignInFixture fixture = new()
|
||||
{
|
||||
Service = _ => Task.FromResult(JsonSerializer.Serialize(new PipeSignOutResult("Signed out of QQQ.", logout), PrintApiClient.JsonOptions)),
|
||||
BrowserFailure = asWindows ? BrowserStartFailure.AsWindows(BrowserStartFailure.SignOutAddress) : BrowserStartFailure.AsAnotherKind(BrowserStartFailure.SignOutAddress),
|
||||
};
|
||||
Assert.True(BrowserStartFailure.Leaks(fixture.BrowserFailure.Message));
|
||||
|
||||
Assert.True(await fixture.Flow.SignOutAsync());
|
||||
|
||||
Assert.Equal("WARN Identity provider sign-out was not confirmed: IOException: " + report, fixture.Log.Entries[^1]);
|
||||
Assert.Equal([new FeedbackMessage("Account sign-out", "Signed out of QQQ. Finish signing out of the identity provider on the page that opened in your browser.", FeedbackSeverity.Informational),
|
||||
new FeedbackMessage("Account sign-out", "Signed out of QQQ. The identity provider's sign-out page could not be opened in your browser, so you may still be signed in to the identity provider and the next sign-in may not ask for credentials. " + BrowserStartFailure.Remedy, FeedbackSeverity.Warning)], fixture.Shown);
|
||||
Assert.DoesNotContain(fixture.Shown, message => message.Message.Contains("was opened in your browser", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(fixture.Log.Entries, BrowserStartFailure.Leaks);
|
||||
Assert.DoesNotContain(fixture.Shown, message => BrowserStartFailure.Leaks(message.Title) || BrowserStartFailure.Leaks(message.Message));
|
||||
Assert.DoesNotContain(BrowserStartFailure.Bound(fixture.Flow), BrowserStartFailure.Leaks);
|
||||
Assert.Empty(fixture.Opened);
|
||||
Assert.True(Assert.Single(fixture.Inboxes).Disposed);
|
||||
Assert.Equal(SignInStage.Idle, fixture.Flow.Stage);
|
||||
Assert.False(fixture.Flow.IsBusy);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// When the provider does not come back within two minutes, the browser cannot be opened, or the caller cancels,
|
||||
/// the wait ends, the sign-out still completes locally, and the operator is warned. The warning says that the
|
||||
/// provider did not confirm, except for a browser that could not be opened, where it says that instead: no page
|
||||
/// was opened that could be closed.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("limit", "TaskCanceledException", "did not confirm; close that page and sign out there if it is still open.")]
|
||||
[InlineData("browser", "IOException: " + BrowserStartFailure.WindowsReport, "could not be opened in your browser, so you may still be signed in to the identity provider and the next sign-in may not ask for credentials. " + BrowserStartFailure.Remedy)]
|
||||
[InlineData("token", "TaskCanceledException", "did not confirm; close that page and sign out there if it is still open.")]
|
||||
public async Task ProviderThatDoesNotReturn_IsReportedAsUnconfirmed(string ending, string cause, string told)
|
||||
{
|
||||
SignInFixture fixture = WithProviderSignOut();
|
||||
fixture.BrowserFailure = string.Equals(ending, "browser", StringComparison.Ordinal) ? new Win32Exception(2, "The system cannot find the file specified.") : null;
|
||||
@@ -335,7 +377,7 @@ public sealed class SignInFlowSignOutTests
|
||||
|
||||
Assert.True(await SignInFixture.EndsAsync(running));
|
||||
Assert.Equal(FeedbackSeverity.Warning, fixture.Shown[^1].Severity);
|
||||
Assert.EndsWith("did not confirm; close that page and sign out there if it is still open.", fixture.Shown[^1].Message, StringComparison.Ordinal);
|
||||
Assert.EndsWith(told, fixture.Shown[^1].Message, StringComparison.Ordinal);
|
||||
Assert.Contains(fixture.Log.Entries, entry => entry.StartsWith("WARN Identity provider sign-out was not confirmed: " + cause, StringComparison.Ordinal));
|
||||
Assert.True(fixture.Inboxes[0].Disposed);
|
||||
Assert.False(fixture.Flow.IsBusy);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
using Qqq.PrintAgent.Presentation.SignIn;
|
||||
using Qqq.PrintAgent.Presentation.Tests.Fakes;
|
||||
using Qqq.PrintAgent.Windows.Management;
|
||||
|
||||
namespace Qqq.PrintAgent.Presentation.Tests.SignIn;
|
||||
|
||||
/// <summary>The sign-in methods on offer: what the service's check found, what a sign-in description offers, what both hold, and how an offer reads in a log line.</summary>
|
||||
public sealed class SignInOfferTests
|
||||
{
|
||||
/// <summary>The check's methods are read by the names the contract gives them, exactly; a name the companion does not know, or one in another letter case, is ignored.</summary>
|
||||
[Theory]
|
||||
[InlineData("password", true, false)]
|
||||
[InlineData("browser", false, true)]
|
||||
[InlineData("browser,password", true, true)]
|
||||
[InlineData("passkey,Password,BROWSER", false, false)]
|
||||
[InlineData("", false, false)]
|
||||
public void Checked_ReadsTheMethodsTheContractNames(string methods, bool password, bool browser)
|
||||
{
|
||||
Assert.Equal(new SignInOffer(password, browser), SignInOffer.Checked(new ServerCheckResult([], methods.Split(',', StringSplitOptions.RemoveEmptyEntries))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A description offers what the service's check would find in it: a table-based server takes a password and nothing else, even when it also publishes a browser grant; any other server offers the browser when it publishes the grant, and nothing without it.</summary>
|
||||
[Fact]
|
||||
public void Read_FollowsTheRuleOfTheServicesCheck()
|
||||
{
|
||||
QqqAuthenticationMetadata grant = SignInWorld.BrowserDescription();
|
||||
|
||||
Assert.Equal(new SignInOffer(true, false), SignInOffer.Read(new QqqAuthenticationMetadata { Type = "TABLE_BASED" }));
|
||||
Assert.Equal(new SignInOffer(true, false), SignInOffer.Read(new QqqAuthenticationMetadata { Type = "TABLE_BASED", Values = grant.Values }));
|
||||
Assert.Equal(new SignInOffer(false, true), SignInOffer.Read(grant));
|
||||
Assert.Equal(new SignInOffer(false, false), SignInOffer.Read(new QqqAuthenticationMetadata { Type = "OAUTH2" }));
|
||||
Assert.Equal(new SignInOffer(false, false), SignInOffer.Read(new QqqAuthenticationMetadata { Type = "OAUTH2", Values = new QqqAuthenticationMetadata.ProviderValues() }));
|
||||
Assert.Equal(new SignInOffer(false, false), SignInOffer.Read(new QqqAuthenticationMetadata { Type = "table_based" }));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>What two offers have in common is each method both hold, and an offer is something only when it holds a method.</summary>
|
||||
[Fact]
|
||||
public void Common_IsWhatBothHold()
|
||||
{
|
||||
Assert.Equal(new SignInOffer(true, false), new SignInOffer(true, true).Common(new SignInOffer(true, false)));
|
||||
Assert.Equal(new SignInOffer(false, true), new SignInOffer(false, true).Common(new SignInOffer(true, true)));
|
||||
Assert.Equal(new SignInOffer(false, false), new SignInOffer(true, false).Common(new SignInOffer(false, true)));
|
||||
Assert.True(new SignInOffer(true, false).Any);
|
||||
Assert.True(new SignInOffer(false, true).Any);
|
||||
Assert.False(new SignInOffer(false, false).Any);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>An offer reads in a log line by the names the contract gives the methods.</summary>
|
||||
[Theory]
|
||||
[InlineData(true, true, "password and browser")]
|
||||
[InlineData(true, false, "password")]
|
||||
[InlineData(false, true, "browser")]
|
||||
[InlineData(false, false, "no method")]
|
||||
public void ToString_NamesTheMethods(bool password, bool browser, string text)
|
||||
{
|
||||
Assert.Equal(text, new SignInOffer(password, browser).ToString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Each of the three needs what it reads from.</summary>
|
||||
[Fact]
|
||||
public void MissingArgument_IsRefused()
|
||||
{
|
||||
Assert.Equal("check", Assert.Throws<ArgumentNullException>(() => SignInOffer.Checked(null!)).ParamName);
|
||||
Assert.Equal("description", Assert.Throws<ArgumentNullException>(() => SignInOffer.Read(null!)).ParamName);
|
||||
Assert.Equal("other", Assert.Throws<ArgumentNullException>(() => new SignInOffer(true, true).Common(null!)).ParamName);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user