mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-24 16:41:30 +02:00
Store the per-account gateway endpoint as {domain, proxy_address} with a
global unique index on the full hostname; dedicated = (domain ==
proxy_address). Bootstrap becomes an explicit POST carrying exactly one
of proxy_address (server allocates an adjective-noun label beneath it)
or endpoint (claimed verbatim, address-first); provider create loses its
bootstrap side effect. PUT is a full replace with every field required —
the immutable identity fields must be echoed unchanged and a mismatch is
rejected with 422. A guarded DELETE releases the endpoint: refused with
412 while providers exist or a proxy is actively serving the endpoint
hostname (matched case-insensitively); re-creating bootstraps fresh. A
self-addressed pin excludes its address from the account's cluster allow
list, and the live mapping update path now addresses the serving proxy
from the synthesized service. Existing rows are migrated on all three
store engines.
226 lines
10 KiB
Go
226 lines
10 KiB
Go
package agentnetwork
|
|
|
|
import (
|
|
"context"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/golang/mock/gomock"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
|
"github.com/netbirdio/netbird/management/server/account"
|
|
"github.com/netbirdio/netbird/management/server/permissions"
|
|
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
|
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
|
"github.com/netbirdio/netbird/management/server/store"
|
|
nbtypes "github.com/netbirdio/netbird/management/server/types"
|
|
"github.com/netbirdio/netbird/shared/management/status"
|
|
)
|
|
|
|
// bootstrapFixture wires a real sqlite store to a gomock permissions manager
|
|
// so tests can grant or deny the settings permission per case.
|
|
type bootstrapFixture struct {
|
|
manager Manager
|
|
store store.Store
|
|
perms *permissions.MockManager
|
|
}
|
|
|
|
func newBootstrapFixture(t *testing.T) *bootstrapFixture {
|
|
t.Helper()
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("sqlite store not properly supported on Windows yet")
|
|
}
|
|
t.Setenv("NETBIRD_STORE_ENGINE", string(nbtypes.SqliteStoreEngine))
|
|
|
|
st, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
|
|
require.NoError(t, err, "test store setup must succeed")
|
|
t.Cleanup(cleanUp)
|
|
|
|
ctrl := gomock.NewController(t)
|
|
perms := permissions.NewMockManager(ctrl)
|
|
|
|
accounts := account.NewMockManager(ctrl)
|
|
accounts.EXPECT().StoreEvent(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
|
|
accounts.EXPECT().UpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
|
|
accounts.EXPECT().BufferUpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
|
|
|
|
return &bootstrapFixture{
|
|
manager: NewManager(st, perms, accounts, nil),
|
|
store: st,
|
|
perms: perms,
|
|
}
|
|
}
|
|
|
|
func (f *bootstrapFixture) expectPermission(accountID, userID string, module modules.Module, op operations.Operation, allowed bool) {
|
|
f.perms.EXPECT().
|
|
ValidateUserPermissions(gomock.Any(), accountID, userID, module, op).
|
|
Return(allowed, context.Background(), nil)
|
|
}
|
|
|
|
func (f *bootstrapFixture) createSettings(ctx context.Context, accountID, userID, proxyAddress, endpoint string) (*types.Settings, error) {
|
|
return f.manager.CreateSettings(ctx, userID, types.DefaultSettings(accountID), proxyAddress, endpoint)
|
|
}
|
|
|
|
// TestCreateSettingsRequiresPermission pins the gate: bootstrap assigns the
|
|
// account's immutable endpoint, a settings write requiring the settings
|
|
// Create permission — and a denial leaves no row behind.
|
|
func TestCreateSettingsRequiresPermission(t *testing.T) {
|
|
ctx := context.Background()
|
|
f := newBootstrapFixture(t)
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, false)
|
|
|
|
_, err := f.createSettings(ctx, "account1", "user1", "cluster1.example.com", "")
|
|
require.Error(t, err, "bootstrap without the settings permission must fail")
|
|
var sErr *status.Error
|
|
require.ErrorAs(t, err, &sErr)
|
|
assert.Equal(t, status.PermissionDenied, sErr.Type(), "denial should surface as permission denied")
|
|
|
|
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
|
assert.Error(t, err, "settings row must not be created when bootstrap is denied")
|
|
}
|
|
|
|
// TestCreateSettingsLabeled pins the labeled shape: the server allocates an
|
|
// adjective-noun label beneath the proxy address, the pin is not dedicated,
|
|
// and the domain records the full endpoint hostname.
|
|
func TestCreateSettingsLabeled(t *testing.T) {
|
|
ctx := context.Background()
|
|
f := newBootstrapFixture(t)
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
|
|
|
created, err := f.createSettings(ctx, "account1", "user1", "Cluster1.Example.com", "")
|
|
require.NoError(t, err, "labeled bootstrap must succeed")
|
|
assert.Equal(t, "cluster1.example.com", created.ProxyAddress, "proxy address must be pinned lowercased")
|
|
require.True(t, strings.HasSuffix(created.Domain, ".cluster1.example.com"),
|
|
"domain must hang one label beneath the proxy address: %s", created.Domain)
|
|
label := strings.TrimSuffix(created.Domain, ".cluster1.example.com")
|
|
assert.NotContains(t, label, ".", "the allocated label must be a single DNS label: %s", label)
|
|
assert.False(t, created.Dedicated(), "a labeled pin is not dedicated")
|
|
assert.Equal(t, created.Domain, created.Endpoint(), "the endpoint is the domain column")
|
|
|
|
stored, err := f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
|
require.NoError(t, err, "bootstrap must persist the row")
|
|
assert.Equal(t, created.Domain, stored.Domain)
|
|
assert.Equal(t, created.ProxyAddress, stored.ProxyAddress)
|
|
}
|
|
|
|
// TestCreateSettingsSelfAddressed pins the dedicated shape: the endpoint is
|
|
// claimed verbatim (normalized), Domain == ProxyAddress, and the claim
|
|
// succeeds with no proxy declaring the address yet (address-first).
|
|
func TestCreateSettingsSelfAddressed(t *testing.T) {
|
|
ctx := context.Background()
|
|
f := newBootstrapFixture(t)
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
|
|
|
created, err := f.createSettings(ctx, "account1", "user1", "", "Brave-Otter.GW.Example.com")
|
|
require.NoError(t, err, "self-addressed bootstrap must succeed")
|
|
assert.Equal(t, "brave-otter.gw.example.com", created.Domain, "endpoint must be claimed lowercased")
|
|
assert.Equal(t, created.Domain, created.ProxyAddress, "self-addressed: proxy address is the endpoint")
|
|
assert.True(t, created.Dedicated(), "a self-addressed pin is dedicated")
|
|
}
|
|
|
|
// TestCreateSettingsIdentityFieldValidation pins the request contract: exactly
|
|
// one of proxyAddress and endpoint, and both must be well-formed hostnames.
|
|
func TestCreateSettingsIdentityFieldValidation(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
cases := map[string]struct {
|
|
proxyAddress string
|
|
endpoint string
|
|
}{
|
|
"neither": {"", ""},
|
|
"both": {"cluster1.example.com", "gw.example.com"},
|
|
"trailing dot endpoint": {"", "gw.example.com."},
|
|
"leading dot endpoint": {"", ".gw.example.com"},
|
|
"whitespace inside": {"", "g w.example.com"},
|
|
"empty label in parent": {"eu..example.com", ""},
|
|
"hyphen-edged label": {"", "-gw.example.com"},
|
|
}
|
|
for name, tc := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
f := newBootstrapFixture(t)
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
|
|
|
_, err := f.createSettings(ctx, "account1", "user1", tc.proxyAddress, tc.endpoint)
|
|
require.Error(t, err, "invalid identity input must be rejected")
|
|
var sErr *status.Error
|
|
require.ErrorAs(t, err, &sErr)
|
|
assert.Equal(t, status.InvalidArgument, sErr.Type(), "rejection must be a validation error")
|
|
|
|
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
|
assert.Error(t, err, "no row may be left behind by a rejected bootstrap")
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCreateSettingsConflictsOnSecondBootstrap pins that bootstrap is a
|
|
// one-time create per account: a second call is a conflict, whatever shape it
|
|
// asks for, and the original row survives untouched.
|
|
func TestCreateSettingsConflictsOnSecondBootstrap(t *testing.T) {
|
|
ctx := context.Background()
|
|
f := newBootstrapFixture(t)
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
|
|
|
first, err := f.createSettings(ctx, "account1", "user1", "cluster1.example.com", "")
|
|
require.NoError(t, err)
|
|
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
|
_, err = f.createSettings(ctx, "account1", "user1", "", "other.example.com")
|
|
require.Error(t, err, "second bootstrap must fail")
|
|
var sErr *status.Error
|
|
require.ErrorAs(t, err, &sErr)
|
|
assert.Equal(t, status.AlreadyExists, sErr.Type(), "second bootstrap must surface as a conflict")
|
|
|
|
stored, err := f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, first.Domain, stored.Domain, "the original endpoint must survive the rejected bootstrap")
|
|
}
|
|
|
|
// TestCreateSettingsEndpointTaken pins global hostname uniqueness: a hostname
|
|
// held by one account cannot be claimed by another, in either direction —
|
|
// self-addressed onto self-addressed, or self-addressed onto an allocated
|
|
// labeled endpoint.
|
|
func TestCreateSettingsEndpointTaken(t *testing.T) {
|
|
ctx := context.Background()
|
|
f := newBootstrapFixture(t)
|
|
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
|
first, err := f.createSettings(ctx, "account1", "user1", "", "gw.example.com")
|
|
require.NoError(t, err)
|
|
|
|
f.expectPermission("account2", "user2", modules.AgentNetworkSettings, operations.Create, true)
|
|
_, err = f.createSettings(ctx, "account2", "user2", "", "gw.example.com")
|
|
require.Error(t, err, "a taken hostname must be refused")
|
|
var sErr *status.Error
|
|
require.ErrorAs(t, err, &sErr)
|
|
assert.Equal(t, status.AlreadyExists, sErr.Type(), "the refusal must surface as a conflict")
|
|
|
|
f.expectPermission("account3", "user3", modules.AgentNetworkSettings, operations.Create, true)
|
|
_, err = f.createSettings(ctx, "account3", "user3", "", first.Domain)
|
|
require.Error(t, err, "claiming another account's endpoint must be refused")
|
|
}
|
|
|
|
// TestCreateProviderHasNoSettingsSideEffects pins the decoupling: provider
|
|
// create needs only the providers permission (gomock fails the test on any
|
|
// settings-permission call) and never creates a settings row.
|
|
func TestCreateProviderHasNoSettingsSideEffects(t *testing.T) {
|
|
ctx := context.Background()
|
|
f := newBootstrapFixture(t)
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
|
|
|
provider := types.NewProvider("account1")
|
|
provider.Name = "openai"
|
|
provider.UpstreamURL = "https://api.openai.com"
|
|
provider.APIKey = "sk-test"
|
|
provider.Enabled = true
|
|
|
|
created, err := f.manager.CreateProvider(ctx, "user1", provider)
|
|
require.NoError(t, err, "provider create must succeed on the providers permission alone")
|
|
require.NotNil(t, created)
|
|
|
|
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
|
assert.Error(t, err, "provider create must not conjure a settings row")
|
|
}
|