[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:
mlsmaycon
2026-06-28 18:56:54 +02:00
parent 4a296c5338
commit d9d437339c
4 changed files with 321 additions and 18 deletions

View File

@@ -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")

View 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()
}

View 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
View 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)
}