feat(19.3-02): per-group CORS policies through cors:<profile> with router-registered preflights
- surf: LoadCORSProfiles reads http.cors_profiles.<name> (http.cors keys without paths, validated the same way); factory cors:<profile> sets the profile's headers; an unknown profile fails boot naming plugin and route - BuildRouter registers OPTIONS <path> (owner surf, recovery only) for every path whose routes name cors:<profile>; two profiles on one path, an existing OPTIONS route or overlap with http.cors.paths fail boot - docs: CORS profiles section, middleware table and README rows
This commit is contained in:
@@ -98,6 +98,7 @@ The framework registers these names:
|
||||
|------|------|
|
||||
| `throttle:<bucket>` or `throttle:<max>,<minutes>` | Rate limiting; see [Rate limiting](rate-limiting.md). |
|
||||
| `body.limit:<bytes>` | Replaces the default request body limit for the route. |
|
||||
| `cors:<profile>` | CORS headers from `http.cors_profiles.<profile>`; see [CORS profiles](#cors-profiles). |
|
||||
| `locale.from-principal` | Switches the request locale to the signed-in user's preferred locale. |
|
||||
| `backend` | The admin guard, when the admin is enabled. |
|
||||
|
||||
@@ -246,3 +247,22 @@ cors:
|
||||
The browser sends the request with `credentials: "include"`; the answer carries `Access-Control-Allow-Origin: https://www.example.com`, `Access-Control-Allow-Credentials: true` and `Vary: Origin`.
|
||||
|
||||
surf answers every `OPTIONS` request on a CORS path itself, with 204, before any route runs, so a plugin never registers an `OPTIONS` route. A preflight gets the same headers Laravel's `HandleCors` sends: with `allowed_methods: ["*"]` the `Access-Control-Allow-Methods` value is the requested method, upper-cased, and with `allowed_headers: ["*"]` the `Access-Control-Allow-Headers` value is the requested header list.
|
||||
|
||||
## CORS profiles
|
||||
|
||||
`http.cors` applies one policy to a set of paths. A route group that needs its own policy, for example a credentialed one for a single-page app while the rest of the API stays public, names a profile with the `cors:<profile>` middleware. Profiles live under `http.cors_profiles.<name>` and take the `http.cors` keys except `paths`:
|
||||
|
||||
```yaml
|
||||
cors_profiles:
|
||||
spa:
|
||||
allowed_origins: ["https://www.example.com"]
|
||||
allowed_methods: ["GET", "POST"]
|
||||
allowed_headers: ["Content-Type"]
|
||||
supports_credentials: true
|
||||
```
|
||||
|
||||
This fragment belongs in `config/http.yaml`. A route group then lists `cors:spa` among its middleware names, next to its guard, exactly as it lists `throttle:60,1`.
|
||||
|
||||
A profile is checked like `http.cors`: credentials with a `*` origin, a pattern that does not compile and a PHP-delimited pattern fail the start-up, with errors naming `http.cors_profiles.<name>.<key>`. Profile names use lower-case letters, digits, underscore and dash, and a profile that sets `paths` fails too.
|
||||
|
||||
ServeMux answers an `OPTIONS` request on a path registered only for `GET` or `POST` with 405 before any route middleware runs, so the middleware alone would never see a browser's preflight. surf therefore registers one `OPTIONS <path>` route for every path whose routes name `cors:<profile>`, owned by `surf` in `route:list`. It answers the preflight from the profile, with the same headers as the global preflight, and runs no other middleware: a preflight carries no credentials, so no auth, throttle or body limit applies. The start-up fails when a route names an unknown profile, when two routes on one path name different profiles, when a plugin already declared an `OPTIONS` route on that path, and when the path also matches `http.cors.paths`, because the global wrapper answers every preflight on its paths before routing.
|
||||
|
||||
@@ -27,7 +27,8 @@ Use cases:
|
||||
- Overlapping constrained routes: two routes that ServeMux would refuse side by side, such as `/shelves/items/{id}` and `/shelves/{shelfId}/follow`, boot as one overlap family when their constraints keep them apart. Requests are tried against the family's routes in registration order, as Laravel does, and each route keeps its own path values and middleware.
|
||||
- Named middleware from plugins (`pact.HasMiddleware`), parameterized middleware used as `name:param` (`pact.HasMiddlewareFactories`) and house middleware for the JSON envelope and error handling (`pact.HasHouseMiddleware`). Duplicate or unknown names fail boot.
|
||||
- Raw groups (`surf.Router.GroupRaw`) for routes that must not be wrapped in house middleware, such as webhooks or file streams: house middleware is refused there, the default body limit is skipped and a panic returns a bare 500.
|
||||
- Built-in middleware names: `throttle:<bucket>` or `throttle:<max>,<minutes>`, `body.limit:<bytes>`, `locale.from-principal`, plus `backend` (the admin guard) when the admin is enabled.
|
||||
- Built-in middleware names: `throttle:<bucket>` or `throttle:<max>,<minutes>`, `body.limit:<bytes>`, `cors:<profile>`, `locale.from-principal`, plus `backend` (the admin guard) when the admin is enabled.
|
||||
- Named CORS profiles: `cors:<profile>` applies `http.cors_profiles.<profile>` (read by `surf.LoadCORSProfiles`, validated like `http.cors`) to a route group, and surf registers an `OPTIONS` route for each such path that answers the preflight from the profile, since ServeMux would answer it with 405 before the middleware runs. An unknown profile, two profiles on one path and a profiled path that `http.cors.paths` also matches fail boot.
|
||||
- Fixed-window rate limiting (`surf.FixedWindowLimiter`): named buckets from plugins that implement `surf.BucketProvider`, or inline limits keyed by the signed-in user, or by client IP for guests. Rejected requests get a 429 with `Retry-After` and `X-RateLimit-*` headers. The in-process `surf.MemoryStore` sits behind the `surf.Store` interface; `surf.BuildRouter` uses a `surf.Store` published on the app instead when there is one, which is how `serve` shares counters across instances through [cooler](../cooler/README.md)'s Redis store (`http.rate_limit.store: redis`).
|
||||
- Client IP resolution for limiter keys (`surf.ClientIP`) that only trusts `X-Forwarded-For` hops when the direct peer is inside a configured trusted proxy range (`surf.TrustedProxies`).
|
||||
- Every non-raw route runs inside JSON panic recovery (an opaque 500 via [wire](../wire/README.md)), gets the request locale from a published `surf.LocaleResolver` when one exists or from the `Accept-Language` header otherwise (see [towel](../towel/README.md)), and a request body cap. Responses are buffered until the handler returns, so a panic never leaves a half-written body.
|
||||
@@ -134,6 +135,7 @@ The generated application `main` wires surf in with `surf.ServeCommand` and `sur
|
||||
| `surf.ClientIP` | Resolves the client IP, honouring trusted proxies. |
|
||||
| `surf.TrustedProxies` | Parses `http.trusted_proxies` into CIDR prefixes. |
|
||||
| `surf.CORSConfig` | CORS settings; `surf.LoadCORSConfig` reads them from config. |
|
||||
| `surf.LoadCORSProfiles` | Reads and validates the `http.cors_profiles.<name>` blocks the `cors:<profile>` middleware uses. |
|
||||
| `surf.LocaleResolver` | Optional backpack-published request locale; `Resolve` returns a validated code and `Rewrite` strips an enabled prefix before routing. |
|
||||
| `surf.LocaleFromPrincipal` | Middleware that applies the signed-in user's preferred locale. |
|
||||
| `surf.ServeCommand` | The `serve` console command. |
|
||||
@@ -156,6 +158,7 @@ The generated application `main` wires surf in with `surf.ServeCommand` and `sur
|
||||
| `http.cors.exposed_headers` | empty | Headers sent in `Access-Control-Expose-Headers`. |
|
||||
| `http.cors.max_age` | `0` | Preflight cache time in seconds. |
|
||||
| `http.cors.supports_credentials` | `false` | Sends `Access-Control-Allow-Credentials: true`. Combined with `*` in `allowed_origins` it fails boot. With credentials, a pattern or several origins, every response on a CORS path carries `Vary: Origin`, preflights and disallowed origins included. |
|
||||
| `http.cors_profiles.<name>.*` | none | A CORS profile for `cors:<name>`: the `http.cors` keys except `paths` (`allowed_origins`, `allowed_origins_patterns`, `allowed_methods`, `allowed_headers`, `exposed_headers`, `max_age`, `supports_credentials`), validated the same way. Names use lower-case letters, digits, underscore and dash. |
|
||||
| `http.rate_limit.store` | `memory` | Where `throttle` counters live: `memory` (per instance) or `redis` (shared by every instance through [cooler](../cooler/README.md), which then needs `redis.url`). Only `serve` installs the Redis store; `route:list` keeps the memory store and never dials Redis. Any other value fails at boot. Environment: `SUMMER_HTTP__RATE_LIMIT__STORE`. |
|
||||
| `http.access_log` | `false` | Logs one `http request` record per request (method, matched pattern, status, duration in milliseconds; never the path or query). Environment: `SUMMER_HTTP__ACCESS_LOG`. |
|
||||
| `storage.uploads.serve` | `false` | Mounts the uploads route `GET <storage.uploads.public_path_prefix>/{path...}` (prefix default `/storage/uploads`; it must then be an absolute URL path, or start-up fails) on `attach.StaticHandlerPublic` with `X-Content-Type-Options: nosniff`, a sandboxing CSP and `Content-Disposition: attachment` for anything but a raster image. Prefer a separate origin for uploads in production. Environment: `SUMMER_STORAGE__UPLOADS__SERVE`. |
|
||||
|
||||
@@ -212,6 +212,145 @@ func pathScopedCORS(cfg CORSConfig, next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// corsProfileName is the character set of a cors_profiles name, the plugin-id
|
||||
// set without dots: lower-case letters, digits, underscore and dash.
|
||||
var corsProfileName = regexp.MustCompile(`^[a-z0-9_-]+$`)
|
||||
|
||||
// LoadCORSProfiles reads every child of http.cors_profiles. A profile takes
|
||||
// the http.cors keys except paths (it applies to the routes that name
|
||||
// cors:<profile>) and is validated like http.cors, with errors naming
|
||||
// http.cors_profiles.<name>.<key>. A missing section yields an empty map.
|
||||
func LoadCORSProfiles(cfg *compass.Config) (map[string]CORSConfig, error) {
|
||||
out := map[string]CORSConfig{}
|
||||
if cfg == nil || !cfg.Has("http.cors_profiles") {
|
||||
return out, nil
|
||||
}
|
||||
raw, _ := cfg.Lookup("http.cors_profiles")
|
||||
tree, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("surf: http.cors_profiles must be a map of profile names, got %T", raw)
|
||||
}
|
||||
for name := range tree {
|
||||
if !corsProfileName.MatchString(name) {
|
||||
return nil, fmt.Errorf("surf: http.cors_profiles.%s: a profile name uses lower-case letters, digits, underscore and dash", name)
|
||||
}
|
||||
prefix := "http.cors_profiles." + name
|
||||
var pc CORSConfig
|
||||
if err := cfg.LoadSection(prefix, &pc); err != nil {
|
||||
return nil, fmt.Errorf("surf: %s: %w", prefix, err)
|
||||
}
|
||||
if len(pc.Paths) > 0 {
|
||||
return nil, fmt.Errorf("surf: %s.paths is not allowed: a profile applies to the routes that name cors:%s", prefix, name)
|
||||
}
|
||||
if err := validateCORSConfig(prefix, pc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[name] = pc
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// profileMiddleware sets the profile's CORS headers on a non-OPTIONS request;
|
||||
// preflights reach the OPTIONS route compile registers instead.
|
||||
func (p *corsPolicy) profileMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodOptions {
|
||||
p.apply(w.Header(), r.Header.Get("Origin"))
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// routeCORSProfile returns the profile a route names through cors:<profile>,
|
||||
// or "" when it names none. Two different profiles on one route are an error.
|
||||
func routeCORSProfile(rt route) (string, error) {
|
||||
profile := ""
|
||||
for _, name := range rt.middleware {
|
||||
base, param, ok := strings.Cut(name, ":")
|
||||
if !ok || base != "cors" {
|
||||
continue
|
||||
}
|
||||
if profile != "" && profile != param {
|
||||
return "", fmt.Errorf("surf: route %s %s (plugin %q) names two CORS profiles, %q and %q", rt.method, rt.path, rt.pluginID, profile, param)
|
||||
}
|
||||
profile = param
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
// addCORSPreflights registers one OPTIONS route per path whose routes name
|
||||
// cors:<profile>, answering the preflight from that profile. ServeMux would
|
||||
// otherwise answer such a preflight with 405 before any route middleware
|
||||
// runs. A path that two profiles claim, a path the global http.cors.paths
|
||||
// also matches (the global wrapper would answer its preflight first) and an
|
||||
// OPTIONS route a plugin already declared are boot errors.
|
||||
func (r *Router) addCORSPreflights() error {
|
||||
if len(r.corsProfiles) == 0 {
|
||||
return nil
|
||||
}
|
||||
globs := make([]*regexp.Regexp, 0, len(r.corsCfg.Paths))
|
||||
for _, p := range r.corsCfg.Paths {
|
||||
if re := compileLaravelGlob(p); re != nil {
|
||||
globs = append(globs, re)
|
||||
}
|
||||
}
|
||||
type claim struct {
|
||||
profile string
|
||||
owner route
|
||||
}
|
||||
claims := map[string]claim{}
|
||||
var order []string
|
||||
for _, rt := range r.routes {
|
||||
if rt.preflight {
|
||||
continue
|
||||
}
|
||||
profile, err := routeCORSProfile(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if profile == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := r.corsProfiles[profile]; !ok {
|
||||
return fmt.Errorf("surf: plugin %q route %s %s references unknown CORS profile %q (define http.cors_profiles.%s)", rt.pluginID, rt.method, rt.path, profile, profile)
|
||||
}
|
||||
if pathMatchesCORS(globs, rt.path) {
|
||||
return fmt.Errorf("surf: route %s %s (plugin %q) names cors:%s but its path also matches http.cors.paths; the global CORS wrapper would answer its preflight first", rt.method, rt.path, rt.pluginID, profile)
|
||||
}
|
||||
if prev, ok := claims[rt.path]; ok {
|
||||
if prev.profile != profile {
|
||||
return fmt.Errorf("surf: path %s has routes with different CORS profiles: %s %s (plugin %q) names cors:%s, %s %s (plugin %q) names cors:%s", rt.path, prev.owner.method, prev.owner.path, prev.owner.pluginID, prev.profile, rt.method, rt.path, rt.pluginID, profile)
|
||||
}
|
||||
continue
|
||||
}
|
||||
claims[rt.path] = claim{profile: profile, owner: rt}
|
||||
order = append(order, rt.path)
|
||||
}
|
||||
for _, path := range order {
|
||||
c := claims[path]
|
||||
key := http.MethodOptions + " " + path
|
||||
if prev, ok := r.seen[key]; ok {
|
||||
return fmt.Errorf("surf: route %s registered by %s collides with the preflight route cors:%s needs", key, prev, c.profile)
|
||||
}
|
||||
r.seen[key] = corsPreflightOwner
|
||||
policy := r.corsProfiles[c.profile]
|
||||
r.routes = append(r.routes, route{
|
||||
pluginID: corsPreflightOwner,
|
||||
method: http.MethodOptions,
|
||||
path: path,
|
||||
handler: http.HandlerFunc(policy.preflight),
|
||||
constraints: append([]Constraint(nil), c.owner.constraints...),
|
||||
raw: true,
|
||||
preflight: true,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// corsPreflightOwner is the plugin ID route:list shows for the OPTIONS
|
||||
// routes addCORSPreflights registers.
|
||||
const corsPreflightOwner = "surf"
|
||||
|
||||
// writeOptions answers an OPTIONS request on a CORS path with 204 and the
|
||||
// headers Laravel's HandleCors (fruitcake/php-cors) sends, so recorded PHP
|
||||
// preflights replay unchanged. Every answer carries Symfony's default
|
||||
|
||||
@@ -41,6 +41,9 @@ type route struct {
|
||||
middleware []string
|
||||
constraints []Constraint
|
||||
raw bool
|
||||
// preflight marks an OPTIONS route addCORSPreflights registered for a
|
||||
// cors:<profile> path: wrapped only in recovery and recordPattern.
|
||||
preflight bool
|
||||
}
|
||||
|
||||
// Router compiles group declarations onto net/http ServeMux.
|
||||
@@ -56,6 +59,7 @@ type Router struct {
|
||||
compileErr error
|
||||
limiter *FixedWindowLimiter
|
||||
corsCfg CORSConfig
|
||||
corsProfiles map[string]*corsPolicy
|
||||
defaultBytes int64
|
||||
uploadBytes int64
|
||||
built map[string]pact.Middleware
|
||||
@@ -397,6 +401,11 @@ func handleRoute(mux *http.ServeMux, rt route, h http.Handler) (err error) {
|
||||
}
|
||||
|
||||
func (r *Router) wrap(rt route) (http.Handler, error) {
|
||||
if rt.preflight {
|
||||
// Preflights carry no credentials: no auth, throttle, body or locale.
|
||||
h := recoverBare(constrain(rt.handler, rt.constraints))
|
||||
return recordPattern(rt.method+" "+rt.path, h), nil
|
||||
}
|
||||
h := constrain(rt.handler, rt.constraints)
|
||||
limit, err := routeBodyLimit(rt, r.defaultBytes)
|
||||
if err != nil {
|
||||
@@ -423,6 +432,11 @@ func (r *Router) wrap(rt route) (http.Handler, error) {
|
||||
return nil, fmt.Errorf("surf: plugin %q: %w", rt.pluginID, err)
|
||||
}
|
||||
}
|
||||
if base == "cors" {
|
||||
if _, ok := r.corsProfiles[param]; !ok {
|
||||
return nil, fmt.Errorf("surf: plugin %q route %s %s references unknown CORS profile %q (define http.cors_profiles.%s)", rt.pluginID, rt.method, rt.path, param, param)
|
||||
}
|
||||
}
|
||||
if base == "body.limit" {
|
||||
if _, err := parseBodyLimit(param); err != nil {
|
||||
return nil, fmt.Errorf("surf: plugin %q: %w", rt.pluginID, err)
|
||||
@@ -500,12 +514,26 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) {
|
||||
if err := r.RegisterMiddleware("surf", "locale.from-principal", LocaleFromPrincipal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.corsProfiles = map[string]*corsPolicy{}
|
||||
if err := r.RegisterMiddlewareFactory("surf", "cors", func(param string) pact.Middleware {
|
||||
// wrap refuses an unknown profile before the factory runs.
|
||||
return r.corsProfiles[param].profileMiddleware
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if app != nil {
|
||||
corsCfg, err := LoadCORSConfig(app.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.corsCfg = corsCfg
|
||||
profiles, err := LoadCORSProfiles(app.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for name, pc := range profiles {
|
||||
r.corsProfiles[name] = newCORSPolicy(pc)
|
||||
}
|
||||
if app.Config != nil {
|
||||
d, err := requiredBytes(app, "http.body_limits.default_bytes")
|
||||
if err != nil {
|
||||
@@ -586,6 +614,9 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := r.addCORSPreflights(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -182,3 +182,83 @@ func TestCORSCredentialsWildcardRefusedSmoke(t *testing.T) {
|
||||
t.Fatalf("BuildRouter error = %v, want one naming http.cors", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSProfileSmoke(t *testing.T) {
|
||||
const www = "https://www.example.com"
|
||||
files := func() map[string]string {
|
||||
return map[string]string{"http.yaml": transportBodyLimits + `cors_profiles:
|
||||
spa:
|
||||
allowed_origins: ["https://www.example.com"]
|
||||
allowed_methods: ["GET", "POST"]
|
||||
allowed_headers: ["Content-Type"]
|
||||
supports_credentials: true
|
||||
`}
|
||||
}
|
||||
plugin := func(profile string) routesPluginFunc {
|
||||
return routesPluginFunc{id: "acme.spa", routes: func(r pact.Router) {
|
||||
r.Group("/x", Use("cors:"+profile), func(g pact.Router) {
|
||||
g.Get("/me", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
})
|
||||
}}
|
||||
}
|
||||
app := transportApp(t, "development", files())
|
||||
router, err := BuildRouter(app, []party.Plugin{plugin("spa")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listed := false
|
||||
for _, ri := range router.Routes() {
|
||||
if ri.Method == http.MethodOptions && ri.Pattern == "/x/me" && ri.PluginID == "surf" {
|
||||
listed = true
|
||||
}
|
||||
}
|
||||
if !listed {
|
||||
t.Errorf("route table lacks OPTIONS /x/me owned by surf: %+v", router.Routes())
|
||||
}
|
||||
h, err := router.compile()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pre := httptest.NewRequest(http.MethodOptions, "/x/me", nil)
|
||||
pre.Header.Set("Origin", www)
|
||||
pre.Header.Set("Access-Control-Request-Method", "GET")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, pre)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("preflight status = %d", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != www {
|
||||
t.Errorf("preflight Allow-Origin = %q", got)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
|
||||
t.Errorf("preflight Allow-Credentials = %q", got)
|
||||
}
|
||||
if !varyHas(rec.Header(), "Origin") {
|
||||
t.Errorf("preflight Vary = %q", rec.Header().Values("Vary"))
|
||||
}
|
||||
|
||||
get := httptest.NewRequest(http.MethodGet, "/x/me", nil)
|
||||
get.Header.Set("Origin", www)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, get)
|
||||
if rec.Code != http.StatusOK || rec.Header().Get("Access-Control-Allow-Origin") != www {
|
||||
t.Errorf("GET = %d, Allow-Origin %q", rec.Code, rec.Header().Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
|
||||
evil := httptest.NewRequest(http.MethodGet, "/x/me", nil)
|
||||
evil.Header.Set("Origin", "https://evil.example.org")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, evil)
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("disallowed origin Allow-Origin = %q", got)
|
||||
}
|
||||
if !varyHas(rec.Header(), "Origin") {
|
||||
t.Errorf("disallowed origin Vary = %q", rec.Header().Values("Vary"))
|
||||
}
|
||||
|
||||
_, err = BuildRouter(transportApp(t, "development", files()), []party.Plugin{plugin("missing")})
|
||||
if err == nil || !strings.Contains(err.Error(), "missing") {
|
||||
t.Fatalf("BuildRouter with cors:missing error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user