package deviceauth import ( "context" "crypto/rand" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "errors" "net/http" "net/http/httptest" "path/filepath" "runtime" "strings" "sync" "testing" "time" "cmbuyer/admin/internal/migrations" "cmbuyer/admin/internal/storage/sqlite" ) func TestIssueStoresOnlyRawTokenHashAndGenericJSONOmitsSecret(t *testing.T) { database, store := newCredentialStore(t) first, err := store.Issue(context.Background(), "采购工具一号") if err != nil { t.Fatalf("Issue first: %v", err) } second, err := store.Issue(context.Background(), "采购工具二号") if err != nil { t.Fatalf("Issue second: %v", err) } if first.DeviceID == second.DeviceID || first.Token == second.Token || !ValidDeviceID(first.DeviceID) || !validLowerHex(first.Token, tokenHexLength) { t.Fatalf("issued identifiers are not independent canonical values") } rawToken, err := hex.DecodeString(first.Token) if err != nil { t.Fatalf("decode issued token: %v", err) } wantHash := sha256.Sum256(rawToken) var storedHash []byte var storageType string if err := database.QueryRow(`SELECT token_sha256, typeof(token_sha256) FROM device_credentials WHERE device_id = ?`, first.DeviceID).Scan(&storedHash, &storageType); err != nil { t.Fatalf("read stored hash: %v", err) } if storageType != "blob" || len(storedHash) != sha256.Size || !equalBytes(storedHash, wantHash[:]) { t.Fatalf("stored hash type/length/value = %q/%d/%t", storageType, len(storedHash), equalBytes(storedHash, wantHash[:])) } var leakedCopies int if err := database.QueryRow(`SELECT COUNT(*) FROM device_credentials WHERE CAST(token_sha256 AS TEXT) IN (?, ?)`, first.Token, hex.EncodeToString(wantHash[:])).Scan(&leakedCopies); err != nil { t.Fatalf("search token copies: %v", err) } if leakedCopies != 0 { t.Fatal("database stored a plaintext or hex-encoded token/hash copy") } encoded, err := json.Marshal(first) if err != nil { t.Fatalf("marshal issued credential: %v", err) } if strings.Contains(string(encoded), first.Token) || strings.Contains(string(encoded), "token") { t.Fatalf("generic serialization disclosed token field: %s", encoded) } } func TestAuthenticateStrictHeaderMatrixAndBinding(t *testing.T) { _, store := newCredentialStore(t) first, err := store.Issue(context.Background(), "one") if err != nil { t.Fatalf("issue first: %v", err) } second, err := store.Issue(context.Background(), "two") if err != nil { t.Fatalf("issue second: %v", err) } authenticator := authenticatorForStore(t, store) for _, scheme := range []string{"Bearer", "bearer", "BEARER"} { request := credentialRequest(first.DeviceID, scheme+" "+first.Token) principal, err := authenticator.Authenticate(request) if err != nil || principal.ID != first.DeviceID { t.Fatalf("scheme %q Authenticate = (%q, %v)", scheme, principal.ID, err) } } unknownID := newRuntimeUUID(t) wrongToken := newRuntimeToken(t) tests := []struct { name string mutate func(*http.Request) }{ {"missing authorization", func(request *http.Request) { request.Header.Del(AuthorizationHeader) }}, {"missing device", func(request *http.Request) { request.Header.Del(DeviceIDHeader) }}, {"empty authorization", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "") }}, {"empty device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, "") }}, {"duplicate authorization", func(request *http.Request) { request.Header.Add(AuthorizationHeader, "Bearer "+first.Token) }}, {"duplicate device", func(request *http.Request) { request.Header.Add(DeviceIDHeader, first.DeviceID) }}, {"combined authorization", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token+", Bearer "+first.Token) }}, {"combined device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, first.DeviceID+", "+first.DeviceID) }}, {"extra separator", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token) }}, {"tab separator", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer\t"+first.Token) }}, {"uppercase token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+strings.ToUpper(first.Token)) }}, {"short token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token[:62]) }}, {"long token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token+"00") }}, {"non hex token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token[:63]+"g") }}, {"token separator", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token[:32]+"-"+first.Token[33:]) }}, {"uppercase device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, strings.ToUpper(first.DeviceID)) }}, {"padded device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, " "+first.DeviceID) }}, {"wrong uuid version", func(request *http.Request) { request.Header.Set(DeviceIDHeader, first.DeviceID[:14]+"3"+first.DeviceID[15:]) }}, {"wrong uuid variant", func(request *http.Request) { request.Header.Set(DeviceIDHeader, first.DeviceID[:19]+"7"+first.DeviceID[20:]) }}, {"unknown device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, unknownID) }}, {"wrong token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+wrongToken) }}, {"token device mismatch", func(request *http.Request) { request.Header.Set(DeviceIDHeader, second.DeviceID) }}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { request := credentialRequest(first.DeviceID, "Bearer "+first.Token) test.mutate(request) principal, err := authenticator.Authenticate(request) if !errors.Is(err, ErrUnauthenticated) || principal != (Principal{}) { t.Fatalf("Authenticate = (%#v, %v), want empty unauthenticated", principal, err) } }) } if principal, err := authenticator.Authenticate(nil); !errors.Is(err, ErrUnauthenticated) || principal != (Principal{}) { t.Fatalf("Authenticate(nil) = (%#v, %v)", principal, err) } } func TestRevokeIsImmediateAndIdempotent(t *testing.T) { _, store := newCredentialStore(t) issued, err := store.Issue(context.Background(), "device") if err != nil { t.Fatalf("Issue: %v", err) } authenticator := authenticatorForStore(t, store) request := credentialRequest(issued.DeviceID, "Bearer "+issued.Token) if _, err := authenticator.Authenticate(request); err != nil { t.Fatalf("Authenticate before revoke: %v", err) } first, changed, err := store.Revoke(context.Background(), issued.DeviceID) if err != nil || !changed || first.Status != StatusRevoked || first.RevokedAt == nil { t.Fatalf("first Revoke = (%#v, %t, %v)", first, changed, err) } if principal, err := authenticator.Authenticate(request); !errors.Is(err, ErrUnauthenticated) || principal != (Principal{}) { t.Fatalf("Authenticate after committed revoke = (%#v, %v)", principal, err) } second, changed, err := store.Revoke(context.Background(), issued.DeviceID) if err != nil || changed || second.RevokedAt == nil || !second.RevokedAt.Equal(*first.RevokedAt) { t.Fatalf("second Revoke = (%#v, %t, %v)", second, changed, err) } listed, err := store.List(context.Background()) if err != nil || len(listed) != 1 || listed[0].Status != StatusRevoked { t.Fatalf("List = (%#v, %v)", listed, err) } } func TestConcurrentAuthenticationAndRevocation(t *testing.T) { _, store := newCredentialStore(t) issued, err := store.Issue(context.Background(), "concurrent") if err != nil { t.Fatalf("Issue: %v", err) } authenticator := authenticatorForStore(t, store) request := func() *http.Request { return credentialRequest(issued.DeviceID, "Bearer "+issued.Token) } start := make(chan struct{}) results := make(chan error, 16) var wait sync.WaitGroup for index := 0; index < 16; index++ { wait.Add(1) go func() { defer wait.Done() <-start _, err := authenticator.Authenticate(request()) results <- err }() } close(start) if _, _, err := store.Revoke(context.Background(), issued.DeviceID); err != nil { t.Fatalf("Revoke: %v", err) } wait.Wait() close(results) for err := range results { if err != nil && !errors.Is(err, ErrUnauthenticated) { t.Fatalf("concurrent Authenticate error = %v", err) } } for index := 0; index < 16; index++ { if _, err := authenticator.Authenticate(request()); !errors.Is(err, ErrUnauthenticated) { t.Fatalf("post-commit Authenticate %d error = %v", index, err) } } } func TestAuthenticationDatabaseFaultAndCorruptionAreUnavailable(t *testing.T) { database, store := newCredentialStore(t) issued, err := store.Issue(context.Background(), "device") if err != nil { t.Fatalf("Issue: %v", err) } authenticator := authenticatorForStore(t, store) if err := database.Close(); err != nil { t.Fatalf("close database: %v", err) } if _, err := authenticator.Authenticate(credentialRequest(issued.DeviceID, "Bearer "+issued.Token)); !errors.Is(err, ErrUnavailable) { t.Fatalf("closed database Authenticate error = %v", err) } corruptDB, err := sqlite.Open(filepath.Join(t.TempDir(), "corrupt.db")) if err != nil { t.Fatalf("open corrupt database: %v", err) } t.Cleanup(func() { _ = corruptDB.Close() }) if _, err := corruptDB.Exec(`CREATE TABLE device_credentials (device_id TEXT PRIMARY KEY, token_sha256 BLOB, status TEXT, revoked_at TEXT)`); err != nil { t.Fatalf("create corrupt table: %v", err) } corruptAuthenticator, err := NewSQLiteAuthenticator(corruptDB) if err != nil { t.Fatalf("new corrupt authenticator: %v", err) } tests := []struct { name string hashValue func([sha256.Size]byte) any status string revokedAt any }{ {name: "null hash", hashValue: func([sha256.Size]byte) any { return nil }, status: StatusActive}, {name: "matching text hash", hashValue: func(hash [sha256.Size]byte) any { return string(hash[:]) }, status: StatusActive}, {name: "unknown status", hashValue: func(hash [sha256.Size]byte) any { return hash[:] }, status: "BROKEN"}, {name: "active with revoked time", hashValue: func(hash [sha256.Size]byte) any { return hash[:] }, status: StatusActive, revokedAt: "2026-08-04T00:00:00Z"}, {name: "revoked without time", hashValue: func(hash [sha256.Size]byte) any { return hash[:] }, status: StatusRevoked}, {name: "revoked with invalid time", hashValue: func(hash [sha256.Size]byte) any { return hash[:] }, status: StatusRevoked, revokedAt: "not-a-time"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { rawToken, token := newRuntimeTokenPair(t) hash := sha256.Sum256(rawToken) deviceID := newRuntimeUUID(t) if _, err := corruptDB.Exec(`INSERT INTO device_credentials VALUES (?, ?, ?, ?)`, deviceID, test.hashValue(hash), test.status, test.revokedAt); err != nil { t.Fatalf("insert corrupt row: %v", err) } principal, err := corruptAuthenticator.Authenticate(credentialRequest(deviceID, "Bearer "+token)) if !errors.Is(err, ErrUnavailable) || principal != (Principal{}) { t.Fatalf("corrupt Authenticate = (%#v, %v), want unavailable", principal, err) } }) } } func TestCredentialAuthenticationSurvivesDatabaseReopen(t *testing.T) { databaseSource := filepath.Join(t.TempDir(), "reopen.db") database, err := sqlite.Open(databaseSource) if err != nil { t.Fatalf("open database: %v", err) } if err := migrations.Up(context.Background(), database, deviceMigrationDirectory(t)); err != nil { _ = database.Close() t.Fatalf("migrate database: %v", err) } store, err := NewCredentialStore(database) if err != nil { _ = database.Close() t.Fatalf("new store: %v", err) } issued, err := store.Issue(context.Background(), "reopen") if err != nil { _ = database.Close() t.Fatalf("issue: %v", err) } if err := database.Close(); err != nil { t.Fatalf("close database: %v", err) } reopened, err := sqlite.Open(databaseSource) if err != nil { t.Fatalf("reopen database: %v", err) } defer reopened.Close() authenticator, err := NewSQLiteAuthenticator(reopened) if err != nil { t.Fatalf("new reopened authenticator: %v", err) } principal, err := authenticator.Authenticate(credentialRequest(issued.DeviceID, "Bearer "+issued.Token)) if err != nil || principal.ID != issued.DeviceID { t.Fatalf("Authenticate after reopen = (%#v, %v)", principal, err) } } func TestCredentialInputAndMigrationAreRequired(t *testing.T) { database, err := sqlite.Open(filepath.Join(t.TempDir(), "unmigrated.db")) if err != nil { t.Fatalf("open database: %v", err) } t.Cleanup(func() { _ = database.Close() }) if _, err := NewCredentialStore(database); err == nil { t.Fatal("NewCredentialStore accepted an unmigrated database") } if _, err := NewSQLiteAuthenticator(database); err == nil { t.Fatal("NewSQLiteAuthenticator accepted an unmigrated database") } _, store := newCredentialStore(t) for _, name := range []string{"", " leading", "trailing ", "line\nbreak", strings.Repeat("名", 129)} { if _, err := store.Issue(context.Background(), name); !errors.Is(err, ErrInvalidCredential) { t.Fatalf("Issue(%q) error = %v", name, err) } } if _, _, err := store.Revoke(context.Background(), "not-a-uuid"); !errors.Is(err, ErrInvalidCredential) { t.Fatalf("Revoke invalid id error = %v", err) } if _, _, err := store.Revoke(context.Background(), newRuntimeUUID(t)); !errors.Is(err, ErrCredentialNotFound) { t.Fatalf("Revoke unknown id error = %v", err) } } func newCredentialStore(t *testing.T) (*sql.DB, *CredentialStore) { t.Helper() databaseSource := filepath.Join(t.TempDir(), "device-auth.db") + "?_busy_timeout=5000&_journal_mode=WAL" database, err := sqlite.Open(databaseSource) if err != nil { t.Fatalf("open database: %v", err) } t.Cleanup(func() { _ = database.Close() }) if err := migrations.Up(context.Background(), database, deviceMigrationDirectory(t)); err != nil { t.Fatalf("migrate database: %v", err) } store, err := NewCredentialStore(database) if err != nil { t.Fatalf("NewCredentialStore: %v", err) } store.now = func() time.Time { return time.Date(2026, 8, 4, 12, 0, 0, 123, time.UTC) } return database, store } func authenticatorForStore(t *testing.T, store *CredentialStore) *SQLiteAuthenticator { t.Helper() authenticator, err := NewSQLiteAuthenticator(store.database) if err != nil { t.Fatalf("NewSQLiteAuthenticator: %v", err) } return authenticator } func credentialRequest(deviceID, authorization string) *http.Request { request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/id/evidence", nil) request.Header.Set(DeviceIDHeader, deviceID) request.Header.Set(AuthorizationHeader, authorization) return request } func newRuntimeToken(t *testing.T) string { t.Helper() _, token := newRuntimeTokenPair(t) return token } func newRuntimeTokenPair(t *testing.T) ([]byte, string) { t.Helper() raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { t.Fatalf("generate runtime token: %v", err) } return raw, hex.EncodeToString(raw) } func newRuntimeUUID(t *testing.T) string { t.Helper() raw := make([]byte, 16) if _, err := rand.Read(raw); err != nil { t.Fatalf("generate runtime UUID: %v", err) } return formatUUIDv4(raw) } func equalBytes(left, right []byte) bool { if len(left) != len(right) { return false } for index := range left { if left[index] != right[index] { return false } } return true } func deviceMigrationDirectory(t *testing.T) string { t.Helper() _, file, _, ok := runtime.Caller(0) if !ok { t.Fatal("locate migrations") } return filepath.Join(filepath.Dir(file), "..", "..", "migrations") }