diff --git a/GVFS/GVFS.Common/GVFSConstants.cs b/GVFS/GVFS.Common/GVFSConstants.cs index 3dd946bff..030b6119f 100644 --- a/GVFS/GVFS.Common/GVFSConstants.cs +++ b/GVFS/GVFS.Common/GVFSConstants.cs @@ -105,6 +105,14 @@ public static class Endpoints public const string InfoRefs = "/info/refs?service=git-upload-pack"; } + public static class WellKnownObjects + { + // SKETCH (design proposal): the git empty-tree object. Its SHA is a fixed constant + // that every git server recognizes, so it is a safe, well-formed target for a + // credential probe - the probe URL never depends on possibly-corrupt request input. + public const string EmptyTreeSha = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; + } + public static class SpecialGitFiles { public const string GitAttributes = ".gitattributes"; diff --git a/GVFS/GVFS.Common/Http/GitObjectsHttpRequestor.cs b/GVFS/GVFS.Common/Http/GitObjectsHttpRequestor.cs index 2cdffcb8d..38f52f57b 100644 --- a/GVFS/GVFS.Common/Http/GitObjectsHttpRequestor.cs +++ b/GVFS/GVFS.Common/Http/GitObjectsHttpRequestor.cs @@ -29,6 +29,58 @@ public GitObjectsHttpRequestor(ITracer tracer, Enlistment enlistment, CacheServe public CacheServerInfo CacheServer { get; private set; } + /// + /// SKETCH (design proposal). Probe the cache server's own objects endpoint with a + /// known-good, well-formed SHA (the git empty tree). This exercises the SAME host and + /// auth path that returned the 400, so the probe's status cleanly separates + /// "credential is bad" (401/302) from "the request was malformed" (any other status). + /// A 404 here still proves auth succeeded - we reached "not found" past the auth gate. + /// + /// + /// SKETCH (design proposal). Probe the objects endpoint of the SAME host that returned the + /// 400 (cache server or origin) with a known-good, well-formed SHA (the git empty tree), so + /// the probe exercises the same auth path. A 404 here still proves auth succeeded - we + /// reached "not found" past the auth gate. + /// + protected override Uri GetCredentialProbeUri(Uri failedRequestUri) + { + string objectsEndpoint = null; + + if (this.CacheServer != null && + !string.IsNullOrEmpty(this.CacheServer.ObjectsEndpointUrl) && + HostMatches(failedRequestUri, this.CacheServer.ObjectsEndpointUrl)) + { + objectsEndpoint = this.CacheServer.ObjectsEndpointUrl; + } + else if (!string.IsNullOrEmpty(this.enlistment.RepoUrl)) + { + // The 400 came from origin (or the host could not be matched to the cache server). + objectsEndpoint = this.enlistment.RepoUrl + GVFSConstants.Endpoints.GVFSObjects; + } + + if (string.IsNullOrEmpty(objectsEndpoint)) + { + return null; + } + + try + { + return new Uri(objectsEndpoint.TrimEnd('/') + "/" + GVFSConstants.WellKnownObjects.EmptyTreeSha); + } + catch (UriFormatException) + { + // A malformed endpoint cannot be probed; caller treats null as "do not reject". + return null; + } + } + + private static bool HostMatches(Uri uri, string candidateUrl) + { + return uri != null && + Uri.TryCreate(candidateUrl, UriKind.Absolute, out Uri candidate) && + string.Equals(uri.Host, candidate.Host, StringComparison.OrdinalIgnoreCase); + } + public virtual List QueryForFileSizes(IEnumerable objectIds, CancellationToken cancellationToken) { long requestId = HttpRequestor.GetNewRequestId(); diff --git a/GVFS/GVFS.Common/Http/HttpRequestor.cs b/GVFS/GVFS.Common/Http/HttpRequestor.cs index 0f9767dde..a2ddf74c7 100644 --- a/GVFS/GVFS.Common/Http/HttpRequestor.cs +++ b/GVFS/GVFS.Common/Http/HttpRequestor.cs @@ -19,6 +19,15 @@ public abstract class HttpRequestor : IDisposable private const int ConnectionPoolWaitTimeoutMs = 30_000; private const int ConnectionPoolContentionThresholdMs = 100; + // SKETCH (design proposal): the credential probe is a diagnostic side-request; keep + // it short so a confirmed-bad credential is not delayed by a slow probe. + private static readonly TimeSpan CredentialProbeTimeout = TimeSpan.FromSeconds(15); + + // SKETCH (design proposal): how long a probe result is reused for the same credential. + // Single-flighting plus this short cache stops a burst of concurrent 400s from fanning + // out into a burst of probes (and RejectCredentials calls). + private static readonly TimeSpan CredentialProbeResultTtl = TimeSpan.FromSeconds(30); + private static long requestCount = 0; private static SemaphoreSlim availableConnections; private static int connectionLimitConfigured = 0; @@ -29,6 +38,19 @@ public abstract class HttpRequestor : IDisposable private HttpClient client; + // SKETCH (design proposal): a separate client for the credential probe. The probe must + // OBSERVE a 302 sign-in redirect as its auth-failure signal, so unlike the main client it + // must NOT auto-follow redirects. Following a same-host redirect would also re-send the + // Basic auth header to an unintended endpoint. SSL config is applied identically. + private HttpClient probeClient; + + // SKETCH (design proposal): single-flight + short-lived memoization of the probe result, + // keyed by the credential that was probed. Guards against a concurrent-400 probe/reject herd. + private readonly object credentialProbeLock = new object(); + private string lastProbedAuthString; + private bool lastProbeRejectResult; + private DateTime lastProbeTimeUtc = DateTime.MinValue; + static HttpRequestor() { // HTTP downloads are I/O-bound, not CPU-bound, so we default to @@ -77,6 +99,25 @@ protected HttpRequestor(ITracer tracer, RetryConfig retryConfig, Enlistment enli DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact, }; + // SKETCH (design proposal): dedicated probe client with redirects disabled so the + // probe can see a 302 instead of silently following it to a 200 sign-in page. + SocketsHttpHandler probeHandler = new SocketsHttpHandler() + { + MaxConnectionsPerServer = Environment.ProcessorCount, + PooledConnectionLifetime = Timeout.InfiniteTimeSpan, + PooledConnectionIdleTimeout = TimeSpan.FromMinutes(5), + AllowAutoRedirect = false, + }; + + this.authentication.ConfigureSocketsHandlerSslIfNeeded(this.Tracer, probeHandler, enlistment.CreateGitProcess()); + + this.probeClient = new HttpClient(probeHandler) + { + Timeout = retryConfig.Timeout, + DefaultRequestVersion = HttpVersion.Version11, + DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact, + }; + this.userAgentHeader = new ProductInfoHeaderValue(ProcessHelper.GetEntryClassName(), ProcessHelper.GetCurrentProcessVersion()); } @@ -96,6 +137,12 @@ public void Dispose() this.client.Dispose(); this.client = null; } + + if (this.probeClient != null) + { + this.probeClient.Dispose(); + this.probeClient = null; + } } protected GitEndPointResponseData SendRequest( @@ -176,6 +223,10 @@ protected GitEndPointResponseData SendRequest( GitEndPointResponseData gitEndPointResponseData = null; HttpResponseMessage response = null; + // SKETCH (design proposal): tracks whether the credential probe already released the + // logical connection slot, so the response-disposed handler does not double-release. + bool connectionSlotReleased = false; + try { requestStopwatch.Restart(); @@ -232,29 +283,71 @@ protected GitEndPointResponseData SendRequest( shouldRetry = false; errorMessage = "Anonymous request was rejected with a 401"; } - else if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.BadRequest || response.StatusCode == HttpStatusCode.Redirect) + else { - this.authentication.RejectCredentials(this.Tracer, authString); - if (!this.authentication.IsBackingOff) + // SKETCH (design proposal): a bare 400 is normally a request/formatting + // problem, NOT an expired credential (an expired/invalid credential + // returns 401 or 302). The one 400 that can mean "no credential reached + // the server" is the missing Basic-auth-header case. Before erasing a + // possibly-good credential we re-send the SAME credential to a known-good, + // auth-enforced endpoint; only a probe that ALSO fails auth (401/302) + // proves a real credential failure. + bool badRequestConfirmedByProbe = false; + if (response.StatusCode == HttpStatusCode.BadRequest && + !this.authentication.IsAnonymous) + { + // Free the logical connection slot BEFORE the (up-to-15s) probe: the + // error body has already been fully read, so the outer request no + // longer needs the slot, and the probe uses its own HttpClient/handler + // so it does not contend for this pool. This prevents a burst of 400s + // from pinning every slot for the probe duration and starving others. + availableConnections.Release(); + connectionSlotReleased = true; + + badRequestConfirmedByProbe = + this.CredentialProbeConfirmsAuthFailure(requestId, requestUri, authString, cancellationToken); + } + + if (ShouldRejectCredentials(response.StatusCode) || badRequestConfirmedByProbe) { - errorMessage = string.Format("Server returned error code {0} ({1}). Your PAT may be expired and we are asking for a new one. Original error message from server: {2}", statusInt, response.StatusCode, errorMessage); + // A probe-confirmed 400 is a real auth failure, so it must join the + // same reject-and-retry contract as a 401: reject the credential AND + // allow a retry so the caller re-authenticates. A bare 400 stays + // non-retryable (ShouldRetry is false for it) - only the confirmed + // case opts back into retry, otherwise the reject is a useless erase. + if (badRequestConfirmedByProbe) + { + shouldRetry = true; + } + + this.authentication.RejectCredentials(this.Tracer, authString); + if (!this.authentication.IsBackingOff) + { + errorMessage = string.Format("Server returned error code {0} ({1}). Your PAT may be expired and we are asking for a new one. Original error message from server: {2}", statusInt, response.StatusCode, errorMessage); + } + else + { + errorMessage = string.Format("Server returned error code {0} ({1}) after successfully renewing your PAT. You may not have access to this repo. Original error message from server: {2}", statusInt, response.StatusCode, errorMessage); + } } else { - errorMessage = string.Format("Server returned error code {0} ({1}) after successfully renewing your PAT. You may not have access to this repo. Original error message from server: {2}", statusInt, response.StatusCode, errorMessage); + errorMessage = string.Format("Server returned error code {0} ({1}). Original error message from server: {2}", statusInt, response.StatusCode, errorMessage); } } - else - { - errorMessage = string.Format("Server returned error code {0} ({1}). Original error message from server: {2}", statusInt, response.StatusCode, errorMessage); - } gitEndPointResponseData = new GitEndPointResponseData( response.StatusCode, new GitObjectsHttpException(response.StatusCode, errorMessage), shouldRetry, message: response, - onResponseDisposed: () => availableConnections.Release()); + onResponseDisposed: () => + { + if (!connectionSlotReleased) + { + availableConnections.Release(); + } + }); } } catch (TaskCanceledException) @@ -326,6 +419,168 @@ private static bool ShouldRetry(HttpStatusCode statusCode) return false; } + /// + /// Determines whether an HTTP status code indicates an authentication failure + /// that warrants rejecting (erasing) the stored credential. + /// + /// + /// Only 401 (Unauthorized) and 302 (Redirect to the Azure DevOps sign-in page) + /// are genuine authentication failures. A 400 (Bad Request) is a request/formatting + /// problem, NOT an expired credential - an expired or invalid credential always + /// returns 401 or 302. + /// + internal static bool ShouldRejectCredentials(HttpStatusCode statusCode) + { + return statusCode == HttpStatusCode.Unauthorized || + statusCode == HttpStatusCode.Redirect; + } + + /// + /// SKETCH (design proposal). Returns the URI of a lightweight, auth-enforced, + /// guaranteed-well-formed endpoint that can be used to confirm whether the current + /// credential is still valid. Returns null when this requestor cannot probe (the + /// caller then treats an ambiguous 400 conservatively and does NOT reject). + /// + /// The URI of the request that returned the 400, so the + /// probe can target the SAME host (cache vs origin) and exercise the same auth path. + /// + /// The probe URI MUST be built from a constant we control - never from the request + /// input that produced the 400 (that input may be the corrupt value that caused it). + /// Derived requestors override this to point at a known-good object on the same host + /// that returned the 400. + /// + protected virtual Uri GetCredentialProbeUri(Uri failedRequestUri) + { + return null; + } + + /// + /// SKETCH (design proposal). Re-sends the SAME credential to the known-good probe + /// endpoint to decide whether a 400 actually reflects a bad credential. Single-flighted + /// and memoized per credential for a short TTL so concurrent 400s do not fan out. + /// + /// + /// true only when the probe itself fails authentication (401/302) - i.e. the + /// credential really is bad and should be rejected. false when the probe succeeds, + /// returns any non-auth status (e.g. 200/404 - both prove auth passed), or cannot + /// run (no probe URI / transport error). The decisive signal is "did the probe get + /// past auth", so ANY response other than 401/302 means the credential is good. + /// + internal bool CredentialProbeConfirmsAuthFailure(long requestId, Uri failedRequestUri, string authString, CancellationToken cancellationToken) + { + lock (this.credentialProbeLock) + { + if (this.lastProbedAuthString == authString && + DateTime.UtcNow - this.lastProbeTimeUtc < CredentialProbeResultTtl) + { + // Reuse the recent result for this exact credential (single-flight/memoize). + return this.lastProbeRejectResult; + } + + bool reject = this.RunCredentialProbe(requestId, failedRequestUri, authString, cancellationToken); + + this.lastProbedAuthString = authString; + this.lastProbeRejectResult = reject; + this.lastProbeTimeUtc = DateTime.UtcNow; + return reject; + } + } + + private bool RunCredentialProbe(long requestId, Uri failedRequestUri, string authString, CancellationToken cancellationToken) + { + Uri probeUri = this.GetCredentialProbeUri(failedRequestUri); + if (probeUri == null) + { + // Cannot probe - be conservative and do NOT reject a possibly-good credential. + return false; + } + + Stopwatch probeStopwatch = Stopwatch.StartNew(); + bool probed = this.TryProbeCredential(probeUri, authString, cancellationToken, out HttpStatusCode probeStatus); + TimeSpan probeElapsed = probeStopwatch.Elapsed; + + if (!probed) + { + // Transport failure probing - inconclusive, so do NOT reject. + return false; + } + + bool reject = ShouldRejectCredentials(probeStatus); + + EventMetadata metadata = new EventMetadata(); + metadata.Add("Area", "Authentication"); + metadata.Add("RequestId", requestId); + metadata.Add(nameof(probeUri), probeUri.ToString()); + metadata.Add(nameof(probeStatus), probeStatus.ToString()); + metadata.Add("probeElapsedMS", $"{probeElapsed.TotalMilliseconds:F4}"); + metadata.Add("rejectCredential", reject); + + // A 200 or a 404 both prove auth passed (a 404 means we reached "object not found" + // past the auth gate). We deliberately KEEP the credential on any non-401/302 status, + // erring toward keeping a possibly-good credential over re-triggering a popup storm. + // Flag genuinely unexpected statuses so an endpoint that masks auth failures behind an + // unusual code is visible in telemetry rather than silently trusted. + if (!reject && + probeStatus != HttpStatusCode.OK && + probeStatus != HttpStatusCode.NotFound) + { + metadata.Add("probeStatusAmbiguous", true); + } + + this.Tracer.RelatedInfo(metadata, "Credential probe after HTTP 400 completed"); + + return reject; + } + + /// + /// SKETCH (design proposal). One-shot, no-retry GET to the probe endpoint carrying + /// the same Basic auth header as the original request. Reads only the status code. + /// Deliberately separate from so it never re-enters the + /// 400/401 handling (no recursion, no retry, no circuit-breaker interaction), and uses + /// the redirect-disabled probe client so a 302 sign-in redirect is observed, not followed. + /// Protected virtual as a test seam so the probe decision can be unit-tested without a + /// live network. + /// + protected virtual bool TryProbeCredential(Uri probeUri, string authString, CancellationToken cancellationToken, out HttpStatusCode probeStatus) + { + probeStatus = default(HttpStatusCode); + + try + { + using (HttpRequestMessage probe = new HttpRequestMessage(HttpMethod.Get, probeUri)) + { + probe.Headers.Add("X-TFS-FedAuthRedirect", "Suppress"); + probe.Headers.UserAgent.Add(this.userAgentHeader); + if (!this.authentication.IsAnonymous) + { + probe.Headers.Authorization = new AuthenticationHeaderValue("Basic", authString); + } + + // Bound the probe by its own timeout AND honor the caller's cancellation so a + // cancelled mount/prefetch is not held hostage by the probe. + using (CancellationTokenSource timeout = new CancellationTokenSource(CredentialProbeTimeout)) + using (CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, cancellationToken)) + using (HttpResponseMessage probeResponse = this.probeClient.SendAsync( + probe, + HttpCompletionOption.ResponseHeadersRead, + linked.Token).GetAwaiter().GetResult()) + { + probeStatus = probeResponse.StatusCode; + return true; + } + } + } + catch (Exception e) when (e is HttpRequestException || e is TaskCanceledException || e is OperationCanceledException) + { + EventMetadata metadata = new EventMetadata(); + metadata.Add("Area", "Authentication"); + metadata.Add(nameof(probeUri), probeUri.ToString()); + metadata.Add("Exception", e.ToString()); + this.Tracer.RelatedWarning(metadata, "Credential probe after HTTP 400 could not complete; treating as inconclusive"); + return false; + } + } + private static string GetSingleHeaderOrEmpty(HttpHeaders headers, string headerName) { IEnumerable values; diff --git a/GVFS/GVFS.UnitTests/Http/CredentialProbeBehaviorTests.cs b/GVFS/GVFS.UnitTests/Http/CredentialProbeBehaviorTests.cs new file mode 100644 index 000000000..2a996a8d1 --- /dev/null +++ b/GVFS/GVFS.UnitTests/Http/CredentialProbeBehaviorTests.cs @@ -0,0 +1,139 @@ +using System; +using System.Net; +using System.Threading; +using GVFS.Common; +using GVFS.Common.Git; +using GVFS.Common.Http; +using GVFS.Tests.Should; +using GVFS.UnitTests.Mock; +using GVFS.UnitTests.Mock.Common; +using NUnit.Framework; + +namespace GVFS.UnitTests.Http +{ + /// + /// SKETCH (design proposal). Exercises the actual probe decision in + /// via a test seam that overrides + /// the network send, so the four decision paths (probe 401/302 = reject; probe 200/404 = keep; + /// no probe URI = keep; transport failure = keep) and the single-flight memoization are covered. + /// + [TestFixture] + public class CredentialProbeBehaviorTests + { + private static readonly Uri FailedRequestUri = new Uri("mock://repo/gvfs/objects/badsha"); + + [TestCase] + public void Probe401ConfirmsAuthFailure() + { + ProbeTestableHttpGitObjects dut = new ProbeTestableHttpGitObjects(ProbeUri(), HttpStatusCode.Unauthorized); + dut.CredentialProbeConfirmsAuthFailure(1, FailedRequestUri, "authString", CancellationToken.None) + .ShouldEqual(true, "A probe that returns 401 confirms the credential is bad"); + dut.ProbeCallCount.ShouldEqual(1); + } + + [TestCase] + public void Probe302ConfirmsAuthFailure() + { + ProbeTestableHttpGitObjects dut = new ProbeTestableHttpGitObjects(ProbeUri(), HttpStatusCode.Redirect); + dut.CredentialProbeConfirmsAuthFailure(1, FailedRequestUri, "authString", CancellationToken.None) + .ShouldEqual(true, "A probe that returns 302 (sign-in redirect) confirms the credential is bad"); + } + + [TestCase] + public void Probe200KeepsCredential() + { + ProbeTestableHttpGitObjects dut = new ProbeTestableHttpGitObjects(ProbeUri(), HttpStatusCode.OK); + dut.CredentialProbeConfirmsAuthFailure(1, FailedRequestUri, "authString", CancellationToken.None) + .ShouldEqual(false, "A probe that returns 200 proves the credential is valid - keep it"); + } + + [TestCase] + public void Probe404KeepsCredential() + { + // A 404 proves auth passed: we reached "object not found" past the auth gate. + ProbeTestableHttpGitObjects dut = new ProbeTestableHttpGitObjects(ProbeUri(), HttpStatusCode.NotFound); + dut.CredentialProbeConfirmsAuthFailure(1, FailedRequestUri, "authString", CancellationToken.None) + .ShouldEqual(false, "A probe that returns 404 proves auth passed - keep the credential"); + } + + [TestCase] + public void TransportFailureKeepsCredential() + { + // probeStatus null => TryProbeCredential returns false (inconclusive). + ProbeTestableHttpGitObjects dut = new ProbeTestableHttpGitObjects(ProbeUri(), probeStatus: null); + dut.CredentialProbeConfirmsAuthFailure(1, FailedRequestUri, "authString", CancellationToken.None) + .ShouldEqual(false, "An inconclusive probe (transport failure) must NOT reject the credential"); + } + + [TestCase] + public void NoProbeUriKeepsCredentialWithoutProbing() + { + ProbeTestableHttpGitObjects dut = new ProbeTestableHttpGitObjects(probeUri: null, probeStatus: HttpStatusCode.Unauthorized); + dut.CredentialProbeConfirmsAuthFailure(1, FailedRequestUri, "authString", CancellationToken.None) + .ShouldEqual(false, "With no probe URI the credential must be kept (conservative)"); + dut.ProbeCallCount.ShouldEqual(0, "No probe should be sent when there is no probe URI"); + } + + [TestCase] + public void RepeatedProbesForSameCredentialAreSingleFlighted() + { + ProbeTestableHttpGitObjects dut = new ProbeTestableHttpGitObjects(ProbeUri(), HttpStatusCode.NotFound); + + bool first = dut.CredentialProbeConfirmsAuthFailure(1, FailedRequestUri, "sameAuth", CancellationToken.None); + bool second = dut.CredentialProbeConfirmsAuthFailure(2, FailedRequestUri, "sameAuth", CancellationToken.None); + + first.ShouldEqual(false); + second.ShouldEqual(false); + dut.ProbeCallCount.ShouldEqual(1, "The second 400 for the same credential should reuse the memoized probe result"); + } + + [TestCase] + public void DifferentCredentialTriggersFreshProbe() + { + ProbeTestableHttpGitObjects dut = new ProbeTestableHttpGitObjects(ProbeUri(), HttpStatusCode.NotFound); + + dut.CredentialProbeConfirmsAuthFailure(1, FailedRequestUri, "authOne", CancellationToken.None); + dut.CredentialProbeConfirmsAuthFailure(2, FailedRequestUri, "authTwo", CancellationToken.None); + + dut.ProbeCallCount.ShouldEqual(2, "A different credential must not reuse the previous credential's probe result"); + } + + private static Uri ProbeUri() + { + return new Uri("mock://cache/gvfs/objects/4b825dc642cb6eb9a060e54bf8d69288fbee4904"); + } + + private sealed class ProbeTestableHttpGitObjects : GitObjectsHttpRequestor + { + private readonly Uri probeUri; + private readonly HttpStatusCode? probeStatus; + + public ProbeTestableHttpGitObjects(Uri probeUri, HttpStatusCode? probeStatus) + : base(new MockTracer(), new MockGVFSEnlistment(), new MockCacheServerInfo(), new RetryConfig(maxRetries: 1)) + { + this.probeUri = probeUri; + this.probeStatus = probeStatus; + } + + public int ProbeCallCount { get; private set; } + + protected override Uri GetCredentialProbeUri(Uri failedRequestUri) + { + return this.probeUri; + } + + protected override bool TryProbeCredential(Uri probeUri, string authString, CancellationToken cancellationToken, out HttpStatusCode probeStatus) + { + this.ProbeCallCount++; + if (this.probeStatus.HasValue) + { + probeStatus = this.probeStatus.Value; + return true; + } + + probeStatus = default(HttpStatusCode); + return false; + } + } + } +} diff --git a/GVFS/GVFS.UnitTests/Http/CredentialProbeDecisionTests.cs b/GVFS/GVFS.UnitTests/Http/CredentialProbeDecisionTests.cs new file mode 100644 index 000000000..032586f0c --- /dev/null +++ b/GVFS/GVFS.UnitTests/Http/CredentialProbeDecisionTests.cs @@ -0,0 +1,53 @@ +using System.Net; +using GVFS.Common.Http; +using GVFS.Tests.Should; +using NUnit.Framework; + +namespace GVFS.UnitTests.Http +{ + /// + /// SKETCH (design proposal). Covers the decisive-signal rule the credential probe relies + /// on: after a 400, we re-send the same credential to a known-good endpoint and reject the + /// credential ONLY when that probe itself fails authentication (401/302). Any other probe + /// status - including 200 and 404 - proves the credential got past auth and must be kept. + /// + [TestFixture] + public class CredentialProbeDecisionTests + { + [TestCase] + public void Probe401MeansRejectCredential() + { + HttpRequestor.ShouldRejectCredentials(HttpStatusCode.Unauthorized) + .ShouldEqual(true, "A 401 probe response is a real auth failure - reject the credential"); + } + + [TestCase] + public void Probe302MeansRejectCredential() + { + HttpRequestor.ShouldRejectCredentials(HttpStatusCode.Redirect) + .ShouldEqual(true, "A 302 probe response is the sign-in redirect - reject the credential"); + } + + [TestCase] + public void Probe200MeansKeepCredential() + { + HttpRequestor.ShouldRejectCredentials(HttpStatusCode.OK) + .ShouldEqual(false, "A 200 probe response proves the credential is valid - keep it"); + } + + [TestCase] + public void Probe404MeansKeepCredential() + { + // A 404 proves auth passed: we reached "object not found" past the auth gate. + HttpRequestor.ShouldRejectCredentials(HttpStatusCode.NotFound) + .ShouldEqual(false, "A 404 probe response proves auth passed - keep the credential"); + } + + [TestCase] + public void Probe400MeansKeepCredential() + { + HttpRequestor.ShouldRejectCredentials(HttpStatusCode.BadRequest) + .ShouldEqual(false, "Even a 400 probe response is not an auth failure - keep the credential"); + } + } +}