Files
cmbuyer/admin/internal/deviceauth/deviceauth_test.go
T

408 lines
16 KiB
Go
Raw Normal View History

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")
}