mirror of
https://github.com/tinyauthapp/tinyauth.git
synced 2026-09-23 11:13:32 +08:00
The app name fallback matches any domain that starts with the app name, so an app named myapp served on myapp.example.com also defined the ACLs of myapp.evil.com. Behind a proxy with a catch-all route, a request can be authorized against the wrong app that way. Label providers now receive the domain being authorized. The Kubernetes provider keeps the hosts of every Ingress, HTTPRoute and GRPCRoute it watches and withholds the apps of the resources that do not route the domain, which bounds the name fallback to the hosts a resource actually serves. Wildcard hostnames keep matching as a suffix, so nested subdomains stay resolvable by app name. Container labels carry no routing information, so the Docker provider cannot narrow its results down and keeps yielding every app. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1263 lines
36 KiB
Go
1263 lines
36 KiB
Go
package service
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/tinyauthapp/tinyauth/internal/model"
|
|
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
|
)
|
|
|
|
func mustWatchedResource(resource string) watchedResource {
|
|
for _, res := range watchedResources {
|
|
if res.gvr.Resource == resource {
|
|
return res
|
|
}
|
|
}
|
|
panic("unknown watched resource: " + resource)
|
|
}
|
|
|
|
var (
|
|
testIngressResource = mustWatchedResource("ingresses")
|
|
testHTTPRouteResource = mustWatchedResource("httproutes")
|
|
testGRPCRouteResource = mustWatchedResource("grpcroutes")
|
|
)
|
|
|
|
// aclLocator mimics the way the access controls service matches apps, first on
|
|
// the configured domain and then on the app name.
|
|
func aclLocator(domain string, got **model.App) func(name string, app *model.App) bool {
|
|
return func(name string, app *model.App) bool {
|
|
if app.Config.Domain == domain {
|
|
*got = app
|
|
return true
|
|
}
|
|
if strings.HasPrefix(strings.ToLower(domain), strings.ToLower(name+".")) {
|
|
*got = app
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
func TestKubernetesService(t *testing.T) {
|
|
log := logger.NewLogger().WithTestConfig()
|
|
log.Init()
|
|
|
|
type testCase struct {
|
|
description string
|
|
run func(t *testing.T, svc *KubernetesService)
|
|
}
|
|
|
|
tests := []testCase{
|
|
{
|
|
description: "Cache by domain returns app and misses unknown domain",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
app := model.App{Config: model.AppConfig{Domain: "foo.example.com"}}
|
|
svc.addResourceEntries(resourceKey{
|
|
resource: "ingresses",
|
|
namespace: "default",
|
|
name: "my-ingress",
|
|
}, []string{"foo.example.com"}, []resourceEntry{
|
|
{
|
|
app: app,
|
|
name: "foo",
|
|
},
|
|
})
|
|
|
|
var got *model.App
|
|
svc.getEntry("foo.example.com", func(name string, app *model.App) bool {
|
|
if app.Config.Domain == "foo.example.com" {
|
|
got = app
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
require.NotNil(t, got)
|
|
assert.Equal(t, "foo.example.com", got.Config.Domain)
|
|
|
|
got = nil
|
|
svc.getEntry("unknown.example.com", func(name string, app *model.App) bool {
|
|
got = app
|
|
return true
|
|
})
|
|
assert.Nil(t, got)
|
|
},
|
|
},
|
|
{
|
|
description: "RemoveResource clears domain and app name entries",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
key := resourceKey{
|
|
resource: "ingresses",
|
|
namespace: "default",
|
|
name: "my-ingress",
|
|
}
|
|
|
|
app := model.App{Config: model.AppConfig{Domain: "foo.example.com"}}
|
|
svc.addResourceEntries(key, []string{"foo.example.com"}, []resourceEntry{
|
|
{
|
|
app: app,
|
|
name: "foo",
|
|
},
|
|
})
|
|
|
|
var got *model.App
|
|
svc.getEntry("foo.example.com", func(name string, app *model.App) bool {
|
|
if app.Config.Domain == "foo.example.com" {
|
|
got = app
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
require.NotNil(t, got)
|
|
assert.Equal(t, "foo.example.com", got.Config.Domain)
|
|
|
|
got = nil
|
|
svc.removeResource(key)
|
|
|
|
svc.getEntry("foo.example.com", func(name string, app *model.App) bool {
|
|
if app.Config.Domain == "foo.example.com" {
|
|
got = app
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
assert.Nil(t, got)
|
|
},
|
|
},
|
|
{
|
|
description: "AddResourceEntries replaces stale entries for the same resource",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
key := resourceKey{
|
|
resource: "ingresses",
|
|
namespace: "default",
|
|
name: "my-ingress",
|
|
}
|
|
|
|
old := model.App{Config: model.AppConfig{Domain: "old.example.com"}}
|
|
svc.addResourceEntries(key, []string{"old.example.com"}, []resourceEntry{
|
|
{
|
|
app: old,
|
|
name: "foo",
|
|
},
|
|
})
|
|
|
|
updated := model.App{Config: model.AppConfig{Domain: "new.example.com"}}
|
|
svc.addResourceEntries(key, []string{"new.example.com"}, []resourceEntry{
|
|
{
|
|
app: updated,
|
|
name: "foo",
|
|
},
|
|
})
|
|
|
|
var got *model.App
|
|
svc.getEntry("old.example.com", func(name string, app *model.App) bool {
|
|
if app.Config.Domain == "old.example.com" {
|
|
got = app
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
assert.Nil(t, got)
|
|
|
|
svc.getEntry("new.example.com", func(name string, app *model.App) bool {
|
|
if app.Config.Domain == "new.example.com" {
|
|
got = app
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
require.NotNil(t, got)
|
|
assert.Equal(t, "new.example.com", got.Config.Domain)
|
|
},
|
|
},
|
|
{
|
|
description: "Resources of different kinds with the same name do not clobber each other",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
ingress := unstructured.Unstructured{}
|
|
ingress.SetNamespace("default")
|
|
ingress.SetName("shared")
|
|
ingress.SetAnnotations(map[string]string{
|
|
"tinyauth.apps.ingapp.config.domain": "ingapp.example.com",
|
|
})
|
|
require.NoError(t, unstructured.SetNestedSlice(ingress.Object, []any{
|
|
map[string]any{
|
|
"host": "ingapp.example.com",
|
|
},
|
|
}, "spec", "rules"))
|
|
|
|
httpRoute := unstructured.Unstructured{}
|
|
httpRoute.SetNamespace("default")
|
|
httpRoute.SetName("shared")
|
|
httpRoute.SetAnnotations(map[string]string{
|
|
"tinyauth.apps.gwapp.config.domain": "gwapp.example.com",
|
|
})
|
|
require.NoError(t, unstructured.SetNestedStringSlice(httpRoute.Object, []string{
|
|
"gwapp.example.com",
|
|
}, "spec", "hostnames"))
|
|
|
|
svc.updateFromItem(testIngressResource, &ingress)
|
|
svc.updateFromItem(testHTTPRouteResource, &httpRoute)
|
|
|
|
var got *model.App
|
|
svc.getEntry("ingapp.example.com", func(name string, app *model.App) bool {
|
|
if app.Config.Domain == "ingapp.example.com" {
|
|
got = app
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
require.NotNil(t, got)
|
|
|
|
got = nil
|
|
svc.getEntry("gwapp.example.com", func(name string, app *model.App) bool {
|
|
if app.Config.Domain == "gwapp.example.com" {
|
|
got = app
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
require.NotNil(t, got)
|
|
},
|
|
},
|
|
{
|
|
description: "GetLabels returns app from cache when connected",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
svc.connected = true
|
|
|
|
app := model.App{Config: model.AppConfig{Domain: "hit.example.com"}}
|
|
svc.addResourceEntries(resourceKey{
|
|
resource: "ingresses",
|
|
namespace: "default",
|
|
name: "my-ingress",
|
|
}, []string{"hit.example.com"}, []resourceEntry{
|
|
{
|
|
app: app,
|
|
name: "foo",
|
|
},
|
|
})
|
|
|
|
var got *model.App
|
|
err := svc.Lookup("hit.example.com", func(name string, app *model.App) bool {
|
|
if app.Config.Domain == "hit.example.com" {
|
|
got = app
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, got)
|
|
assert.Equal(t, "hit.example.com", got.Config.Domain)
|
|
},
|
|
},
|
|
{
|
|
description: "GetLabels returns empty app on cache miss when started",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
svc.connected = true
|
|
|
|
var got *model.App
|
|
err := svc.Lookup("notfound.example.com", func(name string, app *model.App) bool {
|
|
if app.Config.Domain == "notfound.example.com" {
|
|
got = app
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
require.NoError(t, err)
|
|
require.Nil(t, got)
|
|
},
|
|
},
|
|
{
|
|
description: "GetLabels resolves app by app name",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
svc.connected = true
|
|
|
|
app := model.App{Path: model.AppPath{Allow: "/foo"}}
|
|
svc.addResourceEntries(resourceKey{
|
|
resource: "ingresses",
|
|
namespace: "default",
|
|
name: "my-ingress",
|
|
}, []string{"foo.internal.example.com"}, []resourceEntry{
|
|
{
|
|
app: app,
|
|
name: "foo",
|
|
},
|
|
})
|
|
|
|
var got *model.App
|
|
err := svc.Lookup("foo.internal.example.com", aclLocator("foo.internal.example.com", &got))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, got)
|
|
assert.Equal(t, "/foo", got.Path.Allow)
|
|
},
|
|
},
|
|
{
|
|
description: "GetLabels returns empty app when service not yet started",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
app := model.App{Config: model.AppConfig{Domain: "hit.example.com"}}
|
|
svc.addResourceEntries(resourceKey{
|
|
resource: "ingresses",
|
|
namespace: "default",
|
|
name: "my-ingress",
|
|
}, []string{"hit.example.com"}, []resourceEntry{
|
|
{
|
|
app: app,
|
|
name: "foo",
|
|
},
|
|
})
|
|
|
|
var got *model.App
|
|
err := svc.Lookup("hit.example.com", func(name string, app *model.App) bool {
|
|
got = app
|
|
return true
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Nil(t, got)
|
|
},
|
|
},
|
|
{
|
|
description: "Lookup withholds apps that are served on another host",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
svc.connected = true
|
|
|
|
item := unstructured.Unstructured{}
|
|
item.SetNamespace("default")
|
|
item.SetName("test-ingress")
|
|
item.SetAnnotations(map[string]string{
|
|
"tinyauth.apps.myapp.users.allow": "alice",
|
|
})
|
|
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
|
|
map[string]any{
|
|
"host": "myapp.example.com",
|
|
},
|
|
}, "spec", "rules"))
|
|
|
|
svc.updateFromItem(testIngressResource, &item)
|
|
|
|
// The app is served on myapp.example.com, so it must not be
|
|
// able to define the ACLs of a look-alike domain it does not
|
|
// route just because the name happens to prefix it
|
|
var got *model.App
|
|
err := svc.Lookup("myapp.evil.com", aclLocator("myapp.evil.com", &got))
|
|
require.NoError(t, err)
|
|
assert.Nil(t, got)
|
|
|
|
err = svc.Lookup("myapp.example.com", aclLocator("myapp.example.com", &got))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, got)
|
|
assert.Equal(t, "alice", got.Users.Allow)
|
|
},
|
|
},
|
|
{
|
|
description: "Lookup yields apps for any domain covered by a wildcard host",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
svc.connected = true
|
|
|
|
item := unstructured.Unstructured{}
|
|
item.SetNamespace("default")
|
|
item.SetName("test-httproute")
|
|
item.SetAnnotations(map[string]string{
|
|
"tinyauth.apps.myapp.users.allow": "alice",
|
|
})
|
|
require.NoError(t, unstructured.SetNestedStringSlice(item.Object, []string{
|
|
"*.example.com",
|
|
}, "spec", "hostnames"))
|
|
|
|
svc.updateFromItem(testHTTPRouteResource, &item)
|
|
|
|
// A wildcard is a suffix match, so nested subdomains stay
|
|
// resolvable by app name
|
|
var got *model.App
|
|
err := svc.Lookup("myapp.sub.example.com", aclLocator("myapp.sub.example.com", &got))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, got)
|
|
assert.Equal(t, "alice", got.Users.Allow)
|
|
|
|
got = nil
|
|
err = svc.Lookup("myapp.example.net", aclLocator("myapp.example.net", &got))
|
|
require.NoError(t, err)
|
|
assert.Nil(t, got)
|
|
},
|
|
},
|
|
{
|
|
description: "Lookup ignores the port of the domain",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
svc.connected = true
|
|
|
|
app := model.App{Config: model.AppConfig{Domain: "myapp.example.com"}}
|
|
svc.addResourceEntries(resourceKey{
|
|
resource: "ingresses",
|
|
namespace: "default",
|
|
name: "my-ingress",
|
|
}, []string{"myapp.example.com"}, []resourceEntry{
|
|
{
|
|
app: app,
|
|
name: "myapp",
|
|
},
|
|
})
|
|
|
|
var got *model.App
|
|
err := svc.Lookup("myapp.example.com:8443", func(name string, app *model.App) bool {
|
|
got = app
|
|
return true
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, got)
|
|
},
|
|
},
|
|
{
|
|
description: "Lookup skips an invalid domain",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
svc.connected = true
|
|
|
|
app := model.App{Config: model.AppConfig{Domain: "myapp.example.com"}}
|
|
svc.addResourceEntries(resourceKey{
|
|
resource: "ingresses",
|
|
namespace: "default",
|
|
name: "my-ingress",
|
|
}, []string{"myapp.example.com"}, []resourceEntry{
|
|
{
|
|
app: app,
|
|
name: "myapp",
|
|
},
|
|
})
|
|
|
|
var got *model.App
|
|
err := svc.Lookup("not a domain", func(name string, app *model.App) bool {
|
|
got = app
|
|
return true
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Nil(t, got)
|
|
},
|
|
},
|
|
{
|
|
description: "UpdateFromItem parses annotations and populates cache",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
item := unstructured.Unstructured{}
|
|
item.SetNamespace("default")
|
|
item.SetName("test-ingress")
|
|
item.SetAnnotations(map[string]string{
|
|
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
|
|
"tinyauth.apps.myapp.users.allow": "alice",
|
|
})
|
|
item.Object["spec"] = map[string]any{
|
|
"rules": []any{
|
|
map[string]any{
|
|
"host": "myapp.example.com",
|
|
},
|
|
},
|
|
}
|
|
|
|
svc.updateFromItem(testIngressResource, &item)
|
|
|
|
var got *model.App
|
|
svc.getEntry("myapp.example.com", func(name string, app *model.App) bool {
|
|
if app.Config.Domain == "myapp.example.com" {
|
|
got = app
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
|
|
require.NotNil(t, got)
|
|
assert.Equal(t, "myapp.example.com", got.Config.Domain)
|
|
assert.Equal(t, "alice", got.Users.Allow)
|
|
},
|
|
},
|
|
{
|
|
description: "Update from item skips annotations with no hosts",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
item := unstructured.Unstructured{}
|
|
item.SetNamespace("default")
|
|
item.SetName("test-ingress")
|
|
item.SetAnnotations(map[string]string{
|
|
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
|
|
})
|
|
|
|
svc.updateFromItem(testIngressResource, &item)
|
|
|
|
var got *model.App
|
|
svc.getEntry("myapp.example.com", func(name string, app *model.App) bool {
|
|
if app.Config.Domain == "myapp.example.com" {
|
|
got = app
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
assert.Nil(t, got)
|
|
},
|
|
},
|
|
{
|
|
description: "UpdateFromItem fails when label parsing fails",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
item := unstructured.Unstructured{}
|
|
item.SetNamespace("default")
|
|
item.SetName("test-ingress")
|
|
item.SetAnnotations(map[string]string{
|
|
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
|
|
"tinyauth.apps.myapp.users.break": "i-dont-exist",
|
|
})
|
|
item.Object["spec"] = map[string]any{
|
|
"rules": []any{
|
|
map[string]any{
|
|
"host": "myapp.example.com",
|
|
},
|
|
},
|
|
}
|
|
|
|
svc.updateFromItem(testIngressResource, &item)
|
|
|
|
var got *model.App
|
|
svc.getEntry("myapp.example.com", func(name string, app *model.App) bool {
|
|
if app.Config.Domain == "myapp.example.com" {
|
|
got = app
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
|
|
require.Nil(t, got)
|
|
},
|
|
},
|
|
{
|
|
description: "UpdateFromItem with no annotations removes existing cache entries",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
app := model.App{Config: model.AppConfig{Domain: "todelete.example.com"}}
|
|
svc.addResourceEntries(resourceKey{
|
|
resource: "ingresses",
|
|
namespace: "default",
|
|
name: "my-ingress",
|
|
}, []string{"todelete.example.com"}, []resourceEntry{
|
|
{
|
|
app: app,
|
|
name: "foo",
|
|
},
|
|
})
|
|
|
|
item := unstructured.Unstructured{}
|
|
item.SetNamespace("default")
|
|
item.SetName("my-ingress")
|
|
|
|
svc.updateFromItem(testIngressResource, &item)
|
|
|
|
var got *model.App
|
|
svc.getEntry("todelete.example.com", func(name string, app *model.App) bool {
|
|
if app.Config.Domain == "todelete.example.com" {
|
|
got = app
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
assert.Nil(t, got)
|
|
},
|
|
},
|
|
{
|
|
description: "ExtractPaths returns all non empty paths from a rule",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
rule := map[string]any{
|
|
"http": map[string]any{
|
|
"paths": []any{
|
|
map[string]any{"path": "/"},
|
|
map[string]any{"path": "/api"},
|
|
map[string]any{"path": ""},
|
|
map[string]any{"pathType": "Prefix"},
|
|
"not-a-map",
|
|
},
|
|
},
|
|
}
|
|
|
|
paths, err := svc.extractPaths(rule)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"/", "/api"}, paths)
|
|
},
|
|
},
|
|
{
|
|
description: "ExtractPaths returns nothing when http or paths are missing",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
paths, err := svc.extractPaths(map[string]any{})
|
|
require.NoError(t, err)
|
|
assert.Empty(t, paths)
|
|
|
|
paths, err = svc.extractPaths(map[string]any{
|
|
"http": map[string]any{},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Empty(t, paths)
|
|
},
|
|
},
|
|
{
|
|
description: "ExtractPaths errors when http is not a map",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
paths, err := svc.extractPaths(map[string]any{
|
|
"http": "invalid",
|
|
})
|
|
require.Error(t, err)
|
|
assert.Nil(t, paths)
|
|
},
|
|
},
|
|
{
|
|
description: "ExtractPaths errors when paths is not a slice",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
paths, err := svc.extractPaths(map[string]any{
|
|
"http": map[string]any{
|
|
"paths": "invalid",
|
|
},
|
|
})
|
|
require.Error(t, err)
|
|
assert.Nil(t, paths)
|
|
},
|
|
},
|
|
{
|
|
description: "ExtractHosts returns hosts from all rules",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
item := unstructured.Unstructured{}
|
|
item.SetNamespace("default")
|
|
item.SetName("test-ingress")
|
|
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
|
|
map[string]any{
|
|
"host": "foo.example.com",
|
|
"http": map[string]any{
|
|
"paths": []any{
|
|
map[string]any{"path": "/"},
|
|
},
|
|
},
|
|
},
|
|
map[string]any{
|
|
"host": "bar.example.com",
|
|
},
|
|
map[string]any{
|
|
"host": "",
|
|
},
|
|
"not-a-map",
|
|
}, "spec", "rules"))
|
|
|
|
hosts, err := svc.extractHosts(testIngressResource, &item)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"foo.example.com", "bar.example.com"}, hosts)
|
|
},
|
|
},
|
|
{
|
|
description: "ExtractHosts still returns hosts when a rule has no catch all path",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
item := unstructured.Unstructured{}
|
|
item.SetNamespace("default")
|
|
item.SetName("test-ingress")
|
|
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
|
|
map[string]any{
|
|
"host": "foo.example.com",
|
|
"http": map[string]any{
|
|
"paths": []any{
|
|
map[string]any{"path": "/api"},
|
|
},
|
|
},
|
|
},
|
|
}, "spec", "rules"))
|
|
|
|
hosts, err := svc.extractIngressHosts(&item)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"foo.example.com"}, hosts)
|
|
},
|
|
},
|
|
{
|
|
description: "ExtractHosts still returns hosts when path extraction fails",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
item := unstructured.Unstructured{}
|
|
item.SetNamespace("default")
|
|
item.SetName("test-ingress")
|
|
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
|
|
map[string]any{
|
|
"host": "foo.example.com",
|
|
"http": "invalid",
|
|
},
|
|
}, "spec", "rules"))
|
|
|
|
hosts, err := svc.extractIngressHosts(&item)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"foo.example.com"}, hosts)
|
|
},
|
|
},
|
|
{
|
|
description: "ExtractHosts returns nothing when spec.rules is missing",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
item := unstructured.Unstructured{}
|
|
item.SetNamespace("default")
|
|
item.SetName("test-ingress")
|
|
|
|
hosts, err := svc.extractIngressHosts(&item)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, hosts)
|
|
},
|
|
},
|
|
{
|
|
description: "ExtractHosts errors when spec.rules is not a slice",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
item := unstructured.Unstructured{}
|
|
item.SetNamespace("default")
|
|
item.SetName("test-ingress")
|
|
require.NoError(t, unstructured.SetNestedField(item.Object, "invalid", "spec", "rules"))
|
|
|
|
hosts, err := svc.extractIngressHosts(&item)
|
|
require.Error(t, err)
|
|
assert.Nil(t, hosts)
|
|
},
|
|
},
|
|
{
|
|
description: "ExtractRouteHosts returns the hostnames of a route",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
item := unstructured.Unstructured{}
|
|
item.SetNamespace("default")
|
|
item.SetName("test-httproute")
|
|
require.NoError(t, unstructured.SetNestedStringSlice(item.Object, []string{
|
|
"foo.example.com",
|
|
"",
|
|
"*.bar.example.com",
|
|
}, "spec", "hostnames"))
|
|
|
|
hosts, err := svc.extractHosts(testHTTPRouteResource, &item)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"foo.example.com", "*.bar.example.com"}, hosts)
|
|
},
|
|
},
|
|
{
|
|
description: "ExtractRouteHosts returns nothing when spec.hostnames is missing",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
item := unstructured.Unstructured{}
|
|
item.SetNamespace("default")
|
|
item.SetName("test-httproute")
|
|
|
|
hosts, err := svc.extractRouteHosts(testHTTPRouteResource, &item)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, hosts)
|
|
},
|
|
},
|
|
{
|
|
description: "ExtractRouteHosts errors when spec.hostnames is not a string slice",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
item := unstructured.Unstructured{}
|
|
item.SetNamespace("default")
|
|
item.SetName("test-httproute")
|
|
require.NoError(t, unstructured.SetNestedField(item.Object, "invalid", "spec", "hostnames"))
|
|
|
|
hosts, err := svc.extractRouteHosts(testHTTPRouteResource, &item)
|
|
require.Error(t, err)
|
|
assert.Nil(t, hosts)
|
|
},
|
|
},
|
|
{
|
|
description: "ExtractRoutePaths treats omitted matches as a catch all",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
paths, catchAll, err := svc.extractRoutePaths(map[string]any{})
|
|
require.NoError(t, err)
|
|
assert.True(t, catchAll)
|
|
assert.Empty(t, paths)
|
|
},
|
|
},
|
|
{
|
|
description: "ExtractRoutePaths applies the default path match",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
paths, catchAll, err := svc.extractRoutePaths(map[string]any{
|
|
"matches": []any{
|
|
map[string]any{
|
|
"path": map[string]any{},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.True(t, catchAll)
|
|
assert.Equal(t, []string{"/"}, paths)
|
|
},
|
|
},
|
|
{
|
|
description: "ExtractRoutePaths reports no catch all for scoped path matches",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
paths, catchAll, err := svc.extractRoutePaths(map[string]any{
|
|
"matches": []any{
|
|
map[string]any{
|
|
"path": map[string]any{
|
|
"type": "PathPrefix",
|
|
"value": "/api",
|
|
},
|
|
},
|
|
map[string]any{
|
|
"path": map[string]any{
|
|
"type": "Exact",
|
|
"value": "/",
|
|
},
|
|
},
|
|
"not-a-map",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.False(t, catchAll)
|
|
assert.Equal(t, []string{"/api", "/"}, paths)
|
|
},
|
|
},
|
|
{
|
|
description: "ExtractRoutePaths treats a match without a path as a catch all",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
paths, catchAll, err := svc.extractRoutePaths(map[string]any{
|
|
"matches": []any{
|
|
map[string]any{
|
|
"method": map[string]any{
|
|
"service": "com.example.Service",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.True(t, catchAll)
|
|
assert.Empty(t, paths)
|
|
},
|
|
},
|
|
{
|
|
description: "ExtractRoutePaths errors when matches is not a slice",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
paths, catchAll, err := svc.extractRoutePaths(map[string]any{
|
|
"matches": "invalid",
|
|
})
|
|
require.Error(t, err)
|
|
assert.False(t, catchAll)
|
|
assert.Nil(t, paths)
|
|
},
|
|
},
|
|
{
|
|
description: "UpdateFromItem parses annotations and populates cache from httproute",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
item := unstructured.Unstructured{}
|
|
item.SetNamespace("default")
|
|
item.SetName("test-httproute")
|
|
item.SetAnnotations(map[string]string{
|
|
"tinyauth.apps.gwapp.config.domain": "gwapp.example.com",
|
|
"tinyauth.apps.gwapp.users.allow": "bob",
|
|
})
|
|
require.NoError(t, unstructured.SetNestedStringSlice(item.Object, []string{
|
|
"gwapp.example.com",
|
|
}, "spec", "hostnames"))
|
|
|
|
svc.updateFromItem(testHTTPRouteResource, &item)
|
|
|
|
var got *model.App
|
|
svc.getEntry("gwapp.example.com", func(name string, app *model.App) bool {
|
|
if app.Config.Domain == "gwapp.example.com" {
|
|
got = app
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
require.NotNil(t, got)
|
|
assert.Equal(t, "gwapp.example.com", got.Config.Domain)
|
|
assert.Equal(t, "bob", got.Users.Allow)
|
|
},
|
|
},
|
|
{
|
|
description: "UpdateFromItem parses annotations and populates cache from grpcroute",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
item := unstructured.Unstructured{}
|
|
item.SetNamespace("default")
|
|
item.SetName("test-grpcroute")
|
|
item.SetAnnotations(map[string]string{
|
|
"tinyauth.apps.grpcapp.config.domain": "grpcapp.example.com",
|
|
"tinyauth.apps.grpcapp.users.allow": "carol",
|
|
})
|
|
require.NoError(t, unstructured.SetNestedStringSlice(item.Object, []string{
|
|
"grpcapp.example.com",
|
|
}, "spec", "hostnames"))
|
|
|
|
svc.updateFromItem(testGRPCRouteResource, &item)
|
|
|
|
var got *model.App
|
|
svc.getEntry("grpcapp.example.com", func(name string, app *model.App) bool {
|
|
if app.Config.Domain == "grpcapp.example.com" {
|
|
got = app
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
require.NotNil(t, got)
|
|
assert.Equal(t, "grpcapp.example.com", got.Config.Domain)
|
|
assert.Equal(t, "carol", got.Users.Allow)
|
|
},
|
|
},
|
|
{
|
|
description: "UpdateFromItem skips routes without hostnames",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
item := unstructured.Unstructured{}
|
|
item.SetNamespace("default")
|
|
item.SetName("test-httproute")
|
|
item.SetAnnotations(map[string]string{
|
|
"tinyauth.apps.gwapp.config.domain": "gwapp.example.com",
|
|
})
|
|
|
|
svc.updateFromItem(testHTTPRouteResource, &item)
|
|
|
|
var got *model.App
|
|
svc.getEntry("gwapp.example.com", func(name string, app *model.App) bool {
|
|
got = app
|
|
return true
|
|
})
|
|
assert.Nil(t, got)
|
|
},
|
|
},
|
|
{
|
|
description: "UpdateFromItem registers an app whose domain is covered by a wildcard hostname",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
item := unstructured.Unstructured{}
|
|
item.SetNamespace("default")
|
|
item.SetName("test-httproute")
|
|
item.SetAnnotations(map[string]string{
|
|
"tinyauth.apps.gwapp.config.domain": "deep.gwapp.example.com",
|
|
})
|
|
require.NoError(t, unstructured.SetNestedStringSlice(item.Object, []string{
|
|
"*.example.com",
|
|
}, "spec", "hostnames"))
|
|
|
|
svc.updateFromItem(testHTTPRouteResource, &item)
|
|
|
|
var got *model.App
|
|
svc.getEntry("deep.gwapp.example.com", func(name string, app *model.App) bool {
|
|
if name == "gwapp" {
|
|
got = app
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
require.NotNil(t, got)
|
|
assert.Equal(t, "deep.gwapp.example.com", got.Config.Domain)
|
|
},
|
|
},
|
|
{
|
|
description: "UpdateFromItem registers an app by name under a wildcard hostname",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
item := unstructured.Unstructured{}
|
|
item.SetNamespace("default")
|
|
item.SetName("test-httproute")
|
|
item.SetAnnotations(map[string]string{
|
|
"tinyauth.apps.gwapp.users.allow": "alice",
|
|
})
|
|
require.NoError(t, unstructured.SetNestedStringSlice(item.Object, []string{
|
|
"*.example.com",
|
|
}, "spec", "hostnames"))
|
|
|
|
svc.updateFromItem(testHTTPRouteResource, &item)
|
|
|
|
var got *model.App
|
|
svc.getEntry("gwapp.example.com", func(name string, app *model.App) bool {
|
|
if name == "gwapp" {
|
|
got = app
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
require.NotNil(t, got)
|
|
assert.Equal(t, "alice", got.Users.Allow)
|
|
},
|
|
},
|
|
{
|
|
description: "HostMatches honours the gateway api wildcard suffix rule",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
assert.True(t, hostMatches("foo.example.com", "foo.example.com"))
|
|
assert.True(t, hostMatches("Foo.Example.com", "foo.example.com"))
|
|
assert.False(t, hostMatches("bar.example.com", "foo.example.com"))
|
|
|
|
// A wildcard is a suffix match over one or more labels
|
|
assert.True(t, hostMatches("*.example.com", "foo.example.com"))
|
|
assert.True(t, hostMatches("*.example.com", "foo.test.example.com"))
|
|
assert.False(t, hostMatches("*.example.com", "example.com"))
|
|
assert.False(t, hostMatches("*.example.com", "foo.example.net"))
|
|
},
|
|
},
|
|
{
|
|
description: "HostCoversName matches app names against a host",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
assert.True(t, hostCoversName("foo.example.com", "foo"))
|
|
assert.True(t, hostCoversName("Foo.example.com", "FOO"))
|
|
assert.False(t, hostCoversName("bar.example.com", "foo"))
|
|
assert.False(t, hostCoversName("example.com", "foo"))
|
|
|
|
// A wildcard routes <name>.<suffix> for every name
|
|
assert.True(t, hostCoversName("*.example.com", "foo"))
|
|
assert.True(t, hostCoversName("*.example.com", "bar"))
|
|
},
|
|
},
|
|
{
|
|
description: "UpdateFromItem registers a route that has no catch-all path",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
item := unstructured.Unstructured{}
|
|
item.SetNamespace("default")
|
|
item.SetName("test-httproute")
|
|
item.SetAnnotations(map[string]string{
|
|
"tinyauth.apps.gwapp.config.domain": "gwapp.example.com",
|
|
})
|
|
require.NoError(t, unstructured.SetNestedStringSlice(item.Object, []string{
|
|
"gwapp.example.com",
|
|
}, "spec", "hostnames"))
|
|
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
|
|
map[string]any{
|
|
"matches": []any{
|
|
map[string]any{
|
|
"path": map[string]any{
|
|
"type": "PathPrefix",
|
|
"value": "/api",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}, "spec", "rules"))
|
|
|
|
svc.updateFromItem(testHTTPRouteResource, &item)
|
|
|
|
var got *model.App
|
|
svc.getEntry("gwapp.example.com", func(name string, app *model.App) bool {
|
|
if name == "gwapp" {
|
|
got = app
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
require.NotNil(t, got)
|
|
},
|
|
},
|
|
{
|
|
description: "Ingress and HTTPRoute apps coexist in cache",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
ingress := unstructured.Unstructured{}
|
|
ingress.SetNamespace("default")
|
|
ingress.SetName("my-ingress")
|
|
ingress.SetAnnotations(map[string]string{
|
|
"tinyauth.apps.ingapp.config.domain": "ingapp.example.com",
|
|
})
|
|
require.NoError(t, unstructured.SetNestedSlice(ingress.Object, []any{
|
|
map[string]any{
|
|
"host": "ingapp.example.com",
|
|
},
|
|
}, "spec", "rules"))
|
|
|
|
httpRoute := unstructured.Unstructured{}
|
|
httpRoute.SetNamespace("default")
|
|
httpRoute.SetName("my-httproute")
|
|
httpRoute.SetAnnotations(map[string]string{
|
|
"tinyauth.apps.gwapp.config.domain": "gwapp.example.com",
|
|
})
|
|
require.NoError(t, unstructured.SetNestedStringSlice(httpRoute.Object, []string{
|
|
"gwapp.example.com",
|
|
}, "spec", "hostnames"))
|
|
|
|
svc.updateFromItem(testIngressResource, &ingress)
|
|
svc.updateFromItem(testHTTPRouteResource, &httpRoute)
|
|
|
|
var got *model.App
|
|
svc.getEntry("ingapp.example.com", func(name string, app *model.App) bool {
|
|
if app.Config.Domain == "ingapp.example.com" {
|
|
got = app
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
require.NotNil(t, got)
|
|
assert.Equal(t, "ingapp.example.com", got.Config.Domain)
|
|
|
|
got = nil
|
|
svc.getEntry("gwapp.example.com", func(name string, app *model.App) bool {
|
|
if app.Config.Domain == "gwapp.example.com" {
|
|
got = app
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
require.NotNil(t, got)
|
|
assert.Equal(t, "gwapp.example.com", got.Config.Domain)
|
|
},
|
|
},
|
|
{
|
|
description: "UpdateFromItem registers app when its domain matches an ingress host",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
item := unstructured.Unstructured{}
|
|
item.SetNamespace("default")
|
|
item.SetName("test-ingress")
|
|
item.SetAnnotations(map[string]string{
|
|
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
|
|
})
|
|
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
|
|
map[string]any{
|
|
"host": "myapp.example.com",
|
|
},
|
|
}, "spec", "rules"))
|
|
|
|
svc.updateFromItem(testIngressResource, &item)
|
|
|
|
var got *model.App
|
|
svc.getEntry("myapp.example.com", func(name string, app *model.App) bool {
|
|
if name == "myapp" {
|
|
got = app
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
require.NotNil(t, got)
|
|
assert.Equal(t, "myapp.example.com", got.Config.Domain)
|
|
},
|
|
},
|
|
{
|
|
description: "UpdateFromItem registers app when its name matches an ingress host prefix",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
item := unstructured.Unstructured{}
|
|
item.SetNamespace("default")
|
|
item.SetName("test-ingress")
|
|
item.SetAnnotations(map[string]string{
|
|
"tinyauth.apps.myapp.users.allow": "alice",
|
|
})
|
|
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
|
|
map[string]any{
|
|
"host": "MyApp.example.com",
|
|
},
|
|
}, "spec", "rules"))
|
|
|
|
svc.updateFromItem(testIngressResource, &item)
|
|
|
|
var got *model.App
|
|
svc.getEntry("myapp.example.com", func(name string, app *model.App) bool {
|
|
if name == "myapp" {
|
|
got = app
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
require.NotNil(t, got)
|
|
assert.Equal(t, "alice", got.Users.Allow)
|
|
},
|
|
},
|
|
{
|
|
description: "UpdateFromItem skips apps that match neither host nor name",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
item := unstructured.Unstructured{}
|
|
item.SetNamespace("default")
|
|
item.SetName("test-ingress")
|
|
item.SetAnnotations(map[string]string{
|
|
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
|
|
})
|
|
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
|
|
map[string]any{
|
|
"host": "other.example.com",
|
|
},
|
|
}, "spec", "rules"))
|
|
|
|
svc.updateFromItem(testIngressResource, &item)
|
|
|
|
var got *model.App
|
|
svc.getEntry("other.example.com", func(name string, app *model.App) bool {
|
|
got = app
|
|
return true
|
|
})
|
|
assert.Nil(t, got)
|
|
},
|
|
},
|
|
{
|
|
description: "UpdateFromItem falls back to app name when the domain is invalid",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
item := unstructured.Unstructured{}
|
|
item.SetNamespace("default")
|
|
item.SetName("test-ingress")
|
|
item.SetAnnotations(map[string]string{
|
|
"tinyauth.apps.myapp.config.domain": "not a domain",
|
|
})
|
|
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
|
|
map[string]any{
|
|
"host": "myapp.example.com",
|
|
},
|
|
}, "spec", "rules"))
|
|
|
|
svc.updateFromItem(testIngressResource, &item)
|
|
|
|
var got *model.App
|
|
svc.getEntry("myapp.example.com", func(name string, app *model.App) bool {
|
|
if name == "myapp" {
|
|
got = app
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
require.NotNil(t, got)
|
|
},
|
|
},
|
|
{
|
|
description: "UpdateFromItem removes entries when host extraction fails",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
key := resourceKey{
|
|
resource: "ingresses",
|
|
namespace: "default",
|
|
name: "test-ingress",
|
|
}
|
|
svc.addResourceEntries(key, []string{"stale.example.com"}, []resourceEntry{
|
|
{
|
|
app: model.App{Config: model.AppConfig{Domain: "stale.example.com"}},
|
|
name: "foo",
|
|
},
|
|
})
|
|
|
|
item := unstructured.Unstructured{}
|
|
item.SetNamespace(key.namespace)
|
|
item.SetName(key.name)
|
|
item.SetAnnotations(map[string]string{
|
|
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
|
|
})
|
|
require.NoError(t, unstructured.SetNestedField(item.Object, "invalid", "spec", "rules"))
|
|
|
|
svc.updateFromItem(testIngressResource, &item)
|
|
|
|
var got *model.App
|
|
svc.getEntry("stale.example.com", func(name string, app *model.App) bool {
|
|
got = app
|
|
return true
|
|
})
|
|
assert.Nil(t, got)
|
|
},
|
|
},
|
|
{
|
|
description: "UpdateFromItem removes entries when annotations are not decodable",
|
|
run: func(t *testing.T, svc *KubernetesService) {
|
|
key := resourceKey{
|
|
resource: "ingresses",
|
|
namespace: "default",
|
|
name: "test-ingress",
|
|
}
|
|
svc.addResourceEntries(key, []string{"stale.example.com"}, []resourceEntry{
|
|
{
|
|
app: model.App{Config: model.AppConfig{Domain: "stale.example.com"}},
|
|
name: "foo",
|
|
},
|
|
})
|
|
|
|
item := unstructured.Unstructured{}
|
|
item.SetNamespace(key.namespace)
|
|
item.SetName(key.name)
|
|
item.SetAnnotations(map[string]string{
|
|
"tinyauth.apps.myapp.config.oauthWhitelist": "[",
|
|
})
|
|
|
|
svc.updateFromItem(testIngressResource, &item)
|
|
|
|
var got *model.App
|
|
svc.getEntry("stale.example.com", func(name string, app *model.App) bool {
|
|
got = app
|
|
return true
|
|
})
|
|
assert.Nil(t, got)
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.description, func(t *testing.T) {
|
|
svc := &KubernetesService{
|
|
resourceApps: make(map[resourceKey]routedApps),
|
|
log: log,
|
|
}
|
|
test.run(t, svc)
|
|
})
|
|
}
|
|
}
|