Files
phamnazage-jpg 91fa5d6ab4 fix(review): 完成系统性 Review 修复方案 - Task B-01 HTTP Server 超时配置
本次提交包含:
- B-01: HTTP Server 添加超时配置 (ReadTimeout/WriteTimeout/IdleTimeout/MaxHeaderBytes)
- 添加结构化日志包 internal/log/ (B-02 部分完成)
- 添加 Review 报告文档
- 添加系统性修复方案文档
- 添加最佳实践审核报告文档
- 更新任务清单和执行板

测试验证:
- TestServerHasTimeoutConfiguration 通过

关联文档:
- docs/2026-06-01-SYSTEMATIC-REVIEW-REPORT.md
- docs/2026-06-01-SYSTEMATIC-REPAIR-PLAN.md
- docs/2026-06-01-BEST-PRACTICE-AUDIT-REPORT.md
2026-06-01 22:02:01 +08:00

1955 lines
76 KiB
Go

package app
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"sub2api-cn-relay-manager/internal/host/sub2api"
"sub2api-cn-relay-manager/internal/pack"
"sub2api-cn-relay-manager/internal/provision"
"sub2api-cn-relay-manager/internal/reconcile"
"sub2api-cn-relay-manager/internal/store/sqlite"
"sub2api-cn-relay-manager/internal/testutil"
)
func TestServeExposesHealthz(t *testing.T) {
server := NewServer("127.0.0.1:0", NewAPIHandler("admin-token", ActionSet{}), nil)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("net.Listen() error = %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- server.Serve(ctx, listener)
}()
response := waitForHealthz(t, "http://"+listener.Addr().String()+"/healthz")
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("ReadAll() error = %v", err)
}
if string(body) != "ok" {
t.Fatalf("healthz body = %q, want %q", string(body), "ok")
}
cancel()
if err := <-errCh; err != nil {
t.Fatalf("Serve() error = %v, want nil", err)
}
}
func TestRunReturnsAfterContextCancellation(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("net.Listen() error = %v", err)
}
server := NewServer("127.0.0.1:0", NewAPIHandler("admin-token", ActionSet{}), func(string, string) (net.Listener, error) {
return listener, nil
})
ctx, cancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() {
errCh <- server.Run(ctx)
}()
response := waitForHealthz(t, "http://"+listener.Addr().String()+"/healthz")
response.Body.Close()
cancel()
select {
case err := <-errCh:
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
case <-time.After(2 * time.Second):
t.Fatal("Run() did not return after context cancellation")
}
}
func TestRunReturnsListenError(t *testing.T) {
wantErr := errors.New("listen failed")
server := NewServer("127.0.0.1:0", NewAPIHandler("admin-token", ActionSet{}), func(string, string) (net.Listener, error) {
return nil, wantErr
})
err := server.Run(context.Background())
if !errors.Is(err, wantErr) {
t.Fatalf("Run() error = %v, want %v", err, wantErr)
}
}
func TestServeReturnsListenerError(t *testing.T) {
server := NewServer("127.0.0.1:0", NewAPIHandler("admin-token", ActionSet{}), nil)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("net.Listen() error = %v", err)
}
if err := listener.Close(); err != nil {
t.Fatalf("listener.Close() error = %v", err)
}
err = server.Serve(context.Background(), listener)
if err == nil {
t.Fatal("Serve() error = nil, want listener startup error")
}
}
func TestAPIRejectsMissingAdminToken(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{})
request := httptestRequest(t, http.MethodPost, "/api/packs/install", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/pack.zip"}, "")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusUnauthorized)
assertJSONContains(t, response.Body().Bytes(), "error.code", "unauthorized")
}
func TestAPIAdminSessionLoginSetsCookieAndAuthorizesSubsequentRequest(t *testing.T) {
handler := NewAPIHandlerWithAuth(AdminAuthConfig{
Token: "secret-token",
Username: "admin",
Password: "pass-123",
SessionTTL: 2 * time.Hour,
Now: func() time.Time {
return time.Unix(1_717_000_000, 0)
},
}, ActionSet{
ListPacks: func(context.Context) ([]PackInfo, error) {
return []PackInfo{{PackID: "openai-cn-pack", Version: "1.1.6"}}, nil
},
})
loginRequest := httptestRequest(t, http.MethodPost, "/api/admin/session/login", map[string]any{
"username": "admin",
"password": "pass-123",
}, "")
loginResponse := httptestRecorder(handler, loginRequest)
assertStatusCode(t, loginResponse, http.StatusOK)
assertJSONContains(t, loginResponse.Body().Bytes(), "authenticated", true)
assertJSONContains(t, loginResponse.Body().Bytes(), "username", "admin")
cookies := loginResponse.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("login cookies = %d, want 1", len(cookies))
}
if cookies[0].Name != adminSessionCookieName {
t.Fatalf("cookie name = %q, want %q", cookies[0].Name, adminSessionCookieName)
}
if !cookies[0].HttpOnly {
t.Fatal("session cookie HttpOnly = false, want true")
}
authorizedRequest := httptestRequest(t, http.MethodGet, "/api/packs", nil, "")
authorizedRequest.AddCookie(cookies[0])
authorizedResponse := httptestRecorder(handler, authorizedRequest)
assertStatusCode(t, authorizedResponse, http.StatusOK)
if !strings.Contains(authorizedResponse.Body().String(), `"pack_id":"openai-cn-pack"`) {
t.Fatalf("authorized response = %s, want pack_id openai-cn-pack", authorizedResponse.Body().String())
}
}
func TestAPIAdminSessionRejectsInvalidPassword(t *testing.T) {
handler := NewAPIHandlerWithAuth(AdminAuthConfig{
Token: "secret-token",
Username: "admin",
Password: "pass-123",
}, ActionSet{})
request := httptestRequest(t, http.MethodPost, "/api/admin/session/login", map[string]any{
"username": "admin",
"password": "wrong",
}, "")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusUnauthorized)
assertJSONContains(t, response.Body().Bytes(), "error.code", "unauthorized")
}
func TestAPIAdminSessionLogoutClearsCookie(t *testing.T) {
handler := NewAPIHandlerWithAuth(AdminAuthConfig{
Token: "secret-token",
Username: "admin",
Password: "pass-123",
}, ActionSet{})
request := httptestRequest(t, http.MethodPost, "/api/admin/session/logout", nil, "")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusNoContent)
cookies := response.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("logout cookies = %d, want 1", len(cookies))
}
if cookies[0].Name != adminSessionCookieName {
t.Fatalf("cookie name = %q, want %q", cookies[0].Name, adminSessionCookieName)
}
if cookies[0].MaxAge != -1 {
t.Fatalf("cookie MaxAge = %d, want -1", cookies[0].MaxAge)
}
}
func TestAPIAdminSessionMeReportsAuthenticationState(t *testing.T) {
now := time.Unix(1_717_000_000, 0)
handler := NewAPIHandlerWithAuth(AdminAuthConfig{
Token: "secret-token",
Username: "admin",
Password: "pass-123",
SessionTTL: time.Hour,
Now: func() time.Time {
return now
},
}, ActionSet{})
request := httptestRequest(t, http.MethodGet, "/api/admin/session", nil, "")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusOK)
assertJSONContains(t, response.Body().Bytes(), "authenticated", false)
assertJSONContains(t, response.Body().Bytes(), "login_enabled", true)
loginRequest := httptestRequest(t, http.MethodPost, "/api/admin/session/login", map[string]any{
"username": "admin",
"password": "pass-123",
}, "")
loginResponse := httptestRecorder(handler, loginRequest)
assertStatusCode(t, loginResponse, http.StatusOK)
meRequest := httptestRequest(t, http.MethodGet, "/api/admin/session", nil, "")
for _, cookie := range loginResponse.Result().Cookies() {
meRequest.AddCookie(cookie)
}
meResponse := httptestRecorder(handler, meRequest)
assertStatusCode(t, meResponse, http.StatusOK)
assertJSONContains(t, meResponse.Body().Bytes(), "authenticated", true)
assertJSONContains(t, meResponse.Body().Bytes(), "username", "admin")
}
func TestAPIInstallPackReturnsSummary(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
InstallPack: func(context.Context, InstallPackRequest) (provision.PackInstallResult, error) {
return provision.PackInstallResult{
Pack: sqlite.Pack{PackID: "openai-cn-pack", Version: "1.0.0"},
HostVersion: "0.1.126",
Providers: []sqlite.Provider{{ProviderID: "deepseek", DisplayName: "DeepSeek"}},
}, nil
},
})
request := httptestRequest(t, http.MethodPost, "/api/packs/install", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/openai-pack.zip"}, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusOK)
assertJSONContains(t, response.Body().Bytes(), "pack_id", "openai-cn-pack")
assertJSONContains(t, response.Body().Bytes(), "host_version", "0.1.126")
}
func TestAPIPreviewProviderReturnsSummary(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
PreviewProvider: func(_ context.Context, req PreviewProviderRequest) (provision.PreviewReport, error) {
if req.ProviderID != "deepseek" {
t.Fatalf("ProviderID = %q, want deepseek", req.ProviderID)
}
return provision.PreviewReport{
AcceptedKeys: []string{"k1", "k2"},
Names: provision.ResourceNames{Group: "g", Channel: "c", Plan: "p"},
Decisions: map[string]provision.PreviewDecision{
"group": {Action: provision.PreviewActionCreate},
},
}, nil
},
})
request := httptestRequest(t, http.MethodPost, "/api/providers/deepseek/preview-import", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/openai-pack.zip", "keys": []string{"k1", "k2"}, "mode": "partial"}, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusOK)
assertJSONContains(t, response.Body().Bytes(), "accepted_keys_count", float64(2))
}
func TestAPICreateProviderDraftReturnsCreated(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
CreateProviderDraft: func(_ context.Context, req CreateProviderDraftRequest) (ProviderDraftInfo, error) {
if req.ProviderID != "openai-zhongzhuan" {
t.Fatalf("ProviderID = %q, want openai-zhongzhuan", req.ProviderID)
}
return ProviderDraftInfo{
DraftID: "draft_001",
PackID: req.PackID,
ProviderID: req.ProviderID,
DisplayName: req.DisplayName,
Platform: req.Platform,
SmokeTestModel: req.SmokeTestModel,
SupportedModels: []string{
"gpt-5.4",
},
Manifest: map[string]any{"provider_id": req.ProviderID},
}, nil
},
})
request := httptestRequest(t, http.MethodPost, "/api/provider-drafts", map[string]any{
"pack_id": "openai-cn-pack",
"provider_id": "openai-zhongzhuan",
"display_name": "OpenAI 中转",
"platform": "openai",
"smoke_test_model": "gpt-5.4",
"supported_models": []string{"gpt-5.4"},
}, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusCreated)
assertJSONContains(t, response.Body().Bytes(), "draft.draft_id", "draft_001")
assertJSONContains(t, response.Body().Bytes(), "draft.provider_id", "openai-zhongzhuan")
}
func TestAPIListProviderDraftsReturnsCollection(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
ListProviderDrafts: func(_ context.Context, req ListProviderDraftsRequest) ([]ProviderDraftInfo, error) {
if req.PackID != "openai-cn-pack" {
t.Fatalf("PackID = %q, want openai-cn-pack", req.PackID)
}
return []ProviderDraftInfo{{
DraftID: "draft_001",
PackID: req.PackID,
ProviderID: "minimax-53hk",
DisplayName: "MiniMax 53HK",
Platform: "openai",
Manifest: map[string]any{"provider_id": "minimax-53hk"},
SourceHostID: "remote43-current-host",
}}, nil
},
})
request := httptestRequest(t, http.MethodGet, "/api/provider-drafts?pack_id=openai-cn-pack", nil, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusOK)
var payload map[string]any
if err := json.Unmarshal(response.Body().Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
drafts, ok := payload["provider_drafts"].([]any)
if !ok || len(drafts) != 1 {
t.Fatalf("provider_drafts = %#v, want one item", payload["provider_drafts"])
}
item, ok := drafts[0].(map[string]any)
if !ok {
t.Fatalf("draft[0] = %#v, want object", drafts[0])
}
if got := item["provider_id"]; got != "minimax-53hk" {
t.Fatalf("provider_id = %#v, want minimax-53hk", got)
}
}
func TestAPIGetProviderDraftReturnsItem(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
GetProviderDraft: func(_ context.Context, draftID string) (ProviderDraftInfo, error) {
if draftID != "draft_001" {
t.Fatalf("draftID = %q, want draft_001", draftID)
}
return ProviderDraftInfo{
DraftID: draftID,
PackID: "openai-cn-pack",
ProviderID: "deepseek-chat-official",
DisplayName: "DeepSeek Official",
Platform: "openai",
Manifest: map[string]any{"provider_id": "deepseek-chat-official"},
SourceHostID: "remote43-current-host",
}, nil
},
})
request := httptestRequest(t, http.MethodGet, "/api/provider-drafts/draft_001", nil, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusOK)
assertJSONContains(t, response.Body().Bytes(), "draft.provider_id", "deepseek-chat-official")
}
func TestAPIUpdateProviderDraftReturnsUpdatedItem(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
UpdateProviderDraft: func(_ context.Context, req UpdateProviderDraftRequest) (ProviderDraftInfo, error) {
if req.DraftID != "draft_001" {
t.Fatalf("DraftID = %q, want draft_001", req.DraftID)
}
return ProviderDraftInfo{
DraftID: req.DraftID,
PackID: req.PackID,
ProviderID: req.ProviderID,
DisplayName: req.DisplayName,
Platform: req.Platform,
BaseURL: req.BaseURL,
Manifest: map[string]any{"provider_id": req.ProviderID},
SourceHostID: req.SourceHostID,
}, nil
},
})
request := httptestRequest(t, http.MethodPut, "/api/provider-drafts/draft_001", map[string]any{
"pack_id": "openai-cn-pack",
"provider_id": "openai-zhongzhuan",
"display_name": "OpenAI 中转 Updated",
"platform": "openai",
"base_url": "https://api.example.com/v1",
}, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusOK)
assertJSONContains(t, response.Body().Bytes(), "draft.display_name", "OpenAI 中转 Updated")
}
func TestAPIDeleteProviderDraftReturnsNoContent(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
DeleteProviderDraft: func(_ context.Context, draftID string) error {
if draftID != "draft_001" {
t.Fatalf("draftID = %q, want draft_001", draftID)
}
return nil
},
})
request := httptestRequest(t, http.MethodDelete, "/api/provider-drafts/draft_001", nil, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusNoContent)
}
func TestAPIPublishProviderDraftReturnsSummary(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
PublishProviderDraft: func(_ context.Context, req PublishProviderDraftRequest) (PublishProviderDraftResult, error) {
if req.DraftID != "draft_001" {
t.Fatalf("DraftID = %q, want draft_001", req.DraftID)
}
return PublishProviderDraftResult{
DraftID: req.DraftID,
PackID: "openai-cn-pack",
ProviderID: "openai-zhongzhuan",
ProviderPath: "packs/openai-cn-pack/providers/openai-zhongzhuan.json",
PackVersionBefore: "1.1.4",
PackVersionAfter: "1.1.5",
PublishMode: "created",
CommitMessage: "feat(pack): publish provider draft openai-zhongzhuan",
CommitSHA: "abc1234",
RepoRoot: "/srv/sub2api-cn-relay-manager",
}, nil
},
})
request := httptestRequest(t, http.MethodPost, "/api/provider-drafts/draft_001/publish", map[string]any{
"commit_message": "feat(pack): publish provider draft openai-zhongzhuan",
}, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusOK)
assertJSONContains(t, response.Body().Bytes(), "publish.provider_id", "openai-zhongzhuan")
assertJSONContains(t, response.Body().Bytes(), "publish.pack_version_after", "1.1.5")
assertJSONContains(t, response.Body().Bytes(), "publish.commit_sha", "abc1234")
}
func TestAPIImportProviderReturnsConflictWithBatchStatus(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
ImportProvider: func(context.Context, ImportProviderRequest) (provision.RuntimeImportResult, error) {
return provision.RuntimeImportResult{
BatchID: 12,
Report: provision.ImportReport{
BatchStatus: provision.BatchStatusFailed,
ProviderStatus: provision.ProviderStatusFailed,
AccessStatus: provision.AccessStatusBroken,
Accounts: []provision.AccountImportResult{{}},
},
}, errors.New("strict import failed")
},
})
request := httptestRequest(t, http.MethodPost, "/api/providers/deepseek/import", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/openai-pack.zip", "keys": []string{"k1"}, "mode": "strict", "access_mode": "self_service", "access_api_key": "user-key"}, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusConflict)
assertJSONContains(t, response.Body().Bytes(), "batch_id", float64(12))
assertJSONContains(t, response.Body().Bytes(), "batch_status", provision.BatchStatusFailed)
}
func TestAPIBatchDetailReturnsSummary(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
BatchDetail: func(context.Context, BatchDetailRequest) (provision.BatchDetailResult, error) {
return provision.BatchDetailResult{
Batch: sqlite.ImportBatch{ID: 7, BatchStatus: "running", AccessStatus: "pending"},
Items: []sqlite.ImportBatchItem{{ID: 1, KeyFingerprint: "sha256:abc", AccountStatus: "passed"}},
}, nil
},
})
request := httptestRequest(t, http.MethodGet, "/api/import-batches/7", nil, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusOK)
assertJSONContains(t, response.Body().Bytes(), "batch.batch_status", "running")
assertJSONContains(t, response.Body().Bytes(), "items_count", float64(1))
}
func TestAPIProviderStatusReturnsSummary(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
GetProviderStatus: func(_ context.Context, req ProviderQueryRequest) (provision.ProviderSnapshot, error) {
if req.ProviderID != "deepseek" {
t.Fatalf("ProviderID = %q, want deepseek", req.ProviderID)
}
if req.PackID != "openai-cn-pack" {
t.Fatalf("PackID = %q, want openai-cn-pack", req.PackID)
}
return provision.ProviderSnapshot{
Host: sqlite.Host{HostID: "host-1", BaseURL: "https://sub2api.example.com", HostVersion: "0.1.126"},
Pack: sqlite.Pack{PackID: "openai-cn-pack", Version: "1.0.0"},
Provider: sqlite.Provider{ProviderID: "deepseek", DisplayName: "DeepSeek", Platform: "openai"},
Batch: sqlite.ImportBatch{ID: 7, BatchStatus: provision.BatchStatusSucceeded, AccessStatus: provision.AccessStatusSelfServiceReady, Mode: provision.ImportModeStrict},
ProviderStatus: "drifted",
LatestAccessStatus: provision.AccessStatusSelfServiceReady,
LatestReconcileStatus: "drifted",
LatestReconcileSummary: map[string]any{"missing_count": 1, "stale_noise_count": 2},
ManagedResources: []sqlite.ManagedResource{{}, {}},
AccessClosures: []sqlite.AccessClosureRecord{{}},
ReconcileRuns: []sqlite.ReconcileRun{{}},
}, nil
},
})
request := httptestRequest(t, http.MethodGet, "/api/providers/deepseek/status?pack_id=openai-cn-pack", nil, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusOK)
assertJSONContains(t, response.Body().Bytes(), "provider_status", "drifted")
assertJSONContains(t, response.Body().Bytes(), "managed_resources_count", float64(2))
assertJSONContains(t, response.Body().Bytes(), "latest_reconcile_summary.missing_count", float64(1))
assertJSONContains(t, response.Body().Bytes(), "latest_reconcile_summary.stale_noise_count", float64(2))
}
func TestAPIProviderAccessStatusReturnsSummary(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
GetProviderAccessStatus: func(_ context.Context, req ProviderQueryRequest) (provision.ProviderSnapshot, error) {
if req.ProviderID != "deepseek" {
t.Fatalf("ProviderID = %q, want deepseek", req.ProviderID)
}
return provision.ProviderSnapshot{
Pack: sqlite.Pack{PackID: "openai-cn-pack"},
Provider: sqlite.Provider{ProviderID: "deepseek"},
Batch: sqlite.ImportBatch{ID: 7, AccessStatus: provision.AccessStatusSelfServiceReady},
LatestAccessStatus: provision.AccessStatusSelfServiceReady,
AccessClosures: []sqlite.AccessClosureRecord{{ID: 2, ClosureType: provision.AccessModeSelfService, Status: provision.AccessStatusSelfServiceReady, DetailsJSON: `{"ok":true}`}},
}, nil
},
})
request := httptestRequest(t, http.MethodGet, "/api/providers/deepseek/access/status", nil, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusOK)
assertJSONContains(t, response.Body().Bytes(), "latest_access_status", provision.AccessStatusSelfServiceReady)
assertJSONContains(t, response.Body().Bytes(), "closures_count", float64(1))
if !strings.Contains(response.Body().String(), `"closure_type":"self_service"`) {
t.Fatalf("access status payload missing closure type: %s", response.Body().String())
}
}
func TestAPIListPackProvidersReturnsProviderMetadata(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
ListPackProviders: func(_ context.Context, packID string) ([]PackProviderInfo, error) {
if packID != "openai-cn-pack" {
t.Fatalf("packID = %q, want openai-cn-pack", packID)
}
return []PackProviderInfo{{
ProviderID: "deepseek-chat-official",
DisplayName: "DeepSeek Official",
Platform: "openai",
HostOverlays: 0,
BaseURL: "https://api.deepseek.com",
SmokeTestModel: "deepseek-chat",
SupportedModels: []string{"deepseek-chat", "deepseek-reasoner"},
}}, nil
},
})
request := httptestRequest(t, http.MethodGet, "/api/packs/openai-cn-pack/providers", nil, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusOK)
var payload struct {
Providers []PackProviderInfo `json:"providers"`
}
if err := json.Unmarshal(response.Body().Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if len(payload.Providers) != 1 {
t.Fatalf("providers len = %d, want 1", len(payload.Providers))
}
got := payload.Providers[0]
if got.ProviderID != "deepseek-chat-official" {
t.Fatalf("provider_id = %q, want deepseek-chat-official", got.ProviderID)
}
if got.BaseURL != "https://api.deepseek.com" {
t.Fatalf("base_url = %q, want https://api.deepseek.com", got.BaseURL)
}
if got.SmokeTestModel != "deepseek-chat" {
t.Fatalf("smoke_test_model = %q, want deepseek-chat", got.SmokeTestModel)
}
if len(got.SupportedModels) != 2 || got.SupportedModels[0] != "deepseek-chat" || got.SupportedModels[1] != "deepseek-reasoner" {
t.Fatalf("supported_models = %#v, want [deepseek-chat deepseek-reasoner]", got.SupportedModels)
}
}
func TestAPIProviderResourcesReturnsSummary(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
GetProviderResources: func(_ context.Context, req ProviderQueryRequest) (provision.ProviderSnapshot, error) {
if req.ProviderID != "deepseek" {
t.Fatalf("ProviderID = %q, want deepseek", req.ProviderID)
}
return provision.ProviderSnapshot{
Pack: sqlite.Pack{PackID: "openai-cn-pack"},
Provider: sqlite.Provider{ProviderID: "deepseek"},
Batch: sqlite.ImportBatch{ID: 7},
ManagedResources: []sqlite.ManagedResource{{ID: 1, ResourceType: "group", HostResourceID: "group-1", ResourceName: "deepseek-group"}},
AccessClosures: []sqlite.AccessClosureRecord{{ID: 2, ClosureType: provision.AccessModeSelfService, Status: provision.AccessStatusSelfServiceReady, DetailsJSON: `{"ok":true}`}},
ReconcileRuns: []sqlite.ReconcileRun{{ID: 3, Status: "active", SummaryJSON: `{"missing_count":0}`}},
}, nil
},
})
request := httptestRequest(t, http.MethodGet, "/api/providers/deepseek/resources", nil, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusOK)
assertJSONContains(t, response.Body().Bytes(), "provider_id", "deepseek")
assertJSONContains(t, response.Body().Bytes(), "pack_id", "openai-cn-pack")
if !strings.Contains(response.Body().String(), `"resource_type":"group"`) {
t.Fatalf("resources payload missing group resource: %s", response.Body().String())
}
if !strings.Contains(response.Body().String(), `"status":"self_service_ready"`) {
t.Fatalf("resources payload missing access closure status: %s", response.Body().String())
}
if !strings.Contains(response.Body().String(), `"summary_json":"{\"missing_count\":0}"`) {
t.Fatalf("resources payload missing reconcile summary: %s", response.Body().String())
}
}
func TestAPIRollbackProviderReturnsSummary(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
RollbackProvider: func(context.Context, RollbackProviderRequest) (provision.RollbackReport, error) {
return provision.RollbackReport{AccountsDeleted: 2, PlansDeleted: 1, ChannelsDeleted: 1, GroupsDeleted: 1}, nil
},
})
request := httptestRequest(t, http.MethodPost, "/api/providers/deepseek/rollback", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/openai-pack.zip"}, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusOK)
assertJSONContains(t, response.Body().Bytes(), "deleted_accounts", float64(2))
assertJSONContains(t, response.Body().Bytes(), "provider_id", "deepseek")
}
func TestAPIReconcileProviderReturnsSummary(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
ReconcileProvider: func(_ context.Context, req ReconcileProviderRequest) (reconcile.Result, error) {
if req.AccessAPIKey != "user-key" {
t.Fatalf("AccessAPIKey = %q, want user-key", req.AccessAPIKey)
}
return reconcile.Result{BatchID: 7, Status: "drifted", MissingCount: 1, ExtraCount: 2, StaleNoiseCount: 3, ProbeFailureCount: 1, AccessStatus: provision.AccessStatusBroken, Summary: map[string]any{"probe_failures": 1, "stale_noise_count": 3}}, nil
},
})
request := httptestRequest(t, http.MethodPost, "/api/providers/deepseek/reconcile", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/openai-pack.zip", "access_api_key": "user-key"}, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusOK)
assertJSONContains(t, response.Body().Bytes(), "status", "drifted")
assertJSONContains(t, response.Body().Bytes(), "missing_count", float64(1))
assertJSONContains(t, response.Body().Bytes(), "stale_noise_count", float64(3))
assertJSONContains(t, response.Body().Bytes(), "summary.probe_failures", float64(1))
assertJSONContains(t, response.Body().Bytes(), "summary.stale_noise_count", float64(3))
}
func waitForHealthz(t *testing.T, url string) *http.Response {
t.Helper()
client := &http.Client{Timeout: 100 * time.Millisecond}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
response, err := client.Get(url)
if err == nil && response.StatusCode == http.StatusOK {
return response
}
if response != nil {
response.Body.Close()
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("health endpoint %q was not reachable before deadline", url)
return nil
}
func httptestRequest(t *testing.T, method, path string, body any, token string) *http.Request {
t.Helper()
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
request, err := http.NewRequest(method, path, bytes.NewReader(payload))
if err != nil {
t.Fatalf("http.NewRequest() error = %v", err)
}
request.Header.Set("Content-Type", "application/json")
if token != "" {
request.Header.Set("Authorization", "Bearer "+token)
}
return request
}
func httptestRecorder(handler http.Handler, request *http.Request) *responseRecorder {
recorder := &responseRecorder{header: make(http.Header)}
handler.ServeHTTP(recorder, request)
return recorder
}
type responseRecorder struct {
header http.Header
body bytes.Buffer
code int
}
func (r *responseRecorder) Header() http.Header { return r.header }
func (r *responseRecorder) Write(body []byte) (int, error) {
if r.code == 0 {
r.code = http.StatusOK
}
return r.body.Write(body)
}
func (r *responseRecorder) WriteHeader(statusCode int) { r.code = statusCode }
func (r *responseRecorder) Body() *bytes.Buffer { return &r.body }
func (r *responseRecorder) Result() *http.Response {
statusCode := r.code
if statusCode == 0 {
statusCode = http.StatusOK
}
return &http.Response{
StatusCode: statusCode,
Header: r.header.Clone(),
Body: io.NopCloser(bytes.NewReader(r.body.Bytes())),
}
}
func assertStatusCode(t *testing.T, recorder *responseRecorder, want int) {
t.Helper()
if recorder.code != want {
t.Fatalf("status code = %d, want %d; body=%s", recorder.code, want, recorder.body.String())
}
}
func TestServerAddrReturnsConfiguredAddress(t *testing.T) {
server := NewServer("127.0.0.1:9999", nil, nil)
if got := server.Addr(); got != "127.0.0.1:9999" {
t.Fatalf("Addr() = %q, want %q", got, "127.0.0.1:9999")
}
}
func TestServerHasTimeoutConfiguration(t *testing.T) {
server := NewServer("127.0.0.1:0", nil, nil)
s := server.server
if s.ReadTimeout != 30*time.Second {
t.Errorf("ReadTimeout = %v, want 30s", s.ReadTimeout)
}
if s.ReadHeaderTimeout != 10*time.Second {
t.Errorf("ReadHeaderTimeout = %v, want 10s", s.ReadHeaderTimeout)
}
if s.WriteTimeout != 30*time.Second {
t.Errorf("WriteTimeout = %v, want 30s", s.WriteTimeout)
}
if s.IdleTimeout != 120*time.Second {
t.Errorf("IdleTimeout = %v, want 120s", s.IdleTimeout)
}
if s.MaxHeaderBytes != 1<<20 {
t.Errorf("MaxHeaderBytes = %d, want %d", s.MaxHeaderBytes, 1<<20)
}
}
func TestClassifyError(t *testing.T) {
tests := []struct {
name string
err error
wantStatusCode int
wantCode string
wantUpstream int
}{
{name: "nil", err: nil},
{name: "http error passthrough", err: &httpError{StatusCode: http.StatusTeapot, Code: "teapot", Message: "brew"}, wantStatusCode: http.StatusTeapot, wantCode: "teapot"},
{name: "upstream error", err: &sub2api.HTTPError{Method: http.MethodGet, Path: "/x", StatusCode: http.StatusForbidden, Body: "nope"}, wantStatusCode: http.StatusBadGateway, wantCode: "host_request_failed", wantUpstream: http.StatusForbidden},
{name: "pack conflict already installed", err: errors.New("pack already installed"), wantStatusCode: http.StatusConflict, wantCode: "pack_conflict"},
{name: "pack conflict checksum drift", err: errors.New("checksum drift detected"), wantStatusCode: http.StatusConflict, wantCode: "pack_conflict"},
{name: "reconcile blocked rolled_back", err: errors.New("latest import batch is rolled_back; run import again before reconcile"), wantStatusCode: http.StatusConflict, wantCode: "batch_not_reconcilable"},
{name: "not found generic", err: errors.New("host x not found"), wantStatusCode: http.StatusNotFound, wantCode: "not_found"},
{name: "provider not found", err: errors.New("provider \"deepseek\" not found in pack \"openai\""), wantStatusCode: http.StatusBadRequest, wantCode: "provider_not_found"},
{name: "bad request pack path", err: errors.New("pack path is required"), wantStatusCode: http.StatusBadRequest, wantCode: "bad_request"},
{name: "bad request decode", err: errors.New("decode pack.json failed"), wantStatusCode: http.StatusBadRequest, wantCode: "bad_request"},
{name: "internal error", err: errors.New("boom"), wantStatusCode: http.StatusInternalServerError, wantCode: "internal_error"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := classifyError(tt.err)
if tt.err == nil {
if got != nil {
t.Fatalf("classifyError(nil) = %#v, want nil", got)
}
return
}
if got == nil {
t.Fatal("classifyError() = nil, want error")
}
if got.StatusCode != tt.wantStatusCode {
t.Fatalf("StatusCode = %d, want %d", got.StatusCode, tt.wantStatusCode)
}
if got.Code != tt.wantCode {
t.Fatalf("Code = %q, want %q", got.Code, tt.wantCode)
}
if got.UpstreamStatus != tt.wantUpstream {
t.Fatalf("UpstreamStatus = %d, want %d", got.UpstreamStatus, tt.wantUpstream)
}
})
}
}
func TestWriteHTTPError(t *testing.T) {
t.Run("default error when nil", func(t *testing.T) {
recorder := &responseRecorder{header: make(http.Header)}
writeHTTPError(recorder, nil)
assertStatusCode(t, recorder, http.StatusInternalServerError)
if got := recorder.Header().Get("Content-Type"); got != "application/json" {
t.Fatalf("Content-Type = %q, want application/json", got)
}
assertJSONContains(t, recorder.Body().Bytes(), "error.code", "internal_error")
assertJSONContains(t, recorder.Body().Bytes(), "error.message", "internal server error")
})
t.Run("writes provided error", func(t *testing.T) {
recorder := &responseRecorder{header: make(http.Header)}
writeHTTPError(recorder, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "invalid input", UpstreamStatus: http.StatusConflict})
assertStatusCode(t, recorder, http.StatusBadRequest)
assertJSONContains(t, recorder.Body().Bytes(), "error.code", "bad_request")
assertJSONContains(t, recorder.Body().Bytes(), "error.upstream_status", float64(http.StatusConflict))
})
}
func TestDecodeJSON(t *testing.T) {
t.Run("success", func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"host_base_url":"https://example.com","pack_path":"/tmp/pack.zip"}`))
var got InstallPackRequest
if err := decodeJSON(request, &got); err != nil {
t.Fatalf("decodeJSON() error = %v, want nil", err)
}
if got.HostBaseURL != "https://example.com" || got.PackPath != "/tmp/pack.zip" {
t.Fatalf("decoded request = %#v, want expected fields", got)
}
})
t.Run("rejects unknown fields", func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"host_base_url":"https://example.com","unknown":true}`))
var got InstallPackRequest
err := decodeJSON(request, &got)
if err == nil {
t.Fatal("decodeJSON() error = nil, want error")
}
if err.StatusCode != http.StatusBadRequest || err.Code != "bad_request" {
t.Fatalf("decodeJSON() = %#v, want bad_request", err)
}
if !strings.Contains(err.Message, "unknown field") {
t.Fatalf("Message = %q, want unknown field", err.Message)
}
})
t.Run("rejects trailing non-object payload", func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"host_base_url":"https://example.com"}[]`))
var got InstallPackRequest
err := decodeJSON(request, &got)
if err == nil {
t.Fatal("decodeJSON() error = nil, want error")
}
if err.Message != "request body must contain a single JSON object" {
t.Fatalf("Message = %q, want single object error", err.Message)
}
})
t.Run("rejects oversized request body", func(t *testing.T) {
payload := `{"host_base_url":"https://example.com","pack_path":"` + strings.Repeat("x", int(maxJSONBodyBytes)) + `"}`
request := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(payload))
var got InstallPackRequest
err := decodeJSON(request, &got)
if err == nil {
t.Fatal("decodeJSON() error = nil, want oversized error")
}
if err.StatusCode != http.StatusRequestEntityTooLarge || err.Code != "request_too_large" {
t.Fatalf("decodeJSON() = %#v, want request_too_large", err)
}
})
}
func TestWriteJSON(t *testing.T) {
recorder := &responseRecorder{header: make(http.Header)}
writeJSON(recorder, http.StatusCreated, map[string]any{"ok": true, "count": 2})
assertStatusCode(t, recorder, http.StatusCreated)
if got := recorder.Header().Get("Content-Type"); got != "application/json" {
t.Fatalf("Content-Type = %q, want application/json", got)
}
assertJSONContains(t, recorder.Body().Bytes(), "ok", true)
assertJSONContains(t, recorder.Body().Bytes(), "count", float64(2))
}
func TestFindProvider(t *testing.T) {
loaded := pack.LoadedPack{
Manifest: pack.Manifest{PackID: "openai-cn-pack"},
Providers: []pack.ProviderManifest{
{ProviderID: "deepseek", DisplayName: "DeepSeek"},
{ProviderID: "openai", DisplayName: "OpenAI"},
},
}
provider, err := findProvider(loaded, " deepseek ")
if err != nil {
t.Fatalf("findProvider() error = %v, want nil", err)
}
if provider.ProviderID != "deepseek" {
t.Fatalf("ProviderID = %q, want deepseek", provider.ProviderID)
}
_, err = findProvider(loaded, "missing")
if err == nil {
t.Fatal("findProvider() error = nil, want error")
}
if !strings.Contains(err.Error(), `provider "missing" not found in pack "openai-cn-pack"`) {
t.Fatalf("findProvider() error = %v, want provider not found message", err)
}
}
func TestAPIRequiresConfiguredAdminToken(t *testing.T) {
handler := NewAPIHandler("", ActionSet{})
request := httptestRequest(t, http.MethodPost, "/api/packs/install", map[string]any{"host_base_url": "https://sub2api.example.com"}, "any-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusInternalServerError)
assertJSONContains(t, response.Body().Bytes(), "error.code", "server_misconfigured")
}
func TestAPIBatchDetailRejectsInvalidBatchID(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{BatchDetail: func(context.Context, BatchDetailRequest) (provision.BatchDetailResult, error) {
t.Fatal("BatchDetail should not be called for invalid batch id")
return provision.BatchDetailResult{}, nil
}})
request := httptestRequest(t, http.MethodGet, "/api/import-batches/not-a-number", nil, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusBadRequest)
assertJSONContains(t, response.Body().Bytes(), "error.code", "bad_request")
assertJSONContains(t, response.Body().Bytes(), "error.message", "batch_id must be a positive integer")
}
func TestAPIInstallPackRejectsInvalidJSON(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{InstallPack: func(context.Context, InstallPackRequest) (provision.PackInstallResult, error) {
t.Fatal("InstallPack should not be called for invalid JSON")
return provision.PackInstallResult{}, nil
}})
request, err := http.NewRequest(http.MethodPost, "/api/packs/install", strings.NewReader(`{"host_base_url":"https://sub2api.example.com","unknown":true}`))
if err != nil {
t.Fatalf("http.NewRequest() error = %v", err)
}
request.Header.Set("Authorization", "Bearer secret-token")
request.Header.Set("Content-Type", "application/json")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusBadRequest)
assertJSONContains(t, response.Body().Bytes(), "error.code", "bad_request")
}
func TestAPIImportProviderReturnsClassifiedErrorWithoutBatch(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
ImportProvider: func(context.Context, ImportProviderRequest) (provision.RuntimeImportResult, error) {
return provision.RuntimeImportResult{}, errors.New("pack path is required")
},
})
request := httptestRequest(t, http.MethodPost, "/api/providers/deepseek/import", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/openai-pack.zip"}, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusBadRequest)
assertJSONContains(t, response.Body().Bytes(), "error.code", "bad_request")
assertJSONContains(t, response.Body().Bytes(), "batch_id", float64(0))
}
func TestAPIPreviewProviderReturnsUpstreamError(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
PreviewProvider: func(context.Context, PreviewProviderRequest) (provision.PreviewReport, error) {
return provision.PreviewReport{}, &sub2api.HTTPError{Method: http.MethodPost, Path: "/preview", StatusCode: http.StatusTooManyRequests, Body: "rate limited"}
},
})
request := httptestRequest(t, http.MethodPost, "/api/providers/deepseek/preview-import", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/openai-pack.zip"}, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusBadGateway)
assertJSONContains(t, response.Body().Bytes(), "error.code", "host_request_failed")
assertJSONContains(t, response.Body().Bytes(), "error.upstream_status", float64(http.StatusTooManyRequests))
}
func TestAPIRollbackProviderReturnsConfiguredError(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
RollbackProvider: func(context.Context, RollbackProviderRequest) (provision.RollbackReport, error) {
return provision.RollbackReport{}, &httpError{StatusCode: http.StatusGone, Code: "rolled_back", Message: "already removed"}
},
})
request := httptestRequest(t, http.MethodPost, "/api/providers/deepseek/rollback", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/openai-pack.zip"}, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusGone)
assertJSONContains(t, response.Body().Bytes(), "error.code", "rolled_back")
}
func TestAPIReconcileProviderRejectsTrailingNonObjectPayload(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{ReconcileProvider: func(context.Context, ReconcileProviderRequest) (reconcile.Result, error) {
t.Fatal("ReconcileProvider should not be called for invalid JSON")
return reconcile.Result{}, nil
}})
request, err := http.NewRequest(http.MethodPost, "/api/providers/deepseek/reconcile", strings.NewReader(`{"host_base_url":"https://sub2api.example.com"}[]`))
if err != nil {
t.Fatalf("http.NewRequest() error = %v", err)
}
request.Header.Set("Authorization", "Bearer secret-token")
request.Header.Set("Content-Type", "application/json")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusBadRequest)
assertJSONContains(t, response.Body().Bytes(), "error.message", "request body must contain a single JSON object")
}
// --- Coverage edge cases ---
func TestHTTPErrorError(t *testing.T) {
e := &httpError{StatusCode: http.StatusTeapot, Code: "teapot", Message: "i'm a teapot"}
if got := e.Error(); got != "i'm a teapot" {
t.Fatalf("httpError.Error() = %q, want %q", got, "i'm a teapot")
}
}
func TestProviderStatusFnNil(t *testing.T) {
handler := NewAPIHandler("t", ActionSet{})
req := httptestRequest(t, http.MethodGet, "/api/providers/x/status", nil, "t")
res := httptestRecorder(handler, req)
assertStatusCode(t, res, http.StatusInternalServerError)
assertJSONContains(t, res.Body().Bytes(), "error.code", "server_misconfigured")
}
func TestProviderAccessStatusFnNil(t *testing.T) {
handler := NewAPIHandler("t", ActionSet{})
req := httptestRequest(t, http.MethodGet, "/api/providers/x/access/status", nil, "t")
res := httptestRecorder(handler, req)
assertStatusCode(t, res, http.StatusInternalServerError)
assertJSONContains(t, res.Body().Bytes(), "error.code", "server_misconfigured")
}
func TestProviderResourcesFnNil(t *testing.T) {
handler := NewAPIHandler("t", ActionSet{})
req := httptestRequest(t, http.MethodGet, "/api/providers/x/resources", nil, "t")
res := httptestRecorder(handler, req)
assertStatusCode(t, res, http.StatusInternalServerError)
assertJSONContains(t, res.Body().Bytes(), "error.code", "server_misconfigured")
}
func TestProviderStatusReturnsError(t *testing.T) {
handler := NewAPIHandler("t", ActionSet{
GetProviderStatus: func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error) {
return provision.ProviderSnapshot{}, errors.New(`provider "x" not found in pack "p"`)
},
})
req := httptestRequest(t, http.MethodGet, "/api/providers/x/status", nil, "t")
res := httptestRecorder(handler, req)
assertStatusCode(t, res, http.StatusBadRequest)
assertJSONContains(t, res.Body().Bytes(), "error.code", "provider_not_found")
}
func TestPostHandlersFnNil(t *testing.T) {
tests := []struct {
name string
method string
path string
body string
}{
{name: "install-pack", method: http.MethodPost, path: "/api/packs/install", body: `{}`},
{name: "preview", method: http.MethodPost, path: "/api/providers/x/preview-import", body: `{}`},
{name: "import", method: http.MethodPost, path: "/api/providers/x/import", body: `{}`},
{name: "rollback", method: http.MethodPost, path: "/api/providers/x/rollback", body: `{}`},
{name: "reconcile", method: http.MethodPost, path: "/api/providers/x/reconcile", body: `{}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := NewAPIHandler("t", ActionSet{})
req, _ := http.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
req.Header.Set("Authorization", "Bearer t")
req.Header.Set("Content-Type", "application/json")
res := httptestRecorder(handler, req)
assertStatusCode(t, res, http.StatusInternalServerError)
assertJSONContains(t, res.Body().Bytes(), "error.code", "server_misconfigured")
})
}
}
func TestGetHandlersFnNil(t *testing.T) {
tests := []struct {
name string
path string
}{
{name: "list-hosts", path: "/api/hosts"},
{name: "get-host", path: "/api/hosts/my-host"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := NewAPIHandler("t", ActionSet{})
req, _ := http.NewRequest(http.MethodGet, tt.path, nil)
req.Header.Set("Authorization", "Bearer t")
res := httptestRecorder(handler, req)
assertStatusCode(t, res, http.StatusInternalServerError)
assertJSONContains(t, res.Body().Bytes(), "error.code", "server_misconfigured")
})
}
}
func TestDeleteHandlersFnNil(t *testing.T) {
tests := []struct {
name string
path string
}{
{name: "delete-host", path: "/api/hosts/my-host"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := NewAPIHandler("t", ActionSet{})
req, _ := http.NewRequest(http.MethodDelete, tt.path, nil)
req.Header.Set("Authorization", "Bearer t")
res := httptestRecorder(handler, req)
assertStatusCode(t, res, http.StatusInternalServerError)
assertJSONContains(t, res.Body().Bytes(), "error.code", "server_misconfigured")
})
}
}
func TestHandlerErrorPaths(t *testing.T) {
tests := []struct {
name string
method string
path string
body string
actionSet ActionSet
wantStatus int
wantCode string
}{
{
name: "access-status-error",
method: http.MethodGet,
path: "/api/providers/x/access/status",
actionSet: ActionSet{
GetProviderAccessStatus: func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error) {
return provision.ProviderSnapshot{}, errors.New("boom")
},
},
wantStatus: http.StatusInternalServerError,
wantCode: "internal_error",
},
{
name: "preview-error",
method: http.MethodPost,
path: "/api/providers/x/preview-import",
body: `{}`,
actionSet: ActionSet{
PreviewProvider: func(context.Context, PreviewProviderRequest) (provision.PreviewReport, error) {
return provision.PreviewReport{}, errors.New("boom")
},
},
wantStatus: http.StatusInternalServerError,
wantCode: "internal_error",
},
{
name: "rollback-error",
method: http.MethodPost,
path: "/api/providers/x/rollback",
body: `{}`,
actionSet: ActionSet{
RollbackProvider: func(context.Context, RollbackProviderRequest) (provision.RollbackReport, error) {
return provision.RollbackReport{}, errors.New("boom")
},
},
wantStatus: http.StatusInternalServerError,
wantCode: "internal_error",
},
{
name: "reconcile-error",
method: http.MethodPost,
path: "/api/providers/x/reconcile",
body: `{}`,
actionSet: ActionSet{
ReconcileProvider: func(context.Context, ReconcileProviderRequest) (reconcile.Result, error) {
return reconcile.Result{}, errors.New("boom")
},
},
wantStatus: http.StatusInternalServerError,
wantCode: "internal_error",
},
{
name: "list-hosts-error",
method: http.MethodGet,
path: "/api/hosts",
actionSet: ActionSet{
ListHosts: func(context.Context) ([]HostInfo, error) {
return nil, errors.New("boom")
},
},
wantStatus: http.StatusInternalServerError,
wantCode: "internal_error",
},
{
name: "get-host-error",
method: http.MethodGet,
path: "/api/hosts/my-host",
actionSet: ActionSet{
GetHost: func(context.Context, string) (HostInfo, error) {
return HostInfo{}, errors.New("boom")
},
},
wantStatus: http.StatusInternalServerError,
wantCode: "internal_error",
},
{
name: "get-host-not-found",
method: http.MethodGet,
path: "/api/hosts/unknown",
actionSet: ActionSet{
GetHost: func(context.Context, string) (HostInfo, error) {
return HostInfo{}, errors.New("host unknown not found")
},
},
wantStatus: http.StatusNotFound,
wantCode: "not_found",
},
{
name: "delete-host-error",
method: http.MethodDelete,
path: "/api/hosts/my-host",
actionSet: ActionSet{
DeleteHost: func(context.Context, string) error {
return errors.New("boom")
},
},
wantStatus: http.StatusInternalServerError,
wantCode: "internal_error",
},
{
name: "delete-host-not-found",
method: http.MethodDelete,
path: "/api/hosts/unknown",
actionSet: ActionSet{
DeleteHost: func(context.Context, string) error {
return errors.New("host unknown not found")
},
},
wantStatus: http.StatusNotFound,
wantCode: "not_found",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := NewAPIHandler("t", tt.actionSet)
var req *http.Request
if tt.body != "" {
req, _ = http.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
req.Header.Set("Content-Type", "application/json")
} else {
var err error
req, err = http.NewRequest(tt.method, tt.path, nil)
if err != nil {
t.Fatal(err)
}
}
req.Header.Set("Authorization", "Bearer t")
res := httptestRecorder(handler, req)
assertStatusCode(t, res, tt.wantStatus)
assertJSONContains(t, res.Body().Bytes(), "error.code", tt.wantCode)
})
}
}
func TestResolveLatestAccessStatusAggregatesAcrossModeBatches(t *testing.T) {
store := openAppTestStore(t)
defer closeAppTestStore(t, store)
ctx := context.Background()
hostID, err := store.Hosts().Create(ctx, sqlite.Host{HostID: "host-1", BaseURL: "https://sub2api.example.com", HostVersion: "0.1.126", AuthType: "apikey", AuthToken: "token"})
if err != nil {
t.Fatalf("Hosts().Create() error = %v", err)
}
packID, err := store.Packs().Create(ctx, sqlite.Pack{PackID: "openai-cn-pack", Version: "1.0.0", TargetHost: "sub2api", Checksum: "checksum-1"})
if err != nil {
t.Fatalf("Packs().Create() error = %v", err)
}
providerID, err := store.Providers().Create(ctx, sqlite.Provider{PackID: packID, ProviderID: "deepseek", DisplayName: "DeepSeek", BaseURL: "https://api.deepseek.com", Platform: "openai"})
if err != nil {
t.Fatalf("Providers().Create() error = %v", err)
}
batchSubscription, err := store.ImportBatches().Create(ctx, sqlite.ImportBatch{HostID: hostID, PackID: packID, ProviderID: providerID, Mode: provision.ImportModePartial, BatchStatus: provision.BatchStatusSucceeded, AccessStatus: provision.AccessStatusSubscriptionReady})
if err != nil {
t.Fatalf("ImportBatches().Create(subscription) error = %v", err)
}
if _, err := store.AccessClosures().Create(ctx, sqlite.AccessClosureRecord{BatchID: batchSubscription, ClosureType: provision.AccessModeSubscription, Status: provision.AccessStatusSubscriptionReady, DetailsJSON: "{}"}); err != nil {
t.Fatalf("AccessClosures().Create(subscription) error = %v", err)
}
batchSelf, err := store.ImportBatches().Create(ctx, sqlite.ImportBatch{HostID: hostID, PackID: packID, ProviderID: providerID, Mode: provision.ImportModePartial, BatchStatus: provision.BatchStatusSucceeded, AccessStatus: provision.AccessStatusSelfServiceReady})
if err != nil {
t.Fatalf("ImportBatches().Create(self_service) error = %v", err)
}
if _, err := store.AccessClosures().Create(ctx, sqlite.AccessClosureRecord{BatchID: batchSelf, ClosureType: provision.AccessModeSelfService, Status: provision.AccessStatusSelfServiceReady, DetailsJSON: "{}"}); err != nil {
t.Fatalf("AccessClosures().Create(self_service) error = %v", err)
}
got, err := resolveLatestAccessStatus(ctx, store, sqlite.Provider{ID: providerID, ProviderID: "deepseek"}, "host-1")
if err != nil {
t.Fatalf("resolveLatestAccessStatus() error = %v", err)
}
if got != provision.AccessStatusFullyReady {
t.Fatalf("resolveLatestAccessStatus() = %q, want %q", got, provision.AccessStatusFullyReady)
}
}
func TestProviderAccessStatusMultipleClosures(t *testing.T) {
handler := NewAPIHandler("t", ActionSet{
GetProviderAccessStatus: func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error) {
return provision.ProviderSnapshot{
Pack: sqlite.Pack{PackID: "p"},
Provider: sqlite.Provider{ProviderID: "dp"},
Batch: sqlite.ImportBatch{ID: 1},
LatestAccessStatus: "ready",
AccessClosures: []sqlite.AccessClosureRecord{
{ID: 1, ClosureType: "preview", Status: "done", DetailsJSON: `{"v":1}`},
{ID: 2, ClosureType: "self_service", Status: "active", DetailsJSON: `{"v":2}`},
},
}, nil
},
})
req := httptestRequest(t, http.MethodGet, "/api/providers/dp/access/status", nil, "t")
res := httptestRecorder(handler, req)
assertStatusCode(t, res, http.StatusOK)
// Should report the last closure (index n-1)
if !strings.Contains(res.Body().String(), `"closure_type":"self_service"`) {
t.Fatalf("expected latest closure to be self_service, got: %s", res.Body().String())
}
}
func TestAccessStatusSupportsMode(t *testing.T) {
tests := []struct {
name string
status string
mode string
want bool
}{
{name: "subscription ready supports subscription", status: provision.AccessStatusSubscriptionReady, mode: provision.AccessModeSubscription, want: true},
{name: "subscription ready does not support self service", status: provision.AccessStatusSubscriptionReady, mode: provision.AccessModeSelfService, want: false},
{name: "fully ready supports self service", status: provision.AccessStatusFullyReady, mode: provision.AccessModeSelfService, want: true},
{name: "broken does not support any", status: provision.AccessStatusBroken, mode: "", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := accessStatusSupportsMode(tt.status, tt.mode); got != tt.want {
t.Fatalf("accessStatusSupportsMode(%q, %q) = %v, want %v", tt.status, tt.mode, got, tt.want)
}
})
}
}
func TestHostSupportStatusRequiresPlansCapability(t *testing.T) {
status := hostSupportStatus(sub2api.HostCapabilities{Groups: true, Channels: true, Plans: false, Accounts: true, AccountTest: true, AccountModels: true, Subscriptions: true})
if status != "unsupported" {
t.Fatalf("hostSupportStatus() = %q, want unsupported when plans capability is missing", status)
}
}
func openAppTestStore(t *testing.T) *sqlite.DB {
t.Helper()
return testutil.OpenSQLiteStore(t, testutil.SQLiteTestDSN(t, "state.db", true))
}
func closeAppTestStore(t *testing.T, store *sqlite.DB) {
t.Helper()
testutil.CloseSQLiteStore(t, store)
}
func assertJSONContains(t *testing.T, payload []byte, key string, want any) {
t.Helper()
var decoded map[string]any
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("json.Unmarshal() error = %v; payload=%s", err, string(payload))
}
if strings.Contains(key, ".") {
parts := strings.Split(key, ".")
current := any(decoded)
for _, part := range parts {
object, ok := current.(map[string]any)
if !ok {
t.Fatalf("key %q not found in payload %s", key, string(payload))
}
current = object[part]
}
if current != want {
t.Fatalf("json key %q = %#v, want %#v; payload=%s", key, current, want, string(payload))
}
return
}
if decoded[key] != want {
t.Fatalf("json key %q = %#v, want %#v; payload=%s", key, decoded[key], want, string(payload))
}
}
func TestNewActionSetReturnsNonNil(t *testing.T) {
as := NewActionSet("file::memory:?cache=shared")
t.Run("InstallPack", func(t *testing.T) {
if as.InstallPack == nil {
t.Fatal("is nil")
}
})
t.Run("BatchDetail", func(t *testing.T) {
if as.BatchDetail == nil {
t.Fatal("is nil")
}
})
t.Run("GetProviderStatus", func(t *testing.T) {
if as.GetProviderStatus == nil {
t.Fatal("is nil")
}
})
t.Run("GetProviderResources", func(t *testing.T) {
if as.GetProviderResources == nil {
t.Fatal("is nil")
}
})
t.Run("GetProviderAccessStatus", func(t *testing.T) {
if as.GetProviderAccessStatus == nil {
t.Fatal("is nil")
}
})
t.Run("PreviewProvider", func(t *testing.T) {
if as.PreviewProvider == nil {
t.Fatal("is nil")
}
})
t.Run("ImportProvider", func(t *testing.T) {
if as.ImportProvider == nil {
t.Fatal("is nil")
}
})
t.Run("RollbackProvider", func(t *testing.T) {
if as.RollbackProvider == nil {
t.Fatal("is nil")
}
})
t.Run("ReconcileProvider", func(t *testing.T) {
if as.ReconcileProvider == nil {
t.Fatal("is nil")
}
})
t.Run("ListHosts", func(t *testing.T) {
if as.ListHosts == nil {
t.Fatal("is nil")
}
})
t.Run("GetHost", func(t *testing.T) {
if as.GetHost == nil {
t.Fatal("is nil")
}
})
t.Run("DeleteHost", func(t *testing.T) {
if as.DeleteHost == nil {
t.Fatal("is nil")
}
})
t.Run("ProbeHost", func(t *testing.T) {
if as.ProbeHost == nil {
t.Fatal("is nil")
}
})
t.Run("ListProviderImportBatches", func(t *testing.T) {
if as.ListProviderImportBatches == nil {
t.Fatal("is nil")
}
})
t.Run("RollbackBatch", func(t *testing.T) {
if as.RollbackBatch == nil {
t.Fatal("is nil")
}
})
}
func TestBatchDetailReturnsNotFoundForMissingBatch(t *testing.T) {
as := NewActionSet("file::memory:?cache=shared")
_, err := as.BatchDetail(context.Background(), BatchDetailRequest{BatchID: 999})
if err == nil {
t.Fatal("BatchDetail() error = nil for missing batch, want error")
}
}
func TestNewActionSetSQLiteClosures(t *testing.T) {
dsn := "file::memory:?cache=shared"
as := NewActionSet(dsn)
ctx := context.Background()
t.Run("GetProviderStatus on empty DB", func(t *testing.T) {
_, err := as.GetProviderStatus(ctx, ProviderQueryRequest{ProviderID: "x", PackID: "p"})
if err == nil {
t.Fatal("expected error from empty DB, got nil")
}
})
t.Run("GetProviderResources on empty DB", func(t *testing.T) {
_, err := as.GetProviderResources(ctx, ProviderQueryRequest{ProviderID: "x", PackID: "p"})
if err == nil {
t.Fatal("expected error from empty DB, got nil")
}
})
t.Run("GetProviderAccessStatus on empty DB", func(t *testing.T) {
_, err := as.GetProviderAccessStatus(ctx, ProviderQueryRequest{ProviderID: "x", PackID: "p"})
if err == nil {
t.Fatal("expected error from empty DB, got nil")
}
})
t.Run("ListHosts on empty DB", func(t *testing.T) {
hosts, err := as.ListHosts(ctx)
if err != nil {
t.Fatalf("ListHosts() on empty DB error = %v, want nil", err)
}
if len(hosts) != 0 {
t.Fatalf("ListHosts() len = %d, want 0", len(hosts))
}
})
t.Run("GetHost on empty DB", func(t *testing.T) {
_, err := as.GetHost(ctx, "nonexistent")
if err == nil {
t.Fatal("expected error from empty DB, got nil")
}
})
t.Run("ListProviderImportBatches on empty DB", func(t *testing.T) {
batches, err := as.ListProviderImportBatches(ctx, ProviderQueryRequest{ProviderID: "x"})
if err != nil {
t.Fatalf("ListProviderImportBatches() on empty DB error = %v, want nil", err)
}
if len(batches) != 0 {
t.Fatalf("ListProviderImportBatches() len = %d, want 0", len(batches))
}
})
}
func TestAPIProbeHostReturnsHostSnapshot(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
ProbeHost: func(_ context.Context, req ProbeHostRequest) (HostInfo, error) {
if req.HostID != "prod-sub2api" {
t.Fatalf("ProbeHost hostID = %q, want prod-sub2api", req.HostID)
}
if req.Auth.Type != "bearer" || req.Auth.Token != "probe-token" {
t.Fatalf("ProbeHost auth = %#v, want bearer/probe-token", req.Auth)
}
return HostInfo{HostID: req.HostID, BaseURL: "https://sub2api.example.com", HostVersion: "0.1.126", Status: "supported"}, nil
},
})
req := httptestRequest(t, http.MethodPost, "/api/hosts/prod-sub2api/probe", map[string]any{
"auth": map[string]any{"type": "bearer", "token": "probe-token"},
}, "secret-token")
res := httptestRecorder(handler, req)
assertStatusCode(t, res, http.StatusOK)
assertJSONContains(t, res.Body().Bytes(), "host_id", "prod-sub2api")
assertJSONContains(t, res.Body().Bytes(), "host_version", "0.1.126")
assertJSONContains(t, res.Body().Bytes(), "status", "supported")
}
func TestAPIListProviderImportBatchesReturnsItems(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
ListProviderImportBatches: func(_ context.Context, req ProviderQueryRequest) ([]ImportBatchInfo, error) {
if req.ProviderID != "deepseek" {
t.Fatalf("ListProviderImportBatches providerID = %q, want deepseek", req.ProviderID)
}
return []ImportBatchInfo{{BatchID: 7, BatchStatus: provision.BatchStatusSucceeded, AccessStatus: provision.AccessStatusSelfServiceReady}}, nil
},
})
req := httptestRequest(t, http.MethodGet, "/api/providers/deepseek/import-batches", nil, "secret-token")
res := httptestRecorder(handler, req)
assertStatusCode(t, res, http.StatusOK)
body := res.Body().String()
if !strings.Contains(body, `"batch_id":7`) || !strings.Contains(body, `"batch_status":"succeeded"`) || !strings.Contains(body, `"access_status":"self_service_ready"`) {
t.Fatalf("unexpected import batch payload: %s", body)
}
}
func TestAPIRollbackBatchReturnsSummary(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
RollbackBatch: func(_ context.Context, req RollbackBatchRequest) (provision.RollbackReport, error) {
if req.BatchID != 11 {
t.Fatalf("RollbackBatch batchID = %d, want 11", req.BatchID)
}
if req.Auth.Type != "apikey" || req.Auth.Token != "admin-key" {
t.Fatalf("RollbackBatch auth = %#v, want apikey/admin-key", req.Auth)
}
return provision.RollbackReport{AccountsDeleted: 2, PlansDeleted: 1, ChannelsDeleted: 1, GroupsDeleted: 1}, nil
},
})
req := httptestRequest(t, http.MethodPost, "/api/import-batches/11/rollback", map[string]any{
"auth": map[string]any{"type": "apikey", "token": "admin-key"},
}, "secret-token")
res := httptestRecorder(handler, req)
assertStatusCode(t, res, http.StatusOK)
assertJSONContains(t, res.Body().Bytes(), "deleted_accounts", float64(2))
assertJSONContains(t, res.Body().Bytes(), "deleted_plans", float64(1))
assertJSONContains(t, res.Body().Bytes(), "deleted_channels", float64(1))
assertJSONContains(t, res.Body().Bytes(), "deleted_groups", float64(1))
}
func TestNewActionSetPackErrorPaths(t *testing.T) {
dsn := "file::memory:?cache=shared"
as := NewActionSet(dsn)
ctx := context.Background()
t.Run("InstallPack bad path", func(t *testing.T) {
_, err := as.InstallPack(ctx, InstallPackRequest{PackPath: "/nonexistent/pack"})
if err == nil {
t.Fatal("expected error from bad pack path")
}
})
t.Run("PreviewProvider bad path", func(t *testing.T) {
_, err := as.PreviewProvider(ctx, PreviewProviderRequest{PackPath: "/nonexistent/pack", ProviderID: "x"})
if err == nil {
t.Fatal("expected error from bad pack path")
}
})
t.Run("ImportProvider bad path", func(t *testing.T) {
_, err := as.ImportProvider(ctx, ImportProviderRequest{PackPath: "/nonexistent/pack", ProviderID: "x", HostBaseURL: "http://h:8080"})
if err == nil {
t.Fatal("expected error from bad pack path")
}
})
t.Run("RollbackProvider bad path", func(t *testing.T) {
_, err := as.RollbackProvider(ctx, RollbackProviderRequest{PackPath: "/nonexistent/pack", ProviderID: "x", HostBaseURL: "http://h:8080"})
if err == nil {
t.Fatal("expected error from bad pack path")
}
})
t.Run("ReconcileProvider bad path", func(t *testing.T) {
_, err := as.ReconcileProvider(ctx, ReconcileProviderRequest{PackPath: "/nonexistent/pack", ProviderID: "x", HostBaseURL: "http://h:8080"})
if err == nil {
t.Fatal("expected error from bad pack path")
}
})
}
func TestNewActionSetCreateProviderDraftRejectsModelConflictWithStoredProvider(t *testing.T) {
ctx := context.Background()
store, err := sqlite.Open(ctx, "file:"+filepath.Join(t.TempDir(), "create-draft-conflict.db")+"?_foreign_keys=on&_busy_timeout=5000")
if err != nil {
t.Fatalf("sqlite.Open() error = %v", err)
}
defer store.Close()
packID, err := store.Packs().Create(ctx, sqlite.Pack{
PackID: "openai-cn-pack",
Version: "1.1.4",
Checksum: "chk-openai-cn-pack",
Vendor: "YourTeam",
TargetHost: "sub2api",
ManifestJSON: `{"pack_id":"openai-cn-pack","version":"1.1.4","target_host":"sub2api"}`,
})
if err != nil {
t.Fatalf("Packs().Create() error = %v", err)
}
if _, err := store.Providers().Create(ctx, sqlite.Provider{
PackID: packID,
ProviderID: "deepseek-chat-official",
DisplayName: "DeepSeek Official",
BaseURL: "https://api.deepseek.com",
Platform: "openai",
AccountType: "apikey",
DefaultModelsJSON: `["deepseek-chat"]`,
SmokeTestModel: "deepseek-chat",
ManifestJSON: `{"provider_id":"deepseek-chat-official","display_name":"DeepSeek Official","base_url":"https://api.deepseek.com","platform":"openai","default_models":["deepseek-chat"],"smoke_test_model":"deepseek-chat"}`,
}); err != nil {
t.Fatalf("Providers().Create() error = %v", err)
}
actions := NewActionSet(appTestDSN(t, store))
_, err = actions.CreateProviderDraft(ctx, CreateProviderDraftRequest{
PackID: "openai-cn-pack",
ProviderID: "deepseek-chat-relay",
DisplayName: "DeepSeek Relay",
Platform: "openai",
BaseURL: "https://relay.example.com/v1",
SmokeTestModel: "deepseek-chat",
SupportedModels: []string{"deepseek-chat"},
})
if err == nil {
t.Fatal("CreateProviderDraft() error = nil, want provider model conflict")
}
var httpErr *httpError
if !errors.As(err, &httpErr) {
t.Fatalf("CreateProviderDraft() error = %T, want *httpError", err)
}
if httpErr.StatusCode != http.StatusConflict || httpErr.Code != "provider_model_conflict" {
t.Fatalf("CreateProviderDraft() = %#v, want 409/provider_model_conflict", httpErr)
}
}
func TestNewActionSetUpdateProviderDraftRejectsModelConflictWithOtherDraft(t *testing.T) {
ctx := context.Background()
store, err := sqlite.Open(ctx, "file:"+filepath.Join(t.TempDir(), "update-draft-conflict.db")+"?_foreign_keys=on&_busy_timeout=5000")
if err != nil {
t.Fatalf("sqlite.Open() error = %v", err)
}
defer store.Close()
if _, err := store.ProviderDrafts().Create(ctx, sqlite.ProviderDraft{
DraftID: "draft_existing",
PackID: "openai-cn-pack",
ProviderID: "deepseek-chat-official",
DisplayName: "DeepSeek Official",
Platform: "openai",
BaseURL: "https://api.deepseek.com",
SmokeTestModel: "deepseek-chat",
SupportedModelsJSON: `["deepseek-chat"]`,
ManifestJSON: `{"provider_id":"deepseek-chat-official","display_name":"DeepSeek Official","platform":"openai","base_url":"https://api.deepseek.com","smoke_test_model":"deepseek-chat"}`,
}); err != nil {
t.Fatalf("ProviderDrafts().Create(existing) error = %v", err)
}
if _, err := store.ProviderDrafts().Create(ctx, sqlite.ProviderDraft{
DraftID: "draft_target",
PackID: "openai-cn-pack",
ProviderID: "deepseek-chat-relay",
DisplayName: "DeepSeek Relay",
Platform: "openai",
BaseURL: "https://relay.example.com/v1",
SmokeTestModel: "deepseek-reasoner",
SupportedModelsJSON: `["deepseek-reasoner"]`,
ManifestJSON: `{"provider_id":"deepseek-chat-relay","display_name":"DeepSeek Relay","platform":"openai","base_url":"https://relay.example.com/v1","smoke_test_model":"deepseek-reasoner"}`,
}); err != nil {
t.Fatalf("ProviderDrafts().Create(target) error = %v", err)
}
actions := NewActionSet(appTestDSN(t, store))
_, err = actions.UpdateProviderDraft(ctx, UpdateProviderDraftRequest{
DraftID: "draft_target",
CreateProviderDraftRequest: CreateProviderDraftRequest{
PackID: "openai-cn-pack",
ProviderID: "deepseek-chat-relay",
DisplayName: "DeepSeek Relay",
Platform: "openai",
BaseURL: "https://relay.example.com/v1",
SmokeTestModel: "deepseek-chat",
SupportedModels: []string{"deepseek-chat"},
},
})
if err == nil {
t.Fatal("UpdateProviderDraft() error = nil, want provider model conflict")
}
var httpErr *httpError
if !errors.As(err, &httpErr) {
t.Fatalf("UpdateProviderDraft() error = %T, want *httpError", err)
}
if httpErr.StatusCode != http.StatusConflict || httpErr.Code != "provider_model_conflict" {
t.Fatalf("UpdateProviderDraft() = %#v, want 409/provider_model_conflict", httpErr)
}
}
func TestNewActionSetPublishProviderDraftRejectsModelConflictWithRepoProvider(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git is required for publish action test")
}
ctx := context.Background()
store, err := sqlite.Open(ctx, "file:"+filepath.Join(t.TempDir(), "publish-draft-conflict.db")+"?_foreign_keys=on&_busy_timeout=5000")
if err != nil {
t.Fatalf("sqlite.Open() error = %v", err)
}
defer store.Close()
repoRoot := t.TempDir()
packDir := filepath.Join(repoRoot, "packs", "openai-cn-pack")
createPackFixtureAt(t, packDir, map[string]string{
"pack.json": `{
"pack_id": "openai-cn-pack",
"version": "1.1.4",
"vendor": "YourTeam",
"target_host": "sub2api",
"min_host_version": "0.1.126",
"max_host_version": "0.2.x",
"providers_dir": "providers",
"checksum_file": "checksums.txt"
}`,
"providers/deepseek-chat-official.json": `{
"provider_id": "deepseek-chat-official",
"display_name": "DeepSeek Official",
"base_url": "https://api.deepseek.com",
"platform": "openai",
"account_type": "apikey",
"default_models": ["deepseek-chat"],
"smoke_test_model": "deepseek-chat",
"group_template": {"name": "g", "rate_multiplier": 1.0},
"channel_template": {"name": "c", "model_mapping": {"deepseek-chat": "deepseek-chat"}},
"plan_template": {"name": "p", "price": 1, "validity_days": 30, "validity_unit": "day"},
"import": {"supports_multi_key": true, "supports_strict": true, "supports_partial": true}
}`,
})
initGitRepo(t, repoRoot)
if _, err := store.ProviderDrafts().Create(ctx, sqlite.ProviderDraft{
DraftID: "draft_publish_conflict",
PackID: "openai-cn-pack",
ProviderID: "deepseek-chat-relay",
DisplayName: "DeepSeek Relay",
Platform: "openai",
BaseURL: "https://relay.example.com/v1",
SmokeTestModel: "deepseek-chat",
SupportedModelsJSON: `["deepseek-chat"]`,
ManifestJSON: `{"provider_id":"deepseek-chat-relay","display_name":"DeepSeek Relay","platform":"openai","base_url":"https://relay.example.com/v1","smoke_test_model":"deepseek-chat"}`,
}); err != nil {
t.Fatalf("ProviderDrafts().Create() error = %v", err)
}
t.Setenv("SUB2API_CRM_REPO_ROOT", repoRoot)
actions := NewActionSet(appTestDSN(t, store))
_, err = actions.PublishProviderDraft(ctx, PublishProviderDraftRequest{
DraftID: "draft_publish_conflict",
CommitMessage: "feat(pack): publish provider draft deepseek-chat-relay",
})
if err == nil {
t.Fatal("PublishProviderDraft() error = nil, want provider model conflict")
}
var httpErr *httpError
if !errors.As(err, &httpErr) {
t.Fatalf("PublishProviderDraft() error = %T, want *httpError", err)
}
if httpErr.StatusCode != http.StatusConflict || httpErr.Code != "provider_model_conflict" {
t.Fatalf("PublishProviderDraft() = %#v, want 409/provider_model_conflict", httpErr)
}
}
func TestNewActionSetPublishProviderDraftCreatesPackCommit(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git is required for publish action test")
}
ctx := context.Background()
store, err := sqlite.Open(ctx, "file:"+filepath.Join(t.TempDir(), "publish-action.db")+"?_foreign_keys=on&_busy_timeout=5000")
if err != nil {
t.Fatalf("sqlite.Open() error = %v", err)
}
defer store.Close()
repoRoot := t.TempDir()
packDir := filepath.Join(repoRoot, "packs", "openai-cn-pack")
createPackFixtureAt(t, packDir, map[string]string{
"pack.json": `{
"pack_id": "openai-cn-pack",
"version": "1.1.4",
"vendor": "YourTeam",
"target_host": "sub2api",
"min_host_version": "0.1.126",
"max_host_version": "0.2.x",
"providers_dir": "providers",
"checksum_file": "checksums.txt"
}`,
"providers/deepseek.json": `{
"provider_id": "deepseek",
"display_name": "DeepSeek",
"base_url": "https://api.deepseek.com",
"platform": "openai",
"account_type": "apikey",
"default_models": ["deepseek-chat"],
"smoke_test_model": "deepseek-chat",
"group_template": {"name": "g", "rate_multiplier": 1.0},
"channel_template": {"name": "c", "model_mapping": {"deepseek-chat": "deepseek-chat"}},
"plan_template": {"name": "p", "price": 1, "validity_days": 30, "validity_unit": "day"},
"import": {"supports_multi_key": true, "supports_strict": true, "supports_partial": true}
}`,
})
initGitRepo(t, repoRoot)
if _, err := store.ProviderDrafts().Create(ctx, sqlite.ProviderDraft{
DraftID: "draft_publish_001",
PackID: "openai-cn-pack",
ProviderID: "openai-zhongzhuan",
DisplayName: "OpenAI 中转",
Platform: "openai",
BaseURL: "https://api.example.com/v1",
SmokeTestModel: "gpt-5.4",
SupportedModelsJSON: `["gpt-5.4","gpt-5.4-mini"]`,
ManifestJSON: `{"provider_id":"openai-zhongzhuan","display_name":"OpenAI 中转","platform":"openai","base_url":"https://api.example.com/v1","smoke_test_model":"gpt-5.4"}`,
SourceHostID: "remote43-current-host",
}); err != nil {
t.Fatalf("ProviderDrafts().Create() error = %v", err)
}
t.Setenv("SUB2API_CRM_REPO_ROOT", repoRoot)
actions := NewActionSet(appTestDSN(t, store))
result, err := actions.PublishProviderDraft(ctx, PublishProviderDraftRequest{
DraftID: "draft_publish_001",
CommitMessage: "feat(pack): publish provider draft openai-zhongzhuan",
})
if err != nil {
t.Fatalf("PublishProviderDraft() error = %v", err)
}
if result.PublishMode != "created" {
t.Fatalf("PublishMode = %q, want created", result.PublishMode)
}
if result.PackVersionAfter != "1.1.5" {
t.Fatalf("PackVersionAfter = %q, want 1.1.5", result.PackVersionAfter)
}
if strings.TrimSpace(result.CommitSHA) == "" {
t.Fatal("CommitSHA is empty")
}
body, err := os.ReadFile(filepath.Join(packDir, "providers", "openai-zhongzhuan.json"))
if err != nil {
t.Fatalf("os.ReadFile() provider error = %v", err)
}
if !strings.Contains(string(body), `"provider_id": "openai-zhongzhuan"`) {
t.Fatalf("provider body = %s, want openai-zhongzhuan", string(body))
}
}
func createPackFixtureAt(t *testing.T, packDir string, files map[string]string) {
t.Helper()
lines := make([]string, 0, len(files))
for relativePath, content := range files {
absolutePath := filepath.Join(packDir, relativePath)
if err := os.MkdirAll(filepath.Dir(absolutePath), 0o755); err != nil {
t.Fatalf("os.MkdirAll(%q) error = %v", absolutePath, err)
}
if err := os.WriteFile(absolutePath, []byte(content), 0o644); err != nil {
t.Fatalf("os.WriteFile(%q) error = %v", absolutePath, err)
}
sum := sha256.Sum256([]byte(content))
lines = append(lines, hex.EncodeToString(sum[:])+" "+relativePath)
}
checksumPath := filepath.Join(packDir, "checksums.txt")
if err := os.WriteFile(checksumPath, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
t.Fatalf("os.WriteFile(%q) error = %v", checksumPath, err)
}
}
func initGitRepo(t *testing.T, repoRoot string) {
t.Helper()
runGitTest(t, repoRoot, "init")
runGitTest(t, repoRoot, "config", "user.name", "Test User")
runGitTest(t, repoRoot, "config", "user.email", "test@example.com")
runGitTest(t, repoRoot, "add", ".")
runGitTest(t, repoRoot, "commit", "-m", "chore: seed pack fixture")
}
func runGitTest(t *testing.T, repoRoot string, args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", repoRoot}, args...)...)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v error = %v: %s", args, err, strings.TrimSpace(string(output)))
}
}