// Copyright The Pit Project Owners. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // Please see https://officer.openpit.dev and the OWNERS file for details. package officer import ( "context" "crypto/sha256" "encoding/json" "errors" "fmt" "io/fs" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync/atomic" "testing" "testing/fstest" "testing/synctest" "time" "go.openpit.dev/officer/framework/domain" "go.openpit.dev/officer/framework/store" httpx "go.openpit.dev/officer/framework/web/httpapi" "go.openpit.dev/officer/internal/nonrelease" "go.openpit.dev/officer/internal/store/sqlite" ) func TestNonReleaseGateConcurrentChoice(t *testing.T) { synctest.Test(t, func(t *testing.T) { cfg := Config{SQLitePath: filepath.Join(t.TempDir(), "own.db")} release := make(chan struct{}) var prepares, builds atomic.Int32 chosen := make(chan bool, 2) gate, err := newNonReleaseGate(t.Context(), cfg, "release.db", "/mcp", true, gateTestSPA, func(ctx context.Context) (http.Handler, error) { builds.Add(1) if err := ctx.Err(); err != nil { return nil, err } return gateTestApp(ctx) }, func(ctx context.Context, got Config, path string, fromRelease bool) error { prepares.Add(1) chosen <- fromRelease <-release if got.SQLitePath != cfg.SQLitePath || path != "release.db" { return fmt.Errorf("prepare inputs = %q, %q", got.SQLitePath, path) } return ctx.Err() }) if err != nil { t.Fatal(err) } defer gate.Close() defer close(release) results := make(chan struct { fromRelease bool rec *httptest.ResponseRecorder }, 2) for _, fromRelease := range []bool{true, false} { go func() { results <- struct { fromRelease bool rec *httptest.ResponseRecorder }{fromRelease, serveExactPath(gate, http.MethodPost, "/app/api/v1/build/database", fmt.Sprintf(`{"fromRelease":%t}`, fromRelease))} }() } synctest.Wait() first, second := <-results, <-results if first.rec.Code == http.StatusConflict { first, second = second, first } if first.rec.Code != http.StatusAccepted || second.rec.Code != http.StatusConflict { t.Fatalf("concurrent choices = %d and %d, want one 202 and one 409", first.rec.Code, second.rec.Code) } if prepares.Load() != 1 || builds.Load() != 0 { t.Fatalf("while preparing: prepare calls = %d, build calls = %d, want 1 and 0", prepares.Load(), builds.Load()) } if got := <-chosen; got != first.fromRelease { t.Fatalf("prepared choice = %v, accepted choice = %v", got, first.fromRelease) } gateTestStatus(t, gate, nonrelease.PhasePreparing, "") rec := serveExactPath(gate, http.MethodGet, "/application-only", "") if rec.Header().Get("X-Test-App") != "" { t.Fatal("application served before preparation completed") } release <- struct{}{} synctest.Wait() gateTestStatus(t, gate, nonrelease.PhaseReady, "") rec = serveExactPath(gate, http.MethodGet, "/application-only", "") if rec.Header().Get("X-Test-App") != "ready" || rec.Body.String() != "application" { t.Fatalf("ready request missed application handler: %d %s", rec.Code, rec.Body) } if builds.Load() != 1 { t.Fatalf("build calls = %d, want 1", builds.Load()) } }) } func TestNonReleaseGatePrepareFailureRetry(t *testing.T) { synctest.Test(t, func(t *testing.T) { cfg := Config{SQLitePath: filepath.Join(t.TempDir(), "own.db")} if err := os.WriteFile(cfg.SQLitePath, []byte("existing database"), 0600); err != nil { t.Fatal(err) } var prepares, builds atomic.Int32 retry := make(chan struct{}) gate, err := newNonReleaseGate(t.Context(), cfg, "release.db", "/mcp", true, gateTestSPA, func(ctx context.Context) (http.Handler, error) { builds.Add(1) return gateTestApp(ctx) }, func(context.Context, Config, string, bool) error { if prepares.Add(1) == 1 { return errors.New("snapshot failed") } <-retry return nil }) if err != nil { t.Fatal(err) } defer gate.Close() defer close(retry) rec := serveExactPath(gate, http.MethodPost, "/app/api/v1/build/database", `{"fromRelease":true}`) if rec.Code != http.StatusAccepted { t.Fatalf("first choice = %d %s", rec.Code, rec.Body) } synctest.Wait() gateTestStatus(t, gate, nonrelease.PhaseFailed, "snapshot failed") if builds.Load() != 0 { t.Fatal("build ran after failed preparation") } if data, err := os.ReadFile(cfg.SQLitePath); err != nil || string(data) != "existing database" { t.Fatalf("prepare failure removed existing database: %q, %v", data, err) } rec = serveExactPath(gate, http.MethodPost, "/app/api/v1/build/database", `{"fromRelease":false}`) if rec.Code != http.StatusAccepted { t.Fatalf("retry choice = %d %s, want 202", rec.Code, rec.Body) } gateTestStatus(t, gate, nonrelease.PhasePreparing, "") retry <- struct{}{} synctest.Wait() gateTestStatus(t, gate, nonrelease.PhaseReady, "") if prepares.Load() != 2 || builds.Load() != 1 { t.Fatalf("retry calls: prepare = %d, build = %d", prepares.Load(), builds.Load()) } }) } func TestNonReleaseGateBuildFailureRemovesDatabase(t *testing.T) { synctest.Test(t, func(t *testing.T) { cfg := Config{SQLitePath: filepath.Join(t.TempDir(), "own.db")} gate, err := newNonReleaseGate(t.Context(), cfg, "release.db", "/mcp", false, gateTestSPA, func(context.Context) (http.Handler, error) { return nil, errors.New("build failed") }, func(context.Context, Config, string, bool) error { for _, path := range sqlite.DatabaseFiles(cfg.SQLitePath) { if err := os.WriteFile(path, []byte("prepared"), 0600); err != nil { return err } } return nil }) if err != nil { t.Fatal(err) } defer gate.Close() rec := serveExactPath(gate, http.MethodPost, "/app/api/v1/build/database", `{"fromRelease":false}`) if rec.Code != http.StatusAccepted { t.Fatalf("choice = %d %s", rec.Code, rec.Body) } synctest.Wait() gateTestStatus(t, gate, nonrelease.PhaseFailed, "build failed") for _, path := range sqlite.DatabaseFiles(cfg.SQLitePath) { if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { t.Fatalf("build failure left own database %q: %v", path, err) } } }) } func TestNonReleaseGateCloseWaits(t *testing.T) { synctest.Test(t, func(t *testing.T) { cfg := Config{SQLitePath: filepath.Join(t.TempDir(), "own.db")} release := make(chan struct{}) var builds atomic.Int32 gate, err := newNonReleaseGate(t.Context(), cfg, "release.db", "/mcp", true, gateTestSPA, func(ctx context.Context) (http.Handler, error) { builds.Add(1) return gateTestApp(ctx) }, func(ctx context.Context, cfg Config, _ string, _ bool) error { <-ctx.Done() <-release return os.WriteFile(cfg.SQLitePath, []byte("prepared"), 0600) }) if err != nil { t.Fatal(err) } defer gate.Close() defer close(release) rec := serveExactPath(gate, http.MethodPost, "/app/api/v1/build/database", `{"fromRelease":false}`) if rec.Code != http.StatusAccepted { t.Fatalf("choice = %d %s", rec.Code, rec.Body) } closed := make(chan struct{}) go func() { gate.Close(); close(closed) }() synctest.Wait() select { case <-closed: t.Fatal("Close returned while prepare was still blocked") default: } release <- struct{}{} <-closed gate.Close() if builds.Load() != 0 { t.Fatal("build ran after cancellation between prepare and build") } if data, err := os.ReadFile(cfg.SQLitePath); err != nil || string(data) != "prepared" { t.Fatalf("cancelled gate did not keep prepared database: %q, %v", data, err) } gateTestStatus(t, gate, nonrelease.PhaseFailed, context.Canceled.Error()) rec = serveExactPath(gate, http.MethodPost, "/app/api/v1/build/database", `{"fromRelease":false}`) if rec.Code != http.StatusConflict { t.Fatalf("choice after Close = %d %s, want 409", rec.Code, rec.Body) } }) } func TestNonReleaseGateRouter(t *testing.T) { const mcpPath = "/agent/mcp" gate, err := newNonReleaseGate(t.Context(), Config{}, "release.db", mcpPath, false, gateTestSPA, gateTestApp, func(context.Context, Config, string, bool) error { t.Fatal("refused request reached prepare") return nil }) if err != nil { t.Fatal(err) } defer gate.Close() gateTestStatus(t, gate, nonrelease.PhaseChoice, "") for _, path := range []string{"/", "/unknown-client-route"} { rec := serveExactPath(gate, http.MethodGet, path, "") if rec.Code != http.StatusOK || !strings.Contains(rec.Header().Get("Content-Type"), "text/html") || !strings.Contains(rec.Body.String(), "")}}, nil } func gateTestApp(context.Context) (http.Handler, error) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Test-App", "ready") if r.URL.Path == "/app/api/v1/build" { httpx.WriteJSON(w, http.StatusOK, nonrelease.Status{Phase: nonrelease.PhaseReady}) return } _, _ = w.Write([]byte("application")) }), nil } func TestPrepareNonReleaseDatabase(t *testing.T) { for _, fromRelease := range []bool{true, false} { name := "empty" if fromRelease { name = "from release" } t.Run(name, func(t *testing.T) { ctx := context.Background() release := filepath.Join(t.TempDir(), "release.db") key := testMasterKey(t) cfg := Config{SQLitePath: release, MasterKey: &key} st, realm := openNonReleaseTestStore(t, cfg, true) // Use the same store seeding path as TestRegisterTradingComposition. for _, enabled := range []bool{true, false} { label := "disabled" if enabled { label = "enabled" } if _, err := realm.CreateTradingConnection(ctx, domain.TradingConnection{ Provider: domain.TradingProviderAlpaca, Label: label, Mode: domain.TradingModePaper, VenueIdentity: "paper-" + label, Credentials: "{}", Enabled: enabled, }); err != nil { t.Fatal(err) } } if err := st.Close(); err != nil { t.Fatal(err) } before, modified := nonReleaseFileState(t, release) own, err := nonrelease.DatabasePath(release, "own") if err != nil { t.Fatal(err) } cfg.SQLitePath = own if err := PrepareNonReleaseDatabase(ctx, cfg, release, fromRelease); err != nil { t.Fatal(err) } assertNonReleaseFileUnchanged(t, release, before, modified) assertNoPrepareFiles(t, own) ownStore, ownRealm := openNonReleaseTestStore(t, cfg, false) version, err := ownStore.SchemaVersion(ctx) if err != nil || version == 0 { t.Fatalf("own database is not migrated: version=%d, err=%v", version, err) } connections, err := ownRealm.ListTradingConnections(ctx) if err != nil { t.Fatal(err) } wantCount := 0 if fromRelease { wantCount = 2 } if len(connections) != wantCount { t.Errorf("own connections = %d, want %d", len(connections), wantCount) } for _, connection := range connections { if connection.Enabled { t.Errorf("own trading connection %q remains enabled", connection.Label) } } if err := ownStore.Close(); err != nil { t.Fatal(err) } cfg.SQLitePath = release releaseStore, releaseRealm := openNonReleaseTestStore(t, cfg, false) original, err := releaseRealm.ListTradingConnections(ctx) if err != nil { t.Fatal(err) } if len(original) != 2 { t.Fatalf("release connections = %d, want 2", len(original)) } for _, connection := range original { if connection.Enabled != (connection.Label == "enabled") { t.Errorf("release trading connection %q enabled=%v", connection.Label, connection.Enabled) } } if err := releaseStore.Close(); err != nil { t.Fatal(err) } for _, path := range sqlite.DatabaseFiles(own)[1:] { if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { t.Errorf("own sidecar remains %q: %v", path, err) } } }) } } func TestPrepareNonReleaseDatabaseFailureCleansTemporaryFiles(t *testing.T) { for _, name := range []string{"invalid release", "missing release", "migration canceled"} { t.Run(name, func(t *testing.T) { release := filepath.Join(t.TempDir(), "release.db") own := release + ".non-release-own" ctx := context.Background() fromRelease := true if name == "invalid release" { if err := os.WriteFile(release, []byte("not an SQLite database"), 0600); err != nil { t.Fatal(err) } } if name == "migration canceled" { canceled, cancel := context.WithCancel(ctx) cancel() ctx = canceled fromRelease = false } err := PrepareNonReleaseDatabase(ctx, Config{SQLitePath: own}, release, fromRelease) if err == nil { t.Fatal("preparation unexpectedly succeeded") } if !strings.Contains(err.Error(), "officer: prepare non-release database:") { t.Fatalf("missing preparation step in error: %v", err) } if _, err := os.Lstat(own); !errors.Is(err, os.ErrNotExist) { t.Errorf("failed preparation published own database: %v", err) } assertNoPrepareFiles(t, own) if name == "missing release" { if _, err := os.Lstat(release); !errors.Is(err, os.ErrNotExist) { t.Errorf("preparation created missing release: %v", err) } } }) } } func TestPrepareNonReleaseDatabaseRefusesExistingOrReleasePath(t *testing.T) { dir := t.TempDir() release := filepath.Join(dir, "release.db") own := release + ".non-release-own" for _, path := range []string{release, own} { if err := os.WriteFile(path, []byte("leave untouched"), 0600); err != nil { t.Fatal(err) } } releaseSpelling := dir + string(filepath.Separator) + "." + string(filepath.Separator) + "release.db" for _, path := range []string{own, releaseSpelling} { before, modified := nonReleaseFileState(t, filepath.Join(dir, filepath.Base(path))) if err := PrepareNonReleaseDatabase(context.Background(), Config{SQLitePath: path}, release, false); err == nil { t.Errorf("preparation accepted existing or release path %q", path) } assertNonReleaseFileUnchanged(t, filepath.Join(dir, filepath.Base(path)), before, modified) assertNoPrepareFiles(t, own) } if err := os.Remove(release); err != nil { t.Fatal(err) } if err := PrepareNonReleaseDatabase(context.Background(), Config{SQLitePath: releaseSpelling}, release, false); err == nil { t.Error("preparation accepted missing release path as own database") } for _, path := range sqlite.DatabaseFiles(release) { if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { t.Errorf("preparation created release database file %q: %v", path, err) } } assertNoPrepareFiles(t, release) } func TestPublishNonReleaseDatabaseRefusesExistingFile(t *testing.T) { dir := t.TempDir() tmp := filepath.Join(dir, "temporary.db") own := filepath.Join(dir, "own.db") if err := os.WriteFile(tmp, []byte("new database"), 0600); err != nil { t.Fatal(err) } want := []byte("existing database") if err := os.WriteFile(own, want, 0600); err != nil { t.Fatal(err) } if err := publishNonReleaseDatabase(tmp, own); !errors.Is(err, os.ErrExist) { t.Errorf("publication: got %v, want os.ErrExist", err) } got, err := os.ReadFile(own) if err != nil { t.Fatal(err) } if string(got) != string(want) { t.Errorf("own database bytes = %q, want %q", got, want) } } func openNonReleaseTestStore(t *testing.T, cfg Config, migrate bool) (store.Store, store.RealmStore) { t.Helper() st, err := newStore(cfg) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = st.Close() }) if migrate { if err := st.Migrate(context.Background()); err != nil { t.Fatal(err) } } realm, err := st.ForRealm(context.Background(), domain.DefaultRealm) if err != nil { t.Fatal(err) } return st, realm } func nonReleaseFileState(t *testing.T, path string) ([32]byte, time.Time) { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } info, err := os.Stat(path) if err != nil { t.Fatal(err) } return sha256.Sum256(data), info.ModTime() } func assertNonReleaseFileUnchanged(t *testing.T, path string, before [32]byte, modified time.Time) { t.Helper() after, afterModified := nonReleaseFileState(t, path) if after != before { t.Errorf("release database bytes changed: %q", path) } if !afterModified.Equal(modified) { t.Errorf("release database modification time changed: %q", path) } for _, sidecar := range sqlite.DatabaseFiles(path)[1:] { if _, err := os.Lstat(sidecar); !errors.Is(err, os.ErrNotExist) { t.Errorf("release sidecar %q appeared: %v", sidecar, err) } } } func assertNoPrepareFiles(t *testing.T, own string) { t.Helper() entries, err := os.ReadDir(filepath.Dir(own)) if err != nil { t.Fatal(err) } for _, entry := range entries { if strings.HasPrefix(entry.Name(), filepath.Base(own)+".prepare-") { t.Errorf("temporary preparation file remains: %s", entry.Name()) } } }