Skip to content

Commit ea67ace

Browse files
committed
test(golang): run a matches query and verify its rows against PostgreSQL, through database/sql and pgx
The test stores generated rows in separate columns, finds one by its equality term, and runs a matches query whose filter containment PostgreSQL tests with @>, then decrypts the rows it returned and verifies them: the verified rows are the Rust query fixture's. It uses the deterministic guest's keys and skips unless DATABASE_URL is set; the Linux Go job now starts PostgreSQL 17 and sets it. pgx is a test-only import. The declaration is testusers.QueryProfile, the query fixture's. Part of #1189
1 parent cdd07e1 commit ea67ace

4 files changed

Lines changed: 228 additions & 0 deletions

File tree

‎.github/workflows/tests-golang.yml‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,24 @@ jobs:
111111
# caches) the whole test toolset, which `mise run` would otherwise
112112
# install uncached.
113113
MISE_ENV: test
114+
# The Go module's PostgreSQL test (encrypt/postgres_test.go) stores
115+
# generated rows and runs the query methods against it through
116+
# database/sql; it skips where DATABASE_URL is unset, as on the macOS
117+
# and Windows jobs.
118+
services:
119+
postgres:
120+
image: postgres:17
121+
env:
122+
POSTGRES_PASSWORD: postgres
123+
# Container port only: Docker picks the host port, and the Go step
124+
# reads it from `job.services`, so the number is written once.
125+
ports:
126+
- 5432
127+
options: >-
128+
--health-cmd pg_isready
129+
--health-interval 10s
130+
--health-timeout 5s
131+
--health-retries 5
114132
steps:
115133
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
116134
with:
@@ -224,6 +242,7 @@ jobs:
224242
- name: Go binding
225243
env:
226244
STACK_ENCRYPT_TESTS_REQUIRE_LOCK: "1"
245+
DATABASE_URL: "postgres://postgres:postgres@localhost:${{ job.services.postgres.ports['5432'] }}/postgres?sslmode=disable"
227246
run: |
228247
ulimit -l "$(ulimit -H -l)"
229248
echo "RLIMIT_MEMLOCK: $(ulimit -l) KiB"
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package encrypt_test
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
"os"
7+
"slices"
8+
"testing"
9+
10+
_ "github.com/jackc/pgx/v5/stdlib"
11+
12+
"github.com/cipherstash/stack/languages/golang/encrypt"
13+
"github.com/cipherstash/stack/languages/golang/encrypt/internal/testusers"
14+
)
15+
16+
// The query methods against a real PostgreSQL, through database/sql and the
17+
// pgx driver: generated rows stored in separate columns, an eq query on the
18+
// equality term, and a matches query whose rows are decrypted and verified.
19+
// The keys are the deterministic test build's, so only the database is
20+
// outside the test. Skipped unless DATABASE_URL names a database the test
21+
// may create a temporary table in.
22+
23+
// matchPositions reads a stored match filter, or a match query's term, as
24+
// the int[] of its little-endian uint16 positions, so PostgreSQL can test
25+
// that a row's filter holds every position of the query with @>.
26+
const matchPositions = `
27+
CREATE FUNCTION pg_temp.match_positions(term bytea) RETURNS int[]
28+
LANGUAGE sql IMMUTABLE STRICT AS $$
29+
SELECT coalesce(array_agg(get_byte(term, 2 * i) | (get_byte(term, 2 * i + 1) << 8)), '{}')
30+
FROM generate_series(0, length(term) / 2 - 1) AS i
31+
$$`
32+
33+
func TestQueriesRunAgainstPostgres(t *testing.T) {
34+
url := os.Getenv("DATABASE_URL")
35+
if url == "" {
36+
t.Skip("DATABASE_URL is not set")
37+
}
38+
f := readQueryFixture(t)
39+
c := queryFixtureClient(t, f)
40+
ctx := context.Background()
41+
cipher := c.DefaultKeyset()
42+
43+
db, err := sql.Open("pgx", url)
44+
if err != nil {
45+
t.Fatal(err)
46+
}
47+
t.Cleanup(func() { _ = db.Close() })
48+
// One connection: the table and the function are temporary, so they
49+
// live in this session alone.
50+
conn, err := db.Conn(ctx)
51+
if err != nil {
52+
t.Fatal(err)
53+
}
54+
t.Cleanup(func() { _ = conn.Close() })
55+
for _, stmt := range []string{
56+
`CREATE TEMPORARY TABLE profiles (
57+
id bigint PRIMARY KEY,
58+
email bytea NOT NULL,
59+
email_eq bytea NOT NULL,
60+
age bytea NOT NULL,
61+
age_eq bytea NOT NULL,
62+
age_ore bytea NOT NULL,
63+
name bytea NOT NULL,
64+
name_match bytea NOT NULL
65+
)`,
66+
matchPositions,
67+
} {
68+
if _, err := conn.ExecContext(ctx, stmt); err != nil {
69+
t.Fatal(err)
70+
}
71+
}
72+
73+
// The fixture's verify page, as names, and one email each.
74+
var people []testusers.QueryProfile
75+
for i, v := range f.Verify.Values {
76+
if v != nil {
77+
people = append(people, testusers.QueryProfile{Email: "p" + string(rune('a'+i)) + "@example.com", Age: uint32(20 + i), Name: *v})
78+
}
79+
}
80+
encrypted, err := testusers.EncryptQueryProfile(ctx, cipher, people)
81+
if err != nil {
82+
t.Fatal(err)
83+
}
84+
for i, e := range encrypted {
85+
if _, err := conn.ExecContext(ctx,
86+
`INSERT INTO profiles VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
87+
i, e.Email.Ciphertext, e.Email.Equality, e.Age.Ciphertext, e.Age.Equality, e.Age.Ore, e.Name.Ciphertext, e.Name.Match,
88+
); err != nil {
89+
t.Fatal(err)
90+
}
91+
}
92+
if _, err := conn.ExecContext(ctx, `ANALYZE profiles`); err != nil {
93+
t.Fatal(err)
94+
}
95+
96+
// eq: the equality term binds as a parameter and finds its one row.
97+
eq, err := testusers.QueryProfileFields.Email.Eq(ctx, cipher, people[2].Email)
98+
if err != nil {
99+
t.Fatal(err)
100+
}
101+
var id int64
102+
if err := conn.QueryRowContext(ctx, `SELECT id FROM profiles WHERE email_eq = $1`, eq).Scan(&id); err != nil || id != 2 {
103+
t.Fatalf("eq found row %d, %v; want 2", id, err)
104+
}
105+
106+
// matches: size the fetch, let the server answer, then decrypt the rows
107+
// it returned and keep those Verify reports true.
108+
q, err := testusers.QueryProfileFields.Name.Matches(ctx, cipher, f.Verify.Input)
109+
if err != nil {
110+
t.Fatal(err)
111+
}
112+
var rows uint64
113+
if err := conn.QueryRowContext(ctx, `SELECT greatest(reltuples, 0)::bigint FROM pg_class WHERE oid = 'profiles'::regclass`).Scan(&rows); err != nil {
114+
t.Fatal(err)
115+
}
116+
limit, err := q.TargetLimit(ctx, 10, encrypt.Stats{Rows: rows})
117+
if err != nil {
118+
t.Fatal(err)
119+
}
120+
if _, bounded := limit.Rows(); bounded {
121+
t.Fatal("a field declared with tags has no expected token count, so with no histogram the limit is unbounded")
122+
}
123+
result, err := conn.QueryContext(ctx, `
124+
SELECT id, email, email_eq, age, age_eq, age_ore, name, name_match FROM profiles
125+
WHERE pg_temp.match_positions(name_match) @> pg_temp.match_positions($1)
126+
ORDER BY id LIMIT $2`, q, limit.FetchOr(50))
127+
if err != nil {
128+
t.Fatal(err)
129+
}
130+
var ids []int64
131+
var fetched []testusers.EncryptedQueryProfile
132+
for result.Next() {
133+
var e testusers.EncryptedQueryProfile
134+
if err := result.Scan(&id, &e.Email.Ciphertext, &e.Email.Equality, &e.Age.Ciphertext, &e.Age.Equality, &e.Age.Ore, &e.Name.Ciphertext, &e.Name.Match); err != nil {
135+
t.Fatal(err)
136+
}
137+
ids = append(ids, id)
138+
fetched = append(fetched, e)
139+
}
140+
if err := result.Err(); err != nil {
141+
t.Fatal(err)
142+
}
143+
decrypted, err := testusers.DecryptQueryProfile(ctx, cipher, fetched)
144+
if err != nil {
145+
t.Fatal(err)
146+
}
147+
names := make([]string, len(decrypted))
148+
for i, p := range decrypted {
149+
names[i] = p.Name
150+
}
151+
verified, err := q.Verify(ctx, names)
152+
if err != nil {
153+
t.Fatal(err)
154+
}
155+
var hits []string
156+
for i, ok := range verified {
157+
if ok {
158+
hits = append(hits, names[i])
159+
}
160+
}
161+
162+
var want []string
163+
for i, v := range f.Verify.Values {
164+
if v != nil && f.Verify.Verified[i] {
165+
want = append(want, *v)
166+
}
167+
}
168+
if !slices.Equal(hits, want) {
169+
t.Fatalf("matches %q verified %q from the server's %q (rows %v), want %q", f.Verify.Input, hits, names, ids, want)
170+
}
171+
// The server's answer has no false negative: every true match came back.
172+
for _, w := range want {
173+
if !slices.Contains(names, w) {
174+
t.Fatalf("the server did not return %q", w)
175+
}
176+
}
177+
}

‎languages/golang/go.mod‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,18 @@ require (
1010
)
1111

1212
require (
13+
github.com/jackc/pgx/v5 v5.7.6
1314
golang.org/x/sys v0.48.0
1415
golang.org/x/tools v0.51.0
1516
google.golang.org/protobuf v1.36.12
1617
)
1718

1819
require (
20+
github.com/jackc/pgpassfile v1.0.0 // indirect
21+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
22+
github.com/jackc/puddle/v2 v2.2.2 // indirect
23+
golang.org/x/crypto v0.37.0 // indirect
1924
golang.org/x/mod v0.41.0 // indirect
2025
golang.org/x/sync v0.23.0 // indirect
26+
golang.org/x/text v0.24.0 // indirect
2127
)

‎languages/golang/go.sum‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,30 @@ github.com/cipherstash/vitaminc/bindings/go/vcffi v0.0.0-20261017042657-ca2298d3
22
github.com/cipherstash/vitaminc/bindings/go/vcffi v0.0.0-20261017042657-ca2298d3e7e8/go.mod h1:6jpaqAo6f7rjWuxoti0Ymoi9DQZyxpnieWVudhXv38Y=
33
github.com/cipherstash/vitaminc/bindings/go/vcvalue v0.0.0-20261017042657-ca2298d3e7e8 h1:72kmUdUUZENerHctDhFMD31/mWtE80cRbpjLPZOeLCs=
44
github.com/cipherstash/vitaminc/bindings/go/vcvalue v0.0.0-20261017042657-ca2298d3e7e8/go.mod h1:RJODA1DCSm4H+1pbmCzDdT2pAIkxhinwIu6ewjh5sPs=
5+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
6+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
7+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
58
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
69
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
10+
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
11+
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
12+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
13+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
14+
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
15+
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
16+
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
17+
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
18+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
19+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
20+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
21+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
22+
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
23+
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
24+
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
725
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
826
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
27+
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
28+
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
929
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
1030
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
1131
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
@@ -14,7 +34,13 @@ golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
1434
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
1535
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
1636
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
37+
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
38+
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
1739
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
1840
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
1941
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
2042
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
43+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
44+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
45+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
46+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

0 commit comments

Comments
 (0)