Make product API versions independently installable
This commit is contained in:
1 parent
ce6583f454
commit
d04c8b12ab
69 files changed
+755
-404
No files matched your search
@@ -0,0 +1,190 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func testConfig(t *testing.T) *api.Config {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile("../../config.example.yml")
|
||||
require.NoError(t, err)
|
||||
var raw struct {
|
||||
API api.Config `yaml:"api"`
|
||||
}
|
||||
require.NoError(t, yaml.Unmarshal(data, &raw))
|
||||
t.Setenv(raw.API.Mw.EndPointEnv, "/api/v1")
|
||||
t.Setenv(raw.API.Mw.DomainEnv, "example.test")
|
||||
t.Setenv(raw.API.Mw.APIProtocolEnv, "https")
|
||||
return &raw.API
|
||||
}
|
||||
|
||||
func TestInstalledVersionsShareDependenciesAndKeepScopes(t *testing.T) {
|
||||
config := testConfig(t)
|
||||
config.Versions = []api.Mount{{Version: "v1", Path: "/api/v1"}, {Version: "v2", Path: "/gateway/v2"}}
|
||||
initialized, closed := 0, 0
|
||||
deps := &api.Dependencies{}
|
||||
installer := func(version string) api.Installer {
|
||||
return func(scope api.Scope, got *api.Dependencies) error {
|
||||
require.Same(t, deps, got)
|
||||
link, err := scope.URLs.GetAPILink("accounts")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://example.test"+scope.Mount+"/accounts", link)
|
||||
scope.Router.Get("/contract", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(version)) })
|
||||
scope.Router.Post("/accounts/session", func(w http.ResponseWriter, r *http.Request) {
|
||||
browser.SetRefreshCookie(w, r, "credential", time.Now().Add(time.Hour))
|
||||
})
|
||||
scope.Router.Delete("/accounts/session", browser.ClearRefreshCookie)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
runtime := api.RuntimeConfig{Versions: map[string]api.Installer{"v1": installer("old"), "v2": installer("new"), "v3": installer("disabled")}, Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
||||
initialized++
|
||||
return deps, func(context.Context) error { closed++; return nil }, nil
|
||||
}}
|
||||
root, routes := chi.NewRouter(), chi.NewRouter()
|
||||
srv, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, root, routes, false, runtime)
|
||||
require.NoError(t, err)
|
||||
root.Mount("/", routes)
|
||||
for target, expected := range map[string]string{"/api/v1/contract": "old", "/gateway/v2/contract": "new"} {
|
||||
result := httptest.NewRecorder()
|
||||
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, target, nil))
|
||||
require.Equal(t, http.StatusOK, result.Code)
|
||||
require.Equal(t, expected, result.Body.String())
|
||||
}
|
||||
t.Setenv("API_ENDPOINT", "/wrong/global")
|
||||
for _, mount := range config.Versions {
|
||||
for _, method := range []string{http.MethodPost, http.MethodDelete} {
|
||||
result := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(method, mount.Path+"/accounts/session", nil)
|
||||
request.Header.Set(browser.TransportHeader, browser.TransportCookie)
|
||||
request.Header.Set(browser.CSRFHeader, browser.CSRFValue)
|
||||
request.Header.Set("Origin", config.Mw.CORS.AllowedOrigins[0])
|
||||
root.ServeHTTP(result, request)
|
||||
require.Equal(t, http.StatusOK, result.Code)
|
||||
cookies := result.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
require.Equal(t, mount.Path+"/accounts", cookies[0].Path)
|
||||
if method == http.MethodDelete {
|
||||
require.Equal(t, -1, cookies[0].MaxAge)
|
||||
}
|
||||
}
|
||||
}
|
||||
result := httptest.NewRecorder()
|
||||
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, "/api/v3/contract", nil))
|
||||
require.Equal(t, 404, result.Code)
|
||||
require.Equal(t, 1, initialized)
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan error, 3)
|
||||
for range 3 {
|
||||
wg.Go(func() { results <- srv.Finish(context.Background()) })
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
for err := range results {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.Equal(t, 1, closed)
|
||||
}
|
||||
|
||||
func TestInvalidMountsFailBeforeBootstrap(t *testing.T) {
|
||||
for name, mounts := range map[string][]api.Mount{
|
||||
"unknown": {{Version: "v9", Path: "/api/v9"}}, "duplicate": {{Version: "v1", Path: "/api/v1"}, {Version: "v1", Path: "/other"}},
|
||||
"same path": {{Version: "v1", Path: "/api/v1"}, {Version: "v2", Path: "/api/v1"}},
|
||||
"nested": {{Version: "v1", Path: "/api"}, {Version: "v2", Path: "/api/v2"}},
|
||||
"cookie delimiter": {{Version: "v1", Path: "/api;v1"}},
|
||||
"control": {{Version: "v1", Path: "/api/\x01"}},
|
||||
"relative": {{Version: "v1", Path: "api/v1"}}, "wildcard": {{Version: "v1", Path: "/api/*"}},
|
||||
"unclean": {{Version: "v1", Path: "/api/../v1"}}, "metrics": {{Version: "v1", Path: "/metrics"}},
|
||||
"health": {{Version: "v1", Path: "/api/v1/health"}}, "root": {{Version: "v1", Path: "/"}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
config := testConfig(t)
|
||||
config.Versions = mounts
|
||||
install := func(api.Scope, *api.Dependencies) error { t.Fatal("installer must not run"); return nil }
|
||||
_, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, chi.NewRouter(), chi.NewRouter(), false, api.RuntimeConfig{Versions: map[string]api.Installer{"v1": install, "v2": install}, Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
||||
t.Fatal("bootstrap must not run")
|
||||
return nil, nil, nil
|
||||
}})
|
||||
require.ErrorIs(t, err, merrors.ErrInvalidArg)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallFailureCleansResourcesWithoutPublishingRoutes(t *testing.T) {
|
||||
config := testConfig(t)
|
||||
config.Versions = []api.Mount{{Version: "v1", Path: "/api/v1"}, {Version: "v2", Path: "/api/v2"}}
|
||||
closed := 0
|
||||
cause := merrors.InvalidArgument("test installation failure")
|
||||
routes := chi.NewRouter()
|
||||
_, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, chi.NewRouter(), routes, false, api.RuntimeConfig{
|
||||
Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
||||
return &api.Dependencies{}, func(context.Context) error { closed++; return nil }, nil
|
||||
},
|
||||
Versions: map[string]api.Installer{
|
||||
"v1": func(scope api.Scope, _ *api.Dependencies) error {
|
||||
scope.Router.Get("/ready", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })
|
||||
return nil
|
||||
},
|
||||
"v2": func(api.Scope, *api.Dependencies) error { return cause },
|
||||
}})
|
||||
require.True(t, errors.Is(err, cause))
|
||||
require.Equal(t, 1, closed)
|
||||
result := httptest.NewRecorder()
|
||||
routes.ServeHTTP(result, httptest.NewRequest(http.MethodGet, "/api/v1/ready", nil))
|
||||
require.Equal(t, 404, result.Code)
|
||||
}
|
||||
|
||||
func TestExplicitEmptyVersionsInstallNoAPI(t *testing.T) {
|
||||
config := testConfig(t)
|
||||
config.Versions = []api.Mount{}
|
||||
root, routes := chi.NewRouter(), chi.NewRouter()
|
||||
srv, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, root, routes, false, api.RuntimeConfig{Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
||||
return &api.Dependencies{}, nil, nil
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
defer srv.Finish(context.Background())
|
||||
root.Mount("/", routes)
|
||||
for target, status := range map[string]int{"/api/v1/accounts/login": 404, "/api/v1/health": 200, "/metrics": 200, "/api/v1/metrics": 200} {
|
||||
result := httptest.NewRecorder()
|
||||
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, target, nil))
|
||||
require.Equal(t, status, result.Code, target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapFailureClosesReturnedResources(t *testing.T) {
|
||||
config := testConfig(t)
|
||||
config.Versions = []api.Mount{}
|
||||
closed := 0
|
||||
cause := merrors.Internal("bootstrap failure")
|
||||
_, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, chi.NewRouter(), chi.NewRouter(), false, api.RuntimeConfig{Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
||||
return nil, func(context.Context) error { closed++; return nil }, cause
|
||||
}})
|
||||
require.ErrorIs(t, err, cause)
|
||||
require.Equal(t, 1, closed)
|
||||
}
|
||||
|
||||
func TestFinishPreservesCleanupErrorAndRunsOnce(t *testing.T) {
|
||||
cause := merrors.Internal("cleanup failure")
|
||||
closed := 0
|
||||
srv := &instance{logger: zap.NewNop(), cleanup: func(context.Context) error { closed++; return cause }}
|
||||
require.ErrorIs(t, srv.Finish(context.Background()), cause)
|
||||
require.ErrorIs(t, srv.Finish(context.Background()), cause)
|
||||
require.Equal(t, 1, closed)
|
||||
}
|
||||
Reference in new issue
Block a user