mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-24 19:24:16 +00:00
`go fix` carries the modernize analyzers now, and the tree had drifted behind
them. This is the mechanical result, reviewed rather than trusted: the tool is
capable of rewriting code into something that no longer tests or does what it
did, so every non-test change was read individually and the concurrency-bearing
packages were re-run under -race.
Production code, four changes, all semantics-preserving:
- leases/manager.go: wg.Add(1) + go + defer wg.Done() becomes wg.Go. The
comment above that function turns on Add happening before the goroutine
starts, so that a Wait cannot return before the worker has run. wg.Go does
the Add synchronously on the calling goroutine, so the invariant it
describes still holds.
- auth/token/handler.go: strings.Fields -> strings.FieldsSeq, same splitting,
iterated rather than allocated.
- hold/gc/gc.go: a hand-written map copy -> maps.Copy.
- hold/pds/scan_broadcaster.go: three-clause loop -> range over int.
The rest are tests. The one worth naming is carstore_contention_test.go, where a
careless rewrite could have quietly stopped exercising contention: go fix
converted the reader and side-table goroutines to loopWG.Go but correctly
declined to touch the writer loop, which passes its index as a parameter. The
writer/reader/side-table shape and the stop channel are unchanged, so the test
still contends over the same carstore transactions.
Verified: go build for hold and appview, `make lint` 0 issues, the deploy and
credential-helper modules 0 issues, `make test` green across all 43 packages,
and -race green on leases, hold/pds, hold/gc and auth/token. The scanner module's
two lint findings are unchanged from HEAD and are in files go fix never touched.
Kept separate from the HTTP/2 commit so that one stays readable, and so this can
be reverted on its own if a modernization turns out to matter.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TA9D4DjaLZTvzQ7dJbu4eg
887 lines
26 KiB
Go
887 lines
26 KiB
Go
package db
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// setupTestDB creates an in-memory SQLite database for testing
|
|
func setupTestDB(t *testing.T) *DeviceStore {
|
|
t.Helper()
|
|
// Use a named in-memory DB unique to this test to ensure isolation between tests
|
|
safeName := strings.ReplaceAll(t.Name(), "/", "_")
|
|
db, err := InitDB(fmt.Sprintf("file:%s?mode=memory&cache=shared", safeName), LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("Failed to initialize test database: %v", err)
|
|
}
|
|
|
|
// Limit to single connection to avoid race conditions in tests
|
|
db.SetMaxOpenConns(1)
|
|
|
|
t.Cleanup(func() {
|
|
db.Close()
|
|
})
|
|
return NewDeviceStore(db)
|
|
}
|
|
|
|
// createTestUser creates a test user in the database
|
|
func createTestUser(t *testing.T, store *DeviceStore, did, handle string) {
|
|
t.Helper()
|
|
_, err := store.db.Exec(`
|
|
INSERT OR IGNORE INTO users (did, handle, pds_endpoint, last_seen)
|
|
VALUES (?, ?, ?, datetime('now'))
|
|
`, did, handle, "https://pds.example.com")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create test user: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDevice_Struct(t *testing.T) {
|
|
device := &Device{
|
|
DID: "did:plc:test",
|
|
Handle: "alice.bsky.social",
|
|
Name: "My Device",
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
if device.DID != "did:plc:test" {
|
|
t.Errorf("Expected DID, got %q", device.DID)
|
|
}
|
|
}
|
|
|
|
func TestGenerateUserCode(t *testing.T) {
|
|
// Generate multiple codes to test
|
|
codes := make(map[string]bool)
|
|
for range 100 {
|
|
code := generateUserCode()
|
|
|
|
// Test format: XXXX-XXXX
|
|
if len(code) != 9 {
|
|
t.Errorf("Expected code length 9, got %d for code %q", len(code), code)
|
|
}
|
|
|
|
if code[4] != '-' {
|
|
t.Errorf("Expected hyphen at position 4, got %q", string(code[4]))
|
|
}
|
|
|
|
// Test valid characters (A-Z, 2-9, no ambiguous chars)
|
|
validChars := "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
|
parts := strings.Split(code, "-")
|
|
if len(parts) != 2 {
|
|
t.Errorf("Expected 2 parts separated by hyphen, got %d", len(parts))
|
|
}
|
|
|
|
for _, part := range parts {
|
|
for _, ch := range part {
|
|
if !strings.ContainsRune(validChars, ch) {
|
|
t.Errorf("Invalid character %q in code %q", ch, code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Test uniqueness (should be very rare to get duplicates)
|
|
if codes[code] {
|
|
t.Logf("Warning: duplicate code generated: %q (rare but possible)", code)
|
|
}
|
|
codes[code] = true
|
|
}
|
|
|
|
// Verify we got mostly unique codes (at least 95%)
|
|
if len(codes) < 95 {
|
|
t.Errorf("Expected at least 95 unique codes out of 100, got %d", len(codes))
|
|
}
|
|
}
|
|
|
|
func TestGenerateUserCode_Format(t *testing.T) {
|
|
code := generateUserCode()
|
|
|
|
// Test exact format
|
|
if len(code) != 9 {
|
|
t.Fatal("Code must be exactly 9 characters")
|
|
}
|
|
|
|
if code[4] != '-' {
|
|
t.Fatal("Character at index 4 must be hyphen")
|
|
}
|
|
|
|
// Test no ambiguous characters (O, 0, I, 1, L)
|
|
ambiguous := "O01IL"
|
|
for _, ch := range code {
|
|
if strings.ContainsRune(ambiguous, ch) {
|
|
t.Errorf("Code contains ambiguous character %q: %s", ch, code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDeviceStore_CreatePendingAuth tests creating pending authorization
|
|
func TestDeviceStore_CreatePendingAuth(t *testing.T) {
|
|
store := setupTestDB(t)
|
|
|
|
pending, err := store.CreatePendingAuth("My Device", "192.168.1.1", "Test Agent")
|
|
if err != nil {
|
|
t.Fatalf("CreatePendingAuth() error = %v", err)
|
|
}
|
|
|
|
if pending.DeviceCode == "" {
|
|
t.Error("DeviceCode should not be empty")
|
|
}
|
|
if pending.UserCode == "" {
|
|
t.Error("UserCode should not be empty")
|
|
}
|
|
if pending.DeviceName != "My Device" {
|
|
t.Errorf("DeviceName = %v, want My Device", pending.DeviceName)
|
|
}
|
|
if pending.IPAddress != "192.168.1.1" {
|
|
t.Errorf("IPAddress = %v, want 192.168.1.1", pending.IPAddress)
|
|
}
|
|
if pending.UserAgent != "Test Agent" {
|
|
t.Errorf("UserAgent = %v, want Test Agent", pending.UserAgent)
|
|
}
|
|
if pending.ExpiresAt.Before(time.Now()) {
|
|
t.Error("ExpiresAt should be in the future")
|
|
}
|
|
}
|
|
|
|
// TestDeviceStore_GetPendingByUserCode tests retrieving pending auth by user code
|
|
func TestDeviceStore_GetPendingByUserCode(t *testing.T) {
|
|
store := setupTestDB(t)
|
|
|
|
// Create pending auth
|
|
created, err := store.CreatePendingAuth("My Device", "192.168.1.1", "Test Agent")
|
|
if err != nil {
|
|
t.Fatalf("CreatePendingAuth() error = %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
userCode string
|
|
wantFound bool
|
|
}{
|
|
{
|
|
name: "existing user code",
|
|
userCode: created.UserCode,
|
|
wantFound: true,
|
|
},
|
|
{
|
|
name: "non-existent user code",
|
|
userCode: "AAAA-BBBB",
|
|
wantFound: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
pending, found := store.GetPendingByUserCode(tt.userCode)
|
|
if found != tt.wantFound {
|
|
t.Errorf("GetPendingByUserCode() found = %v, want %v", found, tt.wantFound)
|
|
}
|
|
if tt.wantFound && pending == nil {
|
|
t.Error("Expected pending auth, got nil")
|
|
}
|
|
if tt.wantFound && pending != nil {
|
|
if pending.DeviceName != "My Device" {
|
|
t.Errorf("DeviceName = %v, want My Device", pending.DeviceName)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestDeviceStore_GetPendingByDeviceCode tests retrieving pending auth by device code
|
|
func TestDeviceStore_GetPendingByDeviceCode(t *testing.T) {
|
|
store := setupTestDB(t)
|
|
|
|
// Create pending auth
|
|
created, err := store.CreatePendingAuth("My Device", "192.168.1.1", "Test Agent")
|
|
if err != nil {
|
|
t.Fatalf("CreatePendingAuth() error = %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
deviceCode string
|
|
wantFound bool
|
|
}{
|
|
{
|
|
name: "existing device code",
|
|
deviceCode: created.DeviceCode,
|
|
wantFound: true,
|
|
},
|
|
{
|
|
name: "non-existent device code",
|
|
deviceCode: "invalidcode",
|
|
wantFound: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
pending, found := store.GetPendingByDeviceCode(tt.deviceCode)
|
|
if found != tt.wantFound {
|
|
t.Errorf("GetPendingByDeviceCode() found = %v, want %v", found, tt.wantFound)
|
|
}
|
|
if tt.wantFound && pending == nil {
|
|
t.Error("Expected pending auth, got nil")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestDeviceStore_ApprovePending tests approving pending authorization
|
|
func TestDeviceStore_ApprovePending(t *testing.T) {
|
|
store := setupTestDB(t)
|
|
|
|
// Create test users
|
|
createTestUser(t, store, "did:plc:alice123", "alice.bsky.social")
|
|
createTestUser(t, store, "did:plc:bob123", "bob.bsky.social")
|
|
|
|
// Create pending auth
|
|
pending, err := store.CreatePendingAuth("My Device", "192.168.1.1", "Test Agent")
|
|
if err != nil {
|
|
t.Fatalf("CreatePendingAuth() error = %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
userCode string
|
|
did string
|
|
handle string
|
|
wantErr bool
|
|
errString string
|
|
}{
|
|
{
|
|
name: "successful approval",
|
|
userCode: pending.UserCode,
|
|
did: "did:plc:alice123",
|
|
handle: "alice.bsky.social",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "non-existent user code",
|
|
userCode: "AAAA-BBBB",
|
|
did: "did:plc:bob123",
|
|
handle: "bob.bsky.social",
|
|
wantErr: true,
|
|
errString: "not found",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
secret, err := store.ApprovePending(tt.userCode, tt.did, tt.handle)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("ApprovePending() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
if !tt.wantErr {
|
|
if secret == "" {
|
|
t.Error("Expected device secret, got empty string")
|
|
}
|
|
if !strings.HasPrefix(secret, "atcr_device_") {
|
|
t.Errorf("Secret should start with atcr_device_, got %v", secret)
|
|
}
|
|
|
|
// Verify device was created
|
|
devices := store.ListDevices(tt.did)
|
|
if len(devices) != 1 {
|
|
t.Errorf("Expected 1 device, got %d", len(devices))
|
|
}
|
|
}
|
|
if tt.wantErr && tt.errString != "" && err != nil {
|
|
if !strings.Contains(err.Error(), tt.errString) {
|
|
t.Errorf("Error should contain %q, got %v", tt.errString, err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestDeviceStore_ApprovePending_AlreadyApproved tests double approval
|
|
func TestDeviceStore_ApprovePending_AlreadyApproved(t *testing.T) {
|
|
store := setupTestDB(t)
|
|
createTestUser(t, store, "did:plc:alice123", "alice.bsky.social")
|
|
|
|
pending, err := store.CreatePendingAuth("My Device", "192.168.1.1", "Test Agent")
|
|
if err != nil {
|
|
t.Fatalf("CreatePendingAuth() error = %v", err)
|
|
}
|
|
|
|
// First approval
|
|
_, err = store.ApprovePending(pending.UserCode, "did:plc:alice123", "alice.bsky.social")
|
|
if err != nil {
|
|
t.Fatalf("First ApprovePending() error = %v", err)
|
|
}
|
|
|
|
// Second approval should fail
|
|
_, err = store.ApprovePending(pending.UserCode, "did:plc:alice123", "alice.bsky.social")
|
|
if err == nil {
|
|
t.Error("Expected error for double approval, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "already approved") {
|
|
t.Errorf("Error should contain 'already approved', got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestDeviceStore_ValidateDeviceSecret tests device secret validation
|
|
func TestDeviceStore_ValidateDeviceSecret(t *testing.T) {
|
|
store := setupTestDB(t)
|
|
createTestUser(t, store, "did:plc:alice123", "alice.bsky.social")
|
|
|
|
// Create and approve a device
|
|
pending, err := store.CreatePendingAuth("My Device", "192.168.1.1", "Test Agent")
|
|
if err != nil {
|
|
t.Fatalf("CreatePendingAuth() error = %v", err)
|
|
}
|
|
|
|
secret, err := store.ApprovePending(pending.UserCode, "did:plc:alice123", "alice.bsky.social")
|
|
if err != nil {
|
|
t.Fatalf("ApprovePending() error = %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
secret string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "valid secret",
|
|
secret: secret,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "invalid secret",
|
|
secret: "atcr_device_invalid",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "empty secret",
|
|
secret: "",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
device, err := store.ValidateDeviceSecret(tt.secret)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("ValidateDeviceSecret() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
if !tt.wantErr {
|
|
if device.DID != "did:plc:alice123" {
|
|
t.Errorf("DID = %v, want did:plc:alice123", device.DID)
|
|
}
|
|
if device.Name != "My Device" {
|
|
t.Errorf("Name = %v, want My Device", device.Name)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestDeviceStore_ListDevices tests listing devices
|
|
func TestDeviceStore_ListDevices(t *testing.T) {
|
|
store := setupTestDB(t)
|
|
did := "did:plc:alice123"
|
|
createTestUser(t, store, did, "alice.bsky.social")
|
|
|
|
// Initially empty
|
|
devices := store.ListDevices(did)
|
|
if len(devices) != 0 {
|
|
t.Errorf("Expected 0 devices initially, got %d", len(devices))
|
|
}
|
|
|
|
// Create 3 devices
|
|
for i := range 3 {
|
|
pending, err := store.CreatePendingAuth("Device "+string(rune('A'+i)), "192.168.1.1", "Agent")
|
|
if err != nil {
|
|
t.Fatalf("CreatePendingAuth() error = %v", err)
|
|
}
|
|
_, err = store.ApprovePending(pending.UserCode, did, "alice.bsky.social")
|
|
if err != nil {
|
|
t.Fatalf("ApprovePending() error = %v", err)
|
|
}
|
|
}
|
|
|
|
// List devices
|
|
devices = store.ListDevices(did)
|
|
if len(devices) != 3 {
|
|
t.Errorf("Expected 3 devices, got %d", len(devices))
|
|
}
|
|
|
|
// Verify they're sorted by created_at DESC (newest first)
|
|
for i := range len(devices) - 1 {
|
|
if devices[i].CreatedAt.Before(devices[i+1].CreatedAt) {
|
|
t.Error("Devices should be sorted by created_at DESC")
|
|
}
|
|
}
|
|
|
|
// List devices for different DID
|
|
otherDevices := store.ListDevices("did:plc:bob123")
|
|
if len(otherDevices) != 0 {
|
|
t.Errorf("Expected 0 devices for different DID, got %d", len(otherDevices))
|
|
}
|
|
}
|
|
|
|
// TestDeviceStore_RevokeDevice tests revoking a device
|
|
func TestDeviceStore_RevokeDevice(t *testing.T) {
|
|
store := setupTestDB(t)
|
|
did := "did:plc:alice123"
|
|
createTestUser(t, store, did, "alice.bsky.social")
|
|
|
|
// Create device
|
|
pending, err := store.CreatePendingAuth("My Device", "192.168.1.1", "Test Agent")
|
|
if err != nil {
|
|
t.Fatalf("CreatePendingAuth() error = %v", err)
|
|
}
|
|
_, err = store.ApprovePending(pending.UserCode, did, "alice.bsky.social")
|
|
if err != nil {
|
|
t.Fatalf("ApprovePending() error = %v", err)
|
|
}
|
|
|
|
devices := store.ListDevices(did)
|
|
if len(devices) != 1 {
|
|
t.Fatalf("Expected 1 device, got %d", len(devices))
|
|
}
|
|
deviceID := devices[0].ID
|
|
|
|
tests := []struct {
|
|
name string
|
|
did string
|
|
deviceID string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "successful revocation",
|
|
did: did,
|
|
deviceID: deviceID,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "non-existent device",
|
|
did: did,
|
|
deviceID: "non-existent-id",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "wrong DID",
|
|
did: "did:plc:bob123",
|
|
deviceID: deviceID,
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := store.RevokeDevice(tt.did, tt.deviceID)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("RevokeDevice() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Verify device was removed (after first successful test)
|
|
devices = store.ListDevices(did)
|
|
if len(devices) != 0 {
|
|
t.Errorf("Expected 0 devices after revocation, got %d", len(devices))
|
|
}
|
|
}
|
|
|
|
// TestDeviceStore_UpdateLastUsed tests updating last used timestamp
|
|
func TestDeviceStore_UpdateLastUsed(t *testing.T) {
|
|
store := setupTestDB(t)
|
|
createTestUser(t, store, "did:plc:alice123", "alice.bsky.social")
|
|
|
|
// Create device
|
|
pending, err := store.CreatePendingAuth("My Device", "192.168.1.1", "Test Agent")
|
|
if err != nil {
|
|
t.Fatalf("CreatePendingAuth() error = %v", err)
|
|
}
|
|
secret, err := store.ApprovePending(pending.UserCode, "did:plc:alice123", "alice.bsky.social")
|
|
if err != nil {
|
|
t.Fatalf("ApprovePending() error = %v", err)
|
|
}
|
|
|
|
// Get device to get secret hash
|
|
device, err := store.ValidateDeviceSecret(secret)
|
|
if err != nil {
|
|
t.Fatalf("ValidateDeviceSecret() error = %v", err)
|
|
}
|
|
|
|
initialLastUsed := device.LastUsed
|
|
|
|
// Wait a bit to ensure timestamp difference
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
// Update last used
|
|
store.UpdateLastUsed(device.SecretHash)
|
|
|
|
// Verify it was updated
|
|
device2, err := store.ValidateDeviceSecret(secret)
|
|
if err != nil {
|
|
t.Fatalf("ValidateDeviceSecret() error = %v", err)
|
|
}
|
|
|
|
if !device2.LastUsed.After(initialLastUsed) {
|
|
t.Error("LastUsed should be updated to later time")
|
|
}
|
|
}
|
|
|
|
// TestDeviceStore_CleanupExpired tests cleanup of expired pending auths
|
|
func TestDeviceStore_CleanupExpired(t *testing.T) {
|
|
store := setupTestDB(t)
|
|
|
|
// Create pending auth with manual expiration time
|
|
pending, err := store.CreatePendingAuth("My Device", "192.168.1.1", "Test Agent")
|
|
if err != nil {
|
|
t.Fatalf("CreatePendingAuth() error = %v", err)
|
|
}
|
|
|
|
// Manually update expiration to the past
|
|
_, err = store.db.Exec(`
|
|
UPDATE pending_device_auth
|
|
SET expires_at = datetime('now', '-1 hour')
|
|
WHERE device_code = ?
|
|
`, pending.DeviceCode)
|
|
if err != nil {
|
|
t.Fatalf("Failed to update expiration: %v", err)
|
|
}
|
|
|
|
// Run cleanup
|
|
store.CleanupExpired()
|
|
|
|
// Verify it was deleted
|
|
_, found := store.GetPendingByDeviceCode(pending.DeviceCode)
|
|
if found {
|
|
t.Error("Expired pending auth should have been cleaned up")
|
|
}
|
|
}
|
|
|
|
// TestDeviceStore_CleanupExpiredContext tests context-aware cleanup
|
|
func TestDeviceStore_CleanupExpiredContext(t *testing.T) {
|
|
store := setupTestDB(t)
|
|
|
|
// Create and expire pending auth
|
|
pending, err := store.CreatePendingAuth("My Device", "192.168.1.1", "Test Agent")
|
|
if err != nil {
|
|
t.Fatalf("CreatePendingAuth() error = %v", err)
|
|
}
|
|
|
|
_, err = store.db.Exec(`
|
|
UPDATE pending_device_auth
|
|
SET expires_at = datetime('now', '-1 hour')
|
|
WHERE device_code = ?
|
|
`, pending.DeviceCode)
|
|
if err != nil {
|
|
t.Fatalf("Failed to update expiration: %v", err)
|
|
}
|
|
|
|
// Run context-aware cleanup
|
|
ctx := context.Background()
|
|
err = store.CleanupExpiredContext(ctx)
|
|
if err != nil {
|
|
t.Errorf("CleanupExpiredContext() error = %v", err)
|
|
}
|
|
|
|
// Verify it was deleted
|
|
_, found := store.GetPendingByDeviceCode(pending.DeviceCode)
|
|
if found {
|
|
t.Error("Expired pending auth should have been cleaned up")
|
|
}
|
|
}
|
|
|
|
// TestDeviceStore_SecretHashing tests bcrypt hashing
|
|
func TestDeviceStore_SecretHashing(t *testing.T) {
|
|
store := setupTestDB(t)
|
|
createTestUser(t, store, "did:plc:alice123", "alice.bsky.social")
|
|
|
|
pending, err := store.CreatePendingAuth("My Device", "192.168.1.1", "Test Agent")
|
|
if err != nil {
|
|
t.Fatalf("CreatePendingAuth() error = %v", err)
|
|
}
|
|
|
|
secret, err := store.ApprovePending(pending.UserCode, "did:plc:alice123", "alice.bsky.social")
|
|
if err != nil {
|
|
t.Fatalf("ApprovePending() error = %v", err)
|
|
}
|
|
|
|
// Get device via ValidateDeviceSecret to access secret hash
|
|
device, err := store.ValidateDeviceSecret(secret)
|
|
if err != nil {
|
|
t.Fatalf("ValidateDeviceSecret() error = %v", err)
|
|
}
|
|
|
|
// Verify bcrypt hash is valid
|
|
err = bcrypt.CompareHashAndPassword([]byte(device.SecretHash), []byte(secret))
|
|
if err != nil {
|
|
t.Error("Secret hash should match secret")
|
|
}
|
|
|
|
// Verify wrong secret doesn't match
|
|
err = bcrypt.CompareHashAndPassword([]byte(device.SecretHash), []byte("wrong_secret"))
|
|
if err == nil {
|
|
t.Error("Wrong secret should not match hash")
|
|
}
|
|
}
|
|
|
|
// newDeviceForTest approves a pending auth and returns the device secret.
|
|
func newDeviceForTest(t *testing.T, store *DeviceStore, did, handle, name string) string {
|
|
t.Helper()
|
|
pending, err := store.CreatePendingAuth(name, "192.168.1.1", "Test Agent")
|
|
if err != nil {
|
|
t.Fatalf("CreatePendingAuth() error = %v", err)
|
|
}
|
|
secret, err := store.ApprovePending(pending.UserCode, did, handle)
|
|
if err != nil {
|
|
t.Fatalf("ApprovePending() error = %v", err)
|
|
}
|
|
return secret
|
|
}
|
|
|
|
func lookupValueFor(t *testing.T, store *DeviceStore, secret string) string {
|
|
t.Helper()
|
|
var lookup sql.NullString
|
|
err := store.db.QueryRow(
|
|
`SELECT secret_lookup FROM devices WHERE secret_lookup = ?`,
|
|
deviceSecretLookup(secret),
|
|
).Scan(&lookup)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ""
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("query secret_lookup: %v", err)
|
|
}
|
|
return lookup.String
|
|
}
|
|
|
|
// TestDeviceStore_NewDeviceGetsSecretLookup verifies newly created devices are
|
|
// indexed at creation, so they never take the scan path.
|
|
func TestDeviceStore_NewDeviceGetsSecretLookup(t *testing.T) {
|
|
store := setupTestDB(t)
|
|
createTestUser(t, store, "did:plc:alice123", "alice.bsky.social")
|
|
secret := newDeviceForTest(t, store, "did:plc:alice123", "alice.bsky.social", "My Device")
|
|
|
|
if got := lookupValueFor(t, store, secret); got != deviceSecretLookup(secret) {
|
|
t.Fatalf("secret_lookup not populated at creation, got %q", got)
|
|
}
|
|
|
|
device, err := store.ValidateDeviceSecret(secret)
|
|
if err != nil {
|
|
t.Fatalf("ValidateDeviceSecret() error = %v", err)
|
|
}
|
|
if device.DID != "did:plc:alice123" {
|
|
t.Errorf("DID = %v, want did:plc:alice123", device.DID)
|
|
}
|
|
}
|
|
|
|
// TestDeviceStore_LegacyDeviceBackfills covers rows created before migration
|
|
// 0028. They have a NULL secret_lookup, so the first authentication falls back
|
|
// to the scan and must backfill, and subsequent ones resolve via the index.
|
|
func TestDeviceStore_LegacyDeviceBackfills(t *testing.T) {
|
|
store := setupTestDB(t)
|
|
createTestUser(t, store, "did:plc:alice123", "alice.bsky.social")
|
|
secret := newDeviceForTest(t, store, "did:plc:alice123", "alice.bsky.social", "Legacy Device")
|
|
|
|
// Simulate a pre-migration row.
|
|
if _, err := store.db.Exec(`UPDATE devices SET secret_lookup = NULL`); err != nil {
|
|
t.Fatalf("clear secret_lookup: %v", err)
|
|
}
|
|
if got := lookupValueFor(t, store, secret); got != "" {
|
|
t.Fatalf("expected no indexed row before backfill, got %q", got)
|
|
}
|
|
|
|
device, err := store.ValidateDeviceSecret(secret)
|
|
if err != nil {
|
|
t.Fatalf("ValidateDeviceSecret() on legacy row error = %v", err)
|
|
}
|
|
if device.Name != "Legacy Device" {
|
|
t.Errorf("Name = %v, want Legacy Device", device.Name)
|
|
}
|
|
|
|
if got := lookupValueFor(t, store, secret); got != deviceSecretLookup(secret) {
|
|
t.Fatalf("secret_lookup was not backfilled, got %q", got)
|
|
}
|
|
|
|
// Second call must still succeed, now via the indexed path.
|
|
if _, err := store.ValidateDeviceSecret(secret); err != nil {
|
|
t.Fatalf("ValidateDeviceSecret() after backfill error = %v", err)
|
|
}
|
|
}
|
|
|
|
// TestDeviceStore_ValidateDoesNotScanIndexedRows is the regression guard for the
|
|
// O(n) bcrypt scan. Every device is indexed, so a wrong secret must not compare
|
|
// against any of them — it should miss the index and find nothing left to scan.
|
|
func TestDeviceStore_ValidateDoesNotScanIndexedRows(t *testing.T) {
|
|
store := setupTestDB(t)
|
|
createTestUser(t, store, "did:plc:alice123", "alice.bsky.social")
|
|
for i := range 5 {
|
|
newDeviceForTest(t, store, "did:plc:alice123", "alice.bsky.social", fmt.Sprintf("Device %d", i))
|
|
}
|
|
|
|
var unindexed int
|
|
if err := store.db.QueryRow(
|
|
`SELECT COUNT(*) FROM devices WHERE secret_lookup IS NULL OR secret_lookup = ''`,
|
|
).Scan(&unindexed); err != nil {
|
|
t.Fatalf("count unindexed: %v", err)
|
|
}
|
|
if unindexed != 0 {
|
|
t.Fatalf("expected all devices indexed, %d still unindexed", unindexed)
|
|
}
|
|
|
|
if _, err := store.ValidateDeviceSecret("atcr_device_wrong"); err == nil {
|
|
t.Error("expected error for an unknown secret")
|
|
}
|
|
}
|
|
|
|
// TestDeviceSecretLookup_StableAndDistinct guards the lookup derivation.
|
|
func TestDeviceSecretLookup_StableAndDistinct(t *testing.T) {
|
|
a := deviceSecretLookup("atcr_device_aaa")
|
|
if a != deviceSecretLookup("atcr_device_aaa") {
|
|
t.Error("deviceSecretLookup is not deterministic")
|
|
}
|
|
if a == deviceSecretLookup("atcr_device_bbb") {
|
|
t.Error("distinct secrets produced the same lookup")
|
|
}
|
|
if len(a) != 64 {
|
|
t.Errorf("expected 64 hex chars for sha256, got %d", len(a))
|
|
}
|
|
}
|
|
|
|
// TestUpdateLastUsedIsThrottled: callers invoke this on every successful
|
|
// authentication, which means every docker push and pull, including each layer's
|
|
// re-auth. Writing every time is a network round trip per call against a remote
|
|
// primary, for a timestamp read at minute resolution at best.
|
|
func TestUpdateLastUsedIsThrottled(t *testing.T) {
|
|
database := deviceThrottleDB(t)
|
|
store := NewDeviceStore(database)
|
|
hash := seedThrottleDevice(t, database, "dev-a", "hash-a")
|
|
|
|
store.UpdateLastUsed(hash)
|
|
var first sql.NullTime
|
|
if err := database.QueryRow(`SELECT last_used FROM devices WHERE id = ?`, "dev-a").Scan(&first); err != nil {
|
|
t.Fatalf("read last_used: %v", err)
|
|
}
|
|
if !first.Valid {
|
|
t.Fatal("first call did not write last_used")
|
|
}
|
|
|
|
// Force a value the next write would visibly change, then hammer it.
|
|
marker := first.Time.Add(-time.Hour).UTC().Truncate(time.Second)
|
|
if _, err := database.Exec(`UPDATE devices SET last_used = ? WHERE id = ?`, marker, "dev-a"); err != nil {
|
|
t.Fatalf("set marker: %v", err)
|
|
}
|
|
for range 50 {
|
|
store.UpdateLastUsed(hash)
|
|
}
|
|
|
|
var after sql.NullTime
|
|
if err := database.QueryRow(`SELECT last_used FROM devices WHERE id = ?`, "dev-a").Scan(&after); err != nil {
|
|
t.Fatalf("read last_used: %v", err)
|
|
}
|
|
if !after.Time.UTC().Truncate(time.Second).Equal(marker) {
|
|
t.Error("last_used was rewritten during 50 back-to-back calls; the throttle is not holding")
|
|
}
|
|
}
|
|
|
|
// TestUpdateLastUsedThrottlesPerDevice: one busy device must not suppress
|
|
// another device's first write.
|
|
func TestUpdateLastUsedThrottlesPerDevice(t *testing.T) {
|
|
database := deviceThrottleDB(t)
|
|
store := NewDeviceStore(database)
|
|
hashA := seedThrottleDevice(t, database, "dev-a", "hash-a")
|
|
hashB := seedThrottleDevice(t, database, "dev-b", "hash-b")
|
|
|
|
store.UpdateLastUsed(hashA)
|
|
store.UpdateLastUsed(hashB)
|
|
|
|
for _, id := range []string{"dev-a", "dev-b"} {
|
|
var ts sql.NullTime
|
|
if err := database.QueryRow(`SELECT last_used FROM devices WHERE id = ?`, id).Scan(&ts); err != nil {
|
|
t.Fatalf("read last_used for %s: %v", id, err)
|
|
}
|
|
if !ts.Valid {
|
|
t.Errorf("device %s never got a last_used write", id)
|
|
}
|
|
}
|
|
}
|
|
|
|
func seedThrottleDevice(t *testing.T, database *sql.DB, id, secretHash string) string {
|
|
t.Helper()
|
|
if _, err := database.Exec(`
|
|
INSERT INTO devices (id, did, handle, name, secret_hash, created_at)
|
|
VALUES (?, 'did:plc:throttle', 'throttle.example.com', ?, ?, ?)
|
|
`, id, id, secretHash, time.Now()); err != nil {
|
|
t.Fatalf("seed device %s: %v", id, err)
|
|
}
|
|
return secretHash
|
|
}
|
|
|
|
func deviceThrottleDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
database, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("InitDB: %v", err)
|
|
}
|
|
t.Cleanup(func() { database.Close() })
|
|
if err := UpsertUser(database, &User{
|
|
DID: "did:plc:throttle",
|
|
Handle: "throttle.example.com",
|
|
PDSEndpoint: "https://pds.example.com",
|
|
LastSeen: time.Now(),
|
|
}); err != nil {
|
|
t.Fatalf("UpsertUser: %v", err)
|
|
}
|
|
return database
|
|
}
|
|
|
|
// TestUpdateLastUsedThrottlesEvenWhenTheWriteFails pins the statement order
|
|
// 13edb71 calls out: the throttle is stamped before the write, not after.
|
|
//
|
|
// The reason is the authentication path. UpdateLastUsed runs on every
|
|
// successful auth, which during a push means once per layer, concurrently. If
|
|
// the timestamp were recorded only after a successful write, then a write that
|
|
// is slow or failing would let every one of those callers through to queue
|
|
// another one behind it — the pile-up is worst exactly when the database is
|
|
// least able to absorb it.
|
|
//
|
|
// A failing write makes that ordering observable without timing anything: with
|
|
// the stamp after the write, every call retries; with it before, only the first
|
|
// does. The failure is counted through the warning the function logs, since a
|
|
// dropped table leaves no row to inspect.
|
|
func TestUpdateLastUsedThrottlesEvenWhenTheWriteFails(t *testing.T) {
|
|
database := deviceThrottleDB(t)
|
|
store := NewDeviceStore(database)
|
|
hash := seedThrottleDevice(t, database, "dev-fail", "hash-fail")
|
|
|
|
// Take the table away so every UPDATE errors.
|
|
if _, err := database.Exec(`DROP TABLE devices`); err != nil {
|
|
t.Fatalf("drop devices: %v", err)
|
|
}
|
|
|
|
var logged bytes.Buffer
|
|
prev := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(&logged, &slog.HandlerOptions{Level: slog.LevelWarn})))
|
|
t.Cleanup(func() { slog.SetDefault(prev) })
|
|
|
|
const calls = 10
|
|
for range calls {
|
|
store.UpdateLastUsed(hash)
|
|
}
|
|
|
|
got := strings.Count(logged.String(), "Failed to update device last used timestamp")
|
|
if got != 1 {
|
|
t.Errorf("failing write was attempted %d times across %d calls, want 1; "+
|
|
"the throttle is stamped after the write, so a failing or slow write "+
|
|
"lets every concurrent caller pile on another attempt", got, calls)
|
|
}
|
|
}
|