diff --git a/.gitignore b/.gitignore index 7fe6397..f404130 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ *.log .DS_Store node_modules/ +backend/zhinian-api +backend/coverage.out +backend/*.test diff --git a/.project-docs/30-worklog/tasks/20260812-go-migration-foundation-b74c9e21.md b/.project-docs/30-worklog/tasks/20260812-go-migration-foundation-b74c9e21.md new file mode 100644 index 0000000..0938933 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260812-go-migration-foundation-b74c9e21.md @@ -0,0 +1,76 @@ +# Task: Implement Go migration compatibility foundation + +## Identity + +- Task ID: 20260812-go-migration-foundation-b74c9e21 +- Mode: Feature +- Branch: codex/20260812-go-migration-foundation-b74c9e21-go-migration-foundation +- Worktree: /Users/brother7/Documents/AI/NianAIGC-go-foundation-b74c9e21 +- Base commit: 7de3300034accc7a0332b56207298d1e15d91de8 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Establish executable, language-neutral compatibility contracts for the current HTTP route surface, session Cookie wire format, and PostgreSQL runtime/concurrency requirements. +- Add a runnable Go 1.21 backend foundation with strict configuration, PostgreSQL readiness, legacy-session parsing/chunking, and health/readiness HTTP handlers. +- Add developer verification commands without changing the current Next.js, Node Worker, Docker Compose, or ACK production routing. + +## Intent And Constraints + +- Follow red-green TDD at the approved HTTP, Cookie, PostgreSQL, and Adapter seams. +- Preserve ACK-001 as the current deployable truth; this task does not cut traffic, remove Route Handlers, start the embedded WorkerLoop, or move production Secrets. +- Preserve the HMAC-SHA256/base64url/chunked `zhinian_session` wire contract so a future release can avoid forced logout; do not yet make the release-policy choice. +- Keep production PostgreSQL explicit and fail-closed, verified-CA TLS explicit, and both concurrency-sensitive database functions authoritative. +- Use deep Go Modules around configuration, identity Cookie handling, PostgreSQL access, and HTTP lifecycle rather than one shallow Interface per table. + +## Outcome + +- Added language-neutral compatibility contracts for the complete current route surface (47 Route Handler files and 66 method/path entries) and the full version-one `zhinian_session` lifecycle: HMAC wire format, 3000-character chunks, 20-chunk/60000-character ceiling, Cookie attributes, Secure precedence, stale-chunk cleanup, and logout cleanup. +- Added TypeScript contract tests that detect route drift, prove the current TypeScript signer/parser matches the shared Cookie golden fixture, and freeze Cookie writing/clearing semantics. The current writer now rejects values that the 20-chunk reader cannot reconstruct. +- Added a runnable Go 1.21 module under `backend/` with: + - an Identity Module for legacy Cookie signing, parsing, normalization, tamper/expiry validation, bounded chunking/reassembly, Secure resolution, and transport-neutral set/clear operations; + - a PostgreSQL Module for fail-closed backend selection, URI and numeric validation, explicit `disable` or verified-CA `verify-full` TLS, pool lifecycle, the exact readiness privilege matrix, bounded job claims through `claim_generation_jobs`, and wallet posting through `billing_post_wallet_entry`; + - an HTTP Module for the stable `/api/health` liveness contract and three-second `/api/ready` database probe; + - an Application composition Module and `cmd/zhinian-api` process with loopback-by-default binding and bounded graceful shutdown. +- Added cross-platform `npm run go:{fmt,test,vet,build}` commands through a small Node runner that defaults to `CGO_ENABLED=0` without mutating the user's global Go environment. +- Kept the current Next.js, Node Worker, Docker Compose, ACK manifests, Ingress paths, Secrets, and production traffic ownership unchanged. +- Corrected the earlier planning count from 45/64 to the source-derived 47 Route Handler files and 66 method/path entries; the two omitted routes were `/uploads/[...path]` and `/generated-results/[...path]`. + +## Verification + +- TDD RED evidence was captured independently for the HTTP manifest, Identity Module, PostgreSQL configuration/database/opening slices, HTTP health/readiness Module, application composition, cross-platform Go command runner, and final Cookie lifecycle ceiling before each slice reached GREEN. +- `npm test -- --reporter=dot`: 34 files and 126 tests passed. +- `npm run go:test`: all five Go packages passed. +- `npm run go:vet`: passed. +- `npm run go:build`: produced the ignored `backend/zhinian-api` binary. +- Local smoke run on port 18080 returned the stable health payload and successful local readiness, then exited cleanly on SIGTERM. +- `npm run deploy:check`: all 8 current ACK manifests passed, demonstrating that the existing deployment contract was not disturbed. +- `npx tsc --noEmit --incremental false`: passed. +- `npm run build`: Next.js 15.5.18 production build completed with all 33 pages/routes; the existing multiple-lockfile workspace-root warning remains. +- `git diff --check`: passed. +- The installed `/usr/local/go` 1.21.6 internal linker produces `missing LC_UUID` test binaries on this future macOS runtime; the repository runner uses the pure-Go `CGO_ENABLED=0` path, and external linking independently executed affected tests successfully. + +## Follow-ups + +- Add a black-box contract runner that can execute stable health/readiness/OpenAPI assertions against either Next.js or Go by base URL. +- Resolve the observed OpenAPI drift before treating it as authoritative: reused idempotent job responses omit documented HTTP 200, `video.generate.bailian` is absent, and documentation alone advertises video `4k`. +- Implement the first identity vertical slice in Go, including PostgreSQL revalidation of account status, organization status, role constraints, and `sessionVersion`; Cookie parsing alone is not authorization. +- Expand job and wallet database return types only when their owning vertical slices migrate; this foundation intentionally exposes only the minimum needed contract. +- Keep Go unrouted and the Node Worker active until exact path-level parity, single-writer ownership, Worker drain, rollback, and production RDS/ACK checks pass. + +## Promotion Candidates + +- Target: `.project-docs/30-worklog/current-state.md`, `.project-docs/20-architecture/system-overview.md`, `.project-docs/20-architecture/module-map.md`, and `.project-docs/80-commitments/commitments.md`. + Proposal: record that ADR-003 implementation has started with executable route/Cookie contracts and a runnable but unrouted Go foundation; ACK-001 remains authoritative for production. + Evidence: the shared contract fixtures/tests, Go Modules and entry point, complete Node/Go/build/ACK verification, and local smoke run in this task. + Future impact: subsequent slices can use the Go configuration, PostgreSQL, Identity Cookie, health/readiness, and process lifecycle foundations instead of recreating them. + Semantic conflicts: canonical documents currently say no Go implementation has been integrated; that statement becomes stale only after this feature is merged. This work does not supersede ACK-001 or claim a cutover. + Human confirmation required: no new direction is required because ADR-003 and the user's explicit implementation request authorize this first slice; serialized Integration Gate promotion is still required. + +- Target: `.project-docs/90-maintenance/stale-items.md` or corrected planning records if useful. + Proposal: correct the route inventory from 45 files / 64 handlers to 47 files / 66 method-path entries by including both file-serving Route Handlers. + Evidence: executable source-derived route manifest test. + Future impact: migration scope and progress calculations should use the complete surface. + Semantic conflicts: prior task records contain the lower count but are immutable historical evidence. + Human confirmation required: no; factual correction during Integration Gate. diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..b0f6d02 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,34 @@ +# Go backend foundation + +This directory contains the first implementation slice of ADR-003. It is a +runnable compatibility foundation, but it is **not** the current production API +owner: Next.js, the Node Worker, Docker Compose, and the ACK manifests remain +unchanged until later route-by-route cutover work passes the shared contracts. + +Implemented Modules: + +- `identity`: legacy `zhinian_session` HMAC, parsing, and chunking contract. +- `postgres`: fail-closed configuration, verified-CA TLS, readiness, and calls + to the existing atomic claim and wallet PostgreSQL functions. +- `httpapi`: process health and database readiness handlers. +- `application`: composition and the `cmd/zhinian-api` process entry point. + +From the repository root: + +```bash +npm run go:fmt +npm run go:test +npm run go:vet +npm run go:build +``` + +The runner defaults to `CGO_ENABLED=0` for reproducible cross-platform builds. +To exercise the local foundation manually without changing the existing Next +server, use a different port: + +```bash +ZHINIAN_DATA_BACKEND=local GO_BACKEND_PORT=8080 ./backend/zhinian-api +``` + +Only `/api/health` and `/api/ready` are implemented in this foundation. No +Ingress, Docker, ACK, Secret, or Worker ownership has moved to Go yet. diff --git a/backend/cmd/zhinian-api/main.go b/backend/cmd/zhinian-api/main.go new file mode 100644 index 0000000..0732b04 --- /dev/null +++ b/backend/cmd/zhinian-api/main.go @@ -0,0 +1,79 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "net" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/application" +) + +const shutdownTimeout = 10 * time.Second + +func main() { + if err := run(); err != nil { + log.Fatal(err) + } +} + +func run() error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + app, err := application.New(application.Options{Context: ctx}) + if err != nil { + return fmt.Errorf("initialize Go backend: %w", err) + } + defer app.Close() + + server := &http.Server{ + Addr: listenAddress(os.Getenv), + Handler: app.Handler(), + ReadHeaderTimeout: 5 * time.Second, + } + + serveErr := make(chan error, 1) + go func() { + log.Printf("zhinian-api foundation listening on %s", server.Addr) + serveErr <- server.ListenAndServe() + }() + + select { + case err := <-serveErr: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return fmt.Errorf("serve Go backend: %w", err) + case <-ctx.Done(): + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("shut down Go backend: %w", err) + } + if err := <-serveErr; err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("serve Go backend: %w", err) + } + return nil +} + +func listenAddress(getenv func(string) string) string { + host := strings.TrimSpace(getenv("GO_BACKEND_HOST")) + if host == "" { + host = "127.0.0.1" + } + port := strings.TrimSpace(getenv("GO_BACKEND_PORT")) + if port == "" { + port = "8080" + } + return net.JoinHostPort(host, port) +} diff --git a/backend/cmd/zhinian-api/main_test.go b/backend/cmd/zhinian-api/main_test.go new file mode 100644 index 0000000..9a6c853 --- /dev/null +++ b/backend/cmd/zhinian-api/main_test.go @@ -0,0 +1,13 @@ +package main + +import "testing" + +func TestListenAddressDefaultsToLoopbackAndSupportsExplicitBinding(t *testing.T) { + if got := listenAddress(func(string) string { return "" }); got != "127.0.0.1:8080" { + t.Fatalf("listenAddress(defaults) = %q", got) + } + values := map[string]string{"GO_BACKEND_HOST": "0.0.0.0", "GO_BACKEND_PORT": "9080"} + if got := listenAddress(func(name string) string { return values[name] }); got != "0.0.0.0:9080" { + t.Fatalf("listenAddress(explicit) = %q", got) + } +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..237f9b4 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,14 @@ +module git.nianxx.cn/wangxuming/NianAIGC/backend + +go 1.21 + +require github.com/jackc/pgx/v5 v5.5.5 + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..59b2f34 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,28 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/application/application.go b/backend/internal/application/application.go new file mode 100644 index 0000000..38609ed --- /dev/null +++ b/backend/internal/application/application.go @@ -0,0 +1,79 @@ +// Package application composes the Go backend foundation Modules. +package application + +import ( + "context" + "net/http" + "os" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/httpapi" + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/postgres" +) + +type Options struct { + Context context.Context + Getenv postgres.Getenv + ReadFile postgres.ReadFile +} + +type App struct { + handler http.Handler + db *postgres.Module +} + +func New(options Options) (*App, error) { + ctx := options.Context + if ctx == nil { + ctx = context.Background() + } + getenv := options.Getenv + if getenv == nil { + getenv = os.Getenv + } + readFile := options.ReadFile + if readFile == nil { + readFile = os.ReadFile + } + + config, err := postgres.ParseConfig(getenv, readFile) + if err != nil { + return nil, err + } + database, err := postgres.Open(ctx, config) + if err != nil { + return nil, err + } + readiness := databaseReadiness{config: config, store: database.Store} + return &App{ + db: database, + handler: httpapi.NewHandler(readiness), + }, nil +} + +func (app *App) Handler() http.Handler { + return app.handler +} + +func (app *App) Close() { + if app.db != nil { + app.db.Close() + } +} + +type databaseReadiness struct { + config postgres.Config + store *postgres.Store +} + +func (adapter databaseReadiness) Status() httpapi.DatabaseStatus { + configured := adapter.config.Backend == postgres.BackendLocal || adapter.config.DatabaseURL != "" + return httpapi.DatabaseStatus{ + Backend: string(adapter.config.Backend), + Configured: configured, + } +} + +func (adapter databaseReadiness) Ready(ctx context.Context) (httpapi.DatabaseStatus, error) { + status := adapter.Status() + return status, adapter.store.Readiness(ctx) +} diff --git a/backend/internal/application/application_test.go b/backend/internal/application/application_test.go new file mode 100644 index 0000000..22c29d2 --- /dev/null +++ b/backend/internal/application/application_test.go @@ -0,0 +1,68 @@ +package application_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/application" +) + +func TestLocalApplicationServesFoundationHealthAndReadiness(t *testing.T) { + app, err := application.New(application.Options{ + Getenv: func(name string) string { + if name == "ZHINIAN_DATA_BACKEND" { + return "local" + } + return "" + }, + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + t.Cleanup(app.Close) + + for _, test := range []struct { + path string + wantCode int + }{ + {path: "/api/health", wantCode: http.StatusOK}, + {path: "/api/ready", wantCode: http.StatusOK}, + } { + t.Run(test.path, func(t *testing.T) { + response := httptest.NewRecorder() + app.Handler().ServeHTTP(response, httptest.NewRequest(http.MethodGet, test.path, nil)) + if response.Code != test.wantCode { + t.Fatalf("status = %d, want %d", response.Code, test.wantCode) + } + var payload struct { + OK bool `json:"ok"` + Database struct { + Backend string `json:"backend"` + Configured bool `json:"configured"` + } `json:"database"` + } + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if !payload.OK || payload.Database.Backend != "local" || !payload.Database.Configured { + t.Fatalf("payload = %+v", payload) + } + }) + } +} + +func TestApplicationRejectsInvalidProductionDatabaseConfiguration(t *testing.T) { + _, err := application.New(application.Options{ + Getenv: func(name string) string { + if name == "NODE_ENV" { + return "production" + } + return "" + }, + }) + if err == nil { + t.Fatal("New() error = nil, want fail-closed database configuration error") + } +} diff --git a/backend/internal/httpapi/httpapi.go b/backend/internal/httpapi/httpapi.go new file mode 100644 index 0000000..6e67e37 --- /dev/null +++ b/backend/internal/httpapi/httpapi.go @@ -0,0 +1,116 @@ +// Package httpapi exposes the Go backend's process health and database +// readiness endpoints without wiring them into the production application. +package httpapi + +import ( + "context" + "encoding/json" + "net/http" + "time" +) + +const appID = "zhinian-web-studio" + +const defaultReadinessTimeout = 3 * time.Second + +// DatabaseStatus is the database configuration state exposed by health and +// readiness responses. +type DatabaseStatus struct { + Backend string `json:"backend"` + Configured bool `json:"configured"` +} + +// Readiness is the HTTP module's seam to database configuration and probing. +type Readiness interface { + Status() DatabaseStatus + Ready(context.Context) (DatabaseStatus, error) +} + +type handler struct { + readiness Readiness + readinessTimeout time.Duration +} + +// Option configures the HTTP handler. +type Option func(*handler) + +// WithReadinessTimeout sets the maximum duration of a database readiness probe. +func WithReadinessTimeout(timeout time.Duration) Option { + return func(h *handler) { + if timeout > 0 { + h.readinessTimeout = timeout + } + } +} + +// NewHandler returns the foundation health/readiness HTTP handler. +func NewHandler(readiness Readiness, options ...Option) http.Handler { + h := &handler{readiness: readiness, readinessTimeout: defaultReadinessTimeout} + for _, option := range options { + option(h) + } + return h +} + +func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/health" && r.URL.Path != "/api/ready" { + http.NotFound(w, r) + return + } + if r.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return + } + + if r.URL.Path == "/api/ready" { + h.serveReady(w, r) + return + } + + status := DatabaseStatus{Backend: "invalid", Configured: false} + if h.readiness != nil { + status = h.readiness.Status() + } + writeJSON(w, http.StatusOK, struct { + OK bool `json:"ok"` + AppID string `json:"appId"` + WebOnly bool `json:"webOnly"` + Database DatabaseStatus `json:"database"` + }{ + OK: true, + AppID: appID, + WebOnly: true, + Database: status, + }) +} + +func (h *handler) serveReady(w http.ResponseWriter, r *http.Request) { + status := DatabaseStatus{Backend: "invalid", Configured: false} + if h.readiness == nil { + writeReadyJSON(w, http.StatusServiceUnavailable, false, status) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), h.readinessTimeout) + defer cancel() + status, err := h.readiness.Ready(ctx) + if err != nil { + writeReadyJSON(w, http.StatusServiceUnavailable, false, status) + return + } + writeReadyJSON(w, http.StatusOK, true, status) +} + +func writeReadyJSON(w http.ResponseWriter, status int, ok bool, database DatabaseStatus) { + writeJSON(w, status, struct { + OK bool `json:"ok"` + Database DatabaseStatus `json:"database"` + }{OK: ok, Database: database}) +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} diff --git a/backend/internal/httpapi/httpapi_test.go b/backend/internal/httpapi/httpapi_test.go new file mode 100644 index 0000000..00e0b6a --- /dev/null +++ b/backend/internal/httpapi/httpapi_test.go @@ -0,0 +1,180 @@ +package httpapi_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/httpapi" +) + +func TestHealthReportsProcessAndDatabaseConfigurationWithoutReadinessProbe(t *testing.T) { + readiness := &readinessStub{ + status: httpapi.DatabaseStatus{Backend: "postgres", Configured: true}, + } + recorder := httptest.NewRecorder() + + httpapi.NewHandler(readiness).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/health", nil)) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK) + } + var response struct { + OK bool `json:"ok"` + AppID string `json:"appId"` + WebOnly bool `json:"webOnly"` + Database httpapi.DatabaseStatus `json:"database"` + } + if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { + t.Fatalf("decode response: %v", err) + } + if !response.OK || response.AppID != "zhinian-web-studio" || !response.WebOnly { + t.Fatalf("process response = %+v", response) + } + if response.Database != readiness.status { + t.Fatalf("database = %+v, want %+v", response.Database, readiness.status) + } + if readiness.readyCalls != 0 { + t.Fatalf("health made %d readiness probes, want 0", readiness.readyCalls) + } +} + +func TestReadyReportsSuccessfulDatabaseProbe(t *testing.T) { + readiness := &readinessStub{ + status: httpapi.DatabaseStatus{Backend: "postgres", Configured: true}, + readyStatus: httpapi.DatabaseStatus{Backend: "postgres", Configured: true}, + } + recorder := httptest.NewRecorder() + + httpapi.NewHandler(readiness).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/ready", nil)) + + assertReadyResponse(t, recorder, http.StatusOK, true, readiness.readyStatus) +} + +func TestReadyReportsUnavailableDatabase(t *testing.T) { + readiness := &readinessStub{ + status: httpapi.DatabaseStatus{Backend: "postgres", Configured: true}, + readyStatus: httpapi.DatabaseStatus{Backend: "postgres", Configured: true}, + readyErr: errors.New("database unavailable"), + } + recorder := httptest.NewRecorder() + + httpapi.NewHandler(readiness).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/ready", nil)) + + assertReadyResponse(t, recorder, http.StatusServiceUnavailable, false, readiness.readyStatus) +} + +func TestReadyUsesConfiguredProbeTimeout(t *testing.T) { + readiness := &readinessStub{ + status: httpapi.DatabaseStatus{Backend: "postgres", Configured: true}, + readyStatus: httpapi.DatabaseStatus{Backend: "postgres", Configured: true}, + } + recorder := httptest.NewRecorder() + timeout := 17 * time.Millisecond + + httpapi.NewHandler(readiness, httpapi.WithReadinessTimeout(timeout)).ServeHTTP( + recorder, + httptest.NewRequest(http.MethodGet, "/api/ready", nil), + ) + + if readiness.deadlineRemaining <= 0 || readiness.deadlineRemaining > timeout { + t.Fatalf("probe deadline remaining = %s, want within (0, %s]", readiness.deadlineRemaining, timeout) + } +} + +func TestReadyDefaultsToThreeSecondProbeTimeout(t *testing.T) { + readiness := &readinessStub{ + readyStatus: httpapi.DatabaseStatus{Backend: "postgres", Configured: true}, + } + recorder := httptest.NewRecorder() + + httpapi.NewHandler(readiness).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/ready", nil)) + + if readiness.deadlineRemaining < 2900*time.Millisecond || readiness.deadlineRemaining > 3*time.Second { + t.Fatalf("probe deadline remaining = %s, want approximately 3s", readiness.deadlineRemaining) + } +} + +func TestHealthReflectsInvalidDatabaseConfiguration(t *testing.T) { + readiness := &readinessStub{ + status: httpapi.DatabaseStatus{Backend: "invalid", Configured: false}, + } + recorder := httptest.NewRecorder() + + httpapi.NewHandler(readiness).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/health", nil)) + + var response struct { + Database httpapi.DatabaseStatus `json:"database"` + } + if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.Database != readiness.status { + t.Fatalf("database = %+v, want %+v", response.Database, readiness.status) + } +} + +func TestHandlerRejectsUnsupportedMethodsAndUnknownPaths(t *testing.T) { + tests := []struct { + name string + method string + path string + wantStatus int + wantAllow string + }{ + {name: "health post", method: http.MethodPost, path: "/api/health", wantStatus: http.StatusMethodNotAllowed, wantAllow: http.MethodGet}, + {name: "ready post", method: http.MethodPost, path: "/api/ready", wantStatus: http.StatusMethodNotAllowed, wantAllow: http.MethodGet}, + {name: "unknown", method: http.MethodGet, path: "/api/missing", wantStatus: http.StatusNotFound}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + httpapi.NewHandler(&readinessStub{}).ServeHTTP(recorder, httptest.NewRequest(test.method, test.path, nil)) + if recorder.Code != test.wantStatus { + t.Fatalf("status = %d, want %d", recorder.Code, test.wantStatus) + } + if allow := recorder.Header().Get("Allow"); allow != test.wantAllow { + t.Fatalf("Allow = %q, want %q", allow, test.wantAllow) + } + }) + } +} + +func assertReadyResponse(t *testing.T, recorder *httptest.ResponseRecorder, wantStatus int, wantOK bool, wantDatabase httpapi.DatabaseStatus) { + t.Helper() + if recorder.Code != wantStatus { + t.Fatalf("status = %d, want %d", recorder.Code, wantStatus) + } + var response struct { + OK bool `json:"ok"` + Database httpapi.DatabaseStatus `json:"database"` + } + if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.OK != wantOK || response.Database != wantDatabase { + t.Fatalf("response = %+v, want ok=%t database=%+v", response, wantOK, wantDatabase) + } +} + +type readinessStub struct { + status httpapi.DatabaseStatus + readyStatus httpapi.DatabaseStatus + readyErr error + readyCalls int + deadlineRemaining time.Duration +} + +func (s *readinessStub) Status() httpapi.DatabaseStatus { return s.status } + +func (s *readinessStub) Ready(ctx context.Context) (httpapi.DatabaseStatus, error) { + s.readyCalls++ + if deadline, ok := ctx.Deadline(); ok { + s.deadlineRemaining = time.Until(deadline) + } + return s.readyStatus, s.readyErr +} diff --git a/backend/internal/identity/session.go b/backend/internal/identity/session.go new file mode 100644 index 0000000..db282ed --- /dev/null +++ b/backend/internal/identity/session.go @@ -0,0 +1,261 @@ +// Package identity implements the legacy zhinian_session cookie wire contract. +package identity + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/url" + "strings" + "time" +) + +const ( + SessionCookieName = "zhinian_session" + CookieChunkSize = 3000 + CookieMaxChunks = 20 + CookieMaxValueLength = CookieChunkSize * CookieMaxChunks +) + +type AuthMode string + +const ( + AuthModeUser AuthMode = "user" + AuthModeAdmin AuthMode = "admin" +) + +// Session is the validated version-one session contract. +type Session struct { + Version int `json:"version"` + AuthMode AuthMode `json:"authMode"` + IssuedAt int64 `json:"issuedAt"` + ExpiresAt int64 `json:"expiresAt"` + SessionVersion *int `json:"sessionVersion,omitempty"` + AccessToken string `json:"accessToken,omitempty"` + TokenType string `json:"tokenType,omitempty"` + User User `json:"user"` +} + +type User struct { + ID string `json:"id"` + Subject string `json:"subject"` + Username string `json:"username,omitempty"` + Phone string `json:"phone,omitempty"` + DisplayName string `json:"displayName"` + ClientID string `json:"clientId"` + TenantID string `json:"tenantId,omitempty"` + OrganizationID string `json:"organizationId,omitempty"` + OrganizationName string `json:"organizationName,omitempty"` + Role string `json:"role,omitempty"` + Status string `json:"status,omitempty"` + Authorities []string `json:"authorities"` + Scope []string `json:"scope"` +} + +type CookieChunk struct { + Name string + Value string +} + +// CookieAttributes is a transport-neutral description of the legacy +// Set-Cookie attributes. Adapters can translate it to their HTTP framework. +type CookieAttributes struct { + HTTPOnly bool + SameSite string + Secure bool + Path string + Expires *time.Time + MaxAgeSeconds *int +} + +// CookieWrite describes one cookie mutation. A session write always returns +// all 20 names so stale chunks are cleared atomically with the new chunks. +type CookieWrite struct { + Name string + Value string + Attributes CookieAttributes +} + +var ( + ErrMalformedSession = errors.New("malformed session cookie") + ErrInvalidSignature = errors.New("invalid session cookie signature") + ErrInvalidSession = errors.New("invalid session") + ErrSessionTooLarge = errors.New("session cookie exceeds maximum supported size") +) + +// Sign preserves raw JSON bytes, base64url-encodes them without padding, and +// signs that encoded payload with HMAC-SHA256. +func Sign(rawJSON []byte, secret string) (string, error) { + if !json.Valid(rawJSON) { + return "", fmt.Errorf("%w: invalid JSON", ErrInvalidSession) + } + payload := base64.RawURLEncoding.EncodeToString(rawJSON) + signature := signPayload(payload, secret) + return payload + "." + signature, nil +} + +// Parse authenticates and validates a version-one session cookie. +func Parse(value, secret string, now time.Time) (Session, error) { + payload, signature, ok := strings.Cut(value, ".") + if !ok || payload == "" || signature == "" || strings.Contains(signature, ".") { + return Session{}, ErrMalformedSession + } + expected := signPayload(payload, secret) + if !hmac.Equal([]byte(signature), []byte(expected)) { + return Session{}, ErrInvalidSignature + } + rawJSON, err := base64.RawURLEncoding.DecodeString(payload) + if err != nil { + return Session{}, fmt.Errorf("%w: invalid payload encoding", ErrMalformedSession) + } + var session Session + if err := json.Unmarshal(rawJSON, &session); err != nil { + return Session{}, fmt.Errorf("%w: %v", ErrInvalidSession, err) + } + if session.Version != 1 || session.User.ID == "" || session.User.ClientID == "" || session.ExpiresAt <= now.Unix() { + return Session{}, ErrInvalidSession + } + if session.AuthMode != AuthModeAdmin { + session.AuthMode = AuthModeUser + } + if session.User.Authorities == nil { + session.User.Authorities = []string{} + } + if session.User.Scope == nil { + session.User.Scope = []string{} + } + return session, nil +} + +func signPayload(payload, secret string) string { + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(payload)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +// Chunk splits a value into legacy cookie chunks named base, base.1, base.2, +// ... and rejects values the legacy reader cannot fully reassemble. +func Chunk(baseName, value string) ([]CookieChunk, error) { + if len(value) > CookieMaxValueLength { + return nil, ErrSessionTooLarge + } + if value == "" { + return []CookieChunk{{Name: baseName}}, nil + } + chunks := make([]CookieChunk, 0, (len(value)+CookieChunkSize-1)/CookieChunkSize) + for start, index := 0, 0; start < len(value); start, index = start+CookieChunkSize, index+1 { + end := start + CookieChunkSize + if end > len(value) { + end = len(value) + } + chunks = append(chunks, CookieChunk{Name: chunkName(baseName, index), Value: value[start:end]}) + } + return chunks, nil +} + +// CookieNames returns every cookie name read or cleared by the legacy session +// contract, from zhinian_session through zhinian_session.19. +func CookieNames() []string { + names := make([]string, CookieMaxChunks) + for index := range names { + names[index] = chunkName(SessionCookieName, index) + } + return names +} + +// SetSessionCookies creates writes for the current chunks and deletion writes +// for every remaining legacy chunk name. +func SetSessionCookies(value string, expires time.Time, secure bool) ([]CookieWrite, error) { + chunks, err := Chunk(SessionCookieName, value) + if err != nil { + return nil, err + } + writes := make([]CookieWrite, 0, CookieMaxChunks) + for _, chunk := range chunks { + expiresCopy := expires + writes = append(writes, CookieWrite{ + Name: chunk.Name, + Value: chunk.Value, + Attributes: CookieAttributes{ + HTTPOnly: true, + SameSite: "lax", + Secure: secure, + Path: "/", + Expires: &expiresCopy, + }, + }) + } + for index := len(chunks); index < CookieMaxChunks; index++ { + writes = append(writes, clearCookieWrite(chunkName(SessionCookieName, index), secure)) + } + return writes, nil +} + +// ClearSessionCookies clears the base cookie and every possible legacy chunk. +func ClearSessionCookies(secure bool) []CookieWrite { + writes := make([]CookieWrite, 0, CookieMaxChunks) + for _, name := range CookieNames() { + writes = append(writes, clearCookieWrite(name, secure)) + } + return writes +} + +func clearCookieWrite(name string, secure bool) CookieWrite { + maxAge := 0 + return CookieWrite{ + Name: name, + Attributes: CookieAttributes{ + HTTPOnly: true, + SameSite: "lax", + Secure: secure, + Path: "/", + MaxAgeSeconds: &maxAge, + }, + } +} + +// ResolveSecureCookie mirrors the legacy environment precedence: a recognized +// explicit setting wins, then the configured public base URL wins over the +// request URL, and only HTTPS enables Secure. +func ResolveSecureCookie(explicit, publicBaseURL, requestURL string) bool { + switch strings.ToLower(strings.TrimSpace(explicit)) { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + } + candidate := strings.TrimSpace(publicBaseURL) + if candidate == "" { + candidate = strings.TrimSpace(requestURL) + } + parsed, err := url.Parse(candidate) + return err == nil && strings.EqualFold(parsed.Scheme, "https") +} + +// Reassemble reads at most 20 contiguous legacy cookie chunks. +func Reassemble(baseName string, getValue func(name string) (string, bool)) (string, bool) { + first, ok := getValue(baseName) + if !ok || first == "" { + return "", false + } + var value strings.Builder + value.WriteString(first) + for index := 1; index < CookieMaxChunks; index++ { + chunk, found := getValue(chunkName(baseName, index)) + if !found || chunk == "" { + break + } + value.WriteString(chunk) + } + return value.String(), true +} + +func chunkName(baseName string, index int) string { + if index == 0 { + return baseName + } + return fmt.Sprintf("%s.%d", baseName, index) +} diff --git a/backend/internal/identity/session_test.go b/backend/internal/identity/session_test.go new file mode 100644 index 0000000..d84dd46 --- /dev/null +++ b/backend/internal/identity/session_test.go @@ -0,0 +1,292 @@ +package identity + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +type sessionFixture struct { + Version int `json:"version"` + Cookie sessionCookieContract `json:"cookie"` + Secret string `json:"secret"` + RawJSON string `json:"rawJson"` + Payload string `json:"payload"` + Signature string `json:"signature"` + CookieValue string `json:"cookieValue"` +} + +type sessionCookieContract struct { + Name string `json:"name"` + ChunkSize int `json:"chunkSize"` + MaxChunks int `json:"maxChunks"` + MaxValueLength int `json:"maxValueLength"` + ChunkNames []string `json:"chunkNames"` + WriteExample struct { + ValueCharacter string `json:"valueCharacter"` + ValueLength int `json:"valueLength"` + ChunkLengths []int `json:"chunkLengths"` + ExpiresAtUnix int64 `json:"expiresAtUnix"` + } `json:"writeExample"` + Attributes struct { + HTTPOnly bool `json:"httpOnly"` + SameSite string `json:"sameSite"` + Path string `json:"path"` + ProductionSecure bool `json:"productionSecure"` + } `json:"attributes"` + Clear struct { + Value string `json:"value"` + MaxAgeSeconds int `json:"maxAgeSeconds"` + } `json:"clear"` + SecureResolutionCases []struct { + Name string `json:"name"` + Explicit string `json:"explicit"` + PublicBaseURL string `json:"publicBaseUrl"` + RequestURL string `json:"requestUrl"` + Expected bool `json:"expected"` + } `json:"secureResolutionCases"` +} + +func TestSignMatchesTypeScriptFixture(t *testing.T) { + fixture := loadSessionFixture(t) + const expectedSignature = "KTBRAoo5dytZJhvzX9qqd5JFnfVm7hEQd57TW1JSk4M" + if fixture.Signature != expectedSignature { + t.Fatalf("fixture signature = %q, want deterministic vector %q", fixture.Signature, expectedSignature) + } + + got, err := Sign([]byte(fixture.RawJSON), fixture.Secret) + if err != nil { + t.Fatalf("Sign() error = %v", err) + } + if got != fixture.CookieValue { + t.Fatalf("Sign() = %q, want fixture cookie %q", got, fixture.CookieValue) + } + payload, signature, ok := strings.Cut(got, ".") + if !ok || payload != fixture.Payload || signature != expectedSignature { + t.Fatalf("Sign() parts = (%q, %q), want fixture payload and signature", payload, signature) + } + if strings.Contains(payload, "=") { + t.Fatal("Sign() emitted padded base64url") + } +} + +func loadSessionFixture(t *testing.T) sessionFixture { + t.Helper() + path := filepath.Join("..", "..", "..", "contracts", "auth", "session-cookie-v1.json") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + var fixture sessionFixture + if err := json.Unmarshal(data, &fixture); err != nil { + t.Fatalf("decode fixture: %v", err) + } + return fixture +} + +func TestParseValidatesAndNormalizesSession(t *testing.T) { + fixture := loadSessionFixture(t) + session, err := Parse(fixture.CookieValue, fixture.Secret, time.Unix(150, 0)) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if session.AuthMode != AuthModeAdmin || session.AccessToken != "access-token-1" || session.TokenType != "bearer" { + t.Fatalf("Parse() session = %#v", session) + } + if session.User.ID != "auth:customPC:1" || session.User.DisplayName != "张三" { + t.Fatalf("Parse() user = %#v", session.User) + } +} + +func TestParseRejectsTamperExpiryAndMalformedWireValues(t *testing.T) { + fixture := loadSessionFixture(t) + tampered := fixture.CookieValue[:len(fixture.CookieValue)-1] + "x" + tests := []struct { + name string + value string + now time.Time + }{ + {name: "tampered", value: tampered, now: time.Unix(150, 0)}, + {name: "exact expiry", value: fixture.CookieValue, now: time.Unix(200, 0)}, + {name: "missing dot", value: fixture.Payload, now: time.Unix(150, 0)}, + {name: "extra dot", value: fixture.CookieValue + ".extra", now: time.Unix(150, 0)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := Parse(tt.value, fixture.Secret, tt.now); err == nil { + t.Fatal("Parse() error = nil, want rejection") + } + }) + } +} + +func TestParseNormalizesLegacyAndMissingCollections(t *testing.T) { + fixture := loadSessionFixture(t) + raw := []byte(`{"version":1,"issuedAt":100,"expiresAt":200,"user":{"id":"legacy","subject":"legacy","displayName":"Legacy","clientId":"customPC"}}`) + value, err := Sign(raw, fixture.Secret) + if err != nil { + t.Fatal(err) + } + session, err := Parse(value, fixture.Secret, time.Unix(150, 0)) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if session.AuthMode != AuthModeUser { + t.Fatalf("AuthMode = %q, want %q", session.AuthMode, AuthModeUser) + } + if session.User.Authorities == nil || len(session.User.Authorities) != 0 || session.User.Scope == nil || len(session.User.Scope) != 0 { + t.Fatalf("missing collections not normalized: %#v", session.User) + } +} + +func TestParseRejectsInvalidProductionContract(t *testing.T) { + fixture := loadSessionFixture(t) + tests := []string{ + `{"version":2,"expiresAt":200,"user":{"id":"user","clientId":"customPC"}}`, + `{"version":1,"expiresAt":200,"user":{"id":"","clientId":"customPC"}}`, + `{"version":1,"expiresAt":200,"user":{"id":"user","clientId":""}}`, + `{"version":1,"expiresAt":"200","user":{"id":"user","clientId":"customPC"}}`, + `{"version":1,"expiresAt":200,"accessToken":7,"user":{"id":"user","clientId":"customPC"}}`, + } + for _, raw := range tests { + value, err := Sign([]byte(raw), fixture.Secret) + if err != nil { + t.Fatal(err) + } + if _, err := Parse(value, fixture.Secret, time.Unix(150, 0)); err == nil { + t.Fatalf("Parse(%s) accepted invalid contract", raw) + } + } +} + +func TestChunkAndReassembleUseLegacyCookieNamesAndLimits(t *testing.T) { + value := strings.Repeat("x", CookieChunkSize*2+1) + chunks, err := Chunk(SessionCookieName, value) + if err != nil { + t.Fatalf("Chunk() error = %v", err) + } + wantNames := []string{"zhinian_session", "zhinian_session.1", "zhinian_session.2"} + if len(chunks) != len(wantNames) { + t.Fatalf("len(Chunk()) = %d, want %d", len(chunks), len(wantNames)) + } + values := make(map[string]string, len(chunks)) + for index, chunk := range chunks { + if chunk.Name != wantNames[index] { + t.Errorf("chunk %d name = %q, want %q", index, chunk.Name, wantNames[index]) + } + if len(chunk.Value) > CookieChunkSize { + t.Errorf("chunk %d length = %d", index, len(chunk.Value)) + } + values[chunk.Name] = chunk.Value + } + got, ok := Reassemble(SessionCookieName, func(name string) (string, bool) { + value, found := values[name] + return value, found + }) + if !ok || got != value { + t.Fatalf("Reassemble() = (%q, %v), want original", got, ok) + } + + visited := make([]string, 0, CookieMaxChunks) + _, _ = Reassemble(SessionCookieName, func(name string) (string, bool) { + visited = append(visited, name) + return "x", true + }) + if len(visited) != CookieMaxChunks || visited[len(visited)-1] != "zhinian_session.19" { + t.Fatalf("Reassemble visited %v", visited) + } +} + +func TestCookieLifecycleMatchesSharedContract(t *testing.T) { + fixture := loadSessionFixture(t) + cookie := fixture.Cookie + if SessionCookieName != cookie.Name || CookieChunkSize != cookie.ChunkSize || CookieMaxChunks != cookie.MaxChunks || CookieMaxValueLength != cookie.MaxValueLength { + t.Fatalf("Go constants do not match shared cookie contract: %#v", cookie) + } + if got := CookieNames(); !reflect.DeepEqual(got, cookie.ChunkNames) { + t.Fatalf("CookieNames() = %v, want %v", got, cookie.ChunkNames) + } + + value := strings.Repeat(cookie.WriteExample.ValueCharacter, cookie.WriteExample.ValueLength) + chunks, err := Chunk(cookie.Name, value) + if err != nil { + t.Fatalf("Chunk() error = %v", err) + } + gotLengths := make([]int, len(chunks)) + for index, chunk := range chunks { + gotLengths[index] = len(chunk.Value) + } + if !reflect.DeepEqual(gotLengths, cookie.WriteExample.ChunkLengths) { + t.Fatalf("chunk lengths = %v, want %v", gotLengths, cookie.WriteExample.ChunkLengths) + } + + expires := time.Unix(cookie.WriteExample.ExpiresAtUnix, 0).UTC() + writes, err := SetSessionCookies(value, expires, cookie.Attributes.ProductionSecure) + if err != nil { + t.Fatalf("SetSessionCookies() error = %v", err) + } + if len(writes) != cookie.MaxChunks { + t.Fatalf("len(SetSessionCookies()) = %d, want %d", len(writes), cookie.MaxChunks) + } + for index, write := range writes { + if write.Name != cookie.ChunkNames[index] { + t.Errorf("write %d name = %q, want %q", index, write.Name, cookie.ChunkNames[index]) + } + assertCookieAttributes(t, write.Attributes, cookie, index < len(chunks), expires) + if index >= len(chunks) && write.Value != cookie.Clear.Value { + t.Errorf("stale clear %d value = %q, want %q", index, write.Value, cookie.Clear.Value) + } + } + + clears := ClearSessionCookies(cookie.Attributes.ProductionSecure) + if len(clears) != cookie.MaxChunks { + t.Fatalf("len(ClearSessionCookies()) = %d, want %d", len(clears), cookie.MaxChunks) + } + for index, clear := range clears { + if clear.Name != cookie.ChunkNames[index] || clear.Value != cookie.Clear.Value { + t.Errorf("clear %d = %#v", index, clear) + } + assertCookieAttributes(t, clear.Attributes, cookie, false, expires) + } +} + +func TestChunkRejectsValuesBeyondSharedReadCeiling(t *testing.T) { + fixture := loadSessionFixture(t) + _, err := Chunk(fixture.Cookie.Name, strings.Repeat("x", fixture.Cookie.MaxValueLength+1)) + if !errors.Is(err, ErrSessionTooLarge) { + t.Fatalf("Chunk() error = %v, want ErrSessionTooLarge", err) + } +} + +func TestSecureCookieResolutionMatchesSharedContract(t *testing.T) { + fixture := loadSessionFixture(t) + for _, testCase := range fixture.Cookie.SecureResolutionCases { + t.Run(testCase.Name, func(t *testing.T) { + got := ResolveSecureCookie(testCase.Explicit, testCase.PublicBaseURL, testCase.RequestURL) + if got != testCase.Expected { + t.Fatalf("ResolveSecureCookie() = %v, want %v", got, testCase.Expected) + } + }) + } +} + +func assertCookieAttributes(t *testing.T, got CookieAttributes, contract sessionCookieContract, write bool, expires time.Time) { + t.Helper() + if got.HTTPOnly != contract.Attributes.HTTPOnly || got.SameSite != contract.Attributes.SameSite || got.Secure != contract.Attributes.ProductionSecure || got.Path != contract.Attributes.Path { + t.Errorf("attributes = %#v, want shared base attributes", got) + } + if write { + if got.Expires == nil || !got.Expires.Equal(expires) || got.MaxAgeSeconds != nil { + t.Errorf("write attributes = %#v, want Expires=%v and no Max-Age", got, expires) + } + return + } + if got.Expires != nil || got.MaxAgeSeconds == nil || *got.MaxAgeSeconds != contract.Clear.MaxAgeSeconds { + t.Errorf("clear attributes = %#v, want Max-Age=%d and no Expires", got, contract.Clear.MaxAgeSeconds) + } +} diff --git a/backend/internal/postgres/config.go b/backend/internal/postgres/config.go new file mode 100644 index 0000000..1f5c21b --- /dev/null +++ b/backend/internal/postgres/config.go @@ -0,0 +1,165 @@ +package postgres + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "net/url" + "strconv" + "strings" + "time" +) + +type Backend string + +const ( + BackendLocal Backend = "local" + BackendPostgres Backend = "postgres" +) + +type SSLMode string + +const ( + SSLDisable SSLMode = "disable" + SSLVerifyFull SSLMode = "verify-full" +) + +type Getenv func(string) string +type ReadFile func(string) ([]byte, error) + +type Config struct { + Backend Backend + DatabaseURL string + SSLMode SSLMode + TLSConfig *tls.Config + PoolMax int32 + IdleTimeout time.Duration + ConnectionTimeout time.Duration + StatementTimeout time.Duration + ApplicationName string +} + +func ParseConfig(getenv Getenv, readFile ReadFile) (Config, error) { + if getenv == nil { + return Config{}, fmt.Errorf("environment getter is required") + } + cfg := Config{ + PoolMax: 10, + IdleTimeout: 30 * time.Second, + ConnectionTimeout: 10 * time.Second, + StatementTimeout: 30 * time.Second, + ApplicationName: "zhinian-go", + SSLMode: SSLDisable, + } + + backend := strings.ToLower(strings.TrimSpace(getenv("ZHINIAN_DATA_BACKEND"))) + if backend == "" && strings.ToLower(strings.TrimSpace(getenv("NODE_ENV"))) != "production" { + backend = string(BackendLocal) + } + switch Backend(backend) { + case BackendLocal, BackendPostgres: + cfg.Backend = Backend(backend) + default: + return Config{}, fmt.Errorf("ZHINIAN_DATA_BACKEND must be explicitly set to 'local' or 'postgres'") + } + + if cfg.Backend == BackendLocal { + return cfg, nil + } + var err error + if cfg.PoolMax, err = positiveInt32(getenv, "DATABASE_POOL_MAX", cfg.PoolMax); err != nil { + return Config{}, err + } + if cfg.IdleTimeout, err = nonNegativeMilliseconds(getenv, "DATABASE_IDLE_TIMEOUT_MS", cfg.IdleTimeout); err != nil { + return Config{}, err + } + if cfg.ConnectionTimeout, err = positiveMilliseconds(getenv, "DATABASE_CONNECTION_TIMEOUT_MS", cfg.ConnectionTimeout); err != nil { + return Config{}, err + } + if cfg.StatementTimeout, err = positiveMilliseconds(getenv, "DATABASE_STATEMENT_TIMEOUT_MS", cfg.StatementTimeout); err != nil { + return Config{}, err + } + if value := strings.TrimSpace(getenv("DATABASE_APPLICATION_NAME")); value != "" { + cfg.ApplicationName = value + } + cfg.DatabaseURL = strings.TrimSpace(getenv("DATABASE_URL")) + if cfg.DatabaseURL == "" { + return Config{}, fmt.Errorf("DATABASE_URL is required when ZHINIAN_DATA_BACKEND=postgres") + } + parsed, parseErr := url.ParseRequestURI(cfg.DatabaseURL) + if parseErr != nil || parsed.Host == "" { + return Config{}, fmt.Errorf("DATABASE_URL must be a valid PostgreSQL connection URI") + } + if parsed.Scheme != "postgres" && parsed.Scheme != "postgresql" { + return Config{}, fmt.Errorf("DATABASE_URL must use the postgres:// or postgresql:// scheme") + } + for key := range parsed.Query() { + if strings.HasPrefix(strings.ToLower(key), "ssl") { + return Config{}, fmt.Errorf("DATABASE_URL must not contain SSL query parameters (%s); use DATABASE_SSL_MODE and DATABASE_CA_CERT_PATH", key) + } + } + + mode := strings.ToLower(strings.TrimSpace(getenv("DATABASE_SSL_MODE"))) + if mode != "" { + cfg.SSLMode = SSLMode(mode) + } + switch cfg.SSLMode { + case SSLDisable: + case SSLVerifyFull: + path := strings.TrimSpace(getenv("DATABASE_CA_CERT_PATH")) + if path == "" { + return Config{}, fmt.Errorf("DATABASE_CA_CERT_PATH is required when DATABASE_SSL_MODE=verify-full") + } + if readFile == nil { + return Config{}, fmt.Errorf("CA certificate reader is required") + } + pem, readErr := readFile(path) + if readErr != nil { + return Config{}, fmt.Errorf("read DATABASE_CA_CERT_PATH: %w", readErr) + } + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(pem) { + return Config{}, fmt.Errorf("DATABASE_CA_CERT_PATH does not contain a valid CA certificate") + } + cfg.TLSConfig = &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12} + default: + return Config{}, fmt.Errorf("DATABASE_SSL_MODE must be 'disable' or 'verify-full'") + } + return cfg, nil +} + +func positiveInt32(getenv Getenv, name string, fallback int32) (int32, error) { + raw := strings.TrimSpace(getenv(name)) + if raw == "" { + return fallback, nil + } + value, err := strconv.ParseInt(raw, 10, 32) + if err != nil || value <= 0 { + return 0, fmt.Errorf("%s must be a positive integer", name) + } + return int32(value), nil +} + +func nonNegativeMilliseconds(getenv Getenv, name string, fallback time.Duration) (time.Duration, error) { + return milliseconds(getenv, name, fallback, true) +} + +func positiveMilliseconds(getenv Getenv, name string, fallback time.Duration) (time.Duration, error) { + return milliseconds(getenv, name, fallback, false) +} + +func milliseconds(getenv Getenv, name string, fallback time.Duration, allowZero bool) (time.Duration, error) { + raw := strings.TrimSpace(getenv(name)) + if raw == "" { + return fallback, nil + } + value, err := strconv.ParseInt(raw, 10, 64) + if err != nil || value < 0 || (!allowZero && value == 0) || value > int64((1<<63-1)/time.Millisecond) { + qualifier := "positive" + if allowZero { + qualifier = "non-negative" + } + return 0, fmt.Errorf("%s must be a %s integer", name, qualifier) + } + return time.Duration(value) * time.Millisecond, nil +} diff --git a/backend/internal/postgres/config_test.go b/backend/internal/postgres/config_test.go new file mode 100644 index 0000000..9affa56 --- /dev/null +++ b/backend/internal/postgres/config_test.go @@ -0,0 +1,152 @@ +package postgres + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestParseConfigDefaultsToLocalOutsideProduction(t *testing.T) { + cfg, err := ParseConfig(env(map[string]string{"NODE_ENV": "development"}), os.ReadFile) + if err != nil { + t.Fatalf("ParseConfig() error = %v", err) + } + if cfg.Backend != BackendLocal { + t.Fatalf("Backend = %q, want %q", cfg.Backend, BackendLocal) + } +} + +func TestParseConfigRequiresExplicitBackendInProduction(t *testing.T) { + _, err := ParseConfig(env(map[string]string{"NODE_ENV": "production"}), os.ReadFile) + if err == nil || !strings.Contains(err.Error(), "ZHINIAN_DATA_BACKEND") { + t.Fatalf("ParseConfig() error = %v, want backend validation error", err) + } +} + +func TestParseConfigRequiresPostgresURL(t *testing.T) { + _, err := ParseConfig(env(map[string]string{"ZHINIAN_DATA_BACKEND": "postgres"}), os.ReadFile) + if err == nil || !strings.Contains(err.Error(), "DATABASE_URL") { + t.Fatalf("ParseConfig() error = %v, want DATABASE_URL error", err) + } +} + +func TestParseConfigAcceptsOnlyPostgresSchemesAndRejectsSSLQueryParameters(t *testing.T) { + tests := []struct { + name string + url string + }{ + {name: "wrong scheme", url: "https://db.example/app"}, + {name: "sslmode", url: "postgres://db.example/app?sslmode=require"}, + {name: "mixed case ssl parameter", url: "postgresql://db.example/app?SSLcert=x"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseConfig(env(map[string]string{ + "ZHINIAN_DATA_BACKEND": "postgres", + "DATABASE_URL": tt.url, + }), os.ReadFile) + if err == nil { + t.Fatal("ParseConfig() error = nil, want validation error") + } + }) + } +} + +func TestParseConfigBuildsVerifyFullTLSFromCA(t *testing.T) { + dir := t.TempDir() + caPath := filepath.Join(dir, "ca.pem") + const ca = "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n" + read := func(path string) ([]byte, error) { + if path != caPath { + t.Fatalf("read path = %q, want %q", path, caPath) + } + return []byte(ca), nil + } + _, err := ParseConfig(env(map[string]string{ + "ZHINIAN_DATA_BACKEND": "postgres", + "DATABASE_URL": "postgresql://db.example/app", + "DATABASE_SSL_MODE": "verify-full", + "DATABASE_CA_CERT_PATH": caPath, + }), read) + if err == nil || !strings.Contains(err.Error(), "CA certificate") { + t.Fatalf("ParseConfig() error = %v, want invalid CA certificate error", err) + } +} + +func TestParseConfigVerifyFullBuildsRootsWithoutDisablingVerification(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test CA"}, + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), + IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + ca := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + cfg, err := ParseConfig(env(map[string]string{ + "ZHINIAN_DATA_BACKEND": "postgres", + "DATABASE_URL": "postgresql://db.example/app", + "DATABASE_SSL_MODE": "verify-full", + "DATABASE_CA_CERT_PATH": "/ca.pem", + }), func(string) ([]byte, error) { return ca, nil }) + if err != nil { + t.Fatalf("ParseConfig() error = %v", err) + } + if cfg.TLSConfig == nil || cfg.TLSConfig.RootCAs == nil { + t.Fatal("TLSConfig.RootCAs is nil") + } + if cfg.TLSConfig.InsecureSkipVerify { + t.Fatal("TLSConfig.InsecureSkipVerify = true, want full certificate and hostname verification") + } +} + +func TestParseConfigDefaultsAndNumericValidation(t *testing.T) { + cfg, err := ParseConfig(env(map[string]string{ + "ZHINIAN_DATA_BACKEND": "postgres", + "DATABASE_URL": "postgres://db.example/app", + }), os.ReadFile) + if err != nil { + t.Fatalf("ParseConfig() error = %v", err) + } + if cfg.PoolMax != 10 || cfg.IdleTimeout != 30*time.Second || cfg.ConnectionTimeout != 10*time.Second || cfg.StatementTimeout != 30*time.Second { + t.Fatalf("unexpected defaults: %+v", cfg) + } + if cfg.ApplicationName != "zhinian-go" || cfg.SSLMode != SSLDisable { + t.Fatalf("unexpected identity/TLS defaults: %+v", cfg) + } + + for name, value := range map[string]string{ + "DATABASE_POOL_MAX": "0", + "DATABASE_IDLE_TIMEOUT_MS": "-1", + "DATABASE_CONNECTION_TIMEOUT_MS": "0", + "DATABASE_STATEMENT_TIMEOUT_MS": "nope", + } { + t.Run(name, func(t *testing.T) { + _, err := ParseConfig(env(map[string]string{ + "ZHINIAN_DATA_BACKEND": "postgres", + "DATABASE_URL": "postgres://db.example/app", + name: value, + }), os.ReadFile) + if err == nil || !strings.Contains(err.Error(), name) { + t.Fatalf("ParseConfig() error = %v, want %s validation error", err, name) + } + }) + } +} + +func env(values map[string]string) Getenv { + return func(name string) string { return values[name] } +} diff --git a/backend/internal/postgres/database.go b/backend/internal/postgres/database.go new file mode 100644 index 0000000..901394e --- /dev/null +++ b/backend/internal/postgres/database.go @@ -0,0 +1,198 @@ +package postgres + +import ( + "context" + "encoding/json" + "fmt" + "time" +) + +const ReadinessSQL = ` +WITH required_table_privileges(table_name, privilege_name) AS ( + VALUES + ('assets', 'SELECT'), ('assets', 'INSERT'), ('assets', 'DELETE'), + ('generation_jobs', 'SELECT'), ('generation_jobs', 'INSERT'), ('generation_jobs', 'UPDATE'), ('generation_jobs', 'DELETE'), + ('usage_events', 'SELECT'), ('usage_events', 'INSERT'), ('usage_events', 'UPDATE'), + ('projects', 'SELECT'), ('projects', 'UPDATE'), + ('image_templates', 'SELECT'), ('image_templates', 'INSERT'), ('image_templates', 'UPDATE'), ('image_templates', 'DELETE'), + ('platform_organizations', 'SELECT'), ('platform_organizations', 'INSERT'), ('platform_organizations', 'UPDATE'), ('platform_organizations', 'DELETE'), + ('platform_users', 'SELECT'), ('platform_users', 'INSERT'), ('platform_users', 'UPDATE'), ('platform_users', 'DELETE'), + ('platform_account_migrations', 'SELECT'), ('platform_account_migrations', 'INSERT'), ('platform_account_migrations', 'UPDATE'), + ('billing_price_rules', 'SELECT'), ('billing_price_rules', 'INSERT'), ('billing_price_rules', 'UPDATE'), + ('billing_wallets', 'SELECT'), ('billing_wallets', 'INSERT'), ('billing_wallets', 'UPDATE'), + ('billing_ledger', 'SELECT'), ('billing_ledger', 'INSERT') +) +SELECT + NOT EXISTS ( + SELECT 1 + FROM required_table_privileges + WHERE to_regclass('public.' || table_name) IS NULL + OR NOT has_table_privilege(current_user, 'public.' || table_name, privilege_name) + ) + AND has_function_privilege( + current_user, + 'public.claim_generation_jobs(text,integer,integer)', + 'EXECUTE' + ) + AND has_function_privilege( + current_user, + 'public.billing_post_wallet_entry(text,text,text,text,text,bigint,text,text,text,jsonb)', + 'EXECUTE' + ) AS ready +` + +const ClaimGenerationJobsSQL = `SELECT id FROM public.claim_generation_jobs($1::text, $2::integer, $3::integer)` + +const PostWalletEntrySQL = `SELECT ledger_id, balance_after_fen, balance_fen, total_recharged_fen, total_charged_fen, created_at, updated_at, delta_fen FROM public.billing_post_wallet_entry($1::text, $2::text, $3::text, $4::text, $5::text, $6::bigint, $7::text, $8::text, $9::text, $10::jsonb)` + +type Rows interface { + Close() + Err() error + Next() bool + Scan(dest ...any) error +} + +type Querier interface { + Query(context.Context, string, ...any) (Rows, error) +} + +type Pool interface { + Querier + Close() +} + +type Database struct { + config Config + querier Querier +} + +type Store = Database + +func NewDatabase(config Config, querier Querier) *Database { + return &Database{config: config, querier: querier} +} + +func (db *Database) Readiness(ctx context.Context) error { + if db.config.Backend == BackendLocal { + return nil + } + if db.querier == nil { + return fmt.Errorf("PostgreSQL pool is not open") + } + rows, err := db.querier.Query(ctx, ReadinessSQL) + if err != nil { + return fmt.Errorf("query PostgreSQL readiness: %w", err) + } + defer rows.Close() + if !rows.Next() { + if err := rows.Err(); err != nil { + return fmt.Errorf("read PostgreSQL readiness: %w", err) + } + return fmt.Errorf("PostgreSQL readiness query returned no row") + } + var ready bool + if err := rows.Scan(&ready); err != nil { + return fmt.Errorf("scan PostgreSQL readiness: %w", err) + } + if !ready { + return fmt.Errorf("PostgreSQL schema or application privileges are not ready") + } + return rows.Err() +} + +type GenerationJob struct { + ID string +} + +func (db *Database) ClaimGenerationJobs(ctx context.Context, workerID string, limit, lockTimeoutSeconds int) ([]GenerationJob, error) { + if db.config.Backend != BackendPostgres || db.querier == nil { + return nil, fmt.Errorf("PostgreSQL is unavailable when ZHINIAN_DATA_BACKEND=%s", db.config.Backend) + } + limit = max(1, min(limit, 20)) + rows, err := db.querier.Query(ctx, ClaimGenerationJobsSQL, workerID, limit, lockTimeoutSeconds) + if err != nil { + return nil, fmt.Errorf("claim generation jobs: %w", err) + } + defer rows.Close() + var jobs []GenerationJob + for rows.Next() { + var job GenerationJob + if err := rows.Scan(&job.ID); err != nil { + return nil, fmt.Errorf("scan claimed generation job: %w", err) + } + jobs = append(jobs, job) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read claimed generation jobs: %w", err) + } + return jobs, nil +} + +type WalletEntryParams struct { + LedgerID string + OrganizationID string + AccountID string + JobID string + Kind string + DeltaFen int64 + Currency string + IdempotencyKey string + Description string + Metadata json.RawMessage +} + +type WalletEntry struct { + LedgerID string + BalanceAfterFen int64 + BalanceFen int64 + TotalRechargedFen int64 + TotalChargedFen int64 + CreatedAt time.Time + UpdatedAt time.Time + DeltaFen int64 +} + +func (db *Database) PostWalletEntry(ctx context.Context, params WalletEntryParams) (WalletEntry, error) { + if db.config.Backend != BackendPostgres || db.querier == nil { + return WalletEntry{}, fmt.Errorf("PostgreSQL is unavailable when ZHINIAN_DATA_BACKEND=%s", db.config.Backend) + } + accountID := optionalDatabaseText(params.AccountID) + if params.Kind == "recharge" || params.Kind == "adjustment" { + accountID = nil + } + jobID := optionalDatabaseText(params.JobID) + currency := params.Currency + if currency == "" { + currency = "CNY" + } + rows, err := db.querier.Query(ctx, PostWalletEntrySQL, + params.LedgerID, params.OrganizationID, accountID, jobID, params.Kind, + params.DeltaFen, currency, params.IdempotencyKey, params.Description, params.Metadata, + ) + if err != nil { + return WalletEntry{}, fmt.Errorf("post wallet entry: %w", err) + } + defer rows.Close() + if !rows.Next() { + if err := rows.Err(); err != nil { + return WalletEntry{}, fmt.Errorf("read wallet entry: %w", err) + } + return WalletEntry{}, fmt.Errorf("billing_post_wallet_entry returned no row") + } + var entry WalletEntry + if err := rows.Scan( + &entry.LedgerID, &entry.BalanceAfterFen, &entry.BalanceFen, + &entry.TotalRechargedFen, &entry.TotalChargedFen, &entry.CreatedAt, + &entry.UpdatedAt, &entry.DeltaFen, + ); err != nil { + return WalletEntry{}, fmt.Errorf("scan wallet entry: %w", err) + } + return entry, rows.Err() +} + +func optionalDatabaseText(value string) any { + if value == "" { + return nil + } + return value +} diff --git a/backend/internal/postgres/database_test.go b/backend/internal/postgres/database_test.go new file mode 100644 index 0000000..a271ff3 --- /dev/null +++ b/backend/internal/postgres/database_test.go @@ -0,0 +1,183 @@ +package postgres + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" +) + +func TestReadinessSQLFreezesPrivilegeMatrixAndFunctionSignatures(t *testing.T) { + wantTables := []string{ + "assets", "generation_jobs", "usage_events", "projects", "image_templates", + "platform_organizations", "platform_users", "platform_account_migrations", + "billing_price_rules", "billing_wallets", "billing_ledger", + } + for _, table := range wantTables { + if !strings.Contains(ReadinessSQL, "('"+table+"',") { + t.Errorf("ReadinessSQL missing table %q", table) + } + } + if got := strings.Count(ReadinessSQL, "has_function_privilege("); got != 2 { + t.Fatalf("has_function_privilege count = %d, want 2", got) + } + for _, signature := range []string{ + "public.claim_generation_jobs(text,integer,integer)", + "public.billing_post_wallet_entry(text,text,text,text,text,bigint,text,text,text,jsonb)", + } { + if !strings.Contains(ReadinessSQL, signature) { + t.Errorf("ReadinessSQL missing function signature %q", signature) + } + } +} + +func TestReadinessLocalSucceedsWithoutQuery(t *testing.T) { + db := NewDatabase(Config{Backend: BackendLocal}, &fakeQuerier{err: errors.New("must not query")}) + if err := db.Readiness(context.Background()); err != nil { + t.Fatalf("Readiness() error = %v", err) + } +} + +func TestReadinessPostgresFailsWhenMatrixIsNotReady(t *testing.T) { + q := &fakeQuerier{rows: [][]any{{false}}} + db := NewDatabase(Config{Backend: BackendPostgres}, q) + if err := db.Readiness(context.Background()); err == nil { + t.Fatal("Readiness() error = nil, want not-ready error") + } + if q.sql != ReadinessSQL { + t.Fatalf("query = %q, want exact ReadinessSQL", q.sql) + } +} + +func TestClaimGenerationJobsCallsExactFunction(t *testing.T) { + const wantSQL = `SELECT id FROM public.claim_generation_jobs($1::text, $2::integer, $3::integer)` + if ClaimGenerationJobsSQL != wantSQL { + t.Fatalf("ClaimGenerationJobsSQL = %q, want %q", ClaimGenerationJobsSQL, wantSQL) + } + q := &fakeQuerier{rows: [][]any{{"job-1"}}} + db := NewDatabase(Config{Backend: BackendPostgres}, q) + jobs, err := db.ClaimGenerationJobs(context.Background(), "worker-1", 2, 300) + if err != nil { + t.Fatalf("ClaimGenerationJobs() error = %v", err) + } + if q.sql != ClaimGenerationJobsSQL || !reflect.DeepEqual(q.args, []any{"worker-1", 2, 300}) { + t.Fatalf("query = %q args = %#v", q.sql, q.args) + } + if !reflect.DeepEqual(jobs, []GenerationJob{{ID: "job-1"}}) { + t.Fatalf("jobs = %#v", jobs) + } +} + +func TestClaimGenerationJobsBoundsBatchLikeCurrentBackend(t *testing.T) { + for _, test := range []struct { + name string + requested int + want int + }{ + {name: "minimum", requested: 0, want: 1}, + {name: "maximum", requested: 25, want: 20}, + } { + t.Run(test.name, func(t *testing.T) { + q := &fakeQuerier{} + db := NewDatabase(Config{Backend: BackendPostgres}, q) + if _, err := db.ClaimGenerationJobs(context.Background(), "worker-1", test.requested, 300); err != nil { + t.Fatalf("ClaimGenerationJobs() error = %v", err) + } + if !reflect.DeepEqual(q.args, []any{"worker-1", test.want, 300}) { + t.Fatalf("args = %#v, want bounded limit %d", q.args, test.want) + } + }) + } +} + +func TestPostWalletEntryCallsExactFunction(t *testing.T) { + const wantSQL = `SELECT ledger_id, balance_after_fen, balance_fen, total_recharged_fen, total_charged_fen, created_at, updated_at, delta_fen FROM public.billing_post_wallet_entry($1::text, $2::text, $3::text, $4::text, $5::text, $6::bigint, $7::text, $8::text, $9::text, $10::jsonb)` + if PostWalletEntrySQL != wantSQL { + t.Fatalf("PostWalletEntrySQL = %q, want %q", PostWalletEntrySQL, wantSQL) + } + q := &fakeQuerier{rows: [][]any{{"ledger-1", int64(120), int64(120), int64(200), int64(80), nil, nil, int64(-80)}}} + db := NewDatabase(Config{Backend: BackendPostgres}, q) + metadata := json.RawMessage(`{"source":"test"}`) + entry, err := db.PostWalletEntry(context.Background(), WalletEntryParams{ + LedgerID: "ledger-1", OrganizationID: "org-1", AccountID: "acct-1", JobID: "job-1", + Kind: "charge", DeltaFen: -80, Currency: "CNY", IdempotencyKey: "idem-1", + Description: "generation", Metadata: metadata, + }) + if err != nil { + t.Fatalf("PostWalletEntry() error = %v", err) + } + if q.sql != PostWalletEntrySQL { + t.Fatalf("query = %q, want exact PostWalletEntrySQL", q.sql) + } + wantArgs := []any{"ledger-1", "org-1", "acct-1", "job-1", "charge", int64(-80), "CNY", "idem-1", "generation", metadata} + if !reflect.DeepEqual(q.args, wantArgs) { + t.Fatalf("args = %#v, want %#v", q.args, wantArgs) + } + if entry.LedgerID != "ledger-1" || entry.BalanceFen != 120 { + t.Fatalf("entry = %#v", entry) + } +} + +func TestPostWalletEntryNormalizesOptionalValuesLikeCurrentBackend(t *testing.T) { + q := &fakeQuerier{rows: [][]any{{"ledger-1", int64(200), int64(200), int64(200), int64(0), nil, nil, int64(200)}}} + db := NewDatabase(Config{Backend: BackendPostgres}, q) + _, err := db.PostWalletEntry(context.Background(), WalletEntryParams{ + LedgerID: "ledger-1", OrganizationID: "org-1", Kind: "recharge", DeltaFen: 200, + IdempotencyKey: "idem-1", Description: "recharge", + }) + if err != nil { + t.Fatalf("PostWalletEntry() error = %v", err) + } + wantArgs := []any{"ledger-1", "org-1", nil, nil, "recharge", int64(200), "CNY", "idem-1", "recharge", json.RawMessage(nil)} + if !reflect.DeepEqual(q.args, wantArgs) { + t.Fatalf("args = %#v, want %#v", q.args, wantArgs) + } +} + +type fakeQuerier struct { + rows [][]any + err error + sql string + args []any +} + +func (q *fakeQuerier) Query(_ context.Context, sql string, args ...any) (Rows, error) { + q.sql = sql + q.args = args + return &fakeRows{rows: q.rows, err: q.err}, nil +} + +type fakeRows struct { + rows [][]any + idx int + err error +} + +func (r *fakeRows) Close() {} +func (r *fakeRows) Err() error { return r.err } +func (r *fakeRows) Next() bool { return r.idx < len(r.rows) } +func (r *fakeRows) Scan(dest ...any) error { + if r.idx >= len(r.rows) { + return errors.New("scan past end") + } + row := r.rows[r.idx] + r.idx++ + if len(dest) != len(row) { + return errors.New("scan arity mismatch") + } + for i := range dest { + switch target := dest[i].(type) { + case *bool: + *target = row[i].(bool) + case *string: + *target = row[i].(string) + case *int64: + *target = row[i].(int64) + default: + // nil timestamp fixtures intentionally leave zero values. + } + } + return nil +} diff --git a/backend/internal/postgres/open.go b/backend/internal/postgres/open.go new file mode 100644 index 0000000..8270bef --- /dev/null +++ b/backend/internal/postgres/open.go @@ -0,0 +1,75 @@ +package postgres + +import ( + "context" + "fmt" + "strconv" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type Module struct { + pool Pool + Store *Store +} + +func (module *Module) Close() { + if module != nil && module.pool != nil { + module.pool.Close() + } +} + +func Open(ctx context.Context, config Config) (*Module, error) { + if config.Backend == BackendLocal { + return &Module{Store: NewDatabase(config, nil)}, nil + } + if config.Backend != BackendPostgres { + return nil, fmt.Errorf("unsupported data backend %q", config.Backend) + } + poolConfig, err := pgxpool.ParseConfig(config.DatabaseURL) + if err != nil { + return nil, fmt.Errorf("parse DATABASE_URL: %w", err) + } + poolConfig.MaxConns = config.PoolMax + poolConfig.MaxConnIdleTime = config.IdleTimeout + poolConfig.ConnConfig.ConnectTimeout = config.ConnectionTimeout + poolConfig.ConnConfig.RuntimeParams["statement_timeout"] = strconv.FormatInt(config.StatementTimeout.Milliseconds(), 10) + poolConfig.ConnConfig.RuntimeParams["application_name"] = config.ApplicationName + switch config.SSLMode { + case SSLDisable: + poolConfig.ConnConfig.TLSConfig = nil + poolConfig.ConnConfig.Fallbacks = nil + case SSLVerifyFull: + if config.TLSConfig == nil { + return nil, fmt.Errorf("TLS configuration is required when DATABASE_SSL_MODE=verify-full") + } + tlsConfig := config.TLSConfig.Clone() + if tlsConfig.ServerName == "" { + tlsConfig.ServerName = poolConfig.ConnConfig.Host + } + poolConfig.ConnConfig.TLSConfig = tlsConfig + poolConfig.ConnConfig.Fallbacks = nil + default: + return nil, fmt.Errorf("unsupported DATABASE_SSL_MODE %q", config.SSLMode) + } + pool, err := pgxpool.NewWithConfig(ctx, poolConfig) + if err != nil { + return nil, fmt.Errorf("open PostgreSQL pool: %w", err) + } + adapter := &pgxPoolAdapter{pool: pool} + return &Module{pool: adapter, Store: NewDatabase(config, adapter)}, nil +} + +type pgxPoolAdapter struct { + pool *pgxpool.Pool +} + +func (p *pgxPoolAdapter) Query(ctx context.Context, sql string, args ...any) (Rows, error) { + return p.pool.Query(ctx, sql, args...) +} + +func (p *pgxPoolAdapter) Close() { + p.pool.Close() +} + +var _ Pool = (*pgxPoolAdapter)(nil) diff --git a/backend/internal/postgres/open_test.go b/backend/internal/postgres/open_test.go new file mode 100644 index 0000000..56e9ed7 --- /dev/null +++ b/backend/internal/postgres/open_test.go @@ -0,0 +1,62 @@ +package postgres + +import ( + "context" + "strings" + "testing" +) + +func TestOpenLocalReturnsStoreWithoutPool(t *testing.T) { + module, err := Open(context.Background(), Config{Backend: BackendLocal}) + if err != nil { + t.Fatalf("Open() error = %v", err) + } + if module.Store == nil { + t.Fatal("Store = nil") + } + module.Close() + if err := module.Store.Readiness(context.Background()); err != nil { + t.Fatalf("Readiness() error = %v", err) + } +} + +func TestOpenPostgresDoesNotProbeBeforeReadiness(t *testing.T) { + module, err := Open(context.Background(), Config{ + Backend: BackendPostgres, + DatabaseURL: "postgresql://app:secret@127.0.0.1:1/app", + SSLMode: SSLDisable, + PoolMax: 1, + ApplicationName: "zhinian-go-test", + }) + if err != nil { + t.Fatalf("Open() error = %v; connection failures belong to readiness", err) + } + module.Close() +} + +func TestParseConfigIgnoresPostgresTLSSettingsForLocalBackend(t *testing.T) { + cfg, err := ParseConfig(env(map[string]string{ + "ZHINIAN_DATA_BACKEND": "local", + "DATABASE_SSL_MODE": "verify-full", + "DATABASE_CA_CERT_PATH": "/missing/ca.pem", + "DATABASE_POOL_MAX": "0", + "DATABASE_CONNECTION_TIMEOUT_MS": "invalid", + }), nil) + if err != nil { + t.Fatalf("ParseConfig() error = %v", err) + } + if cfg.Backend != BackendLocal || cfg.TLSConfig != nil { + t.Fatalf("config = %+v", cfg) + } +} + +func TestOpenPostgresRejectsInvalidConfiguredTLSMode(t *testing.T) { + _, err := Open(context.Background(), Config{ + Backend: BackendPostgres, + DatabaseURL: "postgresql://app:secret@db.example/app", + SSLMode: SSLMode("prefer"), + }) + if err == nil || !strings.Contains(err.Error(), "DATABASE_SSL_MODE") { + t.Fatalf("Open() error = %v", err) + } +} diff --git a/contracts/auth/session-cookie-v1.json b/contracts/auth/session-cookie-v1.json new file mode 100644 index 0000000..6fe1554 --- /dev/null +++ b/contracts/auth/session-cookie-v1.json @@ -0,0 +1,89 @@ +{ + "version": 1, + "cookie": { + "name": "zhinian_session", + "chunkSize": 3000, + "maxChunks": 20, + "maxValueLength": 60000, + "chunkNames": [ + "zhinian_session", + "zhinian_session.1", + "zhinian_session.2", + "zhinian_session.3", + "zhinian_session.4", + "zhinian_session.5", + "zhinian_session.6", + "zhinian_session.7", + "zhinian_session.8", + "zhinian_session.9", + "zhinian_session.10", + "zhinian_session.11", + "zhinian_session.12", + "zhinian_session.13", + "zhinian_session.14", + "zhinian_session.15", + "zhinian_session.16", + "zhinian_session.17", + "zhinian_session.18", + "zhinian_session.19" + ], + "writeExample": { + "valueCharacter": "x", + "valueLength": 6001, + "chunkLengths": [3000, 3000, 1], + "expiresAtUnix": 200 + }, + "attributes": { + "httpOnly": true, + "sameSite": "lax", + "path": "/", + "productionSecure": true + }, + "clear": { + "value": "", + "maxAgeSeconds": 0 + }, + "secureResolutionCases": [ + { + "name": "plain HTTP request", + "explicit": "", + "publicBaseUrl": "", + "requestUrl": "http://127.0.0.1:3000", + "expected": false + }, + { + "name": "production HTTPS public URL", + "explicit": "", + "publicBaseUrl": "https://app.example.test", + "requestUrl": "http://127.0.0.1:3000", + "expected": true + }, + { + "name": "configured public URL takes precedence", + "explicit": "", + "publicBaseUrl": "http://app.example.test", + "requestUrl": "https://request.example.test", + "expected": false + }, + { + "name": "explicit true wins", + "explicit": "true", + "publicBaseUrl": "http://app.example.test", + "requestUrl": "http://127.0.0.1:3000", + "expected": true + }, + { + "name": "explicit false wins", + "explicit": "false", + "publicBaseUrl": "https://app.example.test", + "requestUrl": "https://request.example.test", + "expected": false + } + ] + }, + "secret": "test-session-secret-with-enough-entropy", + "rawJson": "{\"version\":1,\"authMode\":\"admin\",\"issuedAt\":100,\"expiresAt\":200,\"accessToken\":\"access-token-1\",\"tokenType\":\"bearer\",\"user\":{\"id\":\"auth:customPC:1\",\"subject\":\"zhangsan\",\"username\":\"zhangsan\",\"displayName\":\"张三\",\"clientId\":\"customPC\",\"authorities\":[\"ROLE_1\"],\"scope\":[\"server\"]}}", + "payload": "eyJ2ZXJzaW9uIjoxLCJhdXRoTW9kZSI6ImFkbWluIiwiaXNzdWVkQXQiOjEwMCwiZXhwaXJlc0F0IjoyMDAsImFjY2Vzc1Rva2VuIjoiYWNjZXNzLXRva2VuLTEiLCJ0b2tlblR5cGUiOiJiZWFyZXIiLCJ1c2VyIjp7ImlkIjoiYXV0aDpjdXN0b21QQzoxIiwic3ViamVjdCI6InpoYW5nc2FuIiwidXNlcm5hbWUiOiJ6aGFuZ3NhbiIsImRpc3BsYXlOYW1lIjoi5byg5LiJIiwiY2xpZW50SWQiOiJjdXN0b21QQyIsImF1dGhvcml0aWVzIjpbIlJPTEVfMSJdLCJzY29wZSI6WyJzZXJ2ZXIiXX19", + "signature": "KTBRAoo5dytZJhvzX9qqd5JFnfVm7hEQd57TW1JSk4M", + "cookieValue": "eyJ2ZXJzaW9uIjoxLCJhdXRoTW9kZSI6ImFkbWluIiwiaXNzdWVkQXQiOjEwMCwiZXhwaXJlc0F0IjoyMDAsImFjY2Vzc1Rva2VuIjoiYWNjZXNzLXRva2VuLTEiLCJ0b2tlblR5cGUiOiJiZWFyZXIiLCJ1c2VyIjp7ImlkIjoiYXV0aDpjdXN0b21QQzoxIiwic3ViamVjdCI6InpoYW5nc2FuIiwidXNlcm5hbWUiOiJ6aGFuZ3NhbiIsImRpc3BsYXlOYW1lIjoi5byg5LiJIiwiY2xpZW50SWQiOiJjdXN0b21QQyIsImF1dGhvcml0aWVzIjpbIlJPTEVfMSJdLCJzY29wZSI6WyJzZXJ2ZXIiXX19.KTBRAoo5dytZJhvzX9qqd5JFnfVm7hEQd57TW1JSk4M" +} diff --git a/contracts/http/route-surface.v1.json b/contracts/http/route-surface.v1.json new file mode 100644 index 0000000..715b3f5 --- /dev/null +++ b/contracts/http/route-surface.v1.json @@ -0,0 +1,71 @@ +{ + "version": 1, + "routes": [ + { "method": "DELETE", "path": "/api/admin/accounts" }, + { "method": "GET", "path": "/api/admin/accounts" }, + { "method": "PATCH", "path": "/api/admin/accounts" }, + { "method": "POST", "path": "/api/admin/accounts" }, + { "method": "PUT", "path": "/api/admin/accounts" }, + { "method": "POST", "path": "/api/admin/accounts/groups" }, + { "method": "POST", "path": "/api/admin/accounts/password" }, + { "method": "GET", "path": "/api/admin/billing" }, + { "method": "PATCH", "path": "/api/admin/billing/account" }, + { "method": "POST", "path": "/api/admin/billing/adjustments" }, + { "method": "GET", "path": "/api/admin/billing/prices" }, + { "method": "PATCH", "path": "/api/admin/billing/prices/{id}" }, + { "method": "DELETE", "path": "/api/admin/organizations" }, + { "method": "GET", "path": "/api/admin/organizations" }, + { "method": "PATCH", "path": "/api/admin/organizations" }, + { "method": "POST", "path": "/api/admin/organizations" }, + { "method": "GET", "path": "/api/admin/usage" }, + { "method": "GET", "path": "/api/assets" }, + { "method": "POST", "path": "/api/assets" }, + { "method": "POST", "path": "/api/assets/upload" }, + { "method": "DELETE", "path": "/api/assets/{id}" }, + { "method": "GET", "path": "/api/assets/{id}/download" }, + { "method": "GET", "path": "/api/auth/callback" }, + { "method": "GET", "path": "/api/auth/captcha" }, + { "method": "GET", "path": "/api/auth/login" }, + { "method": "GET", "path": "/api/auth/logout" }, + { "method": "POST", "path": "/api/auth/logout" }, + { "method": "GET", "path": "/api/auth/me" }, + { "method": "POST", "path": "/api/auth/password" }, + { "method": "POST", "path": "/api/auth/password/change" }, + { "method": "GET", "path": "/api/billing" }, + { "method": "POST", "path": "/api/billing/quote" }, + { "method": "GET", "path": "/api/generations/image" }, + { "method": "POST", "path": "/api/generations/image" }, + { "method": "DELETE", "path": "/api/generations/image/{id}" }, + { "method": "GET", "path": "/api/generations/image/{id}" }, + { "method": "POST", "path": "/api/generations/image/{id}/retry" }, + { "method": "GET", "path": "/api/generations/video" }, + { "method": "POST", "path": "/api/generations/video" }, + { "method": "DELETE", "path": "/api/generations/video/{id}" }, + { "method": "GET", "path": "/api/generations/video/{id}" }, + { "method": "GET", "path": "/api/health" }, + { "method": "GET", "path": "/api/image-templates" }, + { "method": "POST", "path": "/api/image-templates" }, + { "method": "DELETE", "path": "/api/image-templates/{id}" }, + { "method": "PATCH", "path": "/api/image-templates/{id}" }, + { "method": "POST", "path": "/api/internal/worker/tick" }, + { "method": "DELETE", "path": "/api/logs" }, + { "method": "GET", "path": "/api/logs" }, + { "method": "POST", "path": "/api/prompt/assemble" }, + { "method": "GET", "path": "/api/ready" }, + { "method": "GET", "path": "/api/settings" }, + { "method": "POST", "path": "/api/settings" }, + { "method": "GET", "path": "/api/usage" }, + { "method": "GET", "path": "/api/v1/assets" }, + { "method": "POST", "path": "/api/v1/assets" }, + { "method": "GET", "path": "/api/v1/assets/{id}" }, + { "method": "GET", "path": "/api/v1/assets/{id}/download" }, + { "method": "GET", "path": "/api/v1/capabilities" }, + { "method": "GET", "path": "/api/v1/jobs" }, + { "method": "POST", "path": "/api/v1/jobs" }, + { "method": "GET", "path": "/api/v1/jobs/{id}" }, + { "method": "POST", "path": "/api/v1/jobs/{id}/cancel" }, + { "method": "GET", "path": "/api/v1/openapi.json" }, + { "method": "GET", "path": "/generated-results/{path...}" }, + { "method": "GET", "path": "/uploads/{path...}" } + ] +} diff --git a/lib/server/auth/session-cookie.ts b/lib/server/auth/session-cookie.ts index 7e706c4..02b9664 100644 --- a/lib/server/auth/session-cookie.ts +++ b/lib/server/auth/session-cookie.ts @@ -13,6 +13,9 @@ export function setSessionCookieValue( expires: Date ) { const chunks = chunkCookieValue(value); + if (chunks.length > MAX_SESSION_COOKIE_CHUNKS) { + throw new RangeError("Session cookie exceeds maximum supported size"); + } const options = { httpOnly: true, sameSite: "lax" as const, diff --git a/package.json b/package.json index 8d31683..2a67e43 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,10 @@ "db:migrate": "node scripts/migrate-postgres.mjs", "deploy:check": "node scripts/check-ack-manifests.mjs", "migrate:accounts": "node scripts/import-legacy-accounts.mjs", + "go:fmt": "node scripts/run-go-command.mjs fmt ./...", + "go:test": "node scripts/run-go-command.mjs test ./...", + "go:vet": "node scripts/run-go-command.mjs vet ./...", + "go:build": "node scripts/run-go-command.mjs build ./cmd/zhinian-api", "test": "vitest run", "test:watch": "vitest" }, diff --git a/scripts/run-go-command.mjs b/scripts/run-go-command.mjs new file mode 100644 index 0000000..591e7d2 --- /dev/null +++ b/scripts/run-go-command.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const [, , command, ...args] = process.argv; +if (!command) { + console.error("Usage: node scripts/run-go-command.mjs [args...]"); + process.exit(2); +} + +const backendDirectory = fileURLToPath(new URL("../backend/", import.meta.url)); +const child = spawn("go", [command, ...args], { + cwd: backendDirectory, + env: { + ...process.env, + CGO_ENABLED: process.env.CGO_ENABLED || "0", + }, + stdio: "inherit", +}); + +child.on("error", (error) => { + console.error(`[go:${command}] ${error.message}`); + process.exit(1); +}); +child.on("exit", (code, signal) => { + if (signal) { + console.error(`[go:${command}] terminated by ${signal}`); + process.exit(1); + } + process.exit(code ?? 1); +}); diff --git a/tests/auth-session-contract.test.ts b/tests/auth-session-contract.test.ts new file mode 100644 index 0000000..68ba954 --- /dev/null +++ b/tests/auth-session-contract.test.ts @@ -0,0 +1,211 @@ +import { readFile } from "node:fs/promises"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { SESSION_COOKIE_NAME, shouldUseSecureAuthCookie } from "@/lib/auth/config"; +import { + chunkCookieValue, + chunkedCookieNames, + createSignedJsonValue, + parseSessionCookieValue, +} from "@/lib/auth/session"; +import { + clearSessionCookieValues, + setSessionCookieValue, +} from "@/lib/server/auth/session-cookie"; + +type CookieContract = { + name: string; + chunkSize: number; + maxChunks: number; + maxValueLength: number; + chunkNames: string[]; + writeExample: { + valueCharacter: string; + valueLength: number; + chunkLengths: number[]; + expiresAtUnix: number; + }; + attributes: { + httpOnly: boolean; + sameSite: "lax"; + path: string; + productionSecure: boolean; + }; + clear: { + value: string; + maxAgeSeconds: number; + }; + secureResolutionCases: Array<{ + name: string; + explicit: string; + publicBaseUrl: string; + requestUrl: string; + expected: boolean; + }>; +}; + +type SessionCookieFixture = { + version: 1; + cookie: CookieContract; + secret: string; + rawJson: string; + payload: string; + signature: string; + cookieValue: string; +}; + +const fixtureUrl = new URL("../contracts/auth/session-cookie-v1.json", import.meta.url); +const cookieEnvironmentKeys = [ + "ZHINIAN_AUTH_COOKIE_SECURE", + "NEXT_PUBLIC_APP_URL", + "ZHINIAN_PUBLIC_BASE_URL", +] as const; +const originalCookieEnvironment = new Map( + cookieEnvironmentKeys.map((key) => [key, process.env[key]]) +); + +type RecordedCookie = { + name: string; + value: string; + options: Record; +}; + +afterEach(() => { + for (const key of cookieEnvironmentKeys) { + const value = originalCookieEnvironment.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); + +async function loadFixture(): Promise { + return JSON.parse(await readFile(fixtureUrl, "utf8")) as SessionCookieFixture; +} + +function cookieRecorder() { + const writes: RecordedCookie[] = []; + const response = { + cookies: { + set(name: string, value: string, options: Record) { + writes.push({ name, value, options }); + }, + }, + } as unknown as Parameters[0]; + return { response, writes }; +} + +function configureCookieEnvironment(explicit: string, publicBaseUrl: string) { + delete process.env.ZHINIAN_AUTH_COOKIE_SECURE; + delete process.env.NEXT_PUBLIC_APP_URL; + delete process.env.ZHINIAN_PUBLIC_BASE_URL; + if (explicit) process.env.ZHINIAN_AUTH_COOKIE_SECURE = explicit; + if (publicBaseUrl) process.env.NEXT_PUBLIC_APP_URL = publicBaseUrl; +} + +describe("session Cookie v1 cross-language contract", () => { + it("matches the language-neutral HMAC and payload golden vector", async () => { + const fixture = await loadFixture(); + const rawSession = JSON.parse(fixture.rawJson) as unknown; + + expect(fixture.version).toBe(1); + expect(await createSignedJsonValue(rawSession, fixture.secret)).toBe(fixture.cookieValue); + expect(fixture.cookieValue).toBe(`${fixture.payload}.${fixture.signature}`); + await expect(parseSessionCookieValue(fixture.cookieValue, fixture.secret, 150)).resolves.toMatchObject({ + version: 1, + authMode: "admin", + expiresAt: 200, + user: { + id: "auth:customPC:1", + clientId: "customPC", + displayName: "张三", + }, + }); + }); + + it("freezes cookie names, chunk boundaries, attributes, and stale-chunk clearing", async () => { + const { cookie } = await loadFixture(); + const value = cookie.writeExample.valueCharacter.repeat(cookie.writeExample.valueLength); + const expires = new Date(cookie.writeExample.expiresAtUnix * 1000); + const { response, writes } = cookieRecorder(); + configureCookieEnvironment("true", ""); + + expect(SESSION_COOKIE_NAME).toBe(cookie.name); + expect(chunkedCookieNames(cookie.name, cookie.maxChunks)).toEqual(cookie.chunkNames); + expect(chunkCookieValue(value, cookie.chunkSize).map((chunk) => chunk.length)).toEqual( + cookie.writeExample.chunkLengths + ); + + setSessionCookieValue(response, "http://127.0.0.1:3000", value, expires); + + expect(writes.map(({ name }) => name)).toEqual(cookie.chunkNames); + expect(writes.slice(0, cookie.writeExample.chunkLengths.length).map(({ value: part }) => part.length)).toEqual( + cookie.writeExample.chunkLengths + ); + for (const write of writes.slice(0, cookie.writeExample.chunkLengths.length)) { + expect(write.options).toMatchObject({ + httpOnly: cookie.attributes.httpOnly, + sameSite: cookie.attributes.sameSite, + secure: cookie.attributes.productionSecure, + path: cookie.attributes.path, + expires, + }); + expect(write.options).not.toHaveProperty("maxAge"); + } + for (const write of writes.slice(cookie.writeExample.chunkLengths.length)) { + expect(write.value).toBe(cookie.clear.value); + expect(write.options).toMatchObject({ + httpOnly: cookie.attributes.httpOnly, + sameSite: cookie.attributes.sameSite, + secure: cookie.attributes.productionSecure, + path: cookie.attributes.path, + maxAge: cookie.clear.maxAgeSeconds, + }); + expect(write.options).not.toHaveProperty("expires"); + } + }); + + it("rejects values beyond the 20-chunk read ceiling before writing", async () => { + const { cookie } = await loadFixture(); + const { response, writes } = cookieRecorder(); + + expect(() => + setSessionCookieValue( + response, + "https://app.example.test", + "x".repeat(cookie.maxValueLength + 1), + new Date(cookie.writeExample.expiresAtUnix * 1000) + ) + ).toThrow(/maximum supported size/i); + expect(writes).toEqual([]); + }); + + it("clears every possible chunk on logout with the legacy attributes", async () => { + const { cookie } = await loadFixture(); + const { response, writes } = cookieRecorder(); + configureCookieEnvironment("true", ""); + + clearSessionCookieValues(response, "http://127.0.0.1:3000"); + + expect(writes.map(({ name }) => name)).toEqual(cookie.chunkNames); + expect(writes).toHaveLength(cookie.maxChunks); + for (const write of writes) { + expect(write.value).toBe(cookie.clear.value); + expect(write.options).toEqual({ + httpOnly: cookie.attributes.httpOnly, + sameSite: cookie.attributes.sameSite, + secure: cookie.attributes.productionSecure, + path: cookie.attributes.path, + maxAge: cookie.clear.maxAgeSeconds, + }); + } + }); + + it("uses the shared Secure resolution precedence", async () => { + const { cookie } = await loadFixture(); + for (const testCase of cookie.secureResolutionCases) { + configureCookieEnvironment(testCase.explicit, testCase.publicBaseUrl); + expect(shouldUseSecureAuthCookie(testCase.requestUrl), testCase.name).toBe(testCase.expected); + } + }); +}); diff --git a/tests/go-command-contract.test.ts b/tests/go-command-contract.test.ts new file mode 100644 index 0000000..fe2ddb7 --- /dev/null +++ b/tests/go-command-contract.test.ts @@ -0,0 +1,20 @@ +import { readFile } from "node:fs/promises"; + +import { describe, expect, it } from "vitest"; + +describe("Go developer command contract", () => { + it("uses one cross-platform runner without changing the caller's global Go environment", async () => { + const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")) as { + scripts: Record; + }; + expect(packageJson.scripts["go:fmt"]).toBe("node scripts/run-go-command.mjs fmt ./..."); + expect(packageJson.scripts["go:test"]).toBe("node scripts/run-go-command.mjs test ./..."); + expect(packageJson.scripts["go:vet"]).toBe("node scripts/run-go-command.mjs vet ./..."); + expect(packageJson.scripts["go:build"]).toBe("node scripts/run-go-command.mjs build ./cmd/zhinian-api"); + + const runner = await readFile(new URL("../scripts/run-go-command.mjs", import.meta.url), "utf8"); + expect(runner).toContain('CGO_ENABLED: process.env.CGO_ENABLED || "0"'); + expect(runner).toContain('new URL("../backend/", import.meta.url)'); + expect(runner).not.toContain("go env -w"); + }); +}); diff --git a/tests/http-route-surface-contract.test.ts b/tests/http-route-surface-contract.test.ts new file mode 100644 index 0000000..be6e243 --- /dev/null +++ b/tests/http-route-surface-contract.test.ts @@ -0,0 +1,74 @@ +import { readFile, readdir } from "node:fs/promises"; +import { dirname, join, relative, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +type RouteContract = { + version: 1; + routes: Array<{ + method: "DELETE" | "GET" | "PATCH" | "POST" | "PUT"; + path: string; + }>; +}; + +const repositoryRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const appRoot = join(repositoryRoot, "app"); +const contractPath = join(repositoryRoot, "contracts", "http", "route-surface.v1.json"); +const exportedMethod = /^\s*export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)\b/gm; + +async function findRouteFiles(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + const nested = await Promise.all( + entries.map(async (entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) return findRouteFiles(path); + return entry.isFile() && entry.name === "route.ts" ? [path] : []; + }), + ); + return nested.flat().sort(); +} + +function routePath(routeFile: string): string { + const routeDirectory = relative(appRoot, dirname(routeFile)); + const segments = routeDirectory.split(sep).map((segment) => { + if (segment.startsWith("[...") && segment.endsWith("]")) { + return `{${segment.slice(4, -1)}...}`; + } + if (segment.startsWith("[") && segment.endsWith("]")) { + return `{${segment.slice(1, -1)}}`; + } + return segment; + }); + return `/${segments.join("/")}`; +} + +async function deriveRouteSurface(routeFiles: string[]): Promise { + const routes = await Promise.all( + routeFiles.map(async (routeFile) => { + const source = await readFile(routeFile, "utf8"); + return Array.from(source.matchAll(exportedMethod), (match) => ({ + method: match[1] as RouteContract["routes"][number]["method"], + path: routePath(routeFile), + })); + }), + ); + return routes.flat().sort((left, right) => { + const leftKey = `${left.path}\u0000${left.method}`; + const rightKey = `${right.path}\u0000${right.method}`; + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; + }); +} + +describe("HTTP route surface contract", () => { + it("exactly records every exported Next route method without loading handlers", async () => { + const routeFiles = await findRouteFiles(appRoot); + const actual = await deriveRouteSurface(routeFiles); + const contract = JSON.parse(await readFile(contractPath, "utf8")) as RouteContract; + + expect(routeFiles).toHaveLength(47); + expect(actual).toHaveLength(66); + expect(contract.version).toBe(1); + expect(contract.routes).toEqual(actual); + }); +});