Skip to content

Commit 9403ed9

Browse files
pike00claude
andcommitted
Add test suite and CI workflow
Add tests for secure memory zeroing, file operations, shredding, key file parsing/validation, key generation, and QR code encoding. Add GitHub Actions CI with test, lint, and Docker build jobs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4961ad0 commit 9403ed9

6 files changed

Lines changed: 341 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
test:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/checkout@v4
17+
18+
- uses: actions/setup-go@v5
19+
with:
20+
go-version-file: go.mod
21+
22+
- name: Vet
23+
run: go vet ./...
24+
25+
- name: Test
26+
run: go test -race -count=1 ./...
27+
28+
- name: Build
29+
run: CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o coldkey ./cmd/coldkey
30+
31+
lint:
32+
runs-on: ubuntu-latest
33+
steps:
34+
- uses: actions/checkout@v4
35+
36+
- uses: actions/setup-go@v5
37+
with:
38+
go-version-file: go.mod
39+
40+
- uses: golangci/golangci-lint-action@v6
41+
with:
42+
version: latest
43+
44+
docker:
45+
runs-on: ubuntu-latest
46+
steps:
47+
- uses: actions/checkout@v4
48+
49+
- name: Build Docker image
50+
run: docker build -t coldkey:ci .

internal/backup/qr_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package backup
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestGenerateQRCodesSingleChunk(t *testing.T) {
9+
data := []byte("short data for QR code")
10+
11+
chunks, err := GenerateQRCodes(data)
12+
if err != nil {
13+
t.Fatalf("GenerateQRCodes: %v", err)
14+
}
15+
if len(chunks) != 1 {
16+
t.Fatalf("expected 1 chunk, got %d", len(chunks))
17+
}
18+
if chunks[0].Part != 1 || chunks[0].Total != 1 {
19+
t.Errorf("chunk = %d/%d, want 1/1", chunks[0].Part, chunks[0].Total)
20+
}
21+
if chunks[0].DataLen != len(data) {
22+
t.Errorf("DataLen = %d, want %d", chunks[0].DataLen, len(data))
23+
}
24+
svg := string(chunks[0].SVG)
25+
if !strings.Contains(svg, "<svg") {
26+
t.Error("SVG output should contain <svg element")
27+
}
28+
}
29+
30+
func TestGenerateQRCodesMultiChunk(t *testing.T) {
31+
// Create data larger than maxPayload to force multi-QR
32+
data := make([]byte, maxPayload+500)
33+
for i := range data {
34+
data[i] = byte('A' + (i % 26))
35+
}
36+
37+
chunks, err := GenerateQRCodes(data)
38+
if err != nil {
39+
t.Fatalf("GenerateQRCodes: %v", err)
40+
}
41+
if len(chunks) < 2 {
42+
t.Fatalf("expected multiple chunks, got %d", len(chunks))
43+
}
44+
for i, c := range chunks {
45+
if c.Part != i+1 {
46+
t.Errorf("chunk %d: Part = %d, want %d", i, c.Part, i+1)
47+
}
48+
if c.Total != len(chunks) {
49+
t.Errorf("chunk %d: Total = %d, want %d", i, c.Total, len(chunks))
50+
}
51+
}
52+
}

internal/keyfile/keyfile_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package keyfile
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
const validPQKey = `# created: 2026-01-15T10:30:00Z
10+
# public key: age1pq1example-public-key
11+
AGE-SECRET-KEY-PQ-1EXAMPLEKEYDATA
12+
`
13+
14+
const classicKey = `# created: 2026-01-15T10:30:00Z
15+
# public key: age1examplepublickey
16+
AGE-SECRET-KEY-1EXAMPLEKEYDATA
17+
`
18+
19+
func TestParseValidPQKey(t *testing.T) {
20+
ki, err := Parse([]byte(validPQKey))
21+
if err != nil {
22+
t.Fatalf("Parse: %v", err)
23+
}
24+
if ki.SecretKey != "AGE-SECRET-KEY-PQ-1EXAMPLEKEYDATA" {
25+
t.Errorf("SecretKey = %q, want AGE-SECRET-KEY-PQ-1EXAMPLEKEYDATA", ki.SecretKey)
26+
}
27+
if ki.PublicKey != "age1pq1example-public-key" {
28+
t.Errorf("PublicKey = %q, want age1pq1example-public-key", ki.PublicKey)
29+
}
30+
if ki.CreatedAt.IsZero() {
31+
t.Error("CreatedAt should be parsed")
32+
}
33+
if ki.SHA256 == "" {
34+
t.Error("SHA256 should be set")
35+
}
36+
}
37+
38+
func TestParseRejectsClassicKey(t *testing.T) {
39+
_, err := Parse([]byte(classicKey))
40+
if err == nil {
41+
t.Fatal("Parse should reject classic X25519 keys")
42+
}
43+
}
44+
45+
func TestParseRejectsEmptyFile(t *testing.T) {
46+
_, err := Parse([]byte(""))
47+
if err == nil {
48+
t.Fatal("Parse should reject empty file")
49+
}
50+
}
51+
52+
func TestParseRejectsNoKey(t *testing.T) {
53+
_, err := Parse([]byte("# just a comment\n"))
54+
if err == nil {
55+
t.Fatal("Parse should reject file with no key")
56+
}
57+
}
58+
59+
func TestReadFileSizeLimit(t *testing.T) {
60+
dir := t.TempDir()
61+
path := filepath.Join(dir, "big.key")
62+
63+
// Create a file larger than maxKeyFileSize
64+
data := make([]byte, maxKeyFileSize+1)
65+
if err := os.WriteFile(path, data, 0600); err != nil {
66+
t.Fatalf("setup: %v", err)
67+
}
68+
69+
_, err := Read(path)
70+
if err == nil {
71+
t.Fatal("Read should reject files larger than maxKeyFileSize")
72+
}
73+
}
74+
75+
func TestReadNonexistent(t *testing.T) {
76+
_, err := Read("/nonexistent/key.txt")
77+
if err == nil {
78+
t.Fatal("Read should return error for nonexistent file")
79+
}
80+
}

internal/keygen/keygen_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package keygen
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestGenerate(t *testing.T) {
9+
kp, err := Generate()
10+
if err != nil {
11+
t.Fatalf("Generate: %v", err)
12+
}
13+
14+
if !strings.HasPrefix(kp.SecretKey, "AGE-SECRET-KEY-PQ-") {
15+
t.Errorf("SecretKey should start with AGE-SECRET-KEY-PQ-, got prefix %q", kp.SecretKey[:20])
16+
}
17+
if kp.PublicKey == "" {
18+
t.Error("PublicKey should not be empty")
19+
}
20+
if kp.CreatedAt.IsZero() {
21+
t.Error("CreatedAt should be set")
22+
}
23+
}
24+
25+
func TestFormatKeyFile(t *testing.T) {
26+
kp, err := Generate()
27+
if err != nil {
28+
t.Fatalf("Generate: %v", err)
29+
}
30+
31+
data := FormatKeyFile(kp)
32+
content := string(data)
33+
34+
if !strings.Contains(content, "# created: ") {
35+
t.Error("FormatKeyFile should contain created timestamp")
36+
}
37+
if !strings.Contains(content, "# public key: ") {
38+
t.Error("FormatKeyFile should contain public key")
39+
}
40+
if !strings.Contains(content, "AGE-SECRET-KEY-PQ-") {
41+
t.Error("FormatKeyFile should contain the secret key")
42+
}
43+
if !strings.HasSuffix(content, "\n") {
44+
t.Error("FormatKeyFile should end with newline")
45+
}
46+
}

internal/secure/file_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package secure
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
func TestWriteFileCreatesWithRestrictedPerms(t *testing.T) {
10+
dir := t.TempDir()
11+
path := filepath.Join(dir, "test.key")
12+
data := []byte("secret-key-data")
13+
14+
if err := WriteFile(path, data); err != nil {
15+
t.Fatalf("WriteFile: %v", err)
16+
}
17+
18+
info, err := os.Stat(path)
19+
if err != nil {
20+
t.Fatalf("Stat: %v", err)
21+
}
22+
if perm := info.Mode().Perm(); perm != 0600 {
23+
t.Errorf("permissions = %o, want 0600", perm)
24+
}
25+
26+
got, err := os.ReadFile(path)
27+
if err != nil {
28+
t.Fatalf("ReadFile: %v", err)
29+
}
30+
if string(got) != string(data) {
31+
t.Errorf("content = %q, want %q", got, data)
32+
}
33+
}
34+
35+
func TestWriteFileRefusesOverwrite(t *testing.T) {
36+
dir := t.TempDir()
37+
path := filepath.Join(dir, "test.key")
38+
39+
if err := WriteFile(path, []byte("first")); err != nil {
40+
t.Fatalf("WriteFile: %v", err)
41+
}
42+
43+
err := WriteFile(path, []byte("second"))
44+
if err == nil {
45+
t.Fatal("WriteFile should refuse to overwrite existing file")
46+
}
47+
}
48+
49+
func TestWriteFileForceOverwrites(t *testing.T) {
50+
dir := t.TempDir()
51+
path := filepath.Join(dir, "test.key")
52+
53+
if err := WriteFile(path, []byte("first")); err != nil {
54+
t.Fatalf("WriteFile: %v", err)
55+
}
56+
if err := WriteFileForce(path, []byte("second")); err != nil {
57+
t.Fatalf("WriteFileForce: %v", err)
58+
}
59+
60+
got, err := os.ReadFile(path)
61+
if err != nil {
62+
t.Fatalf("ReadFile: %v", err)
63+
}
64+
if string(got) != "second" {
65+
t.Errorf("content = %q, want %q", got, "second")
66+
}
67+
}
68+
69+
func TestShred(t *testing.T) {
70+
dir := t.TempDir()
71+
path := filepath.Join(dir, "shred-me")
72+
73+
if err := os.WriteFile(path, []byte("sensitive data here"), 0600); err != nil {
74+
t.Fatalf("setup: %v", err)
75+
}
76+
77+
if err := Shred(path); err != nil {
78+
t.Fatalf("Shred: %v", err)
79+
}
80+
81+
if _, err := os.Stat(path); !os.IsNotExist(err) {
82+
t.Error("Shred should remove the file")
83+
}
84+
}
85+
86+
func TestShredNonexistent(t *testing.T) {
87+
err := Shred("/nonexistent/path/to/file")
88+
if err == nil {
89+
t.Fatal("Shred should return error for nonexistent file")
90+
}
91+
}

internal/secure/memory_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package secure
2+
3+
import "testing"
4+
5+
func TestZero(t *testing.T) {
6+
b := []byte{0xff, 0xfe, 0xfd, 0xfc, 0xfb}
7+
Zero(b)
8+
for i, v := range b {
9+
if v != 0 {
10+
t.Errorf("Zero: byte %d = %#x, want 0", i, v)
11+
}
12+
}
13+
}
14+
15+
func TestZeroEmpty(t *testing.T) {
16+
b := []byte{}
17+
Zero(b) // should not panic
18+
}
19+
20+
func TestZeroNil(t *testing.T) {
21+
Zero(nil) // should not panic
22+
}

0 commit comments

Comments
 (0)