|
| 1 | +package routes |
| 2 | + |
| 3 | +import ( |
| 4 | + "fmt" |
| 5 | + "strings" |
| 6 | + "testing" |
| 7 | + |
| 8 | + "github.com/shellhub-io/shellhub/server/api/pkg/gateway" |
| 9 | + "github.com/stretchr/testify/assert" |
| 10 | + "github.com/stretchr/testify/require" |
| 11 | +) |
| 12 | + |
| 13 | +// unstatedClaims names every declaration that takes an exception to the default rules without |
| 14 | +// saying why. A reason is a required argument, so it cannot be omitted — but it can be left empty, |
| 15 | +// and this is what refuses that. |
| 16 | +func unstatedClaims(declarations []gateway.Declaration) []string { |
| 17 | + unstated := make([]string, 0) |
| 18 | + |
| 19 | + for _, declaration := range declarations { |
| 20 | + if declaration.Unbounded && strings.TrimSpace(declaration.UnboundedReason) == "" { |
| 21 | + unstated = append(unstated, fmt.Sprintf("%s reads across namespaces and states no reason", declaration.Handler)) |
| 22 | + } |
| 23 | + |
| 24 | + if declaration.Anonymous && strings.TrimSpace(declaration.AnonymousReason) == "" { |
| 25 | + unstated = append(unstated, fmt.Sprintf("%s requires no actor and states no reason", declaration.Handler)) |
| 26 | + } |
| 27 | + } |
| 28 | + |
| 29 | + return unstated |
| 30 | +} |
| 31 | + |
| 32 | +// TestRouteTableStatesEveryClaim reads the claims the route table made while it was built. A route |
| 33 | +// that reads across namespaces, or that needs no actor, has to say why — otherwise breadth and |
| 34 | +// anonymity arrive by omission, which is what the two claims exist to prevent. |
| 35 | +func TestRouteTableStatesEveryClaim(t *testing.T) { |
| 36 | + authenticatedRouter(t) |
| 37 | + |
| 38 | + declarations := gateway.Declarations() |
| 39 | + require.NotEmpty(t, declarations, "the route table registered no wrapped route") |
| 40 | + |
| 41 | + assert.Empty(t, unstatedClaims(declarations)) |
| 42 | +} |
| 43 | + |
| 44 | +// TestUnstatedClaimsRefusesAnEmptyReason proves the check above bites, rather than passing because |
| 45 | +// it looks at nothing. |
| 46 | +func TestUnstatedClaimsRefusesAnEmptyReason(t *testing.T) { |
| 47 | + unstated := unstatedClaims([]gateway.Declaration{ |
| 48 | + {Handler: "silent", Unbounded: true, Anonymous: true}, |
| 49 | + {Handler: "stated", Unbounded: true, UnboundedReason: "because"}, |
| 50 | + }) |
| 51 | + |
| 52 | + require.Len(t, unstated, 2) |
| 53 | + for _, complaint := range unstated { |
| 54 | + assert.Contains(t, complaint, "silent") |
| 55 | + } |
| 56 | +} |
| 57 | + |
| 58 | +// wrapperExemptRoutes is the set of routes that are not API resource operations and so keep a |
| 59 | +// direct registration instead of one of the gateway shapes. |
| 60 | +// |
| 61 | +// The install script serves a shell script rather than JSON. The local user authentication |
| 62 | +// exchange derives three non-200 outcomes from values that are not errors, and sets two headers |
| 63 | +// the console reads. The tag creation handler returns its identifier in a response header, and |
| 64 | +// leaves this set once that identifier moves into the response body. |
| 65 | +// |
| 66 | +// Echo does not expose a route's handler, so no test can prove that a route outside this set went |
| 67 | +// through a wrapper. What the set buys is the other direction: joining it is a visible edit here, |
| 68 | +// where a reviewer reads why each member is exempt. |
| 69 | +var wrapperExemptRoutes = []string{ |
| 70 | + "GET /api/install", |
| 71 | + "POST /api/login", |
| 72 | + "POST /api/auth/user", |
| 73 | + "POST /api/tags", |
| 74 | + "POST /api/namespaces/:tenant/tags", |
| 75 | +} |
| 76 | + |
| 77 | +// TestWrapperExemptRoutesAreRegistered catches a stale member: an exempt route that no longer |
| 78 | +// exists, or was renamed, leaves the set claiming an exemption for nothing. |
| 79 | +func TestWrapperExemptRoutesAreRegistered(t *testing.T) { |
| 80 | + router, _, _ := authenticatedRouter(t) |
| 81 | + |
| 82 | + registered := make(map[string]struct{}) |
| 83 | + for _, route := range router.Router().Routes() { |
| 84 | + registered[route.Method+" "+route.Path] = struct{}{} |
| 85 | + } |
| 86 | + |
| 87 | + for _, exempt := range wrapperExemptRoutes { |
| 88 | + assert.Contains(t, registered, exempt, "the exempt set names %q but no such route is registered", exempt) |
| 89 | + } |
| 90 | +} |
| 91 | + |
| 92 | +// methodName reduces the fully qualified name the runtime reports for a method value to the method |
| 93 | +// itself, so a test can name a handler the way the route table does. |
| 94 | +func methodName(qualified string) string { |
| 95 | + return strings.TrimSuffix(qualified[strings.LastIndex(qualified, ".")+1:], "-fm") |
| 96 | +} |
| 97 | + |
| 98 | +// TestConvertedRoutesDeclareTheirShape pins the three routes this change converted: each answers |
| 99 | +// with the shape it was registered under, and each is mounted at the address it claims. |
| 100 | +// |
| 101 | +// The second half is what keeps the declaration honest. Echo does not expose a route's handler, so |
| 102 | +// a declaration cannot be matched to its route in general — but for a named handler at a known |
| 103 | +// address, asserting both is enough to rule out a claim recorded by a wrapper nothing mounted. |
| 104 | +func TestConvertedRoutesDeclareTheirShape(t *testing.T) { |
| 105 | + router, _, _ := authenticatedRouter(t) |
| 106 | + |
| 107 | + registered := make(map[string]struct{}) |
| 108 | + for _, route := range router.Router().Routes() { |
| 109 | + registered[route.Method+" "+route.Path] = struct{}{} |
| 110 | + } |
| 111 | + |
| 112 | + shapes := make(map[string]gateway.Shape) |
| 113 | + for _, declaration := range gateway.Declarations() { |
| 114 | + shapes[methodName(declaration.Handler)] = declaration.Shape |
| 115 | + } |
| 116 | + |
| 117 | + converted := []struct { |
| 118 | + handler string |
| 119 | + shape gateway.Shape |
| 120 | + route string |
| 121 | + }{ |
| 122 | + {handler: "EvaluateHealth", shape: gateway.ShapeNone, route: "GET /api" + HealthCheckURL}, |
| 123 | + {handler: "GetDevice", shape: gateway.ShapeOne, route: "GET /api" + GetDeviceURL}, |
| 124 | + {handler: "GetDeviceList", shape: gateway.ShapeList, route: "GET /api" + GetDeviceListURL}, |
| 125 | + } |
| 126 | + |
| 127 | + for _, tc := range converted { |
| 128 | + t.Run(tc.handler, func(tt *testing.T) { |
| 129 | + declared, found := shapes[tc.handler] |
| 130 | + |
| 131 | + require.True(tt, found, "%s is not registered through a gateway shape", tc.handler) |
| 132 | + assert.Equal(tt, tc.shape, declared, "%s answers with the wrong shape", tc.handler) |
| 133 | + assert.Contains(tt, registered, tc.route, "%s declares a shape but is not mounted", tc.handler) |
| 134 | + }) |
| 135 | + } |
| 136 | +} |
0 commit comments