From 21f24d24fd6f501b32f15a2bef41c89cc461f623 Mon Sep 17 00:00:00 2001 From: Jeff Halter <868228+jhalter@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:48:18 -0700 Subject: Overhaul regression testing: e2e suite, fuzzing, CI, and bug fixes Add a protocol-level end-to-end suite (in-process fully wired server on an ephemeral port pair, driven by hotline.Client over TCP) covering handshake, login, public and private chat, message board, threaded news, file list/download/upload, account admin, disconnect notification, and shutdown broadcast. The harness retries on a fresh port pair when another process steals a probed port before ListenAndServe binds it, and Server gains WithConnectionRateLimit so tests can disable the per-IP connection throttle. Add native fuzz tests for Transaction, Field, and flattened file object decoding, and fix the bugs the new tests surfaced: - Transaction.Write panicked on out-of-range attacker-controlled size fields, and transactionScanner's uint32 length addition could wrap and yield a truncated token. The information fork size declared in an untrusted fork header is now bounded too. - Client keepalive read c.done unsynchronized while Disconnect replaces it under the mutex. - The shared Agreement's Seek+ReadAll login path raced concurrent logins; the server now prefers an AgreementBytes() snapshot. Fill unit-test gaps (main's config-copy helpers, file resume data, ReloaderFunc, R2 error injection and env validation) and add a CI test workflow (build/vet + race-enabled shuffled suite), fixed lint workflow triggers with golangci-lint v2.6, and Makefile test/cover/ lint/fuzz targets. --- cmd/mobius-hotline-server/main_test.go | 80 ++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) (limited to 'cmd') diff --git a/cmd/mobius-hotline-server/main_test.go b/cmd/mobius-hotline-server/main_test.go index a527d8b..827397a 100644 --- a/cmd/mobius-hotline-server/main_test.go +++ b/cmd/mobius-hotline-server/main_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "os" "path" "testing" @@ -179,3 +180,82 @@ func TestFindConfigPath(t *testing.T) { assert.Equal(t, "config", result) }) } + +// r2EnvVars are every R2_* variable newR2FileStore reads. Each case clears all of them and sets +// only what it needs, so ambient credentials in the test environment can't leak in. +var r2EnvVars = []string{ + "R2_BUCKET", "R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY", + "R2_ENDPOINT", "R2_ACCOUNT_ID", "R2_PREFIX", "R2_STAGING_DIR", +} + +func TestNewR2FileStore_EnvValidation(t *testing.T) { + tests := []struct { + name string + env map[string]string + wantErr string // substring; empty means the store must construct successfully + }{ + { + name: "missing bucket and credentials", + env: map[string]string{}, + wantErr: "R2_BUCKET, R2_ACCESS_KEY_ID, and R2_SECRET_ACCESS_KEY must be set", + }, + { + name: "missing secret key", + env: map[string]string{ + "R2_BUCKET": "b", + "R2_ACCESS_KEY_ID": "ak", + }, + wantErr: "R2_BUCKET, R2_ACCESS_KEY_ID, and R2_SECRET_ACCESS_KEY must be set", + }, + { + name: "credentials set but no endpoint or account id", + env: map[string]string{ + "R2_BUCKET": "b", + "R2_ACCESS_KEY_ID": "ak", + "R2_SECRET_ACCESS_KEY": "sk", + }, + wantErr: "either R2_ENDPOINT or R2_ACCOUNT_ID must be set", + }, + { + name: "account id derives the endpoint", + env: map[string]string{ + "R2_BUCKET": "b", + "R2_ACCESS_KEY_ID": "ak", + "R2_SECRET_ACCESS_KEY": "sk", + "R2_ACCOUNT_ID": "acct123", + }, + }, + { + name: "explicit endpoint", + env: map[string]string{ + "R2_BUCKET": "b", + "R2_ACCESS_KEY_ID": "ak", + "R2_SECRET_ACCESS_KEY": "sk", + "R2_ENDPOINT": "https://example.com", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, k := range r2EnvVars { + t.Setenv(k, "") + } + // Keep staging off the shared temp dir even on the success paths. + t.Setenv("R2_STAGING_DIR", t.TempDir()) + for k, v := range tt.env { + t.Setenv(k, v) + } + + store, err := newR2FileStore(context.Background()) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + assert.Nil(t, store) + return + } + require.NoError(t, err) + assert.NotNil(t, store) + }) + } +} -- cgit