package deviceauth import ( "context" "crypto/sha256" "encoding/hex" "errors" "io" "net/http" "net/http/httptest" "strings" "testing" "time" ) type fakeRepository struct { identity Identity err error } func (r fakeRepository) FindByPublicID(context.Context, string) (Identity, error) { return r.identity, r.err } type memoryReplayGuard struct{ claimed map[string]bool } func (g *memoryReplayGuard) Claim(_ context.Context, deviceID, nonce string, _ time.Duration) (bool, error) { key := deviceID + nonce if g.claimed[key] { return false, nil } g.claimed[key] = true return true, nil } func TestVerifierAcceptsValidRequestAndRejectsReplay(t *testing.T) { secret := []byte("01234567890123456789012345678901") now := time.Date(2026, 8, 23, 12, 0, 0, 0, time.UTC) verifier, err := NewVerifier( fakeRepository{identity: Identity{ID: "internal", PublicID: "device-1", TenantID: "tenant-1", Status: "registered", CredentialRef: "local://one"}}, StaticSecretResolver{"local://one": secret}, &memoryReplayGuard{claimed: map[string]bool{}}, 5*time.Minute, ) if err != nil { t.Fatal(err) } verifier.now = func() time.Time { return now } request := signedRequest(secret, now, "0123456789abcdef", `{"firmware":"1"}`) identity, err := verifier.Verify(request) if err != nil || identity.PublicID != "device-1" { t.Fatalf("identity = %#v, err = %v", identity, err) } if body := readTestBody(t, request); body != `{"firmware":"1"}` { t.Fatalf("restored body = %q", body) } request = signedRequest(secret, now, "0123456789abcdef", `{"firmware":"1"}`) if _, err := verifier.Verify(request); !errors.Is(err, ErrReplayDetected) { t.Fatalf("replay error = %v", err) } } func TestVerifierRejectsTamperExpiredAndInactive(t *testing.T) { secret := []byte("01234567890123456789012345678901") now := time.Date(2026, 8, 23, 12, 0, 0, 0, time.UTC) repository := fakeRepository{identity: Identity{PublicID: "device-1", TenantID: "tenant-1", Status: "registered", CredentialRef: "local://one"}} verifier, _ := NewVerifier(repository, StaticSecretResolver{"local://one": secret}, &memoryReplayGuard{claimed: map[string]bool{}}, time.Minute) verifier.now = func() time.Time { return now } tampered := signedRequest(secret, now, "0123456789abcdef", `{}`) tampered.Header.Set(HeaderContentSHA256, strings.Repeat("0", 64)) if _, err := verifier.Verify(tampered); !errors.Is(err, ErrCredentialInvalid) { t.Fatalf("tamper error = %v", err) } expired := signedRequest(secret, now.Add(-2*time.Minute), "fedcba9876543210", `{}`) if _, err := verifier.Verify(expired); !errors.Is(err, ErrRequestExpired) { t.Fatalf("expired error = %v", err) } verifier.repository = fakeRepository{identity: Identity{PublicID: "device-1", TenantID: "tenant-1", Status: "revoked", CredentialRef: "local://one"}} inactive := signedRequest(secret, now, "abcdef0123456789", `{}`) if _, err := verifier.Verify(inactive); !errors.Is(err, ErrDeviceInactive) { t.Fatalf("inactive error = %v", err) } } func TestIdentityContextAndEmptyBodyDigest(t *testing.T) { expected := Identity{ID: "internal", PublicID: "device", TenantID: "tenant", Status: "registered"} if _, ok := FromContext(context.Background()); ok { t.Fatal("empty context must not contain a device identity") } actual, ok := FromContext(WithIdentity(context.Background(), expected)) if !ok || actual != expected { t.Fatalf("identity=%+v ok=%t", actual, ok) } digest := sha256.Sum256(nil) if EmptyBodyDigest() != hex.EncodeToString(digest[:]) { t.Fatalf("empty body digest=%s", EmptyBodyDigest()) } } func signedRequest(secret []byte, timestamp time.Time, nonce, body string) *http.Request { request := httptest.NewRequest(http.MethodPost, "/device/v2/example?x=1", strings.NewReader(body)) digest := sha256.Sum256([]byte(body)) digestText := hex.EncodeToString(digest[:]) timestampText := timestamp.Format(time.RFC3339) request.Header.Set(HeaderDeviceID, "device-1") request.Header.Set(HeaderTimestamp, timestampText) request.Header.Set(HeaderNonce, nonce) request.Header.Set(HeaderContentSHA256, digestText) request.Header.Set(HeaderSignature, SignatureHeader(secret, request.Method, request.URL.RequestURI(), timestampText, nonce, digestText)) return request } func readTestBody(t *testing.T, request *http.Request) string { t.Helper() body, err := io.ReadAll(request.Body) if err != nil { t.Fatal(err) } return string(body) }