Harden host deletion and test stability
This commit is contained in:
@@ -5,12 +5,10 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -20,6 +18,7 @@ import (
|
||||
"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) {
|
||||
@@ -497,6 +496,19 @@ func TestDecodeJSON(t *testing.T) {
|
||||
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) {
|
||||
@@ -975,20 +987,12 @@ func TestHostSupportStatusRequiresPlansCapability(t *testing.T) {
|
||||
|
||||
func openAppTestStore(t *testing.T) *sqlite.DB {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "state.db")
|
||||
dsn := fmt.Sprintf("file:%s?_busy_timeout=5000&_pragma=foreign_keys(0)", filepath.ToSlash(dbPath))
|
||||
store, err := sqlite.Open(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("sqlite.Open() error = %v", err)
|
||||
}
|
||||
return store
|
||||
return testutil.OpenSQLiteStore(t, testutil.SQLiteTestDSN(t, "state.db", true))
|
||||
}
|
||||
|
||||
func closeAppTestStore(t *testing.T, store *sqlite.DB) {
|
||||
t.Helper()
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatalf("store.Close() error = %v", err)
|
||||
}
|
||||
testutil.CloseSQLiteStore(t, store)
|
||||
}
|
||||
|
||||
func assertJSONContains(t *testing.T, payload []byte, key string, want any) {
|
||||
|
||||
@@ -2,12 +2,11 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"sub2api-cn-relay-manager/internal/store/sqlite"
|
||||
"sub2api-cn-relay-manager/internal/testutil"
|
||||
)
|
||||
|
||||
func TestResumePendingBatchImportRunsCompletesStoredRun(t *testing.T) {
|
||||
@@ -16,11 +15,8 @@ func TestResumePendingBatchImportRunsCompletesStoredRun(t *testing.T) {
|
||||
server := httptest.NewServer(newBatchImportActionStubServer(t))
|
||||
defer server.Close()
|
||||
|
||||
dsn := fmt.Sprintf("file:%s?_busy_timeout=5000&_pragma=foreign_keys(0)", filepath.ToSlash(filepath.Join(t.TempDir(), "state.db")))
|
||||
store, err := sqlite.Open(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("sqlite.Open() error = %v", err)
|
||||
}
|
||||
dsn := testutil.SQLiteTestDSN(t, "state.db", true)
|
||||
store := testutil.OpenSQLiteStore(t, dsn)
|
||||
defer closeAppTestStore(t, store)
|
||||
if _, err := store.SQLDB().Exec("PRAGMA foreign_keys = OFF"); err != nil {
|
||||
t.Fatalf("disable foreign keys pragma error = %v", err)
|
||||
|
||||
@@ -1545,11 +1545,13 @@ func TestActionSetHostClosuresAndAccessPreview(t *testing.T) {
|
||||
t.Fatalf("AccessPreview(subscription) = %+v, want available=false", preview)
|
||||
}
|
||||
|
||||
if err := actions.DeleteHost(context.Background(), "host-main"); err != nil {
|
||||
t.Fatalf("DeleteHost() error = %v", err)
|
||||
}
|
||||
if _, err := store.Hosts().GetByHostID(context.Background(), "host-main"); err == nil {
|
||||
t.Fatal("DeleteHost() did not remove host-main")
|
||||
if err := actions.DeleteHost(context.Background(), "host-main"); err == nil {
|
||||
t.Fatal("DeleteHost() error = nil, want host_in_use conflict")
|
||||
} else {
|
||||
httpErr, ok := err.(*httpError)
|
||||
if !ok || httpErr.StatusCode != http.StatusConflict || httpErr.Code != "host_in_use" {
|
||||
t.Fatalf("DeleteHost() error = %T %v, want *httpError host_in_use conflict", err, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ type ActionSet struct {
|
||||
AccessPreview func(context.Context, AccessPreviewRequest) (AccessPreviewResult, error)
|
||||
}
|
||||
|
||||
const maxJSONBodyBytes int64 = 1 << 20
|
||||
|
||||
type HostInfo struct {
|
||||
HostID string `json:"host_id"`
|
||||
BaseURL string `json:"base_url"`
|
||||
@@ -834,9 +836,17 @@ func handleDeleteHost(w http.ResponseWriter, r *http.Request, fn func(context.Co
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, dest any) *httpError {
|
||||
if r == nil {
|
||||
return &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "request is required"}
|
||||
}
|
||||
r.Body = http.MaxBytesReader(nil, r.Body, maxJSONBodyBytes)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(dest); err != nil {
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesErr) {
|
||||
return &httpError{StatusCode: http.StatusRequestEntityTooLarge, Code: "request_too_large", Message: fmt.Sprintf("request body exceeds %d bytes", maxJSONBodyBytes)}
|
||||
}
|
||||
return &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: fmt.Sprintf("decode request body: %v", err)}
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != nil && !errors.Is(err, io.EOF) {
|
||||
@@ -870,6 +880,10 @@ func classifyError(err error) *httpError {
|
||||
if errors.As(err, &upstreamErr) {
|
||||
return &httpError{StatusCode: http.StatusBadGateway, Code: "host_request_failed", Message: err.Error(), UpstreamStatus: upstreamErr.StatusCode}
|
||||
}
|
||||
var hostDeleteBlocker *sqlite.HostDeleteBlocker
|
||||
if errors.As(err, &hostDeleteBlocker) {
|
||||
return &httpError{StatusCode: http.StatusConflict, Code: "host_in_use", Message: err.Error()}
|
||||
}
|
||||
message := err.Error()
|
||||
switch {
|
||||
case strings.Contains(message, "already installed") || strings.Contains(message, "checksum drift"):
|
||||
@@ -1254,7 +1268,10 @@ func NewActionSet(sqliteDSN string) ActionSet {
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
return store.Hosts().DeleteByHostID(ctx, hostID)
|
||||
if err := store.Hosts().DeleteByHostID(ctx, hostID); err != nil {
|
||||
return classifyError(err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
ListPacks: func(ctx context.Context) ([]PackInfo, error) {
|
||||
store, err := sqlite.Open(ctx, sqliteDSN)
|
||||
|
||||
@@ -5,11 +5,11 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sub2api-cn-relay-manager/internal/store/sqlite"
|
||||
"sub2api-cn-relay-manager/internal/testutil"
|
||||
)
|
||||
|
||||
func TestBatchImportHTTP(t *testing.T) {
|
||||
@@ -139,11 +139,8 @@ func TestBatchImportHTTP(t *testing.T) {
|
||||
server := httptest.NewServer(newBatchImportActionStubServer(t))
|
||||
defer server.Close()
|
||||
|
||||
dsn := fmt.Sprintf("file:%s?_busy_timeout=5000&_pragma=foreign_keys(0)", filepath.ToSlash(filepath.Join(t.TempDir(), "state.db")))
|
||||
store, err := sqlite.Open(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("sqlite.Open() error = %v", err)
|
||||
}
|
||||
dsn := testutil.SQLiteTestDSN(t, "state.db", true)
|
||||
store := testutil.OpenSQLiteStore(t, dsn)
|
||||
defer closeAppTestStore(t, store)
|
||||
|
||||
if _, err := store.Hosts().Create(context.Background(), sqlite.Host{
|
||||
@@ -260,6 +257,24 @@ func TestBatchImportWrapperFunctions(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestBatchImportRejectsOversizedJSONBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
CreateBatchImportRun: func(_ context.Context, req CreateBatchImportRunRequest) (BatchImportRunCreateResponse, error) {
|
||||
t.Fatal("CreateBatchImportRun should not be called for oversized body")
|
||||
return BatchImportRunCreateResponse{}, nil
|
||||
},
|
||||
})
|
||||
|
||||
payload := `{"host_id":"host-1","mode":"strict","access_mode":"self_service","probe_api_key":"probe-key","entries":[{"base_url":"https://kimi.example.com/v1","api_key":"` + strings.Repeat("x", int(maxJSONBodyBytes)) + `"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/batch-import/runs", strings.NewReader(payload))
|
||||
req.Header.Set("Authorization", "Bearer secret-token")
|
||||
res := httptestRecorder(handler, req)
|
||||
assertStatusCode(t, res, http.StatusRequestEntityTooLarge)
|
||||
assertJSONContains(t, res.Body().Bytes(), "error.code", "request_too_large")
|
||||
}
|
||||
|
||||
func newBatchImportActionStubServer(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -14,6 +13,7 @@ import (
|
||||
"sub2api-cn-relay-manager/internal/pack"
|
||||
"sub2api-cn-relay-manager/internal/provision"
|
||||
"sub2api-cn-relay-manager/internal/store/sqlite"
|
||||
"sub2api-cn-relay-manager/internal/testutil"
|
||||
)
|
||||
|
||||
func TestRunReconcileBackgroundSweepCreatesReconcileRunForLatestSuccessfulBatch(t *testing.T) {
|
||||
@@ -86,11 +86,7 @@ func TestRunReconcileBackgroundSweepSkipsRecentReconcileRun(t *testing.T) {
|
||||
func openReconcileBackgroundTestStore(t *testing.T) *sqlite.DB {
|
||||
t.Helper()
|
||||
|
||||
dsn := fmt.Sprintf("file:%s?_busy_timeout=5000&_pragma=foreign_keys(0)", filepath.ToSlash(filepath.Join(t.TempDir(), "state.db")))
|
||||
store, err := sqlite.Open(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("sqlite.Open() error = %v", err)
|
||||
}
|
||||
store := testutil.OpenSQLiteStore(t, testutil.SQLiteTestDSN(t, "state.db", true))
|
||||
if _, err := store.SQLDB().Exec("PRAGMA foreign_keys = OFF"); err != nil {
|
||||
t.Fatalf("disable foreign keys pragma error = %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user