| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484 |
- // Copyright (c) Tailscale Inc & AUTHORS
- // SPDX-License-Identifier: BSD-3-Clause
- package auditlog
- import (
- "context"
- "errors"
- "fmt"
- "sync"
- "testing"
- "time"
- qt "github.com/frankban/quicktest"
- "tailscale.com/ipn/store/mem"
- "tailscale.com/tailcfg"
- "tailscale.com/tstest"
- )
- // loggerForTest creates an auditLogger for you and cleans it up
- // (and ensures no goroutines are leaked) when the test is done.
- func loggerForTest(t *testing.T, opts Opts) *Logger {
- t.Helper()
- tstest.ResourceCheck(t)
- if opts.Logf == nil {
- opts.Logf = t.Logf
- }
- if opts.Store == nil {
- t.Fatalf("opts.Store must be set")
- }
- a := NewLogger(opts)
- t.Cleanup(func() {
- a.FlushAndStop(context.Background())
- })
- return a
- }
- func TestNonRetryableErrors(t *testing.T) {
- errorTests := []struct {
- desc string
- err error
- want bool
- }{
- {"DeadlineExceeded", context.DeadlineExceeded, false},
- {"Canceled", context.Canceled, false},
- {"Canceled wrapped", fmt.Errorf("%w: %w", context.Canceled, errors.New("ctx cancelled")), false},
- {"Random error", errors.New("random error"), false},
- }
- for _, tt := range errorTests {
- t.Run(tt.desc, func(t *testing.T) {
- if IsRetryableError(tt.err) != tt.want {
- t.Fatalf("retriable: got %v, want %v", !tt.want, tt.want)
- }
- })
- }
- }
- // TestEnqueueAndFlush enqueues n logs and flushes them.
- // We expect all logs to be flushed and for no
- // logs to remain in the store once FlushAndStop returns.
- func TestEnqueueAndFlush(t *testing.T) {
- c := qt.New(t)
- mockTransport := newMockTransport(nil)
- al := loggerForTest(t, Opts{
- RetryLimit: 200,
- Logf: t.Logf,
- Store: NewLogStore(&mem.Store{}),
- })
- c.Assert(al.SetProfileID("test"), qt.IsNil)
- c.Assert(al.Start(mockTransport), qt.IsNil)
- wantSent := 10
- for i := range wantSent {
- err := al.Enqueue(tailcfg.AuditNodeDisconnect, fmt.Sprintf("log %d", i))
- c.Assert(err, qt.IsNil)
- }
- al.FlushAndStop(context.Background())
- al.mu.Lock()
- defer al.mu.Unlock()
- gotStored, err := al.storedCountLocked()
- c.Assert(err, qt.IsNil)
- if wantStored := 0; gotStored != wantStored {
- t.Fatalf("stored: got %d, want %d", gotStored, wantStored)
- }
- if gotSent := mockTransport.sentCount(); gotSent != wantSent {
- t.Fatalf("sent: got %d, want %d", gotSent, wantSent)
- }
- }
- // TestEnqueueAndFlushWithFlushCancel calls FlushAndCancel with a cancelled
- // context. We expect nothing to be sent and all logs to be stored.
- func TestEnqueueAndFlushWithFlushCancel(t *testing.T) {
- c := qt.New(t)
- mockTransport := newMockTransport(&retriableError)
- al := loggerForTest(t, Opts{
- RetryLimit: 200,
- Logf: t.Logf,
- Store: NewLogStore(&mem.Store{}),
- })
- c.Assert(al.SetProfileID("test"), qt.IsNil)
- c.Assert(al.Start(mockTransport), qt.IsNil)
- for i := range 10 {
- err := al.Enqueue(tailcfg.AuditNodeDisconnect, fmt.Sprintf("log %d", i))
- c.Assert(err, qt.IsNil)
- }
- // Cancel the context before calling FlushAndStop - nothing should get sent.
- // This mimics a timeout before flush() has a chance to execute.
- ctx, cancel := context.WithCancel(context.Background())
- cancel()
- al.FlushAndStop(ctx)
- al.mu.Lock()
- defer al.mu.Unlock()
- gotStored, err := al.storedCountLocked()
- c.Assert(err, qt.IsNil)
- if wantStored := 10; gotStored != wantStored {
- t.Fatalf("stored: got %d, want %d", gotStored, wantStored)
- }
- if gotSent, wantSent := mockTransport.sentCount(), 0; gotSent != wantSent {
- t.Fatalf("sent: got %d, want %d", gotSent, wantSent)
- }
- }
- // TestDeduplicateAndSort tests that the most recent log is kept when deduplicating logs
- func TestDeduplicateAndSort(t *testing.T) {
- c := qt.New(t)
- al := loggerForTest(t, Opts{
- RetryLimit: 100,
- Logf: t.Logf,
- Store: NewLogStore(&mem.Store{}),
- })
- c.Assert(al.SetProfileID("test"), qt.IsNil)
- logs := []*transaction{
- {EventID: "1", Details: "log 1", TimeStamp: time.Now().Add(-time.Minute * 1), Retries: 1},
- }
- al.mu.Lock()
- defer al.mu.Unlock()
- al.appendToStoreLocked(logs)
- // Update the transaction and re-append it
- logs[0].Retries = 2
- al.appendToStoreLocked(logs)
- fromStore, err := al.store.load("test")
- c.Assert(err, qt.IsNil)
- // We should see only one transaction
- if wantStored, gotStored := len(logs), len(fromStore); gotStored != wantStored {
- t.Fatalf("stored: got %d, want %d", gotStored, wantStored)
- }
- // We should see the latest transaction
- if wantRetryCount, gotRetryCount := 2, fromStore[0].Retries; gotRetryCount != wantRetryCount {
- t.Fatalf("reties: got %d, want %d", gotRetryCount, wantRetryCount)
- }
- }
- func TestChangeProfileId(t *testing.T) {
- c := qt.New(t)
- al := loggerForTest(t, Opts{
- RetryLimit: 100,
- Logf: t.Logf,
- Store: NewLogStore(&mem.Store{}),
- })
- c.Assert(al.SetProfileID("test"), qt.IsNil)
- // Calling SetProfileID with the same profile ID must not fail.
- c.Assert(al.SetProfileID("test"), qt.IsNil)
- // Changing a profile ID must fail.
- c.Assert(al.SetProfileID("test2"), qt.IsNotNil)
- }
- // TestSendOnRestore pushes a n logs to the persistent store, and ensures they
- // are sent as soon as Start is called then checks to ensure the sent logs no
- // longer exist in the store.
- func TestSendOnRestore(t *testing.T) {
- c := qt.New(t)
- mockTransport := newMockTransport(nil)
- al := loggerForTest(t, Opts{
- RetryLimit: 100,
- Logf: t.Logf,
- Store: NewLogStore(&mem.Store{}),
- })
- al.SetProfileID("test")
- wantTotal := 10
- for range 10 {
- al.Enqueue(tailcfg.AuditNodeDisconnect, "log")
- }
- c.Assert(al.Start(mockTransport), qt.IsNil)
- al.FlushAndStop(context.Background())
- al.mu.Lock()
- defer al.mu.Unlock()
- gotStored, err := al.storedCountLocked()
- c.Assert(err, qt.IsNil)
- if wantStored := 0; gotStored != wantStored {
- t.Fatalf("stored: got %d, want %d", gotStored, wantStored)
- }
- if gotSent, wantSent := mockTransport.sentCount(), wantTotal; gotSent != wantSent {
- t.Fatalf("sent: got %d, want %d", gotSent, wantSent)
- }
- }
- // TestFailureExhaustion enqueues n logs, with the transport in a failable state.
- // We then set it to a non-failing state, call FlushAndStop and expect all logs to be sent.
- func TestFailureExhaustion(t *testing.T) {
- c := qt.New(t)
- mockTransport := newMockTransport(&retriableError)
- al := loggerForTest(t, Opts{
- RetryLimit: 1,
- Logf: t.Logf,
- Store: NewLogStore(&mem.Store{}),
- })
- c.Assert(al.SetProfileID("test"), qt.IsNil)
- c.Assert(al.Start(mockTransport), qt.IsNil)
- for range 10 {
- err := al.Enqueue(tailcfg.AuditNodeDisconnect, "log")
- c.Assert(err, qt.IsNil)
- }
- al.FlushAndStop(context.Background())
- al.mu.Lock()
- defer al.mu.Unlock()
- gotStored, err := al.storedCountLocked()
- c.Assert(err, qt.IsNil)
- if wantStored := 0; gotStored != wantStored {
- t.Fatalf("stored: got %d, want %d", gotStored, wantStored)
- }
- if gotSent, wantSent := mockTransport.sentCount(), 0; gotSent != wantSent {
- t.Fatalf("sent: got %d, want %d", gotSent, wantSent)
- }
- }
- // TestEnqueueAndFailNoRetry enqueues a set of logs, all of which will fail and are not
- // retriable. We then call FlushAndStop and expect all to be unsent.
- func TestEnqueueAndFailNoRetry(t *testing.T) {
- c := qt.New(t)
- mockTransport := newMockTransport(&nonRetriableError)
- al := loggerForTest(t, Opts{
- RetryLimit: 100,
- Logf: t.Logf,
- Store: NewLogStore(&mem.Store{}),
- })
- c.Assert(al.SetProfileID("test"), qt.IsNil)
- c.Assert(al.Start(mockTransport), qt.IsNil)
- for i := range 10 {
- err := al.Enqueue(tailcfg.AuditNodeDisconnect, fmt.Sprintf("log %d", i))
- c.Assert(err, qt.IsNil)
- }
- al.FlushAndStop(context.Background())
- al.mu.Lock()
- defer al.mu.Unlock()
- gotStored, err := al.storedCountLocked()
- c.Assert(err, qt.IsNil)
- if wantStored := 0; gotStored != wantStored {
- t.Fatalf("stored: got %d, want %d", gotStored, wantStored)
- }
- if gotSent, wantSent := mockTransport.sentCount(), 0; gotSent != wantSent {
- t.Fatalf("sent: got %d, want %d", gotSent, wantSent)
- }
- }
- // TestEnqueueAndRetry enqueues a set of logs, all of which will fail and are retriable.
- // Mid-test, we set the transport to not-fail and expect the queue to flush properly
- // We set the backoff parameters to 0 seconds so retries are immediate.
- func TestEnqueueAndRetry(t *testing.T) {
- c := qt.New(t)
- mockTransport := newMockTransport(&retriableError)
- al := loggerForTest(t, Opts{
- RetryLimit: 100,
- Logf: t.Logf,
- Store: NewLogStore(&mem.Store{}),
- })
- al.backoffOpts = backoffOpts{
- min: 1 * time.Millisecond,
- max: 4 * time.Millisecond,
- multiplier: 2.0,
- }
- c.Assert(al.SetProfileID("test"), qt.IsNil)
- c.Assert(al.Start(mockTransport), qt.IsNil)
- err := al.Enqueue(tailcfg.AuditNodeDisconnect, fmt.Sprintf("log 1"))
- c.Assert(err, qt.IsNil)
- // This will wait for at least 2 retries
- gotRetried, wantRetried := mockTransport.waitForSendAttemptsToReach(3), true
- if gotRetried != wantRetried {
- t.Fatalf("retried: got %v, want %v", gotRetried, wantRetried)
- }
- mockTransport.setErrorCondition(nil)
- al.FlushAndStop(context.Background())
- al.mu.Lock()
- defer al.mu.Unlock()
- gotStored, err := al.storedCountLocked()
- c.Assert(err, qt.IsNil)
- if wantStored := 0; gotStored != wantStored {
- t.Fatalf("stored: got %d, want %d", gotStored, wantStored)
- }
- if gotSent, wantSent := mockTransport.sentCount(), 1; gotSent != wantSent {
- t.Fatalf("sent: got %d, want %d", gotSent, wantSent)
- }
- }
- // TestEnqueueBeforeSetProfileID tests that logs enqueued before SetProfileId are not sent
- func TestEnqueueBeforeSetProfileID(t *testing.T) {
- c := qt.New(t)
- al := loggerForTest(t, Opts{
- RetryLimit: 100,
- Logf: t.Logf,
- Store: NewLogStore(&mem.Store{}),
- })
- err := al.Enqueue(tailcfg.AuditNodeDisconnect, "log")
- c.Assert(err, qt.IsNotNil)
- al.FlushAndStop(context.Background())
- al.mu.Lock()
- defer al.mu.Unlock()
- gotStored, err := al.storedCountLocked()
- c.Assert(err, qt.IsNotNil)
- if wantStored := 0; gotStored != wantStored {
- t.Fatalf("stored: got %d, want %d", gotStored, wantStored)
- }
- }
- // TestLogStoring tests that audit logs are persisted sorted by timestamp, oldest to newest
- func TestLogSorting(t *testing.T) {
- c := qt.New(t)
- mockStore := NewLogStore(&mem.Store{})
- logs := []*transaction{
- {EventID: "1", Details: "log 3", TimeStamp: time.Now().Add(-time.Minute * 1)},
- {EventID: "1", Details: "log 3", TimeStamp: time.Now().Add(-time.Minute * 2)},
- {EventID: "2", Details: "log 2", TimeStamp: time.Now().Add(-time.Minute * 3)},
- {EventID: "3", Details: "log 1", TimeStamp: time.Now().Add(-time.Minute * 4)},
- }
- wantLogs := []transaction{
- {Details: "log 1"},
- {Details: "log 2"},
- {Details: "log 3"},
- }
- mockStore.save("test", logs)
- gotLogs, err := mockStore.load("test")
- c.Assert(err, qt.IsNil)
- gotLogs = deduplicateAndSort(gotLogs)
- for i := range gotLogs {
- if want, got := wantLogs[i].Details, gotLogs[i].Details; want != got {
- t.Fatalf("Details: got %v, want %v", got, want)
- }
- }
- }
- // mock implementations for testing
- // newMockTransport returns a mock transport for testing
- // If err is no nil, SendAuditLog will return this error if the send is attempted
- // before the context is cancelled.
- func newMockTransport(err error) *mockAuditLogTransport {
- return &mockAuditLogTransport{
- err: err,
- attempts: make(chan int, 1),
- }
- }
- type mockAuditLogTransport struct {
- attempts chan int // channel to notify of send attempts
- mu sync.Mutex
- sendAttmpts int // number of attempts to send logs
- sendCount int // number of logs sent by the transport
- err error // error to return when sending logs
- }
- // waitForSendAttemptsToReach blocks until the number of send attempts reaches n
- // This should be use only in tests where the transport is expected to retry sending logs
- func (t *mockAuditLogTransport) waitForSendAttemptsToReach(n int) bool {
- for attempts := range t.attempts {
- if attempts >= n {
- return true
- }
- }
- return false
- }
- func (t *mockAuditLogTransport) setErrorCondition(err error) {
- t.mu.Lock()
- defer t.mu.Unlock()
- t.err = err
- }
- func (t *mockAuditLogTransport) sentCount() int {
- t.mu.Lock()
- defer t.mu.Unlock()
- return t.sendCount
- }
- func (t *mockAuditLogTransport) SendAuditLog(ctx context.Context, _ tailcfg.AuditLogRequest) (err error) {
- t.mu.Lock()
- t.sendAttmpts += 1
- defer func() {
- a := t.sendAttmpts
- t.mu.Unlock()
- select {
- case t.attempts <- a:
- default:
- }
- }()
- select {
- case <-ctx.Done():
- return ctx.Err()
- default:
- }
- if t.err != nil {
- return t.err
- }
- t.sendCount += 1
- return nil
- }
- var (
- retriableError = mockError{errors.New("retriable error")}
- nonRetriableError = mockError{errors.New("permanent failure error")}
- )
- type mockError struct {
- error
- }
- func (e mockError) Retryable() bool {
- return e == retriableError
- }
|