Skip to content

Commit 207dedb

Browse files
authored
Merge pull request #5 from dcadolph/cloud-integration
more integration tests
2 parents 93c23e7 + 79ca34b commit 207dedb

4 files changed

Lines changed: 221 additions & 0 deletions

File tree

‎.github/workflows/integration.yml‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,70 @@ jobs:
3939
- name: Stop vault
4040
if: always()
4141
run: docker stop vault || true
42+
43+
pgp:
44+
name: pgp
45+
runs-on: ubuntu-latest
46+
steps:
47+
- uses: actions/checkout@v4
48+
49+
- uses: actions/setup-go@v5
50+
with:
51+
go-version: "1.25.x"
52+
check-latest: true
53+
54+
- name: Verify gpg
55+
run: gpg --version
56+
57+
- name: Run pgp integration tests
58+
run: go test -tags integration -v ./pgp/...
59+
60+
kms:
61+
name: kms
62+
runs-on: ubuntu-latest
63+
env:
64+
AWS_ENDPOINT_URL: http://localhost:4566
65+
AWS_ENDPOINT_URL_KMS: http://localhost:4566
66+
AWS_ACCESS_KEY_ID: test
67+
AWS_SECRET_ACCESS_KEY: test
68+
AWS_REGION: us-east-1
69+
AWS_DEFAULT_REGION: us-east-1
70+
steps:
71+
- uses: actions/checkout@v4
72+
73+
- uses: actions/setup-go@v5
74+
with:
75+
go-version: "1.25.x"
76+
check-latest: true
77+
78+
- name: Start localstack
79+
run: |
80+
docker run -d --name localstack \
81+
-p 4566:4566 \
82+
-e SERVICES=kms \
83+
-e DEFAULT_REGION=us-east-1 \
84+
localstack/localstack:3
85+
86+
- name: Wait for localstack
87+
run: |
88+
for i in $(seq 1 60); do
89+
if curl -sf http://localhost:4566/_localstack/health > /dev/null; then
90+
exit 0
91+
fi
92+
sleep 1
93+
done
94+
echo "localstack did not become ready"
95+
docker logs localstack
96+
exit 1
97+
98+
- name: Create kms key
99+
run: |
100+
ARN=$(aws kms create-key --query 'KeyMetadata.Arn' --output text)
101+
echo "CIPHER_TEST_KMS_ARN=$ARN" >> "$GITHUB_ENV"
102+
103+
- name: Run kms integration tests
104+
run: go test -tags integration -v ./kms/...
105+
106+
- name: Stop localstack
107+
if: always()
108+
run: docker stop localstack || true

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to cipher are recorded here. The format follows [Keep a Chan
44

55
## Unreleased
66

7+
### Added
8+
9+
- Round trip integration tests for the pgp backend using a throwaway gpg keyring and the aws kms backend using a localstack container. CI runs both on every pull request so real api drift is caught early.
10+
11+
## v0.2.0 (2026-06-24)
12+
713
### Breaking
814

915
- `EncoderOptions.MACOnlyEncrypted bool` is now `EncoderOptions.MAC MACMode`. Library callers using `MACOnlyEncrypted: true` should switch to `MAC: cipher.MACOnEncrypted`. The tri-state `MACInherit / MACOnAll / MACOnEncrypted` lets router rules flip the mode in either direction instead of only enabling it.

‎kms/kms_integration_test.go‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
//go:build integration
2+
3+
// Integration tests for the AWS KMS backend. Skipped unless
4+
// CIPHER_TEST_KMS_ARN points at a usable KMS key. The integration CI
5+
// job runs a LocalStack container, creates a KMS key inside it, and
6+
// passes the ARN through this env var. Local runs can target real
7+
// AWS or a LocalStack instance the same way.
8+
package kms_test
9+
10+
import (
11+
"bytes"
12+
"context"
13+
"os"
14+
"testing"
15+
16+
"github.com/dcadolph/cipher"
17+
"github.com/dcadolph/cipher/kms"
18+
)
19+
20+
// TestKMSIntegrationRoundTrip encrypts a plaintext through cipher
21+
// using the kms provider, then decrypts the bytes and verifies the
22+
// round trip. Reads the target key ARN from CIPHER_TEST_KMS_ARN.
23+
func TestKMSIntegrationRoundTrip(t *testing.T) {
24+
arn := os.Getenv("CIPHER_TEST_KMS_ARN")
25+
if arn == "" {
26+
t.Skip("set CIPHER_TEST_KMS_ARN to run aws kms integration tests")
27+
}
28+
29+
kp, err := kms.NewProvider(arn)
30+
if err != nil {
31+
t.Fatalf("kms.NewProvider: %v", err)
32+
}
33+
34+
plain := []byte("api_key: sk-kms-integration-2026\n")
35+
enc := cipher.NewEncoder(kp)
36+
ciphertext, err := enc.Encode(context.Background(), "secrets.yaml", plain)
37+
if err != nil {
38+
t.Fatalf("encode: %v", err)
39+
}
40+
if bytes.Contains(ciphertext, []byte("sk-kms-integration-2026")) {
41+
t.Fatal("plaintext leaked into ciphertext")
42+
}
43+
44+
dec := cipher.NewDecoder()
45+
round, err := dec.Decode(context.Background(), "secrets.yaml", ciphertext)
46+
if err != nil {
47+
t.Fatalf("decode: %v", err)
48+
}
49+
if !bytes.Equal(plain, round) {
50+
t.Errorf("round trip mismatch\n want: %q\n got: %q", plain, round)
51+
}
52+
}

‎pgp/pgp_integration_test.go‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
//go:build integration
2+
3+
// Integration tests for the PGP backend. Skipped when gpg is not on
4+
// PATH. Uses a fresh GNUPGHOME so generated keys never touch the
5+
// caller's keyring.
6+
package pgp_test
7+
8+
import (
9+
"bytes"
10+
"context"
11+
"os"
12+
"os/exec"
13+
"path/filepath"
14+
"strings"
15+
"testing"
16+
17+
"github.com/dcadolph/cipher"
18+
"github.com/dcadolph/cipher/pgp"
19+
)
20+
21+
// TestPGPIntegrationRoundTrip generates a throwaway RSA key in an
22+
// isolated GNUPGHOME, encrypts a plaintext through cipher and the
23+
// pgp provider, then decrypts the bytes and checks they match.
24+
func TestPGPIntegrationRoundTrip(t *testing.T) {
25+
if _, err := exec.LookPath("gpg"); err != nil {
26+
t.Skip("gpg not on PATH")
27+
}
28+
gnupgHome := t.TempDir()
29+
t.Setenv("GNUPGHOME", gnupgHome)
30+
31+
fp := generateKey(t, gnupgHome)
32+
33+
kp, err := pgp.NewProvider(fp)
34+
if err != nil {
35+
t.Fatalf("pgp.NewProvider: %v", err)
36+
}
37+
38+
plain := []byte("api_key: sk-pgp-integration-2026\n")
39+
enc := cipher.NewEncoder(kp)
40+
ciphertext, err := enc.Encode(context.Background(), "secrets.yaml", plain)
41+
if err != nil {
42+
t.Fatalf("encode: %v", err)
43+
}
44+
if bytes.Contains(ciphertext, []byte("sk-pgp-integration-2026")) {
45+
t.Fatal("plaintext leaked into ciphertext")
46+
}
47+
48+
dec := cipher.NewDecoder()
49+
round, err := dec.Decode(context.Background(), "secrets.yaml", ciphertext)
50+
if err != nil {
51+
t.Fatalf("decode: %v", err)
52+
}
53+
if !bytes.Equal(plain, round) {
54+
t.Errorf("round trip mismatch\n want: %q\n got: %q", plain, round)
55+
}
56+
}
57+
58+
// generateKey writes a gpg batch script then asks gpg to create a
59+
// 2048 bit RSA key with no passphrase. Returns the primary key
60+
// fingerprint. 2048 is enough for tests and finishes in a few
61+
// seconds even on slow CI runners.
62+
func generateKey(t *testing.T, gnupgHome string) string {
63+
t.Helper()
64+
batch := filepath.Join(gnupgHome, "batch")
65+
script := []byte(`%no-protection
66+
Key-Type: RSA
67+
Key-Length: 2048
68+
Name-Real: Cipher Test
69+
Name-Email: cipher-test@example.com
70+
Expire-Date: 0
71+
%commit
72+
`)
73+
if err := os.WriteFile(batch, script, 0o600); err != nil {
74+
t.Fatalf("write batch: %v", err)
75+
}
76+
if out, err := exec.Command("gpg", "--batch", "--gen-key", batch).CombinedOutput(); err != nil {
77+
t.Fatalf("gen-key: %v\n%s", err, out)
78+
}
79+
out, err := exec.Command(
80+
"gpg", "--list-keys", "--with-colons", "cipher-test@example.com",
81+
).Output()
82+
if err != nil {
83+
t.Fatalf("list-keys: %v", err)
84+
}
85+
for _, line := range strings.Split(string(out), "\n") {
86+
if !strings.HasPrefix(line, "fpr:") {
87+
continue
88+
}
89+
parts := strings.Split(line, ":")
90+
if len(parts) >= 10 && parts[9] != "" {
91+
return parts[9]
92+
}
93+
}
94+
t.Fatal("no fingerprint in gpg --list-keys output")
95+
return ""
96+
}

0 commit comments

Comments
 (0)