Skip to content

Commit cf1e78c

Browse files
committed
test: add go fuzz tests for key internal functions
1 parent 220e5fa commit cf1e78c

File tree

14 files changed

+1597
-0
lines changed

14 files changed

+1597
-0
lines changed

FUZZ_TEST.md

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# Fuzz Testing
2+
3+
## Overview
4+
5+
Console employs two complementary fuzzing strategies:
6+
7+
1. **Internal function fuzzing** — Go's built-in `go test -fuzz` framework targets parsing, transformation, validation, and cryptographic functions at the unit level.
8+
2. **API fuzzing***(planned)* HTTP-level fuzzing of Console's REST API endpoints.
9+
10+
---
11+
12+
## Internal Function Fuzzing
13+
14+
Uses Go's native fuzzing to test internal functions for crash resistance, determinism, and input-safety. There are **17 fuzz targets** across **11 test files** covering four categories: JSON/DTO validation, use-case DTO↔entity transforms, cryptographic parsing, and string parsing.
15+
16+
### Coverage Summary
17+
18+
#### 1. JSON Deserialization & DTO Validation (7 targets)
19+
20+
All targets live in a single file and share a generic helper `fuzzJSONAndValidate[T]` that verifies `json.Unmarshal` determinism, `reflect.DeepEqual` consistency, and struct-level validator stability.
21+
22+
| Target | DTO Type | Validators Exercised | File |
23+
|--------|----------|----------------------|------|
24+
| `FuzzDeviceJSONProcessing` | `Device` | default | `internal/entity/dto/v1/json_fuzz_test.go` |
25+
| `FuzzProfileJSONProcessing` | `Profile` | `ValidateAMTPassOrGenRan`, `ValidateCIRAOrTLS`, `ValidateWiFiDHCP` | `internal/entity/dto/v1/json_fuzz_test.go` |
26+
| `FuzzDomainJSONProcessing` | `Domain` | `ValidateAlphaNumHyphenUnderscore` | `internal/entity/dto/v1/json_fuzz_test.go` |
27+
| `FuzzCIRAConfigJSONProcessing` | `CIRAConfig` | default | `internal/entity/dto/v1/json_fuzz_test.go` |
28+
| `FuzzWirelessConfigJSONProcessing` | `WirelessConfig` | `ValidateAuthandIEEE` | `internal/entity/dto/v1/json_fuzz_test.go` |
29+
| `FuzzIEEE8021xJSONProcessing` | `IEEE8021xConfig` | `AuthProtocolValidator` | `internal/entity/dto/v1/json_fuzz_test.go` |
30+
| `FuzzProfileWiFiJSONProcessing` | `ProfileWiFiConfigs` | default | `internal/entity/dto/v1/json_fuzz_test.go` |
31+
32+
**What is tested:** arbitrary JSON payloads (valid, malformed, deeply nested, oversized, unicode/null-byte) are deserialized twice and validated twice. Failures in determinism, panics, or mismatched validation results are caught.
33+
34+
#### 2. Use-Case DTO↔Entity Transform Round-Trips (7 targets)
35+
36+
Each target fuzzes both `dtoToEntity` and `entityToDTO` in its respective use-case package. Cryptographic dependencies are satisfied with mock implementations. All targets assert determinism via dual invocation and `reflect.DeepEqual`.
37+
38+
| Target | Package | Functions Under Test | File |
39+
|--------|---------|----------------------|------|
40+
| `FuzzCIRAConfigTransforms` | `ciraconfigs` | `dtoToEntity`, `entityToDTO` | `internal/usecase/ciraconfigs/transform_fuzz_test.go` |
41+
| `FuzzDeviceTransforms` | `devices` | `dtoToEntity`, `entityToDTO` + GUID lowercasing, cert-hash nil check | `internal/usecase/devices/transform_fuzz_test.go` |
42+
| `FuzzDomainTransforms` | `domains` | `dtoToEntity`, `entityToDTO` + RFC3339 expiration parsing, password encryption | `internal/usecase/domains/transform_fuzz_test.go` |
43+
| `FuzzIEEE8021xConfigTransforms` | `ieee8021xconfigs` | `dtoToEntity`, `entityToDTO` | `internal/usecase/ieee8021xconfigs/transform_fuzz_test.go` |
44+
| `FuzzProfileTransforms` | `profiles` | `dtoToEntity`, `entityToDTO` + tag join consistency | `internal/usecase/profiles/transform_fuzz_test.go` |
45+
| `FuzzProfileWiFiConfigTransforms` | `profilewificonfigs` | `dtoToEntity`, `entityToDTO` | `internal/usecase/profilewificonfigs/transform_fuzz_test.go` |
46+
| `FuzzWirelessConfigTransforms` | `wificonfigs` | `dtoToEntity`, `entityToDTO` + link-policy nil handling | `internal/usecase/wificonfigs/transform_fuzz_test.go` |
47+
48+
**What is tested:** fuzzed field values (strings, ints, bools, timestamps, oversized/unicode/null-byte data) are passed through transform functions. Panics, non-deterministic results, and invariant violations (e.g. GUID not lowercased, nil pointer where expected) are caught.
49+
50+
#### 3. Cryptographic Parsing (2 targets)
51+
52+
| Target | Package | Function Under Test | File |
53+
|--------|---------|---------------------|------|
54+
| `FuzzParseCertificateFromPEM` | `certificates` | `ParseCertificateFromPEM` | `internal/certificates/generate_fuzz_test.go` |
55+
| `FuzzDecryptAndCheckCertExpiration` | `domains` | `DecryptAndCheckCertExpiration` | `internal/usecase/domains/usecase_fuzz_test.go` |
56+
57+
**What is tested:**
58+
- `FuzzParseCertificateFromPEM`: valid, truncated, corrupted, swapped, and random PEM cert+key pairs. Asserts determinism, no data-alongside-error, no nil-without-error, and serial/key consistency.
59+
- `FuzzDecryptAndCheckCertExpiration`: valid, expired, corrupted, and random base64-encoded PKCS12 blobs with varied passwords. Asserts determinism, no expired-cert-without-error, and byte-level certificate consistency.
60+
61+
#### 4. String Parsing (1 target)
62+
63+
| Target | Package | Function Under Test | File |
64+
|--------|---------|---------------------|------|
65+
| `FuzzParseInterval` | `devices` | `ParseInterval` (ISO 8601 duration → minutes) | `internal/usecase/devices/alarms_fuzz_test.go` |
66+
67+
**What is tested:** arbitrary strings (empty, malformed, oversized, unicode/control-character, and valid ISO 8601 durations) are parsed twice. Asserts determinism, no panics, and consistent error/result pairs across invocations.
68+
69+
---
70+
71+
### Seed Corpus Strategy
72+
73+
All targets use explicit `f.Add()` seeds covering:
74+
- **Happy path:** well-formed, realistic inputs.
75+
- **Empty/zero:** empty strings, zero ints, nil-equivalent bools.
76+
- **Unicode & control characters:** CJK, emoji, null bytes (`\x00`), newlines.
77+
- **Oversized inputs:** strings up to 4 KB, arrays with 4096 elements.
78+
- **Type confusion:** wrong JSON types (number where string expected, etc.).
79+
- **Boundary values:** `int` min/max, year-zero and year-9999 timestamps, port 65535.
80+
- **Crypto edge cases:** truncated PEM, corrupted base64, swapped cert/key, wrong passwords, expired certificates.
81+
82+
---
83+
84+
### Running Internal Fuzz Tests
85+
86+
#### Prerequisites
87+
88+
```sh
89+
cp .env.example .env # Makefile includes .env
90+
```
91+
92+
#### Make Targets
93+
94+
```sh
95+
# List all 17 fuzz targets
96+
make fuzz-list
97+
98+
# Run a single target (recommended for local dev)
99+
make fuzz-one PKG=./internal/usecase/devices TARGET=FuzzParseInterval FUZZTIME=30s
100+
101+
# Quick smoke: run every target once (seed corpus only)
102+
make fuzz-smoke
103+
104+
# Full run: every target sequentially with time budget
105+
make fuzz-all FUZZTIME=2m
106+
```
107+
108+
#### Direct go test (single target)
109+
110+
```sh
111+
go test ./internal/usecase/devices -run='^$' -fuzz='^FuzzParseInterval$' -fuzztime=30s
112+
```
113+
114+
> **Note:** Go requires `-fuzz` to match exactly one fuzz function per package. Packages with multiple targets (e.g. `internal/entity/dto/v1` has 7) must be run one target at a time. The `make fuzz-all` target handles this automatically.
115+
116+
#### CI Usage
117+
118+
| Trigger | Command | Purpose |
119+
|---------|---------|---------|
120+
| Pull request | `make fuzz-smoke` | Replay seed corpus, catch regressions |
121+
| Nightly/weekly schedule | `make fuzz-all FUZZTIME=2m` | Discover new crashes with mutation |
122+
123+
---
124+
125+
### File Index
126+
127+
| File | Targets | Package |
128+
|------|---------|---------|
129+
| `internal/certificates/generate_fuzz_test.go` | 1 | `certificates` |
130+
| `internal/entity/dto/v1/json_fuzz_test.go` | 7 | `dto` |
131+
| `internal/usecase/ciraconfigs/transform_fuzz_test.go` | 1 | `ciraconfigs` |
132+
| `internal/usecase/devices/alarms_fuzz_test.go` | 1 | `devices` |
133+
| `internal/usecase/devices/transform_fuzz_test.go` | 1 | `devices` |
134+
| `internal/usecase/domains/transform_fuzz_test.go` | 1 | `domains` |
135+
| `internal/usecase/domains/usecase_fuzz_test.go` | 1 | `domains` |
136+
| `internal/usecase/ieee8021xconfigs/transform_fuzz_test.go` | 1 | `ieee8021xconfigs` |
137+
| `internal/usecase/profiles/transform_fuzz_test.go` | 1 | `profiles` |
138+
| `internal/usecase/profilewificonfigs/transform_fuzz_test.go` | 1 | `profilewificonfigs` |
139+
| `internal/usecase/wificonfigs/transform_fuzz_test.go` | 1 | `wificonfigs` |
140+
| **Total** | **17** | **10 packages** |
141+
142+
---
143+
144+
## API Fuzzing
145+
146+
*Planned — this section will document HTTP-level fuzz testing of Console's REST API endpoints.*

Makefile

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,43 @@ test: ### run test
7474
go test -v -cover -race ./...
7575
.PHONY: test
7676

77+
FUZZ_ROOT ?= internal
78+
FUZZTIME ?= 30s
79+
80+
fuzz-list: ### list all fuzz targets as '<package> <target>'
81+
@set -eu; \
82+
find $(FUZZ_ROOT) -name '*fuzz_test.go' -exec dirname {} \; | sort -u | while read -r dir; do \
83+
pkg="./$$dir"; \
84+
go test "$$pkg" -list '^Fuzz' 2>/dev/null | grep '^Fuzz' | while read -r target; do \
85+
echo "$$pkg $$target"; \
86+
done; \
87+
done
88+
.PHONY: fuzz-list
89+
90+
fuzz-one: ### run one fuzz target, e.g. make fuzz-one PKG=./internal/usecase/devices TARGET=FuzzParseInterval FUZZTIME=30s
91+
@if [ -z "$(PKG)" ] || [ -z "$(TARGET)" ]; then \
92+
echo "usage: make fuzz-one PKG=./path TARGET=FuzzTarget [FUZZTIME=30s]"; \
93+
exit 1; \
94+
fi
95+
go test "$(PKG)" -run=^$$ -fuzz="^$(TARGET)$$" -fuzztime="$(FUZZTIME)"
96+
.PHONY: fuzz-one
97+
98+
fuzz-smoke: ### run all fuzz targets once (quick CI smoke)
99+
$(MAKE) fuzz-all FUZZTIME=1x
100+
.PHONY: fuzz-smoke
101+
102+
fuzz-all: ### run all fuzz targets sequentially with FUZZTIME per target
103+
@set -eu; \
104+
find $(FUZZ_ROOT) -name '*fuzz_test.go' -exec dirname {} \; | sort -u | while read -r dir; do \
105+
pkg="./$$dir"; \
106+
targets=$$(go test "$$pkg" -list '^Fuzz' 2>/dev/null | grep '^Fuzz' || true); \
107+
for target in $$targets; do \
108+
echo "==> $$pkg $$target (FUZZTIME=$(FUZZTIME))"; \
109+
go test "$$pkg" -run=^$$ -fuzz="^$${target}$$" -fuzztime="$(FUZZTIME)"; \
110+
done; \
111+
done
112+
.PHONY: fuzz-all
113+
77114
integration-test: ### run integration-test
78115
go clean -testcache && go test -v ./integration-test/...
79116
.PHONY: integration-test

README.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,56 @@ Console automatically generates OpenAPI documentation when running in debug mode
266266
- Windows: `docker run --rm -v ${pwd}:/app -w /app golangci/golangci-lint:latest golangci-lint run --config=./.golangci.yml -v`
267267
- Unix: `docker run --rm -v .:/app -w /app golangci/golangci-lint:latest golangci-lint run --config=./.golangci.yml -v`
268268

269+
## Fuzz Testing
270+
271+
Console includes Go fuzz tests across multiple packages and targets. For full coverage details, seed corpus strategy, and CI guidance, see [FUZZ_TEST.md](FUZZ_TEST.md).
272+
273+
### Prerequisite
274+
275+
The Makefile includes `.env`, so ensure it exists before running make commands:
276+
277+
```sh
278+
cp .env.example .env
279+
```
280+
281+
### List all fuzz targets
282+
283+
```sh
284+
make fuzz-list
285+
```
286+
287+
Output format:
288+
289+
```text
290+
./internal/usecase/devices FuzzParseInterval
291+
./internal/entity/dto/v1 FuzzDeviceJSONProcessing
292+
...
293+
```
294+
295+
### Run a single fuzz target (recommended local workflow)
296+
297+
Go fuzzing runs one fuzz target at a time. Use `fuzz-one` for focused debugging and development:
298+
299+
```sh
300+
make fuzz-one PKG=./internal/usecase/devices TARGET=FuzzParseInterval FUZZTIME=30s
301+
```
302+
303+
### Run a quick all-target smoke pass
304+
305+
Runs each fuzz target once (`-fuzztime=1x`):
306+
307+
```sh
308+
make fuzz-smoke
309+
```
310+
311+
### Run all fuzz targets with time budget per target
312+
313+
```sh
314+
make fuzz-all FUZZTIME=2m
315+
```
316+
317+
This executes all discovered fuzz targets sequentially and is suitable for scheduled CI jobs.
318+
269319

270320
## Additional Resources
271321

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package certificates
2+
3+
import (
4+
"crypto/rand"
5+
"crypto/rsa"
6+
"crypto/x509"
7+
"crypto/x509/pkix"
8+
"encoding/pem"
9+
"math/big"
10+
"strings"
11+
"testing"
12+
"time"
13+
)
14+
15+
func FuzzParseCertificateFromPEM(f *testing.F) {
16+
validCertPEM, validKeyPEM := generateFuzzPEMCertificate(f)
17+
otherCertPEM, otherKeyPEM := generateFuzzPEMCertificate(f)
18+
truncatedCertPEM := validCertPEM[:len(validCertPEM)/2]
19+
truncatedKeyPEM := validKeyPEM[:len(validKeyPEM)/2]
20+
corruptedCertPEM := corruptPEMBody(validCertPEM)
21+
corruptedKeyPEM := corruptPEMBody(validKeyPEM)
22+
23+
seedInputs := []struct {
24+
certPEM string
25+
keyPEM string
26+
}{
27+
{certPEM: validCertPEM, keyPEM: validKeyPEM},
28+
{certPEM: otherCertPEM, keyPEM: otherKeyPEM},
29+
{certPEM: validCertPEM, keyPEM: otherKeyPEM},
30+
{certPEM: truncatedCertPEM, keyPEM: validKeyPEM},
31+
{certPEM: validCertPEM, keyPEM: truncatedKeyPEM},
32+
{certPEM: corruptedCertPEM, keyPEM: validKeyPEM},
33+
{certPEM: validCertPEM, keyPEM: corruptedKeyPEM},
34+
{certPEM: "", keyPEM: ""},
35+
{certPEM: "invalid-pem", keyPEM: validKeyPEM},
36+
{certPEM: invalidBase64PEM("CERTIFICATE"), keyPEM: validKeyPEM},
37+
{certPEM: validCertPEM, keyPEM: "invalid-key"},
38+
{certPEM: validCertPEM, keyPEM: invalidBase64PEM("RSA PRIVATE KEY")},
39+
{certPEM: validKeyPEM, keyPEM: validCertPEM},
40+
{certPEM: "前置\n" + validCertPEM + "後置", keyPEM: "🔐\n" + validKeyPEM},
41+
{certPEM: strings.Repeat("A", 256), keyPEM: strings.Repeat("B", 256)},
42+
}
43+
44+
for _, input := range seedInputs {
45+
f.Add(input.certPEM, input.keyPEM)
46+
}
47+
48+
f.Fuzz(func(t *testing.T, certPEM, keyPEM string) {
49+
firstCert, firstKey, firstErr := ParseCertificateFromPEM(certPEM, keyPEM)
50+
secondCert, secondKey, secondErr := ParseCertificateFromPEM(certPEM, keyPEM)
51+
52+
if (firstErr == nil) != (secondErr == nil) {
53+
t.Fatalf("ParseCertificateFromPEM error mismatch for cert len=%d key len=%d: first=%v second=%v", len(certPEM), len(keyPEM), firstErr, secondErr)
54+
}
55+
56+
if firstErr != nil {
57+
if firstErr.Error() != secondErr.Error() {
58+
t.Fatalf("ParseCertificateFromPEM error text mismatch for cert len=%d key len=%d: first=%q second=%q", len(certPEM), len(keyPEM), firstErr.Error(), secondErr.Error())
59+
}
60+
61+
if firstCert != nil || secondCert != nil || firstKey != nil || secondKey != nil {
62+
t.Fatalf("ParseCertificateFromPEM returned parsed data alongside an error for cert len=%d key len=%d", len(certPEM), len(keyPEM))
63+
}
64+
65+
return
66+
}
67+
68+
if firstCert == nil || secondCert == nil || firstKey == nil || secondKey == nil {
69+
t.Fatalf("ParseCertificateFromPEM returned nil data without an error for cert len=%d key len=%d", len(certPEM), len(keyPEM))
70+
}
71+
72+
if firstCert.SerialNumber.Cmp(secondCert.SerialNumber) != 0 {
73+
t.Fatalf("ParseCertificateFromPEM serial number mismatch for cert len=%d key len=%d", len(certPEM), len(keyPEM))
74+
}
75+
76+
if firstCert.Subject.CommonName != secondCert.Subject.CommonName {
77+
t.Fatalf("ParseCertificateFromPEM common name mismatch for cert len=%d key len=%d: first=%q second=%q", len(certPEM), len(keyPEM), firstCert.Subject.CommonName, secondCert.Subject.CommonName)
78+
}
79+
80+
if firstKey.E != secondKey.E || firstKey.N.Cmp(secondKey.N) != 0 || firstKey.D.Cmp(secondKey.D) != 0 {
81+
t.Fatalf("ParseCertificateFromPEM returned different private keys for cert len=%d key len=%d", len(certPEM), len(keyPEM))
82+
}
83+
})
84+
}
85+
86+
func invalidBase64PEM(blockType string) string {
87+
return "-----BEGIN " + blockType + "-----\n!!!!\n-----END " + blockType + "-----\n"
88+
}
89+
90+
func corruptPEMBody(input string) string {
91+
block, _ := pem.Decode([]byte(input))
92+
if block == nil || len(block.Bytes) == 0 {
93+
return input
94+
}
95+
96+
corrupted := append([]byte(nil), block.Bytes...)
97+
index := len(corrupted) / 2
98+
corrupted[index] ^= 0xff
99+
100+
return string(pem.EncodeToMemory(&pem.Block{Type: block.Type, Bytes: corrupted}))
101+
}
102+
103+
func generateFuzzPEMCertificate(tb testing.TB) (string, string) {
104+
tb.Helper()
105+
106+
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
107+
if err != nil {
108+
tb.Fatalf("failed to generate RSA key: %v", err)
109+
}
110+
111+
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
112+
if err != nil {
113+
tb.Fatalf("failed to generate serial number: %v", err)
114+
}
115+
116+
template := x509.Certificate{
117+
SerialNumber: serialNumber,
118+
Subject: pkix.Name{
119+
CommonName: "fuzz-cert",
120+
Organization: []string{"console"},
121+
},
122+
NotBefore: time.Now().Add(-time.Hour),
123+
NotAfter: time.Now().Add(24 * time.Hour),
124+
KeyUsage: x509.KeyUsageDigitalSignature,
125+
BasicConstraintsValid: true,
126+
}
127+
128+
certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
129+
if err != nil {
130+
tb.Fatalf("failed to create certificate: %v", err)
131+
}
132+
133+
certPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes}))
134+
keyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}))
135+
136+
return certPEM, keyPEM
137+
}

0 commit comments

Comments
 (0)