From 42953fe720a84739907de4308b2f0a9c9c9bdc77 Mon Sep 17 00:00:00 2001 From: Ali Date: Tue, 2 Jun 2026 22:28:19 +0400 Subject: [PATCH] feat: added NullEncryptedBytes to support pgx/sqlc integration --- README.md | 23 ++++ crypter_bytes.go | 30 +++++ crypter_test.go | 3 +- encrypted_bytes.go | 19 +--- go.mod | 4 +- go.sum | 17 ++- null_encrypted_bytes.go | 180 +++++++++++++++++++++++++++++ null_encrypted_bytes_test.go | 212 +++++++++++++++++++++++++++++++++++ 8 files changed, 468 insertions(+), 20 deletions(-) create mode 100644 crypter_bytes.go create mode 100644 null_encrypted_bytes.go create mode 100644 null_encrypted_bytes_test.go diff --git a/README.md b/README.md index 1ee1ded..724d9e6 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,29 @@ func main() { For a full example, see [example/main.go](https://github.com/bincyber/go-sqlcrypter/blob/master/example/main.go). +#### Nullable columns: `NullEncryptedBytes` + +Use [`NullEncryptedBytes`](https://github.com/bincyber/go-sqlcrypter/blob/master/null_encrypted_bytes.go) when a column may be **SQL `NULL`**, and you need to tell **`NULL`** apart from an empty string stored as ciphertext: + +| State | `Valid` | Plaintext | Stored value | +|-------|---------|-----------|--------------| +| Absent | `false` | _(n/a)_ | SQL `NULL` | +| Present, empty | `true` | `""` | Non-`NULL` BYTEA / blob (encrypted empty) | +| Present | `true` | non-empty | Non-`NULL` BYTEA / blob | + +By contrast, [`EncryptedBytes`](https://github.com/bincyber/go-sqlcrypter/blob/master/encrypted_bytes.go) maps **empty plaintext to `NULL`** on write (`driver.Valuer`), which cannot represent _“empty but not null.”_ + +Constructors: + +```go +present := sqlcrypter.NewNullEncryptedBytes("secret") // Valid=true; empty string allowed +absent := sqlcrypter.NullEncryptedBytesNull() // Valid=false => SQL NULL +``` + +- **sqlc / pgx (Postgres `BYTEA`):** put `sqlcrypter.NullEncryptedBytes` on the generated struct field. The type implements [`pgtype.BytesScanner`](https://pkg.go.dev/github.com/jackc/pgx/v5/pgtype#BytesScanner) and [`pgtype.BytesValuer`](https://pkg.go.dev/github.com/jackc/pgx/v5/pgtype#BytesValuer). The default `BYTEA` codec picks these up, therefore no manual `pgtype.Map` registration is required. +- **JSON:** absent values marshal as JSON `null` while present values marshal as base64-encoded ciphertext (including when plaintext is empty). +- **GORM:** `GormDataType()` is `nullencryptedbytes` and `GormDBDataType` follows the same dialect mapping as `EncryptedBytes` (`bytea` / `binary` / `blob` / `varbinary`). + ### Development [Docker Compose](https://docs.docker.com/compose/) backs local services; `make dev/up` and related targets invoke it against [testing/docker-compose.yml](https://github.com/bincyber/go-sqlcrypter/blob/master/testing/docker-compose.yml). diff --git a/crypter_bytes.go b/crypter_bytes.go new file mode 100644 index 0000000..3d66687 --- /dev/null +++ b/crypter_bytes.go @@ -0,0 +1,30 @@ +package sqlcrypter + +import "bytes" + +// encryptPlaintextBytes encrypts plaintext and returns ciphertext bytes. +func encryptPlaintextBytes(plaintext []byte) ([]byte, error) { + reader := bytes.NewReader(plaintext) + writer := new(bytes.Buffer) + if err := Encrypt(writer, reader); err != nil { + return nil, err + } + ct := writer.Bytes() + // Avoid returning a nil []byte slice: database/sql and JSON callers treat + // nil []byte as NULL/absent; NullEncryptedBytes needs a non-nil ciphertext + // for encrypted-empty plaintext (distinct from SQL NULL). + if ct == nil { + ct = []byte{} + } + return ct, nil +} + +// decryptCiphertextBytes decrypts ciphertext into plaintext bytes. +func decryptCiphertextBytes(ciphertext []byte) (EncryptedBytes, error) { + reader := bytes.NewReader(ciphertext) + writer := new(bytes.Buffer) + if err := Decrypt(writer, reader); err != nil { + return nil, err + } + return EncryptedBytes(writer.Bytes()), nil +} diff --git a/crypter_test.go b/crypter_test.go index 90ecbda..e21be76 100644 --- a/crypter_test.go +++ b/crypter_test.go @@ -60,7 +60,8 @@ func Test_Set(t *testing.T) { c := &base64Crypter{} err := Init(c) require.NoError(t, err) - require.Equal(t, crypter, c) + _, ok := crypter.(*base64Crypter) + require.True(t, ok, "crypter should be a *base64Crypter after Init") } func Test_Init_Nil(t *testing.T) { diff --git a/encrypted_bytes.go b/encrypted_bytes.go index cd3b3f3..7c37fa5 100644 --- a/encrypted_bytes.go +++ b/encrypted_bytes.go @@ -1,7 +1,6 @@ package sqlcrypter import ( - "bytes" "database/sql" "database/sql/driver" "encoding/json" @@ -78,14 +77,12 @@ func (e *EncryptedBytes) Scan(value any) error { } // Decrypt value to e - reader := bytes.NewReader(b) - writer := new(bytes.Buffer) - - if err := Decrypt(writer, reader); err != nil { + pt, err := decryptCiphertextBytes(b) + if err != nil { return err } - *e = writer.Bytes() + *e = pt return nil } @@ -98,15 +95,7 @@ func (e EncryptedBytes) Value() (driver.Value, error) { return b, nil } - // Encrypt contents of e before storing in the database - reader := bytes.NewReader(e) - writer := new(bytes.Buffer) - - if err := Encrypt(writer, reader); err != nil { - return nil, err - } - - return writer.Bytes(), nil + return encryptPlaintextBytes(e) } // MarshalJSON implements json.Marshaler interface diff --git a/go.mod b/go.mod index e9bf26b..1757c45 100644 --- a/go.mod +++ b/go.mod @@ -9,9 +9,11 @@ require ( github.com/aws/aws-sdk-go-v2/service/kms v1.51.1 github.com/dgraph-io/ristretto v0.2.0 github.com/hashicorp/vault/api v1.23.0 + github.com/jackc/pgx/v5 v5.9.2 github.com/pkg/errors v0.9.1 github.com/stretchr/testify v1.11.1 golang.org/x/time v0.15.0 + gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 ) @@ -45,6 +47,7 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/kr/pretty v0.3.1 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect @@ -52,6 +55,5 @@ require ( golang.org/x/net v0.54.0 // indirect golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.37.0 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 68d485b..a08bef5 100644 --- a/go.sum +++ b/go.sum @@ -71,21 +71,28 @@ github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= github.com/hashicorp/vault/api v1.23.0 h1:gXgluBsSECfRWTSW9niY2jwg2e9mMJc4WoHNv4g3h6A= github.com/hashicorp/vault/api v1.23.0/go.mod h1:zransKiB9ftp+kgY8ydjnvCU7Wk8i9L0DYWpXeMj9ko= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -103,6 +110,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= @@ -114,5 +123,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/null_encrypted_bytes.go b/null_encrypted_bytes.go new file mode 100644 index 0000000..d10e2fb --- /dev/null +++ b/null_encrypted_bytes.go @@ -0,0 +1,180 @@ +package sqlcrypter + +import ( + "database/sql" + "database/sql/driver" + "encoding/json" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +// NullEncryptedBytes is a nullable encrypted column value for database/sql, +// GORM, JSON, and pgx/sqlc (BYTEA) workflows. +// +// Semantics: +// - Valid == false: SQL NULL; JSON null; Plaintext/Bytes are empty/nil. +// - Valid == true: plaintext is stored encrypted, including empty plaintext +// (distinct from SQL NULL). +type NullEncryptedBytes struct { + Data EncryptedBytes + Valid bool +} + +// NewNullEncryptedBytes returns a present value with plaintext s (empty string allowed). +func NewNullEncryptedBytes(s string) NullEncryptedBytes { + var data EncryptedBytes + if s == "" { + data = EncryptedBytes([]byte{}) + } else { + data = EncryptedBytes([]byte(s)) + } + return NullEncryptedBytes{Data: data, Valid: true} +} + +// NullEncryptedBytesNull returns an absent (SQL NULL) value. +func NullEncryptedBytesNull() NullEncryptedBytes { + return NullEncryptedBytes{Valid: false} +} + +func (n *NullEncryptedBytes) GormDataType() string { + return "nullencryptedbytes" +} + +func (n *NullEncryptedBytes) GormDBDataType(db *gorm.DB, field *schema.Field) string { + switch db.Name() { + case "mysql": + return "binary" + case "postgres": + return "bytea" + case "sqlite": + return "blob" + case "sqlserver": + return "varbinary" + default: + return "" + } +} + +// String intentionally returns a redacted placeholder. +func (n NullEncryptedBytes) String() string { + return "[REDACTED]" +} + +// Plaintext returns decrypted plaintext when Valid; otherwise "". +func (n NullEncryptedBytes) Plaintext() string { + if !n.Valid { + return "" + } + return string(n.Data) +} + +// Bytes returns a slice view of plaintext when Valid; otherwise nil. +// The returned slice aliases Data; mutating it mutates the stored plaintext. +func (n NullEncryptedBytes) Bytes() []byte { + if !n.Valid { + return nil + } + return n.Data[:] +} + +// Scan implements sql.Scanner. +func (n *NullEncryptedBytes) Scan(value any) error { + if value == nil { + n.Valid = false + n.Data = nil + return nil + } + b, ok := value.([]byte) + if !ok { + return fmt.Errorf("sqlcrypter: NullEncryptedBytes.Scan expected []byte, got %T", value) + } + if b == nil { + n.Valid = false + n.Data = nil + return nil + } + pt, err := decryptCiphertextBytes(b) + if err != nil { + return err + } + n.Data = pt + n.Valid = true + return nil +} + +// Value implements driver.Valuer. +func (n NullEncryptedBytes) Value() (driver.Value, error) { + if !n.Valid { + //nolint:nilnil // driver.Valuer: nil value with nil error means SQL NULL + return nil, nil + } + return encryptPlaintextBytes(n.Data) +} + +// MarshalJSON encodes absent values as JSON null; present values as base64 ciphertext. +func (n NullEncryptedBytes) MarshalJSON() ([]byte, error) { + if !n.Valid { + return []byte("null"), nil + } + ciphertext, err := encryptPlaintextBytes(n.Data) + if err != nil { + return nil, err + } + return json.Marshal(ciphertext) +} + +// UnmarshalJSON decodes JSON null as absent; otherwise expects base64 ciphertext bytes. +func (n *NullEncryptedBytes) UnmarshalJSON(data []byte) error { + var ciphertext []byte + if err := json.Unmarshal(data, &ciphertext); err != nil { + return err + } + if ciphertext == nil { + n.Valid = false + n.Data = nil + return nil + } + n.Valid = true + return n.Scan(ciphertext) +} + +// ScanBytes implements pgtype.BytesScanner for pgx/sqlc BYTEA columns. +func (n *NullEncryptedBytes) ScanBytes(src []byte) error { + if src == nil { + n.Valid = false + n.Data = nil + return nil + } + // src is only valid until the next database call; copy before Scan. + b := append([]byte(nil), src...) + return n.Scan(b) +} + +// BytesValue implements pgtype.BytesValuer for pgx/sqlc BYTEA columns. +func (n NullEncryptedBytes) BytesValue() ([]byte, error) { + v, err := n.Value() + if err != nil { + return nil, err + } + if v == nil { + return nil, nil + } + b, ok := v.([]byte) + if !ok { + return nil, fmt.Errorf("sqlcrypter: NullEncryptedBytes.BytesValue expected []byte, got %T", v) + } + return b, nil +} + +var ( + _ driver.Valuer = &NullEncryptedBytes{} + _ sql.Scanner = &NullEncryptedBytes{} + _ json.Marshaler = &NullEncryptedBytes{} + _ json.Unmarshaler = &NullEncryptedBytes{} + _ fmt.Stringer = &NullEncryptedBytes{} + _ pgtype.BytesScanner = &NullEncryptedBytes{} + _ pgtype.BytesValuer = &NullEncryptedBytes{} +) diff --git a/null_encrypted_bytes_test.go b/null_encrypted_bytes_test.go new file mode 100644 index 0000000..7bdd81f --- /dev/null +++ b/null_encrypted_bytes_test.go @@ -0,0 +1,212 @@ +package sqlcrypter + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func testInitCrypter(t *testing.T) { + t.Helper() + require.NoError(t, Init(&base64Crypter{})) +} + +func TestNewNullEncryptedBytes(t *testing.T) { + testInitCrypter(t) + + n := NewNullEncryptedBytes("hello") + assert.True(t, n.Valid) + assert.Equal(t, "hello", n.Plaintext()) + assert.NotNil(t, n.Bytes()) + + empty := NewNullEncryptedBytes("") + assert.True(t, empty.Valid) + assert.Empty(t, empty.Plaintext()) + assert.Empty(t, empty.Bytes()) + assert.NotNil(t, empty.Bytes()) +} + +func TestNullEncryptedBytesNull(t *testing.T) { + testInitCrypter(t) + + n := NullEncryptedBytesNull() + assert.False(t, n.Valid) + assert.Empty(t, n.Plaintext()) + assert.Nil(t, n.Bytes()) +} + +func TestNullEncryptedBytes_Scan(t *testing.T) { + testInitCrypter(t) + + tests := []struct { + name string + value any + wantValid bool + wantPlain string + wantErr bool + }{ + {name: "untyped_nil", value: nil, wantValid: false, wantPlain: ""}, + {name: "typed_nil_bytes", value: []byte(nil), wantValid: false, wantPlain: ""}, + { + name: "ciphertext", + value: []byte("SGVsbG8gV29ybGQ="), + wantValid: true, + wantPlain: "Hello World", + }, + {name: "wrong_type", value: "not-bytes", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var n NullEncryptedBytes + err := n.Scan(tt.value) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantValid, n.Valid) + assert.Equal(t, tt.wantPlain, n.Plaintext()) + }) + } +} + +func TestNullEncryptedBytes_Value(t *testing.T) { + testInitCrypter(t) + + t.Run("invalid_is_null", func(t *testing.T) { + n := NullEncryptedBytesNull() + v, err := n.Value() + require.NoError(t, err) + assert.Nil(t, v) + }) + + t.Run("valid_empty_encrypts", func(t *testing.T) { + n := NewNullEncryptedBytes("") + v, err := n.Value() + require.NoError(t, err) + b, ok := v.([]byte) + require.True(t, ok) + assert.NotNil(t, b, "driver value must be non-nil []byte for encrypted empty plaintext") + }) + + t.Run("valid_non_empty", func(t *testing.T) { + n := NewNullEncryptedBytes("Hello World") + v, err := n.Value() + require.NoError(t, err) + b, ok := v.([]byte) + require.True(t, ok) + assert.Equal(t, "SGVsbG8gV29ybGQ=", string(b)) + }) +} + +func TestNullEncryptedBytes_JSON(t *testing.T) { + testInitCrypter(t) + + t.Run("marshal_invalid", func(t *testing.T) { + n := NullEncryptedBytesNull() + b, err := json.Marshal(n) + require.NoError(t, err) + assert.Equal(t, "null", string(b)) + }) + + t.Run("marshal_valid_empty_not_null", func(t *testing.T) { + n := NewNullEncryptedBytes("") + b, err := json.Marshal(n) + require.NoError(t, err) + assert.NotEqual(t, "null", string(b)) + var raw []byte + require.NoError(t, json.Unmarshal(b, &raw)) + require.NotNil(t, raw) + }) + + t.Run("unmarshal_null", func(t *testing.T) { + var n NullEncryptedBytes + require.NoError(t, json.Unmarshal([]byte("null"), &n)) + assert.False(t, n.Valid) + }) + + t.Run("round_trip", func(t *testing.T) { + n := NewNullEncryptedBytes("Hello World") + b, err := json.Marshal(n) + require.NoError(t, err) + var out NullEncryptedBytes + require.NoError(t, json.Unmarshal(b, &out)) + assert.True(t, out.Valid) + assert.Equal(t, "Hello World", out.Plaintext()) + }) +} + +func TestNullEncryptedBytes_String(t *testing.T) { + testInitCrypter(t) + + n := NewNullEncryptedBytes("secret") + out := fmt.Sprint(n) + assert.NotContains(t, out, "secret") + assert.Equal(t, "[REDACTED]", out) +} + +func TestNullEncryptedBytes_GormDataType(t *testing.T) { + var n NullEncryptedBytes + assert.Equal(t, "nullencryptedbytes", n.GormDataType()) +} + +func TestNullEncryptedBytes_GormDBDataType_sqlite(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + var n NullEncryptedBytes + assert.Equal(t, "blob", n.GormDBDataType(db, nil)) +} + +func TestNullEncryptedBytes_ScanBytes_BytesValue(t *testing.T) { + testInitCrypter(t) + + t.Run("scan_nil", func(t *testing.T) { + var n NullEncryptedBytes + require.NoError(t, n.ScanBytes(nil)) + assert.False(t, n.Valid) + }) + + t.Run("scan_round_trip", func(t *testing.T) { + src := NewNullEncryptedBytes("Hello World") + ct, err := src.BytesValue() + require.NoError(t, err) + var dst NullEncryptedBytes + require.NoError(t, dst.ScanBytes(ct)) + assert.True(t, dst.Valid) + assert.Equal(t, "Hello World", dst.Plaintext()) + }) + + t.Run("bytes_value_invalid", func(t *testing.T) { + n := NullEncryptedBytesNull() + b, err := n.BytesValue() + require.NoError(t, err) + assert.Nil(t, b) + }) +} + +func TestNullEncryptedBytes_pgtypeMapRoundTrip(t *testing.T) { + testInitCrypter(t) + + m := pgtype.NewMap() + const format = pgtype.BinaryFormatCode + + in := NewNullEncryptedBytes("Hello World") + buf, err := m.Encode(pgtype.ByteaOID, format, in, nil) + require.NoError(t, err) + + var out NullEncryptedBytes + require.NoError(t, m.Scan(pgtype.ByteaOID, format, buf, &out)) + assert.True(t, out.Valid) + assert.Equal(t, "Hello World", out.Plaintext()) + + var nullOut NullEncryptedBytes + require.NoError(t, m.Scan(pgtype.ByteaOID, format, nil, &nullOut)) + assert.False(t, nullOut.Valid) +}