mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-16 02:15:50 +00:00
chore(tooling): golangci-lint + govulncheck + defensive guards
Phase 6 of the 2026-05-09 review remediation plan. Bundle of small
hygiene fixes — none individually urgent but better folded together
than scattered across follow-ups.
- .golangci.yml: enable errcheck/govet/gosec/staticcheck/unused/
ineffassign/gocyclo/misspell/revive. Tuned to the codebase style
(no universal exported-doc requirement, gocyclo cap at 20 to
accommodate handler dispatch). 0 issues across the tree.
- ci.yml: add golangci-lint job + govulncheck (informational).
- Defensive guards:
- registry.go: Module.Name mismatch now errors at Build instead of
silently overwriting (TestBuild_RejectsFactoryNameMismatch).
- cmd/server/main.go: PORT env validated numerically + 0..65535.
- firestore_provider.go: For() re-validates module name; invalid
names return an invalidStore whose every op errors with
ErrInvalidModuleName.
- Dead code removal:
- wordle: gameTTLSeconds const + pickDaily/hashDJB2/todayUTC
helpers + their tests deleted (pickDaily was unused;
daily.go renamed pick_random.go).
- Dependency: golang.org/x/net v0.52.0 -> v0.54.0 (resolves
GO-2026-4918 HTTP/2 infinite-loop CVE).
- Deferred from the original phase plan: Docker digest pinning
(Dependabot handles), per-handler file splits (largest file 279 LOC;
splits would churn for marginal gain).
go test -race -count=1 ./... clean (15 packages); golangci-lint run
clean (0 issues).
This commit is contained in:
1 parent
765cce549d
commit
bdbcc43f85
19 files changed
+279
-136
No files matched your search
@@ -3,7 +3,7 @@ package loldle
|
||||
import "testing"
|
||||
|
||||
func TestAttemptFlavor(t *testing.T) {
|
||||
const max = 8
|
||||
const maxAttempts = 8
|
||||
cases := map[int]string{
|
||||
1: "First try!",
|
||||
2: "Sharp!",
|
||||
@@ -16,8 +16,8 @@ func TestAttemptFlavor(t *testing.T) {
|
||||
9: "Phew — last one!", // attempt > max — defensive; matches JS >=
|
||||
}
|
||||
for attempt, want := range cases {
|
||||
if got := attemptFlavor(attempt, max); got != want {
|
||||
t.Errorf("attemptFlavor(%d, %d) = %q, want %q", attempt, max, got, want)
|
||||
if got := attemptFlavor(attempt, maxAttempts); got != want {
|
||||
t.Errorf("attemptFlavor(%d, %d) = %q, want %q", attempt, maxAttempts, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,14 @@ func Build(enabled []string, factories map[string]Factory, kv storage.KVProvider
|
||||
Registry: reg,
|
||||
}
|
||||
mod := factory(moduleDeps)
|
||||
mod.Name = name // enforce: module name is its registry key, not whatever the factory chose
|
||||
// A factory that hardcodes its own Name is a bug: the registry key is
|
||||
// the source of truth and a mismatch means the catalog and module
|
||||
// disagree about identity. Surface the conflict rather than silently
|
||||
// overwriting it.
|
||||
if mod.Name != "" && mod.Name != name {
|
||||
return nil, fmt.Errorf("module %q: factory returned mismatched Name=%q", name, mod.Name)
|
||||
}
|
||||
mod.Name = name
|
||||
|
||||
for _, cmd := range mod.Commands {
|
||||
if err := validateCommand(cmd); err != nil {
|
||||
|
||||
@@ -223,6 +223,40 @@ func TestBuild_RejectsInvalidModuleName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_RejectsFactoryNameMismatch(t *testing.T) {
|
||||
// A factory that hardcodes its own name disagreeing with the registry key
|
||||
// is a programming bug — surface it instead of silently overwriting.
|
||||
factories := map[string]Factory{
|
||||
"alpha": func(_ Deps) Module {
|
||||
return Module{Name: "imposter", Commands: []Command{noopCmd("a1")}}
|
||||
},
|
||||
}
|
||||
_, err := Build([]string{"alpha"}, factories, newProvider(), nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for factory Name mismatch")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "imposter") {
|
||||
t.Errorf("error should mention mismatched name: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_AllowsFactoryWithBlankName(t *testing.T) {
|
||||
// Factory leaves Name blank; registry fills it from the key. Common,
|
||||
// non-buggy pattern.
|
||||
factories := map[string]Factory{
|
||||
"alpha": func(_ Deps) Module {
|
||||
return Module{Commands: []Command{noopCmd("a1")}}
|
||||
},
|
||||
}
|
||||
reg, err := Build([]string{"alpha"}, factories, newProvider(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
if reg.Modules[0].Name != "alpha" {
|
||||
t.Errorf("blank-name factory: registered Name = %q, want 'alpha'", reg.Modules[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_AcceptsHyphenatedModuleName(t *testing.T) {
|
||||
factories := map[string]Factory{
|
||||
"loldle-emoji": factory("loldle-emoji", []Command{noopCmd("emoji_cmd")}, nil),
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package wordle
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
// errEmptyWordList is returned by pickers when the dictionary is empty —
|
||||
// callers (Init) should fail fast rather than spin a game with no answers.
|
||||
var errEmptyWordList = errors.New("wordle: word list is empty")
|
||||
|
||||
// todayUTC returns the current date as YYYY-MM-DD in UTC. Mirrors JS
|
||||
// `new Date().toISOString().slice(0, 10)`.
|
||||
func todayUTC(now time.Time) string {
|
||||
return now.UTC().Format("2006-01-02")
|
||||
}
|
||||
|
||||
// hashDJB2 is the same djb2 variant the JS source uses, with an explicit
|
||||
// 32-bit mask at the end.
|
||||
func hashDJB2(s string) uint32 {
|
||||
h := uint32(5381)
|
||||
for i := 0; i < len(s); i++ {
|
||||
h = (h * 33) ^ uint32(s[i])
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// pickDaily returns a deterministic pick keyed by a string seed (defaulting
|
||||
// to today's UTC date). Same word for everyone on the same UTC day.
|
||||
//
|
||||
// Currently unused by handlers (which use pickRandom for variety per round)
|
||||
// but kept for parity with the JS source — Phase 5c may switch to a daily.
|
||||
func pickDaily(words []string, seed string) (string, error) {
|
||||
if len(words) == 0 {
|
||||
return "", errEmptyWordList
|
||||
}
|
||||
if seed == "" {
|
||||
seed = todayUTC(time.Now())
|
||||
}
|
||||
idx := int(hashDJB2(seed) % uint32(len(words)))
|
||||
return words[idx], nil
|
||||
}
|
||||
|
||||
// pickRandom is the picker handlers actually use today. Uniform random pick.
|
||||
// rng allows tests to inject a deterministic source. When rng is nil we fall
|
||||
// through to math/rand's package-level Intn, which IS goroutine-safe via an
|
||||
// internal mutex on the global Source — important because the bot dispatcher
|
||||
// runs each Telegram update in its own goroutine and concurrent /wordle_new
|
||||
// calls would otherwise race on a shared *rand.Rand.
|
||||
func pickRandom(words []string, rng *rand.Rand) (string, error) {
|
||||
if len(words) == 0 {
|
||||
return "", errEmptyWordList
|
||||
}
|
||||
if rng != nil {
|
||||
return words[rng.Intn(len(words))], nil
|
||||
}
|
||||
return words[rand.Intn(len(words))], nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package wordle
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/rand"
|
||||
)
|
||||
|
||||
// errEmptyWordList is returned by pickers when the dictionary is empty —
|
||||
// callers (Init) should fail fast rather than spin a game with no answers.
|
||||
var errEmptyWordList = errors.New("wordle: word list is empty")
|
||||
|
||||
// pickRandom is the picker handlers actually use today. Uniform random pick.
|
||||
// rng allows tests to inject a deterministic source. When rng is nil we fall
|
||||
// through to math/rand's package-level Intn, which IS goroutine-safe via an
|
||||
// internal mutex on the global Source — important because the bot dispatcher
|
||||
// runs each Telegram update in its own goroutine and concurrent /wordle_new
|
||||
// calls would otherwise race on a shared *rand.Rand.
|
||||
func pickRandom(words []string, rng *rand.Rand) (string, error) {
|
||||
if len(words) == 0 {
|
||||
return "", errEmptyWordList
|
||||
}
|
||||
if rng != nil {
|
||||
return words[rng.Intn(len(words))], nil
|
||||
}
|
||||
return words[rand.Intn(len(words))], nil
|
||||
}
|
||||
@@ -4,51 +4,8 @@ import (
|
||||
"math/rand"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTodayUTC_FormatStable(t *testing.T) {
|
||||
now := time.Date(2026, 5, 9, 9, 30, 0, 0, time.UTC)
|
||||
if got := todayUTC(now); got != "2026-05-09" {
|
||||
t.Errorf("todayUTC = %s, want 2026-05-09", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickDaily_DeterministicForSameSeed(t *testing.T) {
|
||||
words := []string{"alpha", "bravo", "delta", "gamma"}
|
||||
a, err := pickDaily(words, "2026-05-09")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := pickDaily(words, "2026-05-09")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a != b {
|
||||
t.Errorf("daily picks differ: %s vs %s", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickDaily_DifferentSeedsDiffer(t *testing.T) {
|
||||
// Not strictly required by spec — but a useful smoke that the hash isn't
|
||||
// degenerate. Use a long word list so collisions are unlikely.
|
||||
words := []string{
|
||||
"alpha", "bravo", "delta", "gamma", "echo", "fox",
|
||||
"hotel", "india", "juliet", "kilo", "lima", "mike",
|
||||
}
|
||||
a, _ := pickDaily(words, "2026-05-09")
|
||||
b, _ := pickDaily(words, "2026-05-10")
|
||||
if a == b {
|
||||
t.Logf("warn: same daily for different seeds (acceptable but rare): %s", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickDaily_EmptyErrors(t *testing.T) {
|
||||
if _, err := pickDaily(nil, "x"); err == nil {
|
||||
t.Error("expected error for empty list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickRandom_UsesInjectedRNG(t *testing.T) {
|
||||
rng := rand.New(rand.NewSource(1))
|
||||
words := []string{"a", "b", "c", "d", "e"}
|
||||
@@ -11,11 +11,9 @@ import (
|
||||
// MaxGuesses is the standard wordle round length.
|
||||
const MaxGuesses = 6
|
||||
|
||||
// gameTTLSeconds matches the JS source's KV TTL — informational only. Cloud
|
||||
// Firestore has no native per-document TTL equivalent to Cloudflare KV; old
|
||||
// games linger until manually cleaned. Document the deviation rather than
|
||||
// scaffold a TTL cron we don't need today.
|
||||
const gameTTLSeconds = 60 * 60 * 24 * 7
|
||||
// Cloud Firestore has no native per-document TTL equivalent to Cloudflare KV
|
||||
// — saved games linger until manually cleaned. Out of scope today; tracked
|
||||
// in port plan as a future cron.
|
||||
|
||||
// GuessRecord is one entry in a game's history. JSON shape locks JS parity:
|
||||
//
|
||||
@@ -66,8 +64,7 @@ func loadGame(ctx context.Context, kv storage.KVStore, subject string) (*GameSta
|
||||
}
|
||||
}
|
||||
|
||||
// saveGame writes the round. TTL is not honored on Firestore (see comment on
|
||||
// gameTTLSeconds) — kept here as documentation of intent.
|
||||
// saveGame writes the round.
|
||||
func saveGame(ctx context.Context, kv storage.KVStore, subject string, g *GameState) error {
|
||||
if err := kv.PutJSON(ctx, gameKey(subject), g); err != nil {
|
||||
return fmt.Errorf("wordle saveGame: %w", err)
|
||||
|
||||
Reference in new issue
Block a user