iop/apps/edge/internal/openai/provider_tunnel_auth_test.go
toki 01dc2ef78b refactor: readability baseline 및 테스트 구조 개선
- agent-readable-repository-refactor 완료: 기존 테스트 파일 아카이브 이동
- 새 테스트 파일 추가 (edge, node, client, config, readability)
- readability_audit 스크립트 및 baseline 추가
- roadmap/SDD 문서 갱신
- agent-client/pi/extensions/openai-sampling-parameters 추가
2026-07-17 16:02:12 +09:00

274 lines
11 KiB
Go

package openai
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
edgeservice "iop/apps/edge/internal/service"
"iop/packages/go/config"
iop "iop/proto/gen/iop"
)
// chatProviderAuthServer builds a provider-pool tunnel server with the given
// provider auth forwarding config, mirroring chatSidebandServer's catalog so
// pool-model routes over the provider tunnel path.
func chatProviderAuthServer(frames chan *iop.ProviderTunnelFrame, auth config.EdgeOpenAIProviderAuthConf) (*Server, *providerFakeRunService) {
fake := &providerFakeRunService{
poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel),
tunnelFrames: frames,
}
srv := NewServer(config.EdgeOpenAIConf{ProviderAuth: auth}, fake, nil)
srv.SetModelCatalog([]config.ModelCatalogEntry{
{ID: "pool-model", Providers: map[string]string{"prov-1": "served-model"}},
})
return srv, fake
}
func staticOKTunnelFrames(body string) chan *iop.ProviderTunnelFrame {
frames := make(chan *iop.ProviderTunnelFrame, 3)
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START, StatusCode: 200}
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Body: []byte(body)}
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true}
close(frames)
return frames
}
// TestChatProviderTunnelForwardsProviderAuthHeader verifies SDD S03: a raw user
// token supplied in the configured provider auth request header is forwarded to
// the provider tunnel request as the configured target header, formatted with
// the configured scheme, while already-prefixed values are preserved.
func TestChatProviderTunnelForwardsProviderAuthHeader(t *testing.T) {
auth := config.EdgeOpenAIProviderAuthConf{
Enabled: true,
FromHeader: "X-IOP-Provider-Authorization",
TargetHeader: "Authorization",
Scheme: "Bearer",
Required: true,
}
for _, tc := range []struct {
name string
callerAuth string
wantHeader string
}{
{name: "raw token gets scheme", callerAuth: "user-token", wantHeader: "Bearer user-token"},
{name: "already prefixed preserved", callerAuth: "Bearer user-token", wantHeader: "Bearer user-token"},
} {
t.Run(tc.name, func(t *testing.T) {
srv, fake := chatProviderAuthServer(staticOKTunnelFrames(`{"ok":true}`), auth)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"pool-model",
"messages":[{"role":"user","content":"hello"}]
}`))
req.Header.Set("X-IOP-Provider-Authorization", tc.callerAuth)
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
reqs := fake.tunnelReqsSnapshot()
if len(reqs) != 1 {
t.Fatalf("expected 1 tunnel dispatch, got %d", len(reqs))
}
// The handler sets auth headers on the tunnel request in the
// tunnel path of handleChatCompletionsProviderPool.
if got := reqs[0].Headers["Authorization"]; got != tc.wantHeader {
t.Fatalf("provider Authorization header: got %q, want %q", got, tc.wantHeader)
}
})
}
}
// TestChatProviderTunnelProviderAuthRequired verifies SDD S03: a missing
// required provider auth header is rejected. For provider-pool tunnel path,
// auth is checked after SubmitProviderPool returns. The tunnel handle is closed
// and a 400 is returned (SDD D01).
func TestChatProviderTunnelProviderAuthRequired(t *testing.T) {
auth := config.EdgeOpenAIProviderAuthConf{
Enabled: true,
FromHeader: "X-IOP-Provider-Authorization",
TargetHeader: "Authorization",
Scheme: "Bearer",
Required: true,
}
srv, fake := chatProviderAuthServer(staticOKTunnelFrames(`{"ok":true}`), auth)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"pool-model",
"messages":[{"role":"user","content":"hello"}]
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
// Auth required check happens in the tunnel path of handleChatCompletionsProviderPool.
// Since auth is missing, we get 400 and no tunnel request is sent (SDD S03 pre-dispatch).
if w.Code != http.StatusBadRequest {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
if strings.Contains(w.Body.String(), "user-token") {
t.Fatalf("error body must not echo a raw token: %s", w.Body.String())
}
// No tunnel request should be recorded: PrepareTunnel failed before dispatch.
if got := len(fake.tunnelReqsSnapshot()); got != 0 {
t.Fatalf("missing required provider auth must not dispatch tunnel: got %d tunnel requests", got)
}
}
// TestChatCompletionsProviderPoolTunnelProviderAuthForwardedBeforeDispatch verifies
// SDD S03: when provider auth is configured and present, the auth header lands on
// the actual tunnel request that the service sends, not via late handle mutation.
// The auth check runs inside SubmitProviderPool via PrepareTunnel before the
// tunnel request is built and dispatched.
func TestChatCompletionsProviderPoolTunnelProviderAuthForwardedBeforeDispatch(t *testing.T) {
auth := config.EdgeOpenAIProviderAuthConf{
Enabled: true,
FromHeader: "X-IOP-Provider-Authorization",
TargetHeader: "Authorization",
Scheme: "Bearer",
Required: true,
}
srv, fake := chatProviderAuthServer(staticOKTunnelFrames(`{"ok":true}`), auth)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"pool-model",
"messages":[{"role":"user","content":"hello"}]
}`))
req.Header.Set("X-IOP-Provider-Authorization", "user-token")
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
reqs := fake.tunnelReqsSnapshot()
if len(reqs) != 1 {
t.Fatalf("expected 1 tunnel dispatch, got %d", len(reqs))
}
// Auth header must be on the recorded tunnel request (i.e. pre-dispatch),
// not on a late handle mutation.
if got := reqs[0].Headers["Authorization"]; got != "Bearer user-token" {
t.Fatalf("tunnel request Authorization header: got %q, want %q", got, "Bearer user-token")
}
}
// TestChatCompletionsProviderPoolOllamaSelectionAllowsMissingProviderAuth verifies
// SDD D01: a missing required provider auth does NOT block the normalized
// (Ollama/CLI) execution path. Only tunnel dispatch requires the auth header.
func TestChatCompletionsProviderPoolOllamaSelectionAllowsMissingProviderAuth(t *testing.T) {
auth := config.EdgeOpenAIProviderAuthConf{
Enabled: true,
FromHeader: "X-IOP-Provider-Authorization",
TargetHeader: "Authorization",
Scheme: "Bearer",
Required: true,
}
// Ollama path: poolDispatchPath=normalized, no tunnel frames needed.
fake := &providerFakeRunService{
poolDispatchPath: string(edgeservice.ProviderPoolPathNormalized),
}
catalog := []config.ModelCatalogEntry{
{ID: "ollama-model", Providers: map[string]string{"prov-ollama": "qwen3.6:35b"}},
}
srv := NewServer(config.EdgeOpenAIConf{ProviderAuth: auth}, fake, nil)
srv.SetModelCatalog(catalog)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"ollama-model",
"messages":[{"role":"user","content":"hello"}]
}`))
// No X-IOP-Provider-Authorization header — should still succeed.
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
// No tunnel dispatch for Ollama-normalized path.
reqs := fake.tunnelReqsSnapshot()
if len(reqs) != 0 {
t.Fatalf("expected 0 tunnel dispatches for Ollama path, got %d", len(reqs))
}
}
// TestChatProviderTunnelProviderAuthDoesNotReplaceInboundAuth verifies the
// provider token is sourced only from the configured provider auth header, not
// from the inbound IOP Authorization header, keeping IOP inbound auth and
// outbound provider auth separate.
func TestChatProviderTunnelProviderAuthDoesNotReplaceInboundAuth(t *testing.T) {
auth := config.EdgeOpenAIProviderAuthConf{
Enabled: true,
FromHeader: "X-IOP-Provider-Authorization",
TargetHeader: "Authorization",
Scheme: "Bearer",
Required: true,
}
srv, fake := chatProviderAuthServer(staticOKTunnelFrames(`{"ok":true}`), auth)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"pool-model",
"messages":[{"role":"user","content":"hello"}]
}`))
// Inbound IOP auth and the configured provider auth header carry different
// tokens; the provider header must come from the provider auth header only.
req.Header.Set("Authorization", "Bearer iop-inbound-token")
req.Header.Set("X-IOP-Provider-Authorization", "provider-token")
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
reqs := fake.tunnelReqsSnapshot()
if len(reqs) != 1 {
t.Fatalf("expected 1 tunnel dispatch, got %d", len(reqs))
}
// The tunnel auth headers should come from the provider auth header only,
// not from the inbound IOP Authorization header.
if got := reqs[0].Headers["Authorization"]; got != "Bearer provider-token" {
t.Fatalf("provider Authorization must derive from provider auth header, got %q", got)
}
}
// TestResponsesProviderTunnelForwardsProviderAuthHeader verifies the provider
// auth helper is applied to the Responses tunnel: a raw request-header token is
// forwarded as the configured target header, and a missing required token is
// rejected before dispatch.
func TestResponsesProviderTunnelForwardsProviderAuthHeader(t *testing.T) {
auth := config.EdgeOpenAIProviderAuthConf{
Enabled: true,
FromHeader: "X-IOP-Provider-Authorization",
TargetHeader: "Authorization",
Scheme: "Bearer",
Required: true,
}
t.Run("forwards raw token with scheme", func(t *testing.T) {
srv, fake := chatProviderAuthServer(staticOKTunnelFrames(`{"ok":true}`), auth)
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"pool-model","input":"hi"}`))
req.Header.Set("X-IOP-Provider-Authorization", "user-token")
w := httptest.NewRecorder()
srv.handleResponses(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
reqs := fake.tunnelReqsSnapshot()
if len(reqs) != 1 {
t.Fatalf("expected 1 tunnel dispatch, got %d", len(reqs))
}
if got := reqs[0].Headers["Authorization"]; got != "Bearer user-token" {
t.Fatalf("provider Authorization header: got %q want %q", got, "Bearer user-token")
}
})
t.Run("missing required token rejected", func(t *testing.T) {
srv, fake := chatProviderAuthServer(staticOKTunnelFrames(`{"ok":true}`), auth)
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"pool-model","input":"hi"}`))
w := httptest.NewRecorder()
srv.handleResponses(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
if got := len(fake.tunnelReqsSnapshot()); got != 0 {
t.Fatalf("missing required provider auth must not dispatch: got %d tunnel requests", got)
}
})
}