Files
silo-server/internal/auth/api_key_last_used_test.go
e56a1b3e03 fix(api): throttle api_keys last_used_at writes in auth middleware (#381)
* fix(api): throttle api_keys last_used_at writes in auth middleware

Every API key request spawned a goroutine that ran an UPDATE on
api_keys, so a key driving HLS segments or a polling integration hit the
table with one write per request, and a stalled database could pile
those goroutines up without bound. The jellycompat authenticator already
guards this same write with a once-per-minute throttle per key; the main
middleware was missing it.

Bring the two in line. Track the last write per key ID and only launch
the update once a minute has passed, with a timeout on the background
write. The map is keyed by key ID so it stays bounded.

* fix(auth): bound API key last-used throttling

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-07-20 11:20:50 -04:00

83 lines
2.1 KiB
Go

package auth
import (
"context"
"testing"
"time"
)
type recordingLastUsedUpdater struct {
calls chan lastUsedCall
}
type lastUsedCall struct {
id int64
deadline time.Time
}
func (u *recordingLastUsedUpdater) UpdateLastUsed(ctx context.Context, id int64) error {
deadline, _ := ctx.Deadline()
u.calls <- lastUsedCall{id: id, deadline: deadline}
return nil
}
func TestAPIKeyLastUsedTrackerThrottlesPerKey(t *testing.T) {
now := time.Date(2026, time.July, 20, 12, 0, 0, 0, time.UTC)
tracker := NewAPIKeyLastUsedTracker(nil, func() time.Time { return now })
if !tracker.shouldUpdate(1) {
t.Fatal("first use should update")
}
if tracker.shouldUpdate(1) {
t.Fatal("repeat use inside the interval should be throttled")
}
if !tracker.shouldUpdate(2) {
t.Fatal("a different key should update independently")
}
now = now.Add(apiKeyLastUsedInterval)
if !tracker.shouldUpdate(1) {
t.Fatal("use at the next interval should update")
}
}
func TestAPIKeyLastUsedTrackerPrunesExpiredKeys(t *testing.T) {
now := time.Date(2026, time.July, 20, 12, 0, 0, 0, time.UTC)
tracker := NewAPIKeyLastUsedTracker(nil, func() time.Time { return now })
tracker.shouldUpdate(1)
tracker.shouldUpdate(2)
now = now.Add(apiKeyLastUsedInterval)
tracker.shouldUpdate(3)
if len(tracker.lastUsedAt) != 1 {
t.Fatalf("retained entries = %d, want 1", len(tracker.lastUsedAt))
}
if _, ok := tracker.lastUsedAt[3]; !ok {
t.Fatal("current key was not retained")
}
}
func TestAPIKeyLastUsedTrackerAddsWriteDeadline(t *testing.T) {
updater := &recordingLastUsedUpdater{calls: make(chan lastUsedCall, 1)}
tracker := NewAPIKeyLastUsedTracker(updater, nil)
tracker.Touch(42)
select {
case call := <-updater.calls:
if call.id != 42 {
t.Fatalf("key id = %d, want 42", call.id)
}
if call.deadline.IsZero() {
t.Fatal("last-used update context has no deadline")
}
remaining := time.Until(call.deadline)
if remaining <= 0 || remaining > apiKeyLastUsedTimeout {
t.Fatalf("deadline after %s, want within (0, %s]", remaining, apiKeyLastUsedTimeout)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for last-used update")
}
}