mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-16 11:39:57 +00:00
[e2e] Add management-side agent-network scenarios (Pillar 2)
Port the API-driven agent-network scenarios from the bash suites to Go, sharing one combined server per package run (TestMain) with each test owning its resource cleanup. Drives the /api/agent-network/* endpoints through the shared REST client's NewRequest primitive with the generated api types. Scenarios: - provider lifecycle (create/get/list/delete + 404 after delete) - provider validation (missing api_key, unknown catalog id → 4xx) - settings collection-toggle round-trip with cluster/subdomain immutability - policy window floor (reject <60s enabled limit, accept at 60s) - consumption read endpoint returns an array All deterministic and dependency-free (dummy provider keys; no upstream calls), so they run headless in CI.
This commit is contained in:
@@ -5,32 +5,19 @@ package agentnetwork
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/e2e/harness"
|
||||
)
|
||||
|
||||
// TestCombinedBootstrap proves Pillar 1: a combined NetBird server comes up in a
|
||||
// container, the /api/setup bootstrap mints an admin PAT with no OIDC, and that
|
||||
// PAT authenticates real management API calls through the typed REST client.
|
||||
// TestCombinedBootstrap proves Pillar 1: the shared combined server came up and
|
||||
// the /api/setup-minted PAT authenticates a real management API call through
|
||||
// the typed REST client (the bootstrap itself ran in TestMain).
|
||||
func TestCombinedBootstrap(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
ctx := context.Background()
|
||||
|
||||
srv, err := harness.StartCombined(ctx)
|
||||
require.NoError(t, err, "combined server must build and start")
|
||||
t.Cleanup(func() {
|
||||
_ = srv.Terminate(context.Background())
|
||||
})
|
||||
require.NotEmpty(t, srv.PAT, "TestMain must have minted an admin PAT")
|
||||
|
||||
pat, err := srv.Bootstrap(ctx)
|
||||
require.NoError(t, err, "/api/setup must mint an admin PAT")
|
||||
require.NotEmpty(t, pat, "PAT must be non-empty")
|
||||
|
||||
// The PAT must authenticate a real management API call through the client.
|
||||
users, err := srv.API().Users.List(ctx)
|
||||
require.NoError(t, err, "authenticated Users.List must round-trip")
|
||||
require.NotEmpty(t, users, "the bootstrapped account must have at least one user")
|
||||
|
||||
46
e2e/agentnetwork/main_test.go
Normal file
46
e2e/agentnetwork/main_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
//go:build e2e
|
||||
|
||||
// Package agentnetwork holds the container-based agent-network e2e suite. A
|
||||
// single combined server is built and bootstrapped once per package run
|
||||
// (TestMain) and shared across tests via srv; each test creates and cleans up
|
||||
// its own resources so order doesn't matter.
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/e2e/harness"
|
||||
)
|
||||
|
||||
// srv is the shared combined server for the package, ready (PAT-authenticated)
|
||||
// by the time any Test runs.
|
||||
var srv *harness.Combined
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(run(m))
|
||||
}
|
||||
|
||||
func run(m *testing.M) int {
|
||||
// Generous timeout to cover a cold image build on first run.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
var err error
|
||||
srv, err = harness.StartCombined(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "e2e: start combined server: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
defer func() { _ = srv.Terminate(context.Background()) }()
|
||||
|
||||
if _, err := srv.Bootstrap(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "e2e: bootstrap admin PAT: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
return m.Run()
|
||||
}
|
||||
169
e2e/agentnetwork/management_test.go
Normal file
169
e2e/agentnetwork/management_test.go
Normal file
@@ -0,0 +1,169 @@
|
||||
//go:build e2e
|
||||
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/client/rest"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
// newProvider creates an OpenAI-catalog provider with a dummy key (these tests
|
||||
// never call the upstream) and registers cleanup.
|
||||
func newProvider(t *testing.T, ctx context.Context, name string) api.AgentNetworkProvider {
|
||||
t.Helper()
|
||||
prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
|
||||
Name: name,
|
||||
ProviderId: "openai_api",
|
||||
UpstreamUrl: "https://api.openai.com",
|
||||
ApiKey: ptr("sk-dummy-e2e-key"),
|
||||
BootstrapCluster: ptr("eu.proxy.netbird.test"),
|
||||
})
|
||||
require.NoError(t, err, "create provider %q", name)
|
||||
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })
|
||||
return prov
|
||||
}
|
||||
|
||||
// requireClientError asserts err is a REST APIError with a 4xx status.
|
||||
func requireClientError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
var apiErr *rest.APIError
|
||||
require.ErrorAs(t, err, &apiErr, "expected a REST APIError")
|
||||
assert.GreaterOrEqual(t, apiErr.StatusCode, 400, "expected a 4xx status")
|
||||
assert.Less(t, apiErr.StatusCode, 500, "expected a 4xx status")
|
||||
}
|
||||
|
||||
// TestProviderLifecycle covers create → get → list → delete → 404.
|
||||
func TestProviderLifecycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
prov := newProvider(t, ctx, "Provider Lifecycle")
|
||||
assert.NotEmpty(t, prov.Id, "created provider must have an id")
|
||||
assert.Equal(t, "openai_api", prov.ProviderId)
|
||||
|
||||
got, err := srv.GetProvider(ctx, prov.Id)
|
||||
require.NoError(t, err, "get provider")
|
||||
assert.Equal(t, prov.Id, got.Id)
|
||||
|
||||
list, err := srv.ListProviders(ctx)
|
||||
require.NoError(t, err, "list providers")
|
||||
var ids []string
|
||||
for _, p := range list {
|
||||
ids = append(ids, p.Id)
|
||||
}
|
||||
assert.Contains(t, ids, prov.Id, "created provider must appear in the list")
|
||||
|
||||
require.NoError(t, srv.DeleteProvider(ctx, prov.Id), "delete provider")
|
||||
_, err = srv.GetProvider(ctx, prov.Id)
|
||||
requireClientError(t, err)
|
||||
}
|
||||
|
||||
// TestProviderValidation rejects a missing API key and an unknown catalog id.
|
||||
func TestProviderValidation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
|
||||
Name: "No Key",
|
||||
ProviderId: "openai_api",
|
||||
UpstreamUrl: "https://api.openai.com",
|
||||
})
|
||||
requireClientError(t, err)
|
||||
|
||||
_, err = srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
|
||||
Name: "Unknown Catalog",
|
||||
ProviderId: "totally_unknown_provider",
|
||||
UpstreamUrl: "https://example.com",
|
||||
ApiKey: ptr("sk-dummy"),
|
||||
})
|
||||
requireClientError(t, err)
|
||||
}
|
||||
|
||||
// TestSettingsRoundTrip flips the collection toggles and confirms cluster /
|
||||
// subdomain stay immutable, then restores the original state.
|
||||
func TestSettingsRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Settings are bootstrapped on first provider create.
|
||||
newProvider(t, ctx, "Settings Bootstrap")
|
||||
|
||||
before, err := srv.GetSettings(ctx)
|
||||
require.NoError(t, err, "get settings")
|
||||
require.NotEmpty(t, before.Cluster, "settings must carry an assigned cluster")
|
||||
|
||||
flipped, err := srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||
EnableLogCollection: !before.EnableLogCollection,
|
||||
EnablePromptCollection: !before.EnablePromptCollection,
|
||||
RedactPii: !before.RedactPii,
|
||||
})
|
||||
require.NoError(t, err, "update settings")
|
||||
assert.Equal(t, !before.EnableLogCollection, flipped.EnableLogCollection, "log collection toggle must flip")
|
||||
assert.Equal(t, !before.EnablePromptCollection, flipped.EnablePromptCollection, "prompt collection toggle must flip")
|
||||
assert.Equal(t, before.Cluster, flipped.Cluster, "cluster must be immutable across updates")
|
||||
assert.Equal(t, before.Subdomain, flipped.Subdomain, "subdomain must be immutable across updates")
|
||||
|
||||
// Restore the original toggles.
|
||||
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||
EnableLogCollection: before.EnableLogCollection,
|
||||
EnablePromptCollection: before.EnablePromptCollection,
|
||||
RedactPii: before.RedactPii,
|
||||
})
|
||||
require.NoError(t, err, "restore settings")
|
||||
}
|
||||
|
||||
// TestPolicyWindowFloor rejects an enabled limit below the 60s window floor and
|
||||
// accepts one at the floor.
|
||||
func TestPolicyWindowFloor(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-policy-grp"})
|
||||
require.NoError(t, err, "create source group")
|
||||
t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) })
|
||||
|
||||
prov := newProvider(t, ctx, "Policy Provider")
|
||||
|
||||
limits := func(window int64) *api.AgentNetworkPolicyLimits {
|
||||
return &api.AgentNetworkPolicyLimits{
|
||||
TokenLimit: api.AgentNetworkPolicyTokenLimit{
|
||||
Enabled: true,
|
||||
GroupCap: 1000,
|
||||
UserCap: 1000,
|
||||
WindowSeconds: window,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
_, err = srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{
|
||||
Name: "e2e-below-floor",
|
||||
SourceGroups: []string{grp.Id},
|
||||
DestinationProviderIds: []string{prov.Id},
|
||||
Limits: limits(30),
|
||||
})
|
||||
requireClientError(t, err)
|
||||
|
||||
pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{
|
||||
Name: "e2e-at-floor",
|
||||
SourceGroups: []string{grp.Id},
|
||||
DestinationProviderIds: []string{prov.Id},
|
||||
Limits: limits(60),
|
||||
})
|
||||
require.NoError(t, err, "policy at the 60s floor must be accepted")
|
||||
assert.NotEmpty(t, pol.Id, "created policy must have an id")
|
||||
t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) })
|
||||
}
|
||||
|
||||
// TestConsumptionList confirms the read endpoint always returns an array, never
|
||||
// a 404/500.
|
||||
func TestConsumptionList(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
rows, err := srv.ListConsumption(ctx)
|
||||
require.NoError(t, err, "consumption list must not error")
|
||||
assert.NotNil(t, rows, "consumption must be a JSON array (possibly empty)")
|
||||
}
|
||||
101
e2e/harness/agentnetwork.go
Normal file
101
e2e/harness/agentnetwork.go
Normal file
@@ -0,0 +1,101 @@
|
||||
//go:build e2e
|
||||
|
||||
package harness
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// The shared REST client doesn't (yet) expose typed agent-network methods, so
|
||||
// these helpers drive the /api/agent-network/* endpoints through the client's
|
||||
// NewRequest primitive — reusing its auth, error handling (rest.APIError on
|
||||
// non-2xx), and transport — while still speaking the generated api types.
|
||||
|
||||
// anRequest issues an agent-network API call and decodes the JSON response into
|
||||
// T. A non-2xx response surfaces as a *rest.APIError from the client, which
|
||||
// tests inspect for negative-path status assertions.
|
||||
func anRequest[T any](ctx context.Context, c *Combined, method, path string, body any) (T, error) {
|
||||
var out T
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
bs, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("marshal %s %s: %w", method, path, err)
|
||||
}
|
||||
reader = bytes.NewReader(bs)
|
||||
}
|
||||
|
||||
resp, err := c.api.NewRequest(ctx, method, path, reader, nil)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return out, fmt.Errorf("decode %s %s response: %w", method, path, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// anDelete issues a DELETE and discards the (empty-object) body.
|
||||
func anDelete(ctx context.Context, c *Combined, path string) error {
|
||||
resp, err := c.api.NewRequest(ctx, http.MethodDelete, path, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateProvider creates an agent-network provider.
|
||||
func (c *Combined) CreateProvider(ctx context.Context, req api.AgentNetworkProviderRequest) (api.AgentNetworkProvider, error) {
|
||||
return anRequest[api.AgentNetworkProvider](ctx, c, http.MethodPost, "/api/agent-network/providers", req)
|
||||
}
|
||||
|
||||
// GetProvider fetches a provider by id.
|
||||
func (c *Combined) GetProvider(ctx context.Context, id string) (api.AgentNetworkProvider, error) {
|
||||
return anRequest[api.AgentNetworkProvider](ctx, c, http.MethodGet, "/api/agent-network/providers/"+id, nil)
|
||||
}
|
||||
|
||||
// ListProviders returns all providers for the account.
|
||||
func (c *Combined) ListProviders(ctx context.Context) ([]api.AgentNetworkProvider, error) {
|
||||
return anRequest[[]api.AgentNetworkProvider](ctx, c, http.MethodGet, "/api/agent-network/providers", nil)
|
||||
}
|
||||
|
||||
// DeleteProvider removes a provider by id.
|
||||
func (c *Combined) DeleteProvider(ctx context.Context, id string) error {
|
||||
return anDelete(ctx, c, "/api/agent-network/providers/"+id)
|
||||
}
|
||||
|
||||
// CreatePolicy creates an agent-network policy.
|
||||
func (c *Combined) CreatePolicy(ctx context.Context, req api.AgentNetworkPolicyRequest) (api.AgentNetworkPolicy, error) {
|
||||
return anRequest[api.AgentNetworkPolicy](ctx, c, http.MethodPost, "/api/agent-network/policies", req)
|
||||
}
|
||||
|
||||
// DeletePolicy removes a policy by id.
|
||||
func (c *Combined) DeletePolicy(ctx context.Context, id string) error {
|
||||
return anDelete(ctx, c, "/api/agent-network/policies/"+id)
|
||||
}
|
||||
|
||||
// GetSettings returns the account's agent-network settings row. It exists only
|
||||
// after the first provider create bootstraps it.
|
||||
func (c *Combined) GetSettings(ctx context.Context) (api.AgentNetworkSettings, error) {
|
||||
return anRequest[api.AgentNetworkSettings](ctx, c, http.MethodGet, "/api/agent-network/settings", nil)
|
||||
}
|
||||
|
||||
// UpdateSettings applies the mutable collection toggles.
|
||||
func (c *Combined) UpdateSettings(ctx context.Context, req api.AgentNetworkSettingsRequest) (api.AgentNetworkSettings, error) {
|
||||
return anRequest[api.AgentNetworkSettings](ctx, c, http.MethodPut, "/api/agent-network/settings", req)
|
||||
}
|
||||
|
||||
// ListConsumption returns the account's consumption rows (possibly empty).
|
||||
func (c *Combined) ListConsumption(ctx context.Context) ([]api.AgentNetworkConsumption, error) {
|
||||
return anRequest[[]api.AgentNetworkConsumption](ctx, c, http.MethodGet, "/api/agent-network/consumption", nil)
|
||||
}
|
||||
Reference in New Issue
Block a user