Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
601e6a5144
|
|||
|
9438983f32
|
|||
|
3c6636f905
|
54
CHANGELOG.md
54
CHANGELOG.md
@@ -5,6 +5,60 @@ All notable changes to this module will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.0.2] — 2026-05-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `ClaimsPermissionProvider.ResolveMask` now supports a wildcard resource key `"*"` as
|
||||||
|
a fallback when the requested resource is not present in the JWT masks claim. Specific
|
||||||
|
resource key takes precedence; wildcard is consulted only when the exact key is absent.
|
||||||
|
Primary use case: `ADMIN` role carries `{"*": MaxInt64}` — one JWT entry grants access
|
||||||
|
to every resource without per-resource enumeration.
|
||||||
|
- `ClaimsPermissionProvider.ResolveMask` now handles `json.Number` mask values in
|
||||||
|
addition to the existing `int64` and `float64` paths. `json.Number` is produced by
|
||||||
|
`jwt.WithJSONNumber()` (used in `httpauth-jwt`); it preserves `math.MaxInt64` exactly
|
||||||
|
where float64 would overflow.
|
||||||
|
- `WriteJSONError(w http.ResponseWriter, status int, code, message string)` — exported
|
||||||
|
helper that writes a structured JSON error body (`Content-Type: application/json`,
|
||||||
|
`{"code":"...","message":"..."}`). Intended for use by all `httpauth-*` provider
|
||||||
|
packages so the error format is enforced in one place.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `AuthzMiddleware` and `EnrichmentMiddleware` now return JSON error bodies instead of
|
||||||
|
plain-text `http.Error` responses. Error codes are stable gRPC-aligned names from
|
||||||
|
`xerrors`: `UNAUTHENTICATED` (401), `PERMISSION_DENIED` (403), `INTERNAL` (500).
|
||||||
|
Previously `AuthzMiddleware` also used the wrong code `UNAUTHORIZED` for 401 responses
|
||||||
|
(correct value is `UNAUTHENTICATED`).
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- Added `code.nochebuena.dev/go/xerrors v1.0.1` (for code constants in error responses).
|
||||||
|
|
||||||
|
[1.0.2]: https://code.nochebuena.dev/go/httpauth/releases/tag/v1.0.2
|
||||||
|
|
||||||
|
## [1.0.0] — 2026-05-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `NewChainPermissionProvider(providers ...rbac.PermissionProvider) rbac.PermissionProvider` —
|
||||||
|
tries each provider in order and returns the first non-zero mask; propagates errors
|
||||||
|
immediately without consulting subsequent providers; primary use case is a JWT claims
|
||||||
|
fast-path (`ClaimsPermissionProvider`) chained with a DB-backed fallback
|
||||||
|
(`CachedPermissionProvider`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Dependency `code.nochebuena.dev/go/rbac` bumped from v0.9.0 to v1.0.0
|
||||||
|
|
||||||
|
### Unchanged
|
||||||
|
|
||||||
|
`SetTokenData`, `EnrichmentMiddleware`, `AuthzMiddleware`, `IdentityEnricher`,
|
||||||
|
`WithTenantHeader`, `Cache`, `NewClaimsPermissionProvider`, and
|
||||||
|
`NewCachedPermissionProvider` are API-compatible with v0.1.0.
|
||||||
|
|
||||||
|
[1.0.0]: https://code.nochebuena.dev/go/httpauth/releases/tag/v1.0.0
|
||||||
|
|
||||||
## [0.1.0] - 2026-05-08
|
## [0.1.0] - 2026-05-08
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
18
authz.go
18
authz.go
@@ -1,9 +1,11 @@
|
|||||||
package httpauth
|
package httpauth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"code.nochebuena.dev/go/rbac"
|
"code.nochebuena.dev/go/rbac"
|
||||||
|
"code.nochebuena.dev/go/xerrors"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthzMiddleware reads the rbac.Identity from context (set by EnrichmentMiddleware)
|
// AuthzMiddleware reads the rbac.Identity from context (set by EnrichmentMiddleware)
|
||||||
@@ -16,13 +18,13 @@ func AuthzMiddleware(provider rbac.PermissionProvider, resource string, required
|
|||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
identity, ok := rbac.FromContext(r.Context())
|
identity, ok := rbac.FromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
WriteJSONError(w, http.StatusUnauthorized, string(xerrors.ErrUnauthorized), "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
mask, err := provider.ResolveMask(r.Context(), identity.UID, resource)
|
mask, err := provider.ResolveMask(r.Context(), identity.UID, resource)
|
||||||
if err != nil || !mask.Has(required) {
|
if err != nil || !mask.Has(required) {
|
||||||
http.Error(w, "forbidden", http.StatusForbidden)
|
WriteJSONError(w, http.StatusForbidden, string(xerrors.ErrPermissionDenied), "forbidden")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,3 +32,15 @@ func AuthzMiddleware(provider rbac.PermissionProvider, resource string, required
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WriteJSONError writes a structured JSON error body matching the httputil.Error format.
|
||||||
|
// Used by all httpauth-* provider packages to ensure a consistent error format across
|
||||||
|
// the full middleware stack.
|
||||||
|
func WriteJSONError(w http.ResponseWriter, status int, code, message string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
39
chain_provider.go
Normal file
39
chain_provider.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package httpauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"code.nochebuena.dev/go/rbac"
|
||||||
|
)
|
||||||
|
|
||||||
|
type chainPermissionProvider struct {
|
||||||
|
providers []rbac.PermissionProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewChainPermissionProvider returns an rbac.PermissionProvider that tries each
|
||||||
|
// provider in order and returns the first non-zero mask. If all providers return 0,
|
||||||
|
// the result is 0. On error from any provider, the error is propagated immediately
|
||||||
|
// and subsequent providers are not consulted.
|
||||||
|
//
|
||||||
|
// Primary use case: JWT claims fast-path with DB fallback.
|
||||||
|
//
|
||||||
|
// chain := httpauth.NewChainPermissionProvider(
|
||||||
|
// httpauth.NewClaimsPermissionProvider("permisos"), // JWT claims — no DB call
|
||||||
|
// httpauth.NewCachedPermissionProvider(dbProvider, cache, 5*time.Minute), // fallback
|
||||||
|
// )
|
||||||
|
func NewChainPermissionProvider(providers ...rbac.PermissionProvider) rbac.PermissionProvider {
|
||||||
|
return &chainPermissionProvider{providers: providers}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chainPermissionProvider) ResolveMask(ctx context.Context, uid, resource string) (rbac.PermissionMask, error) {
|
||||||
|
for _, p := range c.providers {
|
||||||
|
mask, err := p.ResolveMask(ctx, uid, resource)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if mask != 0 {
|
||||||
|
return mask, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package httpauth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
"code.nochebuena.dev/go/rbac"
|
"code.nochebuena.dev/go/rbac"
|
||||||
)
|
)
|
||||||
@@ -25,15 +26,24 @@ func (p *claimsPermissionProvider) ResolveMask(ctx context.Context, _, resource
|
|||||||
if !ok {
|
if !ok {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
permisos, ok := claims[p.claimsKey].(map[string]any)
|
masks, ok := claims[p.claimsKey].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
switch v := permisos[resource].(type) {
|
// Check specific resource first, then wildcard fallback.
|
||||||
case int64:
|
for _, key := range []string{resource, "*"} {
|
||||||
return rbac.PermissionMask(v), nil
|
switch v := masks[key].(type) {
|
||||||
case float64:
|
case int64:
|
||||||
return rbac.PermissionMask(int64(v)), nil
|
return rbac.PermissionMask(v), nil
|
||||||
|
case float64:
|
||||||
|
return rbac.PermissionMask(int64(v)), nil
|
||||||
|
case json.Number:
|
||||||
|
n, err := v.Int64()
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return rbac.PermissionMask(n), nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"code.nochebuena.dev/go/rbac"
|
"code.nochebuena.dev/go/rbac"
|
||||||
|
"code.nochebuena.dev/go/xerrors"
|
||||||
)
|
)
|
||||||
|
|
||||||
// IdentityEnricher builds an rbac.Identity from verified token data.
|
// IdentityEnricher builds an rbac.Identity from verified token data.
|
||||||
@@ -42,7 +43,7 @@ func EnrichmentMiddleware(enricher IdentityEnricher, opts ...EnrichOpt) func(htt
|
|||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
uid, ok := getUID(r.Context())
|
uid, ok := getUID(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
WriteJSONError(w, http.StatusUnauthorized, string(xerrors.ErrUnauthorized), "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ func EnrichmentMiddleware(enricher IdentityEnricher, opts ...EnrichOpt) func(htt
|
|||||||
|
|
||||||
identity, err := enricher.Enrich(r.Context(), uid, claims)
|
identity, err := enricher.Enrich(r.Context(), uid, claims)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
WriteJSONError(w, http.StatusInternalServerError, string(xerrors.ErrInternal), "internal server error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
7
go.mod
7
go.mod
@@ -1,5 +1,8 @@
|
|||||||
module code.nochebuena.dev/go/httpauth
|
module code.nochebuena.dev/go/httpauth
|
||||||
|
|
||||||
go 1.25
|
go 1.26
|
||||||
|
|
||||||
require code.nochebuena.dev/go/rbac v0.9.0
|
require (
|
||||||
|
code.nochebuena.dev/go/rbac v1.0.0
|
||||||
|
code.nochebuena.dev/go/xerrors v1.0.1
|
||||||
|
)
|
||||||
|
|||||||
6
go.sum
6
go.sum
@@ -1,2 +1,4 @@
|
|||||||
code.nochebuena.dev/go/rbac v0.9.0 h1:2fQngWIOeluIaMmo+H2ajT0NVw8GjNFJVi6pbdB3f/o=
|
code.nochebuena.dev/go/rbac v1.0.0 h1:FnsU1HU6vvwchKuZNxDa9RPIFeNwJi0vShWvHKABMws=
|
||||||
code.nochebuena.dev/go/rbac v0.9.0/go.mod h1:LzW8tTJmdbu6HHN26NZZ3HzzdlZAd1sp6aml25Cfz5c=
|
code.nochebuena.dev/go/rbac v1.0.0/go.mod h1:LzW8tTJmdbu6HHN26NZZ3HzzdlZAd1sp6aml25Cfz5c=
|
||||||
|
code.nochebuena.dev/go/xerrors v1.0.1 h1:fgXoabY/ZwxAzaM1sKFf3sbL7ZHWyDxItB/rdbnl0mo=
|
||||||
|
code.nochebuena.dev/go/xerrors v1.0.1/go.mod h1:03MMVfrhaf4XmTMgMrEUFCmuZPGHUCKDitiQvwCuwvY=
|
||||||
|
|||||||
159
httpauth_test.go
159
httpauth_test.go
@@ -2,7 +2,9 @@ package httpauth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -299,3 +301,160 @@ func TestCachedPermissionProvider_InnerError(t *testing.T) {
|
|||||||
t.Error("expected error from inner, got nil")
|
t.Error("expected error from inner, got nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- ChainPermissionProvider ---
|
||||||
|
|
||||||
|
func TestChainPermissionProvider_FirstNonZero(t *testing.T) {
|
||||||
|
first := &mockProvider{mask: 515}
|
||||||
|
second := &mockProvider{mask: 999}
|
||||||
|
p := NewChainPermissionProvider(first, second)
|
||||||
|
mask, err := p.ResolveMask(context.Background(), "uid1", "usuarios")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if mask != 515 {
|
||||||
|
t.Errorf("want 515 from first provider, got %d", mask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChainPermissionProvider_Fallthrough(t *testing.T) {
|
||||||
|
first := &mockProvider{mask: 0}
|
||||||
|
second := &mockProvider{mask: 42}
|
||||||
|
p := NewChainPermissionProvider(first, second)
|
||||||
|
mask, err := p.ResolveMask(context.Background(), "uid1", "usuarios")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if mask != 42 {
|
||||||
|
t.Errorf("want 42 from second provider, got %d", mask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChainPermissionProvider_AllZero(t *testing.T) {
|
||||||
|
p := NewChainPermissionProvider(&mockProvider{mask: 0}, &mockProvider{mask: 0})
|
||||||
|
mask, err := p.ResolveMask(context.Background(), "uid1", "usuarios")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if mask != 0 {
|
||||||
|
t.Errorf("want 0 when all providers return 0, got %d", mask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChainPermissionProvider_ErrorPropagates(t *testing.T) {
|
||||||
|
first := &mockProvider{err: errors.New("db error")}
|
||||||
|
second := &mockProvider{mask: 42}
|
||||||
|
p := NewChainPermissionProvider(first, second)
|
||||||
|
_, err := p.ResolveMask(context.Background(), "uid1", "usuarios")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error from first provider, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ClaimsPermissionProvider wildcard + json.Number ---
|
||||||
|
|
||||||
|
func TestClaimsPermissionProvider_WildcardFallback(t *testing.T) {
|
||||||
|
p := NewClaimsPermissionProvider("permisos")
|
||||||
|
ctx := SetTokenData(context.Background(), "uid1", map[string]any{
|
||||||
|
"permisos": map[string]any{"*": int64(515)},
|
||||||
|
})
|
||||||
|
mask, err := p.ResolveMask(ctx, "uid1", "cualquier_recurso")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if mask != 515 {
|
||||||
|
t.Errorf("want 515 from wildcard, got %d", mask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimsPermissionProvider_SpecificBeatsWildcard(t *testing.T) {
|
||||||
|
p := NewClaimsPermissionProvider("permisos")
|
||||||
|
ctx := SetTokenData(context.Background(), "uid1", map[string]any{
|
||||||
|
"permisos": map[string]any{
|
||||||
|
"system": int64(42),
|
||||||
|
"*": int64(999),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
mask, err := p.ResolveMask(ctx, "uid1", "system")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if mask != 42 {
|
||||||
|
t.Errorf("want 42 from specific key, got %d (wildcard must not override specific)", mask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimsPermissionProvider_JSONNumber(t *testing.T) {
|
||||||
|
p := NewClaimsPermissionProvider("permisos")
|
||||||
|
ctx := SetTokenData(context.Background(), "uid1", map[string]any{
|
||||||
|
"permisos": map[string]any{"system": json.Number("9223372036854775807")},
|
||||||
|
})
|
||||||
|
mask, err := p.ResolveMask(ctx, "uid1", "system")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if int64(mask) != math.MaxInt64 {
|
||||||
|
t.Errorf("want MaxInt64 (%d), got %d", int64(math.MaxInt64), int64(mask))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- JSON error body assertions ---
|
||||||
|
|
||||||
|
func assertJSONError(t *testing.T, rec *httptest.ResponseRecorder, wantStatus int, wantCode string) {
|
||||||
|
t.Helper()
|
||||||
|
if rec.Code != wantStatus {
|
||||||
|
t.Errorf("want status %d, got %d", wantStatus, rec.Code)
|
||||||
|
}
|
||||||
|
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||||
|
t.Errorf("want Content-Type application/json, got %q", ct)
|
||||||
|
}
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatalf("response body is not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
if body["code"] != wantCode {
|
||||||
|
t.Errorf("want code %q, got %q", wantCode, body["code"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthzMiddleware_ForbiddenJSON(t *testing.T) {
|
||||||
|
mp := &mockProvider{mask: rbac.PermissionMask(0)}
|
||||||
|
h := AuthzMiddleware(mp, "resource", testPerm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
ctx := rbac.SetInContext(req.Context(), rbac.NewIdentity("uid1", "", ""))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req.WithContext(ctx))
|
||||||
|
assertJSONError(t, rec, http.StatusForbidden, "PERMISSION_DENIED")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthzMiddleware_UnauthorizedJSON(t *testing.T) {
|
||||||
|
mp := &mockProvider{}
|
||||||
|
h := AuthzMiddleware(mp, "resource", testPerm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
assertJSONError(t, rec, http.StatusUnauthorized, "UNAUTHENTICATED")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrichmentMiddleware_NoUIDJSON(t *testing.T) {
|
||||||
|
h := EnrichmentMiddleware(&mockEnricher{})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
assertJSONError(t, rec, http.StatusUnauthorized, "UNAUTHENTICATED")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrichmentMiddleware_EnricherErrorJSON(t *testing.T) {
|
||||||
|
me := &mockEnricher{err: errors.New("db error")}
|
||||||
|
inner := EnrichmentMiddleware(me)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
h := injectTokenData("uid1", nil, inner)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
assertJSONError(t, rec, http.StatusInternalServerError, "INTERNAL")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user