aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat
-rw-r--r--.github/workflows/build.yml3+3 −0
-rw-r--r--Taskfile.yml12+8 −4
-rw-r--r--internal/protocol/backoff.go2+1 −1
-rw-r--r--internal/protocol/backoff_test.go74+74 −0
-rw-r--r--internal/protocol/hmac_test.go87+87 −0
-rw-r--r--internal/protocol/version_test.go45+45 −0
6 files changed, 218 insertions, 5 deletions
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 7a7119e..20aa471 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -44,6 +44,9 @@ jobs:
- name: Check formatting
run: test -z "$(gofmt -l .)"
+ - name: Test
+ run: task test
+
- name: Determine version
id: version
run: |
diff --git a/Taskfile.yml b/Taskfile.yml
index 1fc37eb..ba31fe4 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -18,22 +18,26 @@ tasks:
cmds:
- protoc --go_out=. --go-grpc_out=. proto/mirum.proto
+ test:
+ desc: Run all tests
+ deps: [proto]
+ cmds:
+ - go test -race -count=1 ./...
+
build:
desc: "Build binaries (override GOOS/GOARCH for cross-compilation)"
deps: [proto]
vars:
GOOS: { sh: "echo ${GOOS:-$(go env GOOS)}" }
GOARCH: { sh: "echo ${GOARCH:-$(go env GOARCH)}" }
- VERSION: { sh: cat VERSION }
- LDFLAGS: "-X mrdimidium/mirum/internal/protocol.raw={{.VERSION}}"
env:
CGO_ENABLED: "0"
GOOS: "{{.GOOS}}"
GOARCH: "{{.GOARCH}}"
cmds:
- mkdir -p {{.BUILD_DIR}}
- - go build -ldflags '{{.LDFLAGS}}' -o {{.BUILD_DIR}}/mirumd-{{.GOOS}}-{{.GOARCH}} ./cmd/mirumd
- - go build -ldflags '{{.LDFLAGS}}' -o {{.BUILD_DIR}}/mirumw-{{.GOOS}}-{{.GOARCH}} ./cmd/mirumw
+ - go build -o {{.BUILD_DIR}}/mirumd-{{.GOOS}}-{{.GOARCH}} ./cmd/mirumd
+ - go build -o {{.BUILD_DIR}}/mirumw-{{.GOOS}}-{{.GOARCH}} ./cmd/mirumw
- cp {{.BUILD_DIR}}/mirumd-{{.GOOS}}-{{.GOARCH}} {{.BUILD_DIR}}/mirumd
- cp {{.BUILD_DIR}}/mirumw-{{.GOOS}}-{{.GOARCH}} {{.BUILD_DIR}}/mirumw
diff --git a/internal/protocol/backoff.go b/internal/protocol/backoff.go
index ca5e193..dc53e60 100644
--- a/internal/protocol/backoff.go
+++ b/internal/protocol/backoff.go
@@ -24,7 +24,7 @@ func (b *Backoff) Reset() {
// Wait sleeps with exponential backoff + jitter. Returns false if ctx is cancelled.
func (b *Backoff) Wait(ctx context.Context) bool {
- d := max(b.Max, b.Min<<b.attempt)
+ d := min(b.Max, b.Min<<b.attempt)
// Add jitter: 50%-100% of the computed duration
d = d/2 + time.Duration(rand.Int64N(int64(d/2)))
diff --git a/internal/protocol/backoff_test.go b/internal/protocol/backoff_test.go
new file mode 100644
--- /dev/null
+++ b/internal/protocol/backoff_test.go
@@ -0,0 +1,74 @@
+// Copyright (c) 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package protocol
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+func TestBackoff_ExponentialGrowth(t *testing.T) {
+ b := &Backoff{Min: 10 * time.Millisecond, Max: 200 * time.Millisecond}
+
+ var durations []time.Duration
+ for range 5 {
+ start := time.Now()
+ b.Wait(context.Background())
+ durations = append(durations, time.Since(start))
+ }
+
+ if durations[4] < durations[0] {
+ t.Errorf("no exponential growth: first=%v last=%v", durations[0], durations[4])
+ }
+}
+
+func TestBackoff_NeverExceedsMax(t *testing.T) {
+ b := &Backoff{Min: time.Millisecond, Max: 50 * time.Millisecond}
+
+ for range 20 {
+ start := time.Now()
+ b.Wait(context.Background())
+ d := time.Since(start)
+ if d > 80*time.Millisecond {
+ t.Fatalf("wait %v exceeded max %v", d, b.Max)
+ }
+ }
+}
+
+func TestBackoff_Reset(t *testing.T) {
+ b := &Backoff{Min: time.Millisecond, Max: time.Second}
+
+ for range 10 {
+ b.Wait(context.Background())
+ }
+
+ b.Reset()
+
+ start := time.Now()
+ b.Wait(context.Background())
+ d := time.Since(start)
+
+ if d > 10*time.Millisecond {
+ t.Fatalf("after Reset, wait %v is too long", d)
+ }
+}
+
+func TestBackoff_CancelledContext(t *testing.T) {
+ b := &Backoff{Min: time.Hour, Max: time.Hour}
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ start := time.Now()
+ ok := b.Wait(ctx)
+ d := time.Since(start)
+
+ if ok {
+ t.Fatal("Wait returned true on cancelled context")
+ }
+ if d > 10*time.Millisecond {
+ t.Fatalf("Wait took %v on cancelled context", d)
+ }
+}
diff --git a/internal/protocol/hmac_test.go b/internal/protocol/hmac_test.go
new file mode 100644
--- /dev/null
+++ b/internal/protocol/hmac_test.go
@@ -0,0 +1,87 @@
+// Copyright (c) 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package protocol
+
+import (
+ "bytes"
+ "crypto/hmac"
+ "crypto/sha256"
+ "testing"
+)
+
+func TestGenerateNonce(t *testing.T) {
+ nonce, err := GenerateNonce()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(nonce) != NonceSize {
+ t.Fatalf("len = %d, want %d", len(nonce), NonceSize)
+ }
+
+ nonce2, _ := GenerateNonce()
+ if bytes.Equal(nonce, nonce2) {
+ t.Fatal("two nonces are identical")
+ }
+}
+
+func TestComputeProof(t *testing.T) {
+ secret := []byte("secret")
+ first := []byte("first")
+ second := []byte("second")
+
+ proof := ComputeProof(secret, first, second)
+
+ // Golden value: HMAC-SHA256("secret", "first" || "second")
+ mac := hmac.New(sha256.New, secret)
+ mac.Write(first)
+ mac.Write(second)
+ want := mac.Sum(nil)
+
+ if !bytes.Equal(proof, want) {
+ t.Fatalf("proof mismatch")
+ }
+
+ // Different secret → different proof
+ other := ComputeProof([]byte("other"), first, second)
+ if bytes.Equal(proof, other) {
+ t.Fatal("different secrets produced same proof")
+ }
+
+ // Order matters: (first, second) != (second, first)
+ reversed := ComputeProof(secret, second, first)
+ if bytes.Equal(proof, reversed) {
+ t.Fatal("argument order did not affect proof")
+ }
+}
+
+func TestVerifyProof(t *testing.T) {
+ secret := []byte("secret")
+ first := []byte("first")
+ second := []byte("second")
+ proof := ComputeProof(secret, first, second)
+
+ tests := []struct {
+ name string
+ secret []byte
+ first []byte
+ second []byte
+ proof []byte
+ want bool
+ }{
+ {"valid", secret, first, second, proof, true},
+ {"wrong proof", secret, first, second, []byte("wrong"), false},
+ {"wrong secret", []byte("wrong"), first, second, proof, false},
+ {"swapped args", secret, second, first, proof, false},
+ {"empty proof", secret, first, second, nil, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := VerifyProof(tt.secret, tt.first, tt.second, tt.proof)
+ if got != tt.want {
+ t.Fatalf("VerifyProof = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/protocol/version_test.go b/internal/protocol/version_test.go
new file mode 100644
--- /dev/null
+++ b/internal/protocol/version_test.go
@@ -0,0 +1,45 @@
+// Copyright (c) 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package protocol
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestParseVersion(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ major, minor, patch uint32
+ wantErr bool
+ }{
+ {"valid", "0.1.0", 0, 1, 0, false},
+ {"large", "10.20.30", 10, 20, 30, false},
+ {"whitespace", " 1.2.3 ", 1, 2, 3, false},
+ {"empty", "", 0, 0, 0, true},
+ {"two parts", "1.2", 0, 0, 0, true},
+ {"one part", "1", 0, 0, 0, true},
+ {"letters", "a.b.c", 0, 0, 0, true},
+ {"negative", "-1.0.0", 0, 0, 0, true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ major, minor, patch, err := ParseVersion(tt.in)
+ if tt.wantErr {
+ if !errors.Is(err, ErrInvalidVersion) {
+ t.Fatalf("ParseVersion(%q) err = %v, want ErrInvalidVersion", tt.in, err)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("ParseVersion(%q) unexpected error: %v", tt.in, err)
+ }
+ if major != tt.major || minor != tt.minor || patch != tt.patch {
+ t.Fatalf("ParseVersion(%q) = %d.%d.%d, want %d.%d.%d",
+ tt.in, major, minor, patch, tt.major, tt.minor, tt.patch)
+ }
+ })
+ }
+}