Bound authentication response recognition

This commit is contained in:
2026-11-05 13:49:51 +00:00
parent 1c075bd510
commit 7be677c80b
6 changed files with 93 additions and 7 deletions

View File

@@ -125,3 +125,17 @@ func TestBundledSmokeSelectionUsesEffectiveConfiguration(t *testing.T) {
} }
} }
} }
func TestBundledSmokeAuthenticationResponseOverflow(t *testing.T) {
transport := roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(strings.Repeat("x", (1<<20)+1)))}, nil
})
engine, err := promptkit.NewEngine(promptkit.Config{HTTPClient: &http.Client{Transport: transport}})
if err != nil {
t.Fatal(err)
}
report, err := engine.SmokeTest(context.Background(), promptkit.SmokeTestRequest{Kind: promptkit.SmokeTestAuthentication, BackendID: "openrouter", APIKey: "private-key-marker"})
if err != nil || report == nil || !report.Completed || report.Reason != promptkit.SmokeTestReasonAuthenticationResponseLimitExceeded || report.Failure != promptkit.ExecutionFailureOther || report.Facts.CredentialsAccepted != nil {
t.Fatalf("overflow = %+v, %v", report, err)
}
}

View File

@@ -224,6 +224,10 @@ limit plus one, missing/inaccurate lengths, malformed bounded bodies, closure,
401 without reading, cancellation, and redacted explicit overflow. Keep this 401 without reading, cancellation, and redacted explicit overflow. Keep this
fix independent of the later protocol relocation. fix independent of the later protocol relocation.
Completion: authentication recognition reads at most 1 MiB plus one byte and
reports a distinct overflow reason without asserting credential rejection.
Focused authentication and public smoke tests pass on Go 1.27.3.
## Stage 8: Correct Artifact Result Ownership ## Stage 8: Correct Artifact Result Ownership
**Catalog:** 4. **Files:** public ArtifactReader GoDoc, adapter and ownership tests. **Catalog:** 4. **Files:** public ArtifactReader GoDoc, adapter and ownership tests.

View File

@@ -112,12 +112,13 @@ type SmokeTestAttempt struct {
type SmokeTestReason string type SmokeTestReason string
const ( const (
SmokeTestReasonNone SmokeTestReason = "" SmokeTestReasonNone SmokeTestReason = ""
SmokeTestReasonAuthenticationNotApplicable SmokeTestReason = "authentication_not_applicable" SmokeTestReasonAuthenticationNotApplicable SmokeTestReason = "authentication_not_applicable"
SmokeTestReasonProbeNotConfigured SmokeTestReason = "probe_not_configured" SmokeTestReasonProbeNotConfigured SmokeTestReason = "probe_not_configured"
SmokeTestReasonHTTPDiagnosticsUnavailable SmokeTestReason = "http_diagnostics_unavailable" SmokeTestReasonHTTPDiagnosticsUnavailable SmokeTestReason = "http_diagnostics_unavailable"
SmokeTestReasonRequiredCredentialMissing SmokeTestReason = "required_credential_missing" SmokeTestReasonRequiredCredentialMissing SmokeTestReason = "required_credential_missing"
SmokeTestReasonAuthenticationResponseUnrecognized SmokeTestReason = "authentication_response_unrecognized" SmokeTestReasonAuthenticationResponseUnrecognized SmokeTestReason = "authentication_response_unrecognized"
SmokeTestReasonAuthenticationResponseLimitExceeded SmokeTestReason = "authentication_response_limit_exceeded"
) )
// CloneBackend returns a copy of a backend, including its mutable settings. // CloneBackend returns a copy of a backend, including its mutable settings.

View File

@@ -17,6 +17,8 @@ type authenticationProbe struct {
client smokeHTTPGetter client smokeHTTPGetter
} }
const maxAuthenticationResponseBytes = 1 << 20
type authenticationProbeOutcome struct { type authenticationProbeOutcome struct {
supported bool supported bool
applicable *bool applicable *bool
@@ -104,7 +106,17 @@ func (p authenticationProbe) runOpenRouterKey(
outcome.failure = domain.ExecutionFailureMalformedResponse outcome.failure = domain.ExecutionFailureMalformedResponse
return outcome, nil return outcome, nil
} }
recognized, bodyErr := recognizedOpenRouterKeyResponse(response.Body) contents, bodyErr := io.ReadAll(io.LimitReader(response.Body, maxAuthenticationResponseBytes+1))
if len(contents) > maxAuthenticationResponseBytes {
outcome.reason = domain.SmokeTestReasonAuthenticationResponseLimitExceeded
outcome.failure = domain.ExecutionFailureOther
return outcome, nil
}
if bodyErr != nil {
outcome.failure = httpProbeFailure(ctx, bodyErr)
return outcome, nil
}
recognized, bodyErr := recognizedOpenRouterKeyResponse(bytes.NewReader(contents))
if bodyErr != nil { if bodyErr != nil {
outcome.failure = httpProbeFailure(ctx, bodyErr) outcome.failure = httpProbeFailure(ctx, bodyErr)
return outcome, nil return outcome, nil

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"strings" "strings"
"testing" "testing"
@@ -11,6 +12,57 @@ import (
"go.maximumdirect.net/promptkit/internal/domain" "go.maximumdirect.net/promptkit/internal/domain"
) )
type measuredAuthenticationBody struct {
reader io.Reader
read int
closed bool
}
func (b *measuredAuthenticationBody) Read(p []byte) (int, error) {
n, err := b.reader.Read(p)
b.read += n
return n, err
}
func (b *measuredAuthenticationBody) Close() error { b.closed = true; return nil }
func TestAuthenticationResponseRecognitionBound(t *testing.T) {
valid := `{"data":{"label":"member","is_free_tier":false,"usage":0}}`
for _, test := range []struct {
name string
size int
declared int64
status int
overflow bool
}{
{"exact limit", maxAuthenticationResponseBytes, -1, 200, false},
{"overflow unknown length", maxAuthenticationResponseBytes + 10, -1, 200, true},
{"overflow understated length", maxAuthenticationResponseBytes + 10, 1, 200, true},
{"unauthorized does not read", maxAuthenticationResponseBytes + 10, -1, 401, false},
} {
t.Run(test.name, func(t *testing.T) {
body := &measuredAuthenticationBody{reader: strings.NewReader(valid + strings.Repeat(" ", test.size-len(valid)))}
client := reachabilityClient(t, func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: test.status, Header: make(http.Header), Body: body, ContentLength: test.declared}, nil
})
outcome, err := (authenticationProbe{client: client}).run(context.Background(), backendWithAuthenticationKind("openrouter_key"), domain.SmokeTestTarget{Endpoint: "https://example.invalid/key"})
if err != nil || !body.closed {
t.Fatalf("outcome = %+v, err = %v, closed = %v", outcome, err, body.closed)
}
if test.overflow {
if outcome.reason != domain.SmokeTestReasonAuthenticationResponseLimitExceeded || outcome.failure != domain.ExecutionFailureOther || outcome.credentialsAccepted != nil || body.read != maxAuthenticationResponseBytes+1 {
t.Fatalf("overflow = %+v, read = %d", outcome, body.read)
}
} else if test.status == 401 {
if body.read != 0 || outcome.credentialsAccepted == nil || *outcome.credentialsAccepted {
t.Fatalf("unauthorized = %+v, read = %d", outcome, body.read)
}
} else if outcome.credentialsAccepted == nil || !*outcome.credentialsAccepted {
t.Fatalf("exact limit = %+v", outcome)
}
})
}
}
func TestAuthenticationProbeApplicabilityAndHTTPAvailability(t *testing.T) { func TestAuthenticationProbeApplicabilityAndHTTPAvailability(t *testing.T) {
client := reachabilityClient(t, func(*http.Request) (*http.Response, error) { client := reachabilityClient(t, func(*http.Request) (*http.Response, error) {
t.Fatal("authentication applicability unexpectedly dispatched") t.Fatal("authentication applicability unexpectedly dispatched")

View File

@@ -219,6 +219,9 @@ const (
// SmokeTestReasonAuthenticationResponseUnrecognized marks an HTTP response // SmokeTestReasonAuthenticationResponseUnrecognized marks an HTTP response
// whose authentication meaning could not be established. // whose authentication meaning could not be established.
SmokeTestReasonAuthenticationResponseUnrecognized SmokeTestReason = "authentication_response_unrecognized" SmokeTestReasonAuthenticationResponseUnrecognized SmokeTestReason = "authentication_response_unrecognized"
// SmokeTestReasonAuthenticationResponseLimitExceeded indicates that the
// authentication response exceeded the recognition allowance.
SmokeTestReasonAuthenticationResponseLimitExceeded SmokeTestReason = "authentication_response_limit_exceeded"
) )
// SmokeTest runs one built-in diagnostic against the engine's captured // SmokeTest runs one built-in diagnostic against the engine's captured