[test] Add e2e coverage for remote-jobs opt-in and bundle params

Adds an e2e suite (e2e/remotejobs) that runs on the container harness and
exercises the two stacked PRs end-to-end against a live management server
and a real client:

- Remote-jobs opt-in (#7153): a peer that ran plain `netbird up` reports
  remote_jobs_allowed=false via the peers API, and the client refuses a
  streamed job ("remote jobs are not enabled on this peer"). After
  `netbird up --allow-remote-jobs`, the flag flips to true on the API and
  the same job is accepted for execution.
- Bundle job parameters (#7147): an unknown anonymize_level is rejected at
  job creation, and a messy-but-valid value ('  Strict  ') is normalized to
  'strict' in the stored job the API returns.

Adds a small harness helper, Client.Up(extraArgs...), to re-run
`netbird up` with flags so the opt-in can be toggled mid-test without
recreating the container.
This commit is contained in:
mlsmaycon
2026-08-13 14:10:46 +00:00
parent b766a9d8ab
commit 00ee3869c0
3 changed files with 250 additions and 0 deletions

View File

@@ -120,6 +120,26 @@ func (cl *Client) Restart(ctx context.Context) error {
return nil
}
// Up re-runs `netbird up` inside the client with the given extra flags (e.g.
// "--allow-remote-jobs"), bouncing the connection first so the new config is
// picked up and re-synced to management. Used to toggle peer options that ride
// on the login/sync request without recreating the container.
func (cl *Client) Up(ctx context.Context, extraArgs ...string) error {
if _, _, err := cl.container.Exec(ctx, []string{"netbird", "down"}, tcexec.Multiplexed()); err != nil {
return fmt.Errorf("netbird down: %w", err)
}
time.Sleep(2 * time.Second)
code, reader, err := cl.container.Exec(ctx, append([]string{"netbird", "up"}, extraArgs...), tcexec.Multiplexed())
if err != nil {
return fmt.Errorf("netbird up: %w", err)
}
if code != 0 {
out, _ := io.ReadAll(reader)
return fmt.Errorf("netbird up %v exited %d: %s", extraArgs, code, string(out))
}
return nil
}
// Status returns `netbird status` output from inside the client.
func (cl *Client) Status(ctx context.Context) (string, error) {
code, reader, err := cl.container.Exec(ctx, []string{"netbird", "status"}, tcexec.Multiplexed())

View File

@@ -0,0 +1,47 @@
//go:build e2e
// Package remotejobs holds the container-based e2e suite for the remote-jobs
// opt-in (PR #7153) and the debug-bundle job parameters anonymize_level /
// upload_url (PR #7147). A combined server is built and bootstrapped once per
// package run (TestMain) and shared via srv; each test registers its own client
// and cleans it up.
package remotejobs
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/netbirdio/netbird/e2e/harness"
)
// srv is the shared combined server for the package, 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,183 @@
//go:build e2e
package remotejobs
import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/e2e/harness"
"github.com/netbirdio/netbird/shared/management/http/api"
)
const refusedReason = "remote jobs are not enabled on this peer"
// TestRemoteJobsOptInAndBundleParams exercises the two PRs end-to-end against a
// live management server and a real client:
//
// - #7153: the peer's remote-jobs opt-in defaults off, is reported to
// management (visible via the peers API as remote_jobs_allowed), and gates
// job execution on the client — a streamed job is refused until the peer
// opts in with `netbird up --allow-remote-jobs`, after which it runs.
// - #7147: the debug-bundle job's anonymize_level is validated (an unknown
// value is rejected at creation) and normalized (trimmed + lowercased) in
// the stored job the API returns.
func TestRemoteJobsOptInAndBundleParams(t *testing.T) {
ctx := context.Background()
// A group for the setup key to auto-assign; peers must land in some group.
grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-remotejobs"})
require.NoError(t, err, "create group")
t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) })
sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{
Name: "e2e-remotejobs",
Type: "reusable",
ExpiresIn: 86400,
UsageLimit: 0,
AutoGroups: []string{grp.Id},
})
require.NoError(t, err, "mint setup key")
require.NotEmpty(t, sk.Key, "setup key plaintext")
// Start the client with a plain `netbird up` (remote jobs NOT enabled).
cl, err := harness.StartClient(ctx, srv, sk.Key)
require.NoError(t, err, "start client")
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
peerID := waitForPeer(ctx, t)
t.Run("opt-in flag defaults to false and is reported to management (#7153)", func(t *testing.T) {
p, err := srv.API().Peers.Get(ctx, peerID)
require.NoError(t, err)
allowed := remoteJobsAllowed(p)
require.NotNil(t, allowed, "remote_jobs_allowed must be present on the peer API")
assert.False(t, *allowed, "a peer that ran plain `netbird up` must default to opt-out")
})
t.Run("anonymize_level is validated and normalized (#7147)", func(t *testing.T) {
// Unknown level is rejected at job creation.
_, err := srv.API().Peers.Jobs(peerID).Create(ctx, bundleJob("bogus", "https://uploads.example.com/bundle"))
require.Error(t, err, "an unknown anonymize_level must be rejected")
assert.Contains(t, strings.ToLower(err.Error()), "anonymize_level",
"the rejection must name the offending field")
// A messy but valid level is normalized (trimmed + lowercased) in the
// stored job the API echoes back.
job, err := srv.API().Peers.Jobs(peerID).Create(ctx, bundleJob(" Strict ", "https://uploads.example.com/bundle"))
require.NoError(t, err, "a valid anonymize_level must be accepted")
bw, err := job.Workload.AsBundleWorkloadResponse()
require.NoError(t, err, "job workload must be a bundle")
require.NotNil(t, bw.Parameters.AnonymizeLevel)
assert.Equal(t, "strict", *bw.Parameters.AnonymizeLevel,
"anonymize_level must be normalized to trimmed lowercase")
waitForJobTerminal(ctx, t, peerID, job.Id) // let it settle before the next create
})
t.Run("a job is refused while the peer has not opted in (#7153 enforcement)", func(t *testing.T) {
job, err := srv.API().Peers.Jobs(peerID).Create(ctx, bundleJob("default", "https://uploads.example.com/bundle"))
require.NoError(t, err, "job creation itself is allowed; enforcement is on the client")
final := waitForJobTerminal(ctx, t, peerID, job.Id)
assert.Equal(t, api.JobResponseStatusFailed, final.Status, "the client must refuse the job")
require.NotNil(t, final.FailedReason)
assert.Contains(t, *final.FailedReason, refusedReason,
"the failure must be the opt-out refusal, not some other error")
})
t.Run("opting in flips the flag and lets the job run (#7153)", func(t *testing.T) {
require.NoError(t, cl.Up(ctx, "--allow-remote-jobs"), "re-run up with --allow-remote-jobs")
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must reconnect")
// The new opt-in must round-trip to management and surface on the API.
require.Eventually(t, func() bool {
p, err := srv.API().Peers.Get(ctx, peerID)
if err != nil {
return false
}
allowed := remoteJobsAllowed(p)
return allowed != nil && *allowed
}, 60*time.Second, 2*time.Second, "remote_jobs_allowed must become true after opt-in")
// The same job that was refused before must now be accepted for
// execution: whatever its outcome, it must NOT be the opt-out refusal.
job, err := srv.API().Peers.Jobs(peerID).Create(ctx, bundleJob("default", "https://uploads.example.com/bundle"))
require.NoError(t, err)
final := waitForJobTerminal(ctx, t, peerID, job.Id)
if final.Status == api.JobResponseStatusFailed && final.FailedReason != nil {
assert.NotContains(t, *final.FailedReason, refusedReason,
"once opted in, the job must not be refused for opt-out; any failure must be for another reason (e.g. upload)")
}
})
}
// remoteJobsAllowed returns the peer's remote-jobs opt-in flag from the API
// response (nil if the peer or its local flags are absent).
func remoteJobsAllowed(p *api.Peer) *bool {
if p == nil || p.LocalFlags == nil {
return nil
}
return p.LocalFlags.RemoteJobsAllowed
}
// bundleJob builds a debug-bundle job request with the given anonymize_level
// (omitted when empty) and upload_url (omitted when empty).
func bundleJob(anonymizeLevel, uploadURL string) api.JobRequest {
params := api.BundleParameters{
Anonymize: true,
LogFileCount: 1,
}
if anonymizeLevel != "" {
params.AnonymizeLevel = &anonymizeLevel
}
if uploadURL != "" {
params.UploadUrl = &uploadURL
}
var wl api.WorkloadRequest
// FromBundleWorkloadRequest cannot fail for a well-formed value.
_ = wl.FromBundleWorkloadRequest(api.BundleWorkloadRequest{
Type: api.WorkloadTypeBundle,
Parameters: params,
})
return api.JobRequest{Workload: wl}
}
// waitForPeer polls the peers API until the single registered client appears
// and returns its ID.
func waitForPeer(ctx context.Context, t *testing.T) string {
t.Helper()
var peerID string
require.Eventually(t, func() bool {
peers, err := srv.API().Peers.List(ctx)
if err != nil || len(peers) == 0 {
return false
}
peerID = peers[0].Id
return true
}, 60*time.Second, 2*time.Second, "the client peer must register with management")
return peerID
}
// waitForJobTerminal polls a job until it leaves the pending state, then returns
// the final response.
func waitForJobTerminal(ctx context.Context, t *testing.T, peerID, jobID string) *api.JobResponse {
t.Helper()
var final *api.JobResponse
require.Eventually(t, func() bool {
j, err := srv.API().Peers.Jobs(peerID).Get(ctx, jobID)
if err != nil || j == nil {
return false
}
if j.Status == api.JobResponseStatusPending {
return false
}
final = j
return true
}, 120*time.Second, 2*time.Second, "job must reach a terminal state")
return final
}