mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-29 11:01:29 +02:00
435 lines
12 KiB
Go
435 lines
12 KiB
Go
package approval
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/netbirdio/netbird/client/proto"
|
|
)
|
|
|
|
// fakePublisher records published events and reports whether subscribers
|
|
// are connected. The subscribers flag is the security-critical signal:
|
|
// when false the broker must refuse to emit and the gate must fail closed.
|
|
type fakePublisher struct {
|
|
mu sync.Mutex
|
|
subscribers bool
|
|
events []*proto.SystemEvent
|
|
}
|
|
|
|
func (p *fakePublisher) PublishEvent(
|
|
severity proto.SystemEvent_Severity,
|
|
category proto.SystemEvent_Category,
|
|
msg string,
|
|
userMsg string,
|
|
metadata map[string]string,
|
|
) {
|
|
p.mu.Lock()
|
|
p.events = append(p.events, &proto.SystemEvent{
|
|
Severity: severity,
|
|
Category: category,
|
|
Message: msg,
|
|
UserMessage: userMsg,
|
|
Metadata: metadata,
|
|
})
|
|
p.mu.Unlock()
|
|
}
|
|
|
|
func (p *fakePublisher) HasEventSubscribers() bool {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
return p.subscribers
|
|
}
|
|
|
|
func (p *fakePublisher) lastEvent(t *testing.T) *proto.SystemEvent {
|
|
t.Helper()
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
require.NotEmpty(t, p.events, "publisher saw no events")
|
|
return p.events[len(p.events)-1]
|
|
}
|
|
|
|
func (p *fakePublisher) eventCount() int {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
return len(p.events)
|
|
}
|
|
|
|
// TestRequestNoSubscriberFailsClosed is the core fail-closed invariant:
|
|
// when the UI is not subscribed, the broker must refuse without emitting
|
|
// an event or arming a waiter. A regression here is a silent bypass.
|
|
func TestRequestNoSubscriberFailsClosed(t *testing.T) {
|
|
pub := &fakePublisher{subscribers: false}
|
|
b := New(pub)
|
|
|
|
_, err := b.Request(context.Background(), Prompt{Kind: KindVNC, Subject: "test"})
|
|
assert.ErrorIs(t, err, ErrNoSubscriber)
|
|
assert.Equal(t, 0, pub.eventCount(), "no event must be emitted when fail-closed")
|
|
|
|
b.mu.Lock()
|
|
pending := len(b.pending)
|
|
b.mu.Unlock()
|
|
assert.Equal(t, 0, pending, "no waiter must be registered on fail-closed")
|
|
}
|
|
|
|
// TestRequestTimeoutDenies verifies that a request without a UI response
|
|
// returns ErrTimeout (deny) rather than nil (silent accept). Uses a short
|
|
// per-test broker timeout via Respond after the fact to keep the test fast.
|
|
func TestRequestTimeoutDenies(t *testing.T) {
|
|
// Replace DefaultTimeout for the lifetime of this test.
|
|
orig := DefaultTimeout
|
|
defaultTimeout(t, 60*time.Millisecond)
|
|
defer defaultTimeout(t, orig)
|
|
|
|
pub := &fakePublisher{subscribers: true}
|
|
b := New(pub)
|
|
|
|
start := time.Now()
|
|
_, err := b.Request(context.Background(), Prompt{Kind: KindVNC, Subject: "test"})
|
|
assert.ErrorIs(t, err, ErrTimeout, "missing user response must yield ErrTimeout, not nil")
|
|
assert.GreaterOrEqual(t, time.Since(start), 50*time.Millisecond, "timeout fired prematurely")
|
|
}
|
|
|
|
// TestRequestDenied returns ErrDenied when the UI responds with false.
|
|
func TestRequestDenied(t *testing.T) {
|
|
pub := &fakePublisher{subscribers: true}
|
|
b := New(pub)
|
|
|
|
var requestID string
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC, Subject: "test"})
|
|
}()
|
|
|
|
requestID = waitForRequestID(t, pub)
|
|
require.True(t, b.Respond(requestID, Decision{Accept: false}))
|
|
|
|
select {
|
|
case err := <-done:
|
|
assert.ErrorIs(t, err, ErrDenied)
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Request did not return after Respond(false)")
|
|
}
|
|
}
|
|
|
|
// TestRequestAccepted is the happy path. Failure here doesn't bypass the
|
|
// gate but breaks the feature.
|
|
func TestRequestAccepted(t *testing.T) {
|
|
pub := &fakePublisher{subscribers: true}
|
|
b := New(pub)
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC, Subject: "test"})
|
|
}()
|
|
|
|
id := waitForRequestID(t, pub)
|
|
require.True(t, b.Respond(id, Decision{Accept: true}))
|
|
|
|
select {
|
|
case err := <-done:
|
|
assert.NoError(t, err)
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Request did not return after Respond(true)")
|
|
}
|
|
}
|
|
|
|
// TestRequestCtxCancelDenies verifies that an upstream cancel (e.g. the
|
|
// engine shutting down mid-prompt) returns the cancel error rather than
|
|
// nil. A nil here would be a silent bypass on shutdown races.
|
|
func TestRequestCtxCancelDenies(t *testing.T) {
|
|
pub := &fakePublisher{subscribers: true}
|
|
b := New(pub)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- requestErr(b, ctx, Prompt{Kind: KindVNC, Subject: "test"})
|
|
}()
|
|
|
|
// Wait until the prompt is in flight so cancel races a live waiter.
|
|
_ = waitForRequestID(t, pub)
|
|
cancel()
|
|
|
|
select {
|
|
case err := <-done:
|
|
assert.ErrorIs(t, err, context.Canceled)
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Request did not return after ctx cancel")
|
|
}
|
|
}
|
|
|
|
// TestRespondUnknownIsNoop ensures a stray RespondApproval RPC cannot
|
|
// affect or accidentally accept any in-flight request whose id it doesn't
|
|
// match. Also confirms it doesn't panic.
|
|
func TestRespondUnknownIsNoop(t *testing.T) {
|
|
pub := &fakePublisher{subscribers: true}
|
|
b := New(pub)
|
|
|
|
// No in-flight prompts: Respond returns false.
|
|
assert.False(t, b.Respond("does-not-exist", Decision{Accept: true}))
|
|
|
|
// With an in-flight prompt, a wrong id still returns false and the
|
|
// prompt remains armed (eventually timing out as a deny).
|
|
defaultTimeout(t, 60*time.Millisecond)
|
|
defer defaultTimeout(t, DefaultTimeout)
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC})
|
|
}()
|
|
realID := waitForRequestID(t, pub)
|
|
assert.False(t, b.Respond("totally-bogus", Decision{Accept: true}), "unknown id must not match")
|
|
assert.NotEqual(t, "totally-bogus", realID)
|
|
|
|
select {
|
|
case err := <-done:
|
|
assert.ErrorIs(t, err, ErrTimeout, "armed prompt must still time out, not accept")
|
|
case <-time.After(time.Second):
|
|
t.Fatal("prompt did not resolve")
|
|
}
|
|
}
|
|
|
|
// TestRespondAfterTimeoutNoop confirms a late accept response can't
|
|
// retroactively flip a denied (timed-out) request. The dropPending defer
|
|
// in Request must have removed the entry by the time Respond races in.
|
|
func TestRespondAfterTimeoutNoop(t *testing.T) {
|
|
defaultTimeout(t, 30*time.Millisecond)
|
|
defer defaultTimeout(t, DefaultTimeout)
|
|
|
|
pub := &fakePublisher{subscribers: true}
|
|
b := New(pub)
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC})
|
|
}()
|
|
id := waitForRequestID(t, pub)
|
|
|
|
select {
|
|
case err := <-done:
|
|
require.ErrorIs(t, err, ErrTimeout)
|
|
case <-time.After(time.Second):
|
|
t.Fatal("prompt did not time out")
|
|
}
|
|
|
|
assert.False(t, b.Respond(id, Decision{Accept: true}), "late respond must be no-op")
|
|
}
|
|
|
|
// TestRespondDoubleNoop ensures a duplicate ack from the UI doesn't leak
|
|
// past the matched waiter or panic on a closed/full channel.
|
|
func TestRespondDoubleNoop(t *testing.T) {
|
|
pub := &fakePublisher{subscribers: true}
|
|
b := New(pub)
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC})
|
|
}()
|
|
id := waitForRequestID(t, pub)
|
|
require.True(t, b.Respond(id, Decision{Accept: true}))
|
|
assert.False(t, b.Respond(id, Decision{Accept: false}), "second response must be no-op")
|
|
|
|
select {
|
|
case err := <-done:
|
|
assert.NoError(t, err)
|
|
case <-time.After(time.Second):
|
|
t.Fatal("prompt did not resolve")
|
|
}
|
|
}
|
|
|
|
// TestNilBrokerRequestErrors guards the engine pre-init path where the
|
|
// broker may not yet exist (or its publisher is nil): Request must
|
|
// error, never silently accept.
|
|
func TestNilBrokerRequestErrors(t *testing.T) {
|
|
var b *Broker
|
|
_, err := b.Request(context.Background(), Prompt{Kind: KindVNC})
|
|
assert.Error(t, err, "nil broker must error, never silently accept")
|
|
|
|
b2 := New(nil)
|
|
_, err = b2.Request(context.Background(), Prompt{Kind: KindVNC})
|
|
assert.Error(t, err, "broker with nil publisher must error, never silently accept")
|
|
}
|
|
|
|
// TestPromptMetadataInjected confirms the broker stamps request_id, kind,
|
|
// and expires_at on the emitted event. The UI relies on these keys; if
|
|
// they are dropped, the user cannot route the prompt and the response
|
|
// path breaks (which fails closed via timeout).
|
|
func TestPromptMetadataInjected(t *testing.T) {
|
|
pub := &fakePublisher{subscribers: true}
|
|
b := New(pub)
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- requestErr(b, context.Background(), Prompt{
|
|
Kind: KindVNC,
|
|
Subject: "VNC connection from peerA",
|
|
Metadata: map[string]string{"peer_name": "peerA"},
|
|
})
|
|
}()
|
|
|
|
id := waitForRequestID(t, pub)
|
|
ev := pub.lastEvent(t)
|
|
|
|
assert.Equal(t, proto.SystemEvent_APPROVAL, ev.Category)
|
|
assert.Equal(t, KindVNC, ev.Metadata[MetaKind])
|
|
assert.Equal(t, id, ev.Metadata[MetaRequestID])
|
|
assert.NotEmpty(t, ev.Metadata[MetaExpiresAt])
|
|
assert.Equal(t, "peerA", ev.Metadata["peer_name"], "caller metadata must pass through")
|
|
|
|
require.True(t, b.Respond(id, Decision{Accept: true}))
|
|
<-done
|
|
}
|
|
|
|
// TestConcurrentRequests verifies that two concurrent prompts are tracked
|
|
// independently. A bug that aliases ids would let one Respond unblock
|
|
// the wrong waiter (a silent accept across prompts).
|
|
func TestConcurrentRequests(t *testing.T) {
|
|
pub := &fakePublisher{subscribers: true}
|
|
b := New(pub)
|
|
|
|
const n = 20
|
|
results := make(chan error, n)
|
|
for i := 0; i < n; i++ {
|
|
go func() {
|
|
results <- requestErr(b, context.Background(), Prompt{Kind: KindVNC})
|
|
}()
|
|
}
|
|
|
|
ids := waitForNRequestIDs(t, pub, n)
|
|
require.Len(t, ids, n)
|
|
|
|
// Deny exactly half, accept the rest. Track outcome per id so we can
|
|
// match each Request's return value against the response we sent.
|
|
denySet := make(map[string]bool, n)
|
|
for i, id := range ids {
|
|
deny := i%2 == 0
|
|
denySet[id] = deny
|
|
require.True(t, b.Respond(id, Decision{Accept: !deny}))
|
|
}
|
|
|
|
// Collect all returns and check no nil errors slipped past a deny.
|
|
var accepted, denied atomic.Int32
|
|
for i := 0; i < n; i++ {
|
|
select {
|
|
case err := <-results:
|
|
if err == nil {
|
|
accepted.Add(1)
|
|
} else {
|
|
assert.ErrorIs(t, err, ErrDenied)
|
|
denied.Add(1)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatalf("only got %d/%d responses", i, n)
|
|
}
|
|
}
|
|
assert.Equal(t, int32(n/2), denied.Load())
|
|
assert.Equal(t, int32(n/2), accepted.Load())
|
|
}
|
|
|
|
// waitForRequestID blocks until the publisher sees its next event and
|
|
// returns the request_id stamped on it.
|
|
func waitForRequestID(t *testing.T, pub *fakePublisher) string {
|
|
t.Helper()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
pub.mu.Lock()
|
|
count := len(pub.events)
|
|
var id string
|
|
if count > 0 {
|
|
id = pub.events[count-1].Metadata[MetaRequestID]
|
|
}
|
|
pub.mu.Unlock()
|
|
if id != "" {
|
|
return id
|
|
}
|
|
time.Sleep(2 * time.Millisecond)
|
|
}
|
|
t.Fatal("timeout waiting for emitted event")
|
|
return ""
|
|
}
|
|
|
|
func waitForNRequestIDs(t *testing.T, pub *fakePublisher, n int) []string {
|
|
t.Helper()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
pub.mu.Lock()
|
|
count := len(pub.events)
|
|
pub.mu.Unlock()
|
|
if count >= n {
|
|
break
|
|
}
|
|
time.Sleep(2 * time.Millisecond)
|
|
}
|
|
pub.mu.Lock()
|
|
defer pub.mu.Unlock()
|
|
out := make([]string, 0, len(pub.events))
|
|
seen := make(map[string]struct{}, len(pub.events))
|
|
for _, ev := range pub.events {
|
|
id := ev.Metadata[MetaRequestID]
|
|
if id == "" {
|
|
continue
|
|
}
|
|
if _, dup := seen[id]; dup {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
out = append(out, id)
|
|
}
|
|
if len(out) < n {
|
|
t.Fatalf("only got %d/%d request ids", len(out), n)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// defaultTimeout swaps the broker's per-request wall-clock window so the
|
|
// timeout tests run quickly. Restores the prior value on the next call.
|
|
func defaultTimeout(t *testing.T, d time.Duration) {
|
|
t.Helper()
|
|
if d <= 0 {
|
|
t.Fatal("defaultTimeout must be > 0")
|
|
}
|
|
timeoutValue = func() time.Duration { return d }
|
|
}
|
|
|
|
// requestErr wraps Broker.Request to drop the Decision when tests only
|
|
// care about the error path. Keeps the goroutine bodies tight.
|
|
func requestErr(b *Broker, ctx context.Context, p Prompt) error {
|
|
_, err := b.Request(ctx, p)
|
|
return err
|
|
}
|
|
|
|
// TestRequestViewOnly checks the view-only outcome flows through Request's
|
|
// Decision return without being silently swallowed.
|
|
func TestRequestViewOnly(t *testing.T) {
|
|
pub := &fakePublisher{subscribers: true}
|
|
b := New(pub)
|
|
|
|
type result struct {
|
|
d Decision
|
|
err error
|
|
}
|
|
done := make(chan result, 1)
|
|
go func() {
|
|
d, err := b.Request(context.Background(), Prompt{Kind: KindVNC})
|
|
done <- result{d, err}
|
|
}()
|
|
|
|
id := waitForRequestID(t, pub)
|
|
require.True(t, b.Respond(id, Decision{Accept: true, ViewOnly: true}))
|
|
|
|
select {
|
|
case r := <-done:
|
|
assert.NoError(t, r.err)
|
|
assert.True(t, r.d.Accept)
|
|
assert.True(t, r.d.ViewOnly, "ViewOnly must survive the round-trip")
|
|
case <-time.After(time.Second):
|
|
t.Fatal("view-only request did not resolve")
|
|
}
|
|
}
|