Files
coder/enterprise/coderd/usage/publisher_test.go
T

747 lines
24 KiB
Go

package usage_test
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
"go.uber.org/mock/gomock"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/usage/usagetypes"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/usage"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m, testutil.GoleakOptions...)
}
// TestIntegration tests the inserter and publisher by running them with a real
// database.
func TestIntegration(t *testing.T) {
t.Parallel()
const eventCount = 3
ctx := testutil.Context(t, testutil.WaitLong)
log := slogtest.Make(t, nil)
db, _ := dbtestutil.NewDB(t)
clock := quartz.NewMock(t)
deploymentID, licenseJWT := configureDeployment(ctx, t, db)
now := time.Now()
var (
calls int
handler func(req usagetypes.TallymanV1IngestRequest) any
)
ingestURL := fakeServer(t, tallymanHandler(t, deploymentID.String(), licenseJWT, func(req usagetypes.TallymanV1IngestRequest) any {
calls++
t.Logf("tallyman backend received call %d", calls)
if handler == nil {
t.Errorf("handler is nil")
return usagetypes.TallymanV1IngestResponse{}
}
return handler(req)
}))
inserter := usage.NewDBInserter(
usage.InserterWithClock(clock),
)
// Insert an old event that should never be published.
clock.Set(now.Add(-31 * 24 * time.Hour))
err := inserter.InsertDiscreteUsageEvent(ctx, db, usagetypes.DCManagedAgentsV1{
Count: 31,
})
require.NoError(t, err)
// Insert the events we expect to be published.
clock.Set(now.Add(1 * time.Second))
for i := 0; i < eventCount; i++ {
clock.Advance(time.Second)
err := inserter.InsertDiscreteUsageEvent(ctx, db, usagetypes.DCManagedAgentsV1{
Count: uint64(i + 1), // nolint:gosec // these numbers are tiny and will not overflow
})
require.NoErrorf(t, err, "collecting event %d", i)
}
// Wrap the publisher's DB in a dbauthz to ensure that the publisher has
// enough permissions.
authzDB := dbauthz.New(db, rbac.NewAuthorizer(prometheus.NewRegistry()), log, coderdtest.AccessControlStorePointer())
publisher := usage.NewTallymanPublisher(ctx, log, authzDB, coderdenttest.Keys,
usage.PublisherWithClock(clock),
usage.PublisherWithIngestURL(ingestURL),
)
defer publisher.Close()
// Start the publisher with a trap.
tickerTrap := clock.Trap().NewTicker()
defer tickerTrap.Close()
startErr := make(chan error)
go func() {
err := publisher.Start()
testutil.AssertSend(ctx, t, startErr, err)
}()
tickerCall := tickerTrap.MustWait(ctx)
tickerCall.MustRelease(ctx)
// The initial duration will always be some time between 5m and 17m.
require.GreaterOrEqual(t, tickerCall.Duration, 5*time.Minute)
require.LessOrEqual(t, tickerCall.Duration, 17*time.Minute)
require.NoError(t, testutil.RequireReceive(ctx, t, startErr))
// Set up a trap for the ticker.Reset call.
tickerResetTrap := clock.Trap().TickerReset()
defer tickerResetTrap.Close()
// Configure the handler for the first publish. This handler will accept the
// first event, temporarily reject the second, and permanently reject the
// third.
var temporarilyRejectedEventID string
handler = func(req usagetypes.TallymanV1IngestRequest) any {
// On the first call, accept the first event, temporarily reject the
// second, and permanently reject the third.
acceptedEvents := make([]usagetypes.TallymanV1IngestAcceptedEvent, 1)
rejectedEvents := make([]usagetypes.TallymanV1IngestRejectedEvent, 2)
if assert.Len(t, req.Events, eventCount) {
assert.JSONEqf(t, jsoninate(t, usagetypes.DCManagedAgentsV1{
Count: 1,
}), string(req.Events[0].EventData), "event data did not match for event %d", 0)
acceptedEvents[0].ID = req.Events[0].ID
temporarilyRejectedEventID = req.Events[1].ID
assert.JSONEqf(t, jsoninate(t, usagetypes.DCManagedAgentsV1{
Count: 2,
}), string(req.Events[1].EventData), "event data did not match for event %d", 1)
rejectedEvents[0].ID = req.Events[1].ID
rejectedEvents[0].Message = "temporarily rejected"
rejectedEvents[0].Permanent = false
assert.JSONEqf(t, jsoninate(t, usagetypes.DCManagedAgentsV1{
Count: 3,
}), string(req.Events[2].EventData), "event data did not match for event %d", 2)
rejectedEvents[1].ID = req.Events[2].ID
rejectedEvents[1].Message = "permanently rejected"
rejectedEvents[1].Permanent = true
}
return usagetypes.TallymanV1IngestResponse{
AcceptedEvents: acceptedEvents,
RejectedEvents: rejectedEvents,
}
}
// Advance the clock to the initial tick, which should trigger the first
// publish, then wait for the reset call. The duration will always be 17m
// for resets (only the initial tick is variable).
clock.Advance(tickerCall.Duration)
tickerResetCall := tickerResetTrap.MustWait(ctx)
require.Equal(t, 17*time.Minute, tickerResetCall.Duration)
tickerResetCall.MustRelease(ctx)
// The publisher should have published the events once.
require.Equal(t, 1, calls)
// Set the handler for the next publish call. This call should only include
// the temporarily rejected event from earlier. This time we'll accept it.
handler = func(req usagetypes.TallymanV1IngestRequest) any {
assert.Len(t, req.Events, 1)
acceptedEvents := make([]usagetypes.TallymanV1IngestAcceptedEvent, len(req.Events))
for i, event := range req.Events {
assert.Equal(t, temporarilyRejectedEventID, event.ID)
acceptedEvents[i].ID = event.ID
}
return usagetypes.TallymanV1IngestResponse{
AcceptedEvents: acceptedEvents,
RejectedEvents: []usagetypes.TallymanV1IngestRejectedEvent{},
}
}
// Advance the clock to the next tick and wait for the reset call.
clock.Advance(tickerResetCall.Duration)
tickerResetCall = tickerResetTrap.MustWait(ctx)
tickerResetCall.MustRelease(ctx)
// The publisher should have published the events again.
require.Equal(t, 2, calls)
// There should be no more publish calls after this, so set the handler to
// nil.
handler = nil
// Advance the clock to the next tick.
clock.Advance(tickerResetCall.Duration)
tickerResetTrap.MustWait(ctx).MustRelease(ctx)
// No publish should have taken place since there are no more events to
// publish.
require.Equal(t, 2, calls)
require.NoError(t, publisher.Close())
}
func TestPublisherNoEligibleLicenses(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
log := slogtest.Make(t, nil)
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
clock := quartz.NewMock(t)
// Configure the deployment manually.
deploymentID := uuid.New()
db.EXPECT().GetDeploymentID(gomock.Any()).Return(deploymentID.String(), nil).Times(1)
var calls int
ingestURL := fakeServer(t, tallymanHandler(t, deploymentID.String(), "", func(req usagetypes.TallymanV1IngestRequest) any {
calls++
return usagetypes.TallymanV1IngestResponse{
AcceptedEvents: []usagetypes.TallymanV1IngestAcceptedEvent{},
RejectedEvents: []usagetypes.TallymanV1IngestRejectedEvent{},
}
}))
publisher := usage.NewTallymanPublisher(ctx, log, db, coderdenttest.Keys,
usage.PublisherWithClock(clock),
usage.PublisherWithIngestURL(ingestURL),
)
defer publisher.Close()
// Start the publisher with a trap.
tickerTrap := clock.Trap().NewTicker()
defer tickerTrap.Close()
startErr := make(chan error)
go func() {
err := publisher.Start()
testutil.RequireSend(ctx, t, startErr, err)
}()
tickerCall := tickerTrap.MustWait(ctx)
tickerCall.MustRelease(ctx)
require.NoError(t, testutil.RequireReceive(ctx, t, startErr))
// Mock zero licenses.
db.EXPECT().GetUnexpiredLicenses(gomock.Any()).Return([]database.License{}, nil).Times(1)
// Tick and wait for the reset call.
tickerResetTrap := clock.Trap().TickerReset()
defer tickerResetTrap.Close()
clock.Advance(tickerCall.Duration)
tickerResetCall := tickerResetTrap.MustWait(ctx)
tickerResetCall.MustRelease(ctx)
// The publisher should not have published the events.
require.Equal(t, 0, calls)
// Mock a single license with usage publishing disabled.
licenseJWT := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
PublishUsageData: false,
})
db.EXPECT().GetUnexpiredLicenses(gomock.Any()).Return([]database.License{
{
ID: 1,
JWT: licenseJWT,
UploadedAt: dbtime.Now(),
Exp: dbtime.Now().Add(48 * time.Hour), // fake
UUID: uuid.New(),
},
}, nil).Times(1)
// Tick and wait for the reset call.
clock.Advance(tickerResetCall.Duration)
tickerResetTrap.MustWait(ctx).MustRelease(ctx)
// The publisher should still not have published the events.
require.Equal(t, 0, calls)
}
// TestPublisherClaimExpiry tests the claim query to ensure that events are not
// claimed if they've recently been claimed by another publisher.
func TestPublisherClaimExpiry(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
log := slogtest.Make(t, nil)
db, _ := dbtestutil.NewDB(t)
clock := quartz.NewMock(t)
deploymentID, licenseJWT := configureDeployment(ctx, t, db)
now := time.Now()
var calls int
ingestURL := fakeServer(t, tallymanHandler(t, deploymentID.String(), licenseJWT, func(req usagetypes.TallymanV1IngestRequest) any {
calls++
return tallymanAcceptAllHandler(req)
}))
inserter := usage.NewDBInserter(
usage.InserterWithClock(clock),
)
publisher := usage.NewTallymanPublisher(ctx, log, db, coderdenttest.Keys,
usage.PublisherWithClock(clock),
usage.PublisherWithIngestURL(ingestURL),
usage.PublisherWithInitialDelay(17*time.Minute),
)
defer publisher.Close()
// Create an event that was claimed 1h-18m ago. The ticker has a forced
// delay of 17m in this test.
clock.Set(now)
err := inserter.InsertDiscreteUsageEvent(ctx, db, usagetypes.DCManagedAgentsV1{
Count: 1,
})
require.NoError(t, err)
// Claim the event in the past. Claiming it this way via the database
// directly means it won't be marked as published or unclaimed.
events, err := db.SelectUsageEventsForPublishing(ctx, now.Add(-42*time.Minute))
require.NoError(t, err)
require.Len(t, events, 1)
// Start the publisher with a trap.
tickerTrap := clock.Trap().NewTicker()
defer tickerTrap.Close()
startErr := make(chan error)
go func() {
err := publisher.Start()
testutil.RequireSend(ctx, t, startErr, err)
}()
tickerCall := tickerTrap.MustWait(ctx)
require.Equal(t, 17*time.Minute, tickerCall.Duration)
tickerCall.MustRelease(ctx)
require.NoError(t, testutil.RequireReceive(ctx, t, startErr))
// Set up a trap for the ticker.Reset call.
tickerResetTrap := clock.Trap().TickerReset()
defer tickerResetTrap.Close()
// Advance the clock to the initial tick, which should trigger the first
// publish, then wait for the reset call. The duration will always be 17m
// for resets (only the initial tick is variable).
clock.Advance(tickerCall.Duration)
tickerResetCall := tickerResetTrap.MustWait(ctx)
require.Equal(t, 17*time.Minute, tickerResetCall.Duration)
tickerResetCall.MustRelease(ctx)
// No events should have been published since none are eligible.
require.Equal(t, 0, calls)
// Advance the clock to the next tick and wait for the reset call.
clock.Advance(tickerResetCall.Duration)
tickerResetCall = tickerResetTrap.MustWait(ctx)
tickerResetCall.MustRelease(ctx)
// The publisher should have published the event, as it's now eligible.
require.Equal(t, 1, calls)
}
// TestPublisherMissingEvents tests that the publisher notices events that are
// not returned by the Tallyman server and marks them as temporarily rejected.
func TestPublisherMissingEvents(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
log := slogtest.Make(t, nil)
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
deploymentID, licenseJWT := configureMockDeployment(t, db)
clock := quartz.NewMock(t)
now := time.Now()
clock.Set(now)
var calls int
ingestURL := fakeServer(t, tallymanHandler(t, deploymentID.String(), licenseJWT, func(req usagetypes.TallymanV1IngestRequest) any {
calls++
return usagetypes.TallymanV1IngestResponse{
AcceptedEvents: []usagetypes.TallymanV1IngestAcceptedEvent{},
RejectedEvents: []usagetypes.TallymanV1IngestRejectedEvent{},
}
}))
publisher := usage.NewTallymanPublisher(ctx, log, db, coderdenttest.Keys,
usage.PublisherWithClock(clock),
usage.PublisherWithIngestURL(ingestURL),
)
// Expect the publisher to call SelectUsageEventsForPublishing, followed by
// UpdateUsageEventsPostPublish.
events := []database.UsageEvent{
{
ID: uuid.New().String(),
EventType: string(usagetypes.UsageEventTypeDCManagedAgentsV1),
EventData: []byte(jsoninate(t, usagetypes.DCManagedAgentsV1{
Count: 1,
})),
CreatedAt: now,
PublishedAt: sql.NullTime{},
PublishStartedAt: sql.NullTime{},
FailureMessage: sql.NullString{},
},
}
db.EXPECT().SelectUsageEventsForPublishing(gomock.Any(), gomock.Any()).Return(events, nil).Times(1)
db.EXPECT().UpdateUsageEventsPostPublish(gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, params database.UpdateUsageEventsPostPublishParams) error {
assert.Equal(t, []string{events[0].ID}, params.IDs)
assert.Equal(t, []string{"tallyman did not include the event in the response"}, params.FailureMessages)
assert.Equal(t, []bool{false}, params.SetPublishedAts)
return nil
},
).Times(1)
// Start the publisher with a trap.
tickerTrap := clock.Trap().NewTicker()
defer tickerTrap.Close()
startErr := make(chan error)
go func() {
err := publisher.Start()
testutil.RequireSend(ctx, t, startErr, err)
}()
tickerCall := tickerTrap.MustWait(ctx)
tickerCall.MustRelease(ctx)
require.NoError(t, testutil.RequireReceive(ctx, t, startErr))
// Tick and wait for the reset call.
tickerResetTrap := clock.Trap().TickerReset()
defer tickerResetTrap.Close()
clock.Advance(tickerCall.Duration)
tickerResetTrap.MustWait(ctx).MustRelease(ctx)
// The publisher should have published the events once.
require.Equal(t, 1, calls)
require.NoError(t, publisher.Close())
}
func TestPublisherLicenseSelection(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
log := slogtest.Make(t, nil)
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
clock := quartz.NewMock(t)
now := time.Now()
// Configure the deployment manually.
deploymentID := uuid.New()
db.EXPECT().GetDeploymentID(gomock.Any()).Return(deploymentID.String(), nil).Times(1)
// Insert multiple licenses:
// 1. PublishUsageData false, type=salesforce, iat 30m ago (ineligible, publish not enabled)
// 2. PublishUsageData true, type=trial, iat 1h ago (ineligible, not salesforce)
// 3. PublishUsageData true, type=salesforce, iat 30m ago, exp 10m ago (ineligible, expired)
// 4. PublishUsageData true, type=salesforce, iat 1h ago (eligible)
// 5. PublishUsageData true, type=salesforce, iat 30m ago (eligible, and newer!)
badLicense1 := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
PublishUsageData: false,
IssuedAt: now.Add(-30 * time.Minute),
})
badLicense2 := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
PublishUsageData: true,
IssuedAt: now.Add(-1 * time.Hour),
AccountType: "trial",
})
badLicense3 := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
PublishUsageData: true,
IssuedAt: now.Add(-30 * time.Minute),
ExpiresAt: now.Add(-10 * time.Minute),
})
badLicense4 := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
PublishUsageData: true,
IssuedAt: now.Add(-1 * time.Hour),
})
expectedLicense := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
PublishUsageData: true,
IssuedAt: now.Add(-30 * time.Minute),
})
// GetUnexpiredLicenses is not supposed to return expired licenses, but for
// the purposes of this test we're going to do it anyway.
db.EXPECT().GetUnexpiredLicenses(gomock.Any()).Return([]database.License{
{
ID: 1,
JWT: badLicense1,
Exp: now.Add(48 * time.Hour), // fake times, the JWT should be checked
UUID: uuid.New(),
UploadedAt: now,
},
{
ID: 2,
JWT: badLicense2,
Exp: now.Add(48 * time.Hour),
UUID: uuid.New(),
UploadedAt: now,
},
{
ID: 3,
JWT: badLicense3,
Exp: now.Add(48 * time.Hour),
UUID: uuid.New(),
UploadedAt: now,
},
{
ID: 4,
JWT: badLicense4,
Exp: now.Add(48 * time.Hour),
UUID: uuid.New(),
UploadedAt: now,
},
{
ID: 5,
JWT: expectedLicense,
Exp: now.Add(48 * time.Hour),
UUID: uuid.New(),
UploadedAt: now,
},
}, nil)
called := false
ingestURL := fakeServer(t, tallymanHandler(t, deploymentID.String(), expectedLicense, func(req usagetypes.TallymanV1IngestRequest) any {
called = true
return tallymanAcceptAllHandler(req)
}))
publisher := usage.NewTallymanPublisher(ctx, log, db, coderdenttest.Keys,
usage.PublisherWithClock(clock),
usage.PublisherWithIngestURL(ingestURL),
)
defer publisher.Close()
// Start the publisher with a trap.
tickerTrap := clock.Trap().NewTicker()
defer tickerTrap.Close()
startErr := make(chan error)
go func() {
err := publisher.Start()
testutil.RequireSend(ctx, t, startErr, err)
}()
tickerCall := tickerTrap.MustWait(ctx)
tickerCall.MustRelease(ctx)
require.NoError(t, testutil.RequireReceive(ctx, t, startErr))
// Mock events to be published.
events := []database.UsageEvent{
{
ID: uuid.New().String(),
EventType: string(usagetypes.UsageEventTypeDCManagedAgentsV1),
EventData: []byte(jsoninate(t, usagetypes.DCManagedAgentsV1{
Count: 1,
})),
},
}
db.EXPECT().SelectUsageEventsForPublishing(gomock.Any(), gomock.Any()).Return(events, nil).Times(1)
db.EXPECT().UpdateUsageEventsPostPublish(gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, params database.UpdateUsageEventsPostPublishParams) error {
assert.Equal(t, []string{events[0].ID}, params.IDs)
assert.Equal(t, []string{""}, params.FailureMessages)
assert.Equal(t, []bool{true}, params.SetPublishedAts)
return nil
},
).Times(1)
// Tick and wait for the reset call.
tickerResetTrap := clock.Trap().TickerReset()
defer tickerResetTrap.Close()
clock.Advance(tickerCall.Duration)
tickerResetTrap.MustWait(ctx).MustRelease(ctx)
// The publisher should have published the events once.
require.True(t, called)
}
func TestPublisherTallymanError(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
log := slogtest.Make(t, nil)
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
clock := quartz.NewMock(t)
now := time.Now()
clock.Set(now)
deploymentID, licenseJWT := configureMockDeployment(t, db)
const errorMessage = "tallyman error"
var calls int
ingestURL := fakeServer(t, tallymanHandler(t, deploymentID.String(), licenseJWT, func(req usagetypes.TallymanV1IngestRequest) any {
calls++
return usagetypes.TallymanV1Response{
Message: errorMessage,
}
}))
publisher := usage.NewTallymanPublisher(ctx, log, db, coderdenttest.Keys,
usage.PublisherWithClock(clock),
usage.PublisherWithIngestURL(ingestURL),
)
defer publisher.Close()
// Start the publisher with a trap.
tickerTrap := clock.Trap().NewTicker()
defer tickerTrap.Close()
startErr := make(chan error)
go func() {
err := publisher.Start()
testutil.RequireSend(ctx, t, startErr, err)
}()
tickerCall := tickerTrap.MustWait(ctx)
tickerCall.MustRelease(ctx)
require.NoError(t, testutil.RequireReceive(ctx, t, startErr))
// Mock events to be published.
events := []database.UsageEvent{
{
ID: uuid.New().String(),
EventType: string(usagetypes.UsageEventTypeDCManagedAgentsV1),
EventData: []byte(jsoninate(t, usagetypes.DCManagedAgentsV1{
Count: 1,
})),
},
}
db.EXPECT().SelectUsageEventsForPublishing(gomock.Any(), gomock.Any()).Return(events, nil).Times(1)
db.EXPECT().UpdateUsageEventsPostPublish(gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, params database.UpdateUsageEventsPostPublishParams) error {
assert.Equal(t, []string{events[0].ID}, params.IDs)
assert.Contains(t, params.FailureMessages[0], errorMessage)
assert.Equal(t, []bool{false}, params.SetPublishedAts)
return nil
},
).Times(1)
// Tick and wait for the reset call.
tickerResetTrap := clock.Trap().TickerReset()
defer tickerResetTrap.Close()
clock.Advance(tickerCall.Duration)
tickerResetTrap.MustWait(ctx).MustRelease(ctx)
// The publisher should have published the events once.
require.Equal(t, 1, calls)
}
func jsoninate(t *testing.T, v any) string {
t.Helper()
if e, ok := v.(usagetypes.Event); ok {
v = e.Fields()
}
buf, err := json.Marshal(v)
require.NoError(t, err)
return string(buf)
}
func configureDeployment(ctx context.Context, t *testing.T, db database.Store) (uuid.UUID, string) {
t.Helper()
deploymentID := uuid.New()
err := db.InsertDeploymentID(ctx, deploymentID.String())
require.NoError(t, err)
licenseRaw := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
PublishUsageData: true,
})
_, err = db.InsertLicense(ctx, database.InsertLicenseParams{
UploadedAt: dbtime.Now(),
JWT: licenseRaw,
Exp: dbtime.Now().Add(48 * time.Hour),
UUID: uuid.New(),
})
require.NoError(t, err)
return deploymentID, licenseRaw
}
func configureMockDeployment(t *testing.T, db *dbmock.MockStore) (uuid.UUID, string) {
t.Helper()
deploymentID := uuid.New()
db.EXPECT().GetDeploymentID(gomock.Any()).Return(deploymentID.String(), nil).Times(1)
licenseRaw := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
PublishUsageData: true,
})
db.EXPECT().GetUnexpiredLicenses(gomock.Any()).Return([]database.License{
{
ID: 1,
UploadedAt: dbtime.Now(),
JWT: licenseRaw,
Exp: dbtime.Now().Add(48 * time.Hour),
UUID: uuid.New(),
},
}, nil)
return deploymentID, licenseRaw
}
func fakeServer(t *testing.T, handler http.Handler) string {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
return server.URL
}
func tallymanHandler(t *testing.T, expectDeploymentID string, expectLicenseJWT string, handler func(req usagetypes.TallymanV1IngestRequest) any) http.Handler {
t.Helper()
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
t.Helper()
licenseJWT := r.Header.Get(usagetypes.TallymanCoderLicenseKeyHeader)
if expectLicenseJWT != "" && !assert.Equal(t, expectLicenseJWT, licenseJWT, "license JWT in request did not match") {
rw.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(rw).Encode(usagetypes.TallymanV1Response{
Message: "license JWT in request did not match",
})
return
}
deploymentID := r.Header.Get(usagetypes.TallymanCoderDeploymentIDHeader)
if expectDeploymentID != "" && !assert.Equal(t, expectDeploymentID, deploymentID, "deployment ID in request did not match") {
rw.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(rw).Encode(usagetypes.TallymanV1Response{
Message: "deployment ID in request did not match",
})
return
}
var req usagetypes.TallymanV1IngestRequest
err := json.NewDecoder(r.Body).Decode(&req)
if !assert.NoError(t, err, "could not decode request body") {
rw.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(rw).Encode(usagetypes.TallymanV1Response{
Message: "could not decode request body",
})
return
}
resp := handler(req)
switch resp.(type) {
case usagetypes.TallymanV1Response:
rw.WriteHeader(http.StatusInternalServerError)
default:
rw.WriteHeader(http.StatusOK)
}
err = json.NewEncoder(rw).Encode(resp)
if !assert.NoError(t, err, "could not encode response body") {
rw.WriteHeader(http.StatusInternalServerError)
return
}
})
}
func tallymanAcceptAllHandler(req usagetypes.TallymanV1IngestRequest) usagetypes.TallymanV1IngestResponse {
acceptedEvents := make([]usagetypes.TallymanV1IngestAcceptedEvent, len(req.Events))
for i, event := range req.Events {
acceptedEvents[i].ID = event.ID
}
return usagetypes.TallymanV1IngestResponse{
AcceptedEvents: acceptedEvents,
RejectedEvents: []usagetypes.TallymanV1IngestRejectedEvent{},
}
}