feat(server,modules): bootstrap server and module framework

Implements Phases 02 (partial) and 03 of the go-port-cloud-run plan.
Introduces module framework with per-module KV prefix isolation,
health check endpoint, request timeout protection, and comprehensive
test coverage. Cloud Run deployment deferred to Phase 01.

Security hardening: constant-time secret comparison, cron auth bridge,
and secrets stripped from dependency environment exports. Includes
Dockerfile, GitHub CI workflow (vet + race + build), and integration
tests for module lifecycle.
This commit is contained in:
2026-05-08 23:27:12 +07:00
parent 76a3b3af49
commit ffe91cb32c
26 changed files with 1605 additions and 1 deletions
+8
View File
@@ -0,0 +1,8 @@
.git
.github
plans
docs
*.md
LICENSE
Dockerfile
.dockerignore
+33
View File
@@ -0,0 +1,33 @@
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
go:
runs-on: ubuntu-latest
strategy:
matrix:
go: ['1.23']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
cache: true
- name: go vet
run: go vet ./...
- name: go test
run: go test -race -count=1 ./...
- name: go build
run: go build ./...
+17
View File
@@ -0,0 +1,17 @@
FROM golang:1.23-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w" \
-o /out/server \
./cmd/server
FROM gcr.io/distroless/static:nonroot
COPY --from=builder /out/server /server
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]
+54 -1
View File
@@ -1,2 +1,55 @@
# miti99bot-go
Plug-n-play Telegram bot framework in Go, deployed on Google Cloud Run with Firestore + Gemini. Free-tier port of miti99bot.
Plug-n-play Telegram bot framework in Go, deployed on Google Cloud Run with Firestore + Gemini. Free-tier port of [miti99bot](https://github.com/tiennm99/miti99bot).
## Status
Early scaffolding. See [`plans/260508-2222-go-port-cloud-run/plan.md`](plans/260508-2222-go-port-cloud-run/plan.md) for the full roadmap.
| Phase | What | Status |
|-------|------|--------|
| 01 | GCP setup, Cloud Run baseline | pending |
| 02 | Repo bootstrap + webhook skeleton | **partial** (local pieces done; Cloud Run deploy deferred to Phase 01) |
| 03 | Module framework + KVStore | **done** |
| 04+ | Firestore, modules, cron, CI/CD, cutover | pending |
## Layout
```
cmd/server/ entrypoint
internal/server/ HTTP routes (/, /webhook, /cron/{name})
internal/telegram/ Telegram webhook + bot wrapper
internal/modules/ Module framework, registry, dispatchers
internal/storage/ KVStore interface, in-memory impl, prefix wrapper
```
## Run locally
```sh
TELEGRAM_BOT_TOKEN=\
TELEGRAM_WEBHOOK_SECRET=local \
PORT=8080 \
MODULES= \
go run ./cmd/server
```
End-to-end smoke test against a Telegram dev bot requires `ngrok` (local) or a Cloud Run deployment. The dev bot is created manually; token is injected via env vars only.
## Test
```sh
go vet ./...
go test ./...
```
## Build
```sh
docker build -t miti99bot-go .
```
The image is multi-stage (`golang:1.23-alpine``gcr.io/distroless/static:nonroot`); resulting image is ~15 MiB.
## License
[Apache-2.0](LICENSE).
+147
View File
@@ -0,0 +1,147 @@
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/tiennm99/miti99bot-go/internal/modules"
"github.com/tiennm99/miti99bot-go/internal/server"
"github.com/tiennm99/miti99bot-go/internal/storage"
"github.com/tiennm99/miti99bot-go/internal/telegram"
)
// secretEnvKeys are stripped from Deps.Env before any module sees it. Each
// new credential added to the environment must be appended here.
var secretEnvKeys = []string{
"TELEGRAM_BOT_TOKEN",
"TELEGRAM_WEBHOOK_SECRET",
"CRON_SHARED_SECRET",
}
func main() {
cfg := loadConfig()
if cfg.TelegramBotToken == "" {
log.Fatal("TELEGRAM_BOT_TOKEN is required")
}
if cfg.WebhookSecret == "" {
log.Fatal("TELEGRAM_WEBHOOK_SECRET is required (a non-empty secret is the only auth on /webhook)")
}
rootCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
b, err := telegram.NewBot(cfg.TelegramBotToken)
if err != nil {
log.Fatalf("telegram bot init: %v", err)
}
kv := storage.NewMemoryKVStore()
deps := modules.Deps{KV: kv, Env: cfg.ModuleEnv}
reg, err := modules.Build(cfg.Modules, modules.Factories, deps)
if err != nil {
log.Fatalf("module registry: %v", err)
}
modules.Install(b, reg)
log.Printf("loaded %d module(s), %d command(s), %d cron(s)",
len(reg.Modules), len(reg.AllCommands), len(reg.Crons()))
if cfg.CronSecret == "" {
log.Println("WARN: CRON_SHARED_SECRET unset; /cron/{name} disabled (404 to all)")
}
handler := server.New(server.Config{
Bot: b,
Registry: reg,
WebhookSecret: cfg.WebhookSecret,
CronSecret: cfg.CronSecret,
})
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
// 6 min accommodates /cron/{name}; the webhook handler enforces a
// tighter per-update ctx timeout internally.
WriteTimeout: 6 * time.Minute,
IdleTimeout: 120 * time.Second,
}
go func() {
log.Printf("server listening on :%s", cfg.Port)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server: %v", err)
}
}()
<-rootCtx.Done()
log.Println("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("graceful shutdown: %v", err)
}
}
type config struct {
Port string
TelegramBotToken string
WebhookSecret string
CronSecret string
Modules []string
ModuleEnv map[string]string // sensitive keys stripped, safe to hand to modules
}
func loadConfig() config {
envMap := make(map[string]string, len(os.Environ()))
for _, kv := range os.Environ() {
if eq := strings.IndexByte(kv, '='); eq >= 0 {
envMap[kv[:eq]] = kv[eq+1:]
}
}
port := envMap["PORT"]
if port == "" {
port = "8080"
}
return config{
Port: port,
TelegramBotToken: envMap["TELEGRAM_BOT_TOKEN"],
WebhookSecret: envMap["TELEGRAM_WEBHOOK_SECRET"],
CronSecret: envMap["CRON_SHARED_SECRET"],
Modules: splitCSV(envMap["MODULES"]),
ModuleEnv: envForModules(envMap),
}
}
func envForModules(env map[string]string) map[string]string {
out := make(map[string]string, len(env))
for k, v := range env {
out[k] = v
}
for _, k := range secretEnvKeys {
delete(out, k)
}
return out
}
func splitCSV(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := parts[:0]
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return out
}
+5
View File
@@ -0,0 +1,5 @@
module github.com/tiennm99/miti99bot-go
go 1.23
require github.com/go-telegram/bot v1.20.0
+2
View File
@@ -0,0 +1,2 @@
github.com/go-telegram/bot v1.20.0 h1:4Pea/qTidSspr4WBJw9FbHUMNhYeqszBqQUfsQEyFbc=
github.com/go-telegram/bot v1.20.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
+30
View File
@@ -0,0 +1,30 @@
package modules
import (
"context"
"errors"
"fmt"
)
// ErrCronNotFound is returned when /cron/{name} addresses an unregistered cron.
var ErrCronNotFound = errors.New("cron not found")
// DispatchScheduled runs the cron registered under name with the per-module
// prefixed Deps the registry stored at Build time. Returns ErrCronNotFound if
// no module owns that name — Cloud Scheduler hitting an unknown route is a
// configuration bug worth surfacing as a 404 at the HTTP layer.
//
// The handler runs synchronously in the calling goroutine; ctx propagates
// cancellation/timeout from the HTTP request.
func DispatchScheduled(ctx context.Context, name string, reg *Registry) error {
cron, ok := reg.Cron(name)
if !ok {
return fmt.Errorf("%w: %q", ErrCronNotFound, name)
}
deps, ok := reg.CronDeps(name)
if !ok {
// Should be impossible: Build always co-registers cron and cronDeps.
return fmt.Errorf("modules: cron %q has no registered deps (registry corruption)", name)
}
return cron.Handler(ctx, deps)
}
+29
View File
@@ -0,0 +1,29 @@
package modules
import (
"context"
"log"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
)
// Install registers every command in the registry with the Telegram bot.
//
// MatchTypeCommand expects the bare command name without the leading slash;
// the library compares against entity bytes after the "/" prefix.
func Install(b *bot.Bot, reg *Registry) {
for name, cmd := range reg.AllCommands {
cmdCopy := cmd // capture by value for the closure
b.RegisterHandler(
bot.HandlerTypeMessageText,
name,
bot.MatchTypeCommand,
func(ctx context.Context, b *bot.Bot, update *models.Update) {
if err := cmdCopy.Handler(ctx, b, update); err != nil {
log.Printf("command /%s failed: %v", cmdCopy.Name, err)
}
},
)
}
}
+76
View File
@@ -0,0 +1,76 @@
package modules
import (
"context"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot-go/internal/storage"
)
// Visibility classifies who may invoke a command. The dispatcher does not
// enforce visibility today; the field exists so /help and chat-scoping can
// filter consistently in later phases.
type Visibility int
const (
VisibilityPublic Visibility = iota
VisibilityProtected
VisibilityPrivate
)
// CommandHandler runs in response to a Telegram command. Returning an error
// causes the dispatcher to log the failure. Telegram retries are governed by
// the webhook HTTP status (200), not handler errors — so the error return is
// purely for logging/metrics, not flow control.
type CommandHandler func(ctx context.Context, b *bot.Bot, update *models.Update) error
// CronHandler runs when Cloud Scheduler hits /cron/{name}. Crons receive the
// per-module-prefixed Deps via the registry; handlers should not capture the
// base Deps from the factory closure or KV writes will collide across modules.
type CronHandler func(ctx context.Context, deps Deps) error
// Command is a single Telegram bot command exposed by a module.
type Command struct {
Name string // ^[a-z0-9_]{1,32}$ — Telegram BotFather rules
Visibility Visibility // public/protected/private
Description string // shown in /help (required, non-empty)
Handler CommandHandler // required
}
// Cron is a single scheduled job exposed by a module.
type Cron struct {
Schedule string // documentation only; real schedule lives in Cloud Scheduler
Name string // unique within module
Handler CronHandler // required
}
// Module is a self-contained feature unit: a name plus zero or more commands
// and crons. Modules are constructed by Factory functions that capture their
// per-module Deps via closure.
//
// Module.Name is overridden by the registry to its catalog key; factories may
// leave it blank.
type Module struct {
Name string
Commands []Command
Crons []Cron
}
// Deps is the dependency bundle a Factory receives. Each field is added in the
// phase that introduces it; today only KV + Env exist (Firestore: Phase 04,
// Gemini: Phase 07).
//
// Deps.Env is the process environment with sensitive keys stripped. Modules
// must not assume Env contains every variable — see cmd/server.envForModules.
type Deps struct {
KV storage.KVStore // already prefixed with the module name when passed to a Factory
Env map[string]string // process env minus known-sensitive keys
}
// Factory constructs a Module from its Deps. Spec deviation: Phase 03 plan
// defines `Factory func() Module` with a separate Init step. We pass Deps
// directly so handler closures can capture them — idiomatic Go and removes a
// lifecycle ordering trap.
type Factory func(deps Deps) Module
+10
View File
@@ -0,0 +1,10 @@
package modules
// Factories is the static module catalog. Each phase that introduces a module
// adds an entry here. Phase 03 ships an empty catalog; Phase 05 onwards
// populates it.
//
// Spec deviation: Phase 03 plan defined a `[]Factory` slice. A map keyed by
// module name is required for Build() to honor the MODULES env CSV without a
// linear scan, and prevents duplicate module names at compile-load time.
var Factories = map[string]Factory{}
+156
View File
@@ -0,0 +1,156 @@
package modules
import (
"fmt"
"sort"
"github.com/tiennm99/miti99bot-go/internal/storage"
)
// moduleNameRe enforces the same alphabet as command names so KV prefix
// isolation is preserved (no ":" in module names → no prefix collision) and
// the cron route's path segment regex matches.
var moduleNameRe = commandNameRe // alias kept for symmetry; one regex serves both
// Registry holds the resolved set of modules selected by the MODULES env var.
// It is built once at startup; Build fails fast on validation or conflict.
type Registry struct {
Modules []Module // in MODULES-env order
AllCommands map[string]Command // name → Command, deduped across modules
publicCmds map[string]Command
protected map[string]Command
private map[string]Command
crons map[string]Cron // name → Cron, unique across modules
cronDeps map[string]Deps // cron name → owning module's prefixed Deps
}
// PublicCommands returns commands tagged VisibilityPublic, sorted by name.
func (r *Registry) PublicCommands() []Command { return sortedCommands(r.publicCmds) }
// ProtectedCommands returns commands tagged VisibilityProtected, sorted by name.
func (r *Registry) ProtectedCommands() []Command { return sortedCommands(r.protected) }
// PrivateCommands returns commands tagged VisibilityPrivate, sorted by name.
func (r *Registry) PrivateCommands() []Command { return sortedCommands(r.private) }
// Cron looks up a cron by global name across all loaded modules.
func (r *Registry) Cron(name string) (Cron, bool) {
c, ok := r.crons[name]
return c, ok
}
// CronDeps returns the per-module-prefixed Deps the cron's owning module
// received. The cron dispatcher uses this to pass scoped Deps to the handler.
func (r *Registry) CronDeps(name string) (Deps, bool) {
d, ok := r.cronDeps[name]
return d, ok
}
// Crons returns all loaded crons, sorted by name. Allocates a fresh slice on
// every call — fine for startup-time logging, not for hot paths.
func (r *Registry) Crons() []Cron {
out := make([]Cron, 0, len(r.crons))
for _, c := range r.crons {
out = append(out, c)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Build constructs a Registry from the requested module names. It calls each
// factory with a per-module-prefixed KVStore, validates every command/cron,
// and aborts on duplicate command names across the union of all visibilities.
//
// Names not present in factories are reported as a single error so a typo in
// MODULES does not silently load a smaller bot than intended. Duplicate names
// in MODULES are also a hard error to keep startup deterministic.
func Build(enabled []string, factories map[string]Factory, base Deps) (*Registry, error) {
if base.KV == nil {
return nil, fmt.Errorf("modules: Deps.KV is required")
}
reg := &Registry{
AllCommands: map[string]Command{},
publicCmds: map[string]Command{},
protected: map[string]Command{},
private: map[string]Command{},
crons: map[string]Cron{},
cronDeps: map[string]Deps{},
}
owners := map[string]string{} // command name → module that registered it
cronOwners := map[string]string{}
seenModule := map[string]bool{}
var unknown []string
for _, name := range enabled {
if !moduleNameRe.MatchString(name) {
return nil, fmt.Errorf("modules: invalid name %q in MODULES env (must match %s)", name, moduleNameRe)
}
if seenModule[name] {
return nil, fmt.Errorf("modules: duplicate name %q in MODULES env", name)
}
seenModule[name] = true
factory, ok := factories[name]
if !ok {
unknown = append(unknown, name)
continue
}
moduleDeps := Deps{
KV: storage.Prefixed(base.KV, name),
Env: base.Env,
}
mod := factory(moduleDeps)
mod.Name = name // enforce: module name is its registry key, not whatever the factory chose
for _, cmd := range mod.Commands {
if err := validateCommand(cmd); err != nil {
return nil, fmt.Errorf("module %q: %w", name, err)
}
if prev, dup := owners[cmd.Name]; dup {
return nil, fmt.Errorf("command conflict: /%s defined in %q and %q", cmd.Name, prev, name)
}
owners[cmd.Name] = name
reg.AllCommands[cmd.Name] = cmd
switch cmd.Visibility {
case VisibilityPublic:
reg.publicCmds[cmd.Name] = cmd
case VisibilityProtected:
reg.protected[cmd.Name] = cmd
case VisibilityPrivate:
reg.private[cmd.Name] = cmd
}
}
for _, cron := range mod.Crons {
if err := validateCron(cron); err != nil {
return nil, fmt.Errorf("module %q: %w", name, err)
}
if prev, dup := cronOwners[cron.Name]; dup {
return nil, fmt.Errorf("cron conflict: %q defined in %q and %q", cron.Name, prev, name)
}
cronOwners[cron.Name] = name
reg.crons[cron.Name] = cron
reg.cronDeps[cron.Name] = moduleDeps
}
reg.Modules = append(reg.Modules, mod)
}
if len(unknown) > 0 {
return nil, fmt.Errorf("modules: unknown name(s) in MODULES env: %v", unknown)
}
return reg, nil
}
func sortedCommands(m map[string]Command) []Command {
out := make([]Command, 0, len(m))
for _, c := range m {
out = append(out, c)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
+269
View File
@@ -0,0 +1,269 @@
package modules
import (
"context"
"errors"
"strings"
"testing"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot-go/internal/storage"
)
func noopCmd(name string) Command {
return Command{
Name: name,
Visibility: VisibilityPublic,
Description: "test " + name,
Handler: func(_ context.Context, _ *bot.Bot, _ *models.Update) error { return nil },
}
}
func noopCron(name string) Cron {
return Cron{
Schedule: "@every 24h",
Name: name,
Handler: func(_ context.Context, _ Deps) error { return nil },
}
}
func factory(name string, cmds []Command, crons []Cron) Factory {
return func(_ Deps) Module {
return Module{Name: name, Commands: cmds, Crons: crons}
}
}
func baseDeps() Deps { return Deps{KV: storage.NewMemoryKVStore()} }
func TestBuild_EmptyModulesBootsCleanly(t *testing.T) {
reg, err := Build(nil, map[string]Factory{}, baseDeps())
if err != nil {
t.Fatalf("Build empty: %v", err)
}
if len(reg.AllCommands) != 0 {
t.Errorf("expected 0 commands, got %d", len(reg.AllCommands))
}
}
func TestBuild_LoadsRequestedModules(t *testing.T) {
factories := map[string]Factory{
"alpha": factory("alpha", []Command{noopCmd("a1")}, nil),
"beta": factory("beta", []Command{noopCmd("b1")}, []Cron{noopCron("daily")}),
}
reg, err := Build([]string{"alpha", "beta"}, factories, baseDeps())
if err != nil {
t.Fatalf("Build: %v", err)
}
if len(reg.Modules) != 2 {
t.Errorf("expected 2 modules, got %d", len(reg.Modules))
}
if _, ok := reg.AllCommands["a1"]; !ok {
t.Error("missing command a1")
}
if _, ok := reg.Cron("daily"); !ok {
t.Error("missing cron daily")
}
}
func TestBuild_SkipsModulesNotInEnv(t *testing.T) {
factories := map[string]Factory{
"alpha": factory("alpha", []Command{noopCmd("a1")}, nil),
"beta": factory("beta", []Command{noopCmd("b1")}, nil),
}
reg, err := Build([]string{"alpha"}, factories, baseDeps())
if err != nil {
t.Fatalf("Build: %v", err)
}
if _, ok := reg.AllCommands["b1"]; ok {
t.Error("beta should not have been loaded")
}
}
func TestBuild_RejectsUnknownModule(t *testing.T) {
_, err := Build([]string{"ghost"}, map[string]Factory{}, baseDeps())
if err == nil || !strings.Contains(err.Error(), "ghost") {
t.Errorf("expected error mentioning ghost, got %v", err)
}
}
func TestBuild_DetectsCommandConflict(t *testing.T) {
factories := map[string]Factory{
"alpha": factory("alpha", []Command{noopCmd("ping")}, nil),
"beta": factory("beta", []Command{noopCmd("ping")}, nil),
}
_, err := Build([]string{"alpha", "beta"}, factories, baseDeps())
if err == nil {
t.Fatal("expected conflict error")
}
if !strings.Contains(err.Error(), "command conflict") {
t.Errorf("error should mention conflict, got %v", err)
}
}
func TestBuild_DetectsCronConflict(t *testing.T) {
factories := map[string]Factory{
"alpha": factory("alpha", nil, []Cron{noopCron("daily")}),
"beta": factory("beta", nil, []Cron{noopCron("daily")}),
}
_, err := Build([]string{"alpha", "beta"}, factories, baseDeps())
if err == nil || !strings.Contains(err.Error(), "cron conflict") {
t.Errorf("expected cron conflict, got %v", err)
}
}
func TestBuild_RequiresKV(t *testing.T) {
_, err := Build(nil, map[string]Factory{}, Deps{})
if err == nil {
t.Error("expected error when Deps.KV is nil")
}
}
func TestBuild_ValidationErrorsMentionModule(t *testing.T) {
bad := Command{Name: "BAD-NAME", Visibility: VisibilityPublic, Description: "x", Handler: noopCmd("x").Handler}
factories := map[string]Factory{
"alpha": factory("alpha", []Command{bad}, nil),
}
_, err := Build([]string{"alpha"}, factories, baseDeps())
if err == nil || !strings.Contains(err.Error(), "alpha") {
t.Errorf("expected error mentioning module 'alpha', got %v", err)
}
}
func TestDispatchScheduled_RunsHandler(t *testing.T) {
called := false
factories := map[string]Factory{
"alpha": factory("alpha", nil, []Cron{{
Name: "tick",
Handler: func(_ context.Context, _ Deps) error {
called = true
return nil
},
}}),
}
reg, err := Build([]string{"alpha"}, factories, baseDeps())
if err != nil {
t.Fatalf("Build: %v", err)
}
if err := DispatchScheduled(context.Background(), "tick", reg); err != nil {
t.Fatalf("DispatchScheduled: %v", err)
}
if !called {
t.Error("cron handler not invoked")
}
}
func TestDispatchScheduled_UnknownReturnsErrCronNotFound(t *testing.T) {
reg, err := Build(nil, map[string]Factory{}, baseDeps())
if err != nil {
t.Fatalf("Build: %v", err)
}
err = DispatchScheduled(context.Background(), "missing", reg)
if !errors.Is(err, ErrCronNotFound) {
t.Errorf("expected ErrCronNotFound, got %v", err)
}
}
func TestDispatchScheduled_PassesPrefixedDeps(t *testing.T) {
ctx := context.Background()
base := storage.NewMemoryKVStore()
factories := map[string]Factory{
"alpha": func(d Deps) Module {
return Module{Crons: []Cron{{
Name: "tick_a",
Handler: func(ctx context.Context, deps Deps) error {
return deps.KV.Put(ctx, "last", []byte("A"))
},
}}}
},
"beta": func(d Deps) Module {
return Module{Crons: []Cron{{
Name: "tick_b",
Handler: func(ctx context.Context, deps Deps) error {
return deps.KV.Put(ctx, "last", []byte("B"))
},
}}}
},
}
reg, err := Build([]string{"alpha", "beta"}, factories, Deps{KV: base})
if err != nil {
t.Fatalf("Build: %v", err)
}
if err := DispatchScheduled(ctx, "tick_a", reg); err != nil {
t.Fatalf("tick_a: %v", err)
}
if err := DispatchScheduled(ctx, "tick_b", reg); err != nil {
t.Fatalf("tick_b: %v", err)
}
// Underlying base store should hold each module's prefixed key separately.
gotA, err := base.Get(ctx, "alpha:last")
if err != nil || string(gotA) != "A" {
t.Errorf("alpha:last = %q (err=%v), want A", gotA, err)
}
gotB, err := base.Get(ctx, "beta:last")
if err != nil || string(gotB) != "B" {
t.Errorf("beta:last = %q (err=%v), want B", gotB, err)
}
}
func TestBuild_RejectsInvalidModuleName(t *testing.T) {
for _, name := range []string{"BadName", "with-dash", "a:b", ""} {
t.Run(name, func(t *testing.T) {
_, err := Build([]string{name}, map[string]Factory{}, baseDeps())
if err == nil {
t.Errorf("name %q: expected error", name)
}
})
}
}
func TestBuild_RejectsDuplicateModuleInEnv(t *testing.T) {
factories := map[string]Factory{
"alpha": factory("alpha", []Command{noopCmd("a1")}, nil),
}
_, err := Build([]string{"alpha", "alpha"}, factories, baseDeps())
if err == nil || !strings.Contains(err.Error(), "duplicate") {
t.Errorf("expected duplicate-module error, got %v", err)
}
}
func TestBuild_PerModulePrefixedKV(t *testing.T) {
ctx := context.Background()
base := storage.NewMemoryKVStore()
// Each module writes a value to the same key; with per-module prefixing
// they must not collide.
captured := map[string]Deps{}
factories := map[string]Factory{
"alpha": func(d Deps) Module {
captured["alpha"] = d
return Module{Commands: []Command{noopCmd("a")}}
},
"beta": func(d Deps) Module {
captured["beta"] = d
return Module{Commands: []Command{noopCmd("b")}}
},
}
if _, err := Build([]string{"alpha", "beta"}, factories, Deps{KV: base}); err != nil {
t.Fatalf("Build: %v", err)
}
if err := captured["alpha"].KV.Put(ctx, "score", []byte("1")); err != nil {
t.Fatal(err)
}
if err := captured["beta"].KV.Put(ctx, "score", []byte("2")); err != nil {
t.Fatal(err)
}
got, _ := captured["alpha"].KV.Get(ctx, "score")
if string(got) != "1" {
t.Errorf("alpha.KV.score = %q, want 1", got)
}
got, _ = captured["beta"].KV.Get(ctx, "score")
if string(got) != "2" {
t.Errorf("beta.KV.score = %q, want 2", got)
}
}
+36
View File
@@ -0,0 +1,36 @@
package modules
import (
"fmt"
"regexp"
)
var commandNameRe = regexp.MustCompile(`^[a-z0-9_]{1,32}$`)
func validateCommand(c Command) error {
if !commandNameRe.MatchString(c.Name) {
return fmt.Errorf("command name %q must match %s", c.Name, commandNameRe)
}
switch c.Visibility {
case VisibilityPublic, VisibilityProtected, VisibilityPrivate:
default:
return fmt.Errorf("command %q: unknown visibility %d", c.Name, c.Visibility)
}
if c.Description == "" {
return fmt.Errorf("command %q: description is required", c.Name)
}
if c.Handler == nil {
return fmt.Errorf("command %q: handler is nil", c.Name)
}
return nil
}
func validateCron(c Cron) error {
if c.Name == "" {
return fmt.Errorf("cron: name is required")
}
if c.Handler == nil {
return fmt.Errorf("cron %q: handler is nil", c.Name)
}
return nil
}
+56
View File
@@ -0,0 +1,56 @@
package modules
import (
"context"
"testing"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
)
func okHandler(_ context.Context, _ *bot.Bot, _ *models.Update) error { return nil }
func TestValidateCommand_RejectsBadNames(t *testing.T) {
cases := map[string]string{
"empty": "",
"uppercase": "Ping",
"hyphen": "do-thing",
"too long": "abcdefghijklmnopqrstuvwxyzabcdefg", // 33 chars
"with slash": "/ping",
"unicode": "пинг",
}
for label, name := range cases {
t.Run(label, func(t *testing.T) {
err := validateCommand(Command{Name: name, Visibility: VisibilityPublic, Description: "d", Handler: okHandler})
if err == nil {
t.Errorf("name %q: expected error", name)
}
})
}
}
func TestValidateCommand_AcceptsLegalNames(t *testing.T) {
for _, name := range []string{"ping", "do_it", "a", "abc123", "x_1_y"} {
if err := validateCommand(Command{Name: name, Visibility: VisibilityPublic, Description: "d", Handler: okHandler}); err != nil {
t.Errorf("name %q: unexpected error %v", name, err)
}
}
}
func TestValidateCommand_RequiresDescriptionAndHandler(t *testing.T) {
if err := validateCommand(Command{Name: "ok", Visibility: VisibilityPublic, Description: "", Handler: okHandler}); err == nil {
t.Error("expected error for empty description")
}
if err := validateCommand(Command{Name: "ok", Visibility: VisibilityPublic, Description: "d", Handler: nil}); err == nil {
t.Error("expected error for nil handler")
}
}
func TestValidateCron_RequiresNameAndHandler(t *testing.T) {
if err := validateCron(Cron{Name: "", Handler: func(_ context.Context, _ Deps) error { return nil }}); err == nil {
t.Error("expected error for empty name")
}
if err := validateCron(Cron{Name: "x", Handler: nil}); err == nil {
t.Error("expected error for nil handler")
}
}
+22
View File
@@ -0,0 +1,22 @@
package server
import "net/http"
// HealthHandler answers GET / with a stable string so Cloud Run's HTTP probe
// and any uptime monitor can distinguish "process up" from "process listening
// but routing broken".
func HealthHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Anything other than the root path on this exact handler is a 404.
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("miti99bot-go ok\n"))
}
}
+92
View File
@@ -0,0 +1,92 @@
package server
import (
"context"
"crypto/subtle"
"errors"
"log"
"net/http"
"regexp"
"strings"
"github.com/go-telegram/bot"
"github.com/tiennm99/miti99bot-go/internal/modules"
"github.com/tiennm99/miti99bot-go/internal/telegram"
)
// cronNameRe limits cron path segments to a safe alphabet so log injection via
// the route is impossible (newlines, ANSI escapes, etc. are rejected at the
// router boundary). Same shape as Telegram command names.
var cronNameRe = regexp.MustCompile(`^[a-z0-9_]{1,32}$`)
// cronAuthHeader is the shared-secret header name. Replaced by OIDC in Phase 09.
const cronAuthHeader = "X-Cron-Token"
// Config wires the router's runtime dependencies.
type Config struct {
Bot *bot.Bot
Registry *modules.Registry
WebhookSecret string
// CronSecret is the shared-secret bridge until Phase 09 adds OIDC. Empty
// means /cron/{name} is fully disabled (404). Required to prevent
// unauthenticated triggering of billable side effects.
CronSecret string
}
// New builds the application's HTTP handler. Routes:
//
// GET / → health
// POST /webhook → Telegram update intake (constant-time secret check)
// POST /cron/{name} → Cloud Scheduler entry (shared-secret check; OIDC in Phase 09)
//
// Anything else is 404.
func New(cfg Config) http.Handler {
mux := http.NewServeMux()
mux.Handle("/", HealthHandler())
mux.Handle("/webhook", telegram.WebhookHandler(cfg.Bot, cfg.WebhookSecret))
mux.Handle("/cron/", cronHandler(cfg.Registry, cfg.CronSecret))
return mux
}
func cronHandler(reg *modules.Registry, secret string) http.HandlerFunc {
secretBytes := []byte(secret)
cronDisabled := secret == ""
return func(w http.ResponseWriter, r *http.Request) {
if cronDisabled {
http.NotFound(w, r)
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
got := []byte(r.Header.Get(cronAuthHeader))
if subtle.ConstantTimeCompare(got, secretBytes) != 1 {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
name := strings.TrimPrefix(r.URL.Path, "/cron/")
if !cronNameRe.MatchString(name) {
http.NotFound(w, r)
return
}
log.Printf("cron name=%s", name)
ctx, cancel := context.WithTimeout(r.Context(), defaultCronTimeout)
defer cancel()
if err := modules.DispatchScheduled(ctx, name, reg); err != nil {
if errors.Is(err, modules.ErrCronNotFound) {
http.NotFound(w, r)
return
}
log.Printf("cron %s failed: %v", name, err)
http.Error(w, "cron failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
}
+137
View File
@@ -0,0 +1,137 @@
package server
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/tiennm99/miti99bot-go/internal/modules"
"github.com/tiennm99/miti99bot-go/internal/storage"
)
const testCronSecret = "shared-cron-secret"
func buildRegistry(t *testing.T, factories map[string]modules.Factory, names ...string) *modules.Registry {
t.Helper()
reg, err := modules.Build(names, factories, modules.Deps{KV: storage.NewMemoryKVStore()})
if err != nil {
t.Fatalf("modules.Build: %v", err)
}
return reg
}
func TestHealthHandler_OK(t *testing.T) {
rec := httptest.NewRecorder()
HealthHandler()(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "ok") {
t.Errorf("body = %q, want contains 'ok'", rec.Body.String())
}
}
func TestCronHandler_DisabledWhenSecretEmpty(t *testing.T) {
reg := buildRegistry(t, nil)
h := cronHandler(reg, "")
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodPost, "/cron/anything", nil))
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404 (disabled)", rec.Code)
}
}
func TestCronHandler_RejectsNonPost(t *testing.T) {
reg := buildRegistry(t, nil)
h := cronHandler(reg, testCronSecret)
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/cron/anything", nil))
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want 405", rec.Code)
}
}
func TestCronHandler_RejectsMissingAuth(t *testing.T) {
reg := buildRegistry(t, nil)
h := cronHandler(reg, testCronSecret)
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodPost, "/cron/anything", nil))
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rec.Code)
}
}
func TestCronHandler_RejectsInvalidName(t *testing.T) {
reg := buildRegistry(t, nil)
h := cronHandler(reg, testCronSecret)
// Use bare names that fail the regex; net/http already %-decodes the
// path, so a smuggled \n on the wire would arrive here as a literal byte.
cases := map[string]string{
"uppercase": "/cron/BadName",
"hyphen": "/cron/with-dash",
"newline": "/cron/with\nnewline",
"empty": "/cron/",
"too long": "/cron/abcdefghijklmnopqrstuvwxyzabcdefg", // 33 chars
"path nested": "/cron/foo/bar",
}
for label, path := range cases {
t.Run(label, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/cron/x", nil)
req.URL.Path = path // bypass NewRequest's URL parser
req.Header.Set(cronAuthHeader, testCronSecret)
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("path %q: status = %d, want 404", path, rec.Code)
}
})
}
}
func TestCronHandler_UnknownNameReturns404(t *testing.T) {
reg := buildRegistry(t, nil)
h := cronHandler(reg, testCronSecret)
req := httptest.NewRequest(http.MethodPost, "/cron/missing", nil)
req.Header.Set(cronAuthHeader, testCronSecret)
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
}
func TestCronHandler_RunsRegisteredCron(t *testing.T) {
called := false
factories := map[string]modules.Factory{
"alpha": func(_ modules.Deps) modules.Module {
return modules.Module{Crons: []modules.Cron{{
Name: "tick",
Handler: func(_ context.Context, _ modules.Deps) error {
called = true
return nil
},
}}}
},
}
reg := buildRegistry(t, factories, "alpha")
h := cronHandler(reg, testCronSecret)
req := httptest.NewRequest(http.MethodPost, "/cron/tick", nil)
req.Header.Set(cronAuthHeader, testCronSecret)
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
if !called {
t.Error("cron handler not invoked")
}
}
+8
View File
@@ -0,0 +1,8 @@
package server
import "time"
// defaultCronTimeout caps a single /cron/{name} invocation. Cloud Run request
// timeout is 60 minutes max, but we keep crons under our HTTP read timeout so
// runaway handlers cannot pin an instance.
const defaultCronTimeout = 5 * time.Minute
+20
View File
@@ -0,0 +1,20 @@
package storage
import (
"context"
"errors"
)
// ErrNotFound is returned by KVStore implementations when a key has no value.
var ErrNotFound = errors.New("storage: key not found")
// KVStore is the per-module key-value contract. Implementations must be safe
// for concurrent use and must return ErrNotFound for missing keys.
type KVStore interface {
Get(ctx context.Context, key string) ([]byte, error)
GetJSON(ctx context.Context, key string, dst any) error
Put(ctx context.Context, key string, val []byte) error
PutJSON(ctx context.Context, key string, val any) error
Delete(ctx context.Context, key string) error
List(ctx context.Context, prefix string) ([]string, error)
}
+79
View File
@@ -0,0 +1,79 @@
package storage
import (
"bytes"
"context"
"encoding/json"
"sort"
"strings"
"sync"
)
// MemoryKVStore is an in-process KVStore for tests and local smoke runs.
// It is the only implementation available until Phase 04 adds Firestore.
type MemoryKVStore struct {
mu sync.RWMutex
m map[string][]byte
}
// NewMemoryKVStore returns an empty in-memory store.
func NewMemoryKVStore() *MemoryKVStore {
return &MemoryKVStore{m: make(map[string][]byte)}
}
func (s *MemoryKVStore) Get(_ context.Context, key string) ([]byte, error) {
s.mu.RLock()
defer s.mu.RUnlock()
v, ok := s.m[key]
if !ok {
return nil, ErrNotFound
}
out := make([]byte, len(v))
copy(out, v)
return out, nil
}
func (s *MemoryKVStore) GetJSON(ctx context.Context, key string, dst any) error {
raw, err := s.Get(ctx, key)
if err != nil {
return err
}
return json.NewDecoder(bytes.NewReader(raw)).Decode(dst)
}
func (s *MemoryKVStore) Put(_ context.Context, key string, val []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
stored := make([]byte, len(val))
copy(stored, val)
s.m[key] = stored
return nil
}
func (s *MemoryKVStore) PutJSON(ctx context.Context, key string, val any) error {
raw, err := json.Marshal(val)
if err != nil {
return err
}
return s.Put(ctx, key, raw)
}
func (s *MemoryKVStore) Delete(_ context.Context, key string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.m, key)
return nil
}
func (s *MemoryKVStore) List(_ context.Context, prefix string) ([]string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
keys := make([]string, 0)
for k := range s.m {
if strings.HasPrefix(k, prefix) {
keys = append(keys, k)
}
}
sort.Strings(keys)
return keys, nil
}
+55
View File
@@ -0,0 +1,55 @@
package storage
import (
"context"
"strings"
)
// Prefixed returns a KVStore that transparently prepends prefix+":" to every
// key. List() strips the prefix from returned keys so callers see their own
// flat namespace. The prefix must be non-empty.
func Prefixed(inner KVStore, prefix string) KVStore {
if prefix == "" {
panic("storage: Prefixed requires non-empty prefix")
}
return &prefixedStore{inner: inner, prefix: prefix + ":"}
}
type prefixedStore struct {
inner KVStore
prefix string
}
func (p *prefixedStore) k(key string) string { return p.prefix + key }
func (p *prefixedStore) Get(ctx context.Context, key string) ([]byte, error) {
return p.inner.Get(ctx, p.k(key))
}
func (p *prefixedStore) GetJSON(ctx context.Context, key string, dst any) error {
return p.inner.GetJSON(ctx, p.k(key), dst)
}
func (p *prefixedStore) Put(ctx context.Context, key string, val []byte) error {
return p.inner.Put(ctx, p.k(key), val)
}
func (p *prefixedStore) PutJSON(ctx context.Context, key string, val any) error {
return p.inner.PutJSON(ctx, p.k(key), val)
}
func (p *prefixedStore) Delete(ctx context.Context, key string) error {
return p.inner.Delete(ctx, p.k(key))
}
func (p *prefixedStore) List(ctx context.Context, prefix string) ([]string, error) {
keys, err := p.inner.List(ctx, p.k(prefix))
if err != nil {
return nil, err
}
out := make([]string, len(keys))
for i, k := range keys {
out[i] = strings.TrimPrefix(k, p.prefix)
}
return out, nil
}
+77
View File
@@ -0,0 +1,77 @@
package storage
import (
"context"
"reflect"
"testing"
)
func TestPrefixed_RoundTrip(t *testing.T) {
ctx := context.Background()
base := NewMemoryKVStore()
a := Prefixed(base, "modA")
b := Prefixed(base, "modB")
if err := a.Put(ctx, "score", []byte("10")); err != nil {
t.Fatalf("a.Put: %v", err)
}
if err := b.Put(ctx, "score", []byte("20")); err != nil {
t.Fatalf("b.Put: %v", err)
}
got, err := a.Get(ctx, "score")
if err != nil {
t.Fatalf("a.Get: %v", err)
}
if string(got) != "10" {
t.Errorf("a.Get score = %q, want %q", got, "10")
}
got, err = b.Get(ctx, "score")
if err != nil {
t.Fatalf("b.Get: %v", err)
}
if string(got) != "20" {
t.Errorf("b.Get score = %q, want %q", got, "20")
}
}
func TestPrefixed_ListStripsPrefix(t *testing.T) {
ctx := context.Background()
base := NewMemoryKVStore()
mod := Prefixed(base, "wordle")
for _, k := range []string{"u:1", "u:2", "session:abc"} {
if err := mod.Put(ctx, k, []byte("x")); err != nil {
t.Fatalf("Put %q: %v", k, err)
}
}
got, err := mod.List(ctx, "u:")
if err != nil {
t.Fatalf("List: %v", err)
}
want := []string{"u:1", "u:2"}
if !reflect.DeepEqual(got, want) {
t.Errorf("List u: = %v, want %v", got, want)
}
}
func TestPrefixed_NotFoundPropagates(t *testing.T) {
ctx := context.Background()
base := NewMemoryKVStore()
mod := Prefixed(base, "modA")
if _, err := mod.Get(ctx, "missing"); err != ErrNotFound {
t.Errorf("Get missing = %v, want ErrNotFound", err)
}
}
func TestPrefixed_PanicsOnEmptyPrefix(t *testing.T) {
defer func() {
if recover() == nil {
t.Error("Prefixed(_, \"\") did not panic")
}
}()
Prefixed(NewMemoryKVStore(), "")
}
+22
View File
@@ -0,0 +1,22 @@
package telegram
import (
"github.com/go-telegram/bot"
)
// NewBot constructs a Telegram bot configured for webhook mode:
//
// - WithSkipGetMe: avoid a 5s blocking call to Telegram during cold start.
// Token validity surfaces on the first outgoing API call instead.
// - WithNotAsyncHandlers: handlers run synchronously inside the dispatcher's
// goroutine. The webhook handler can rely on r.Context() staying live for
// the duration of dispatch, which a goroutine-spawning default would break.
//
// Callers may pass extra options that override these defaults.
func NewBot(token string, opts ...bot.Option) (*bot.Bot, error) {
defaults := []bot.Option{
bot.WithSkipGetMe(),
bot.WithNotAsyncHandlers(),
}
return bot.New(token, append(defaults, opts...)...)
}
+61
View File
@@ -0,0 +1,61 @@
package telegram
import (
"context"
"crypto/subtle"
"encoding/json"
"net/http"
"time"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
)
// secretTokenHeader is the case-insensitive HTTP header Telegram sets when it
// POSTs an update to the webhook. It must equal the value passed to setWebhook.
// See: https://core.telegram.org/bots/api#setwebhook
const secretTokenHeader = "X-Telegram-Bot-Api-Secret-Token"
// maxWebhookBody bounds inbound JSON. Telegram updates are well under 100 KiB
// even with media; 1 MiB is a defensive ceiling against malformed clients.
const maxWebhookBody = 1 << 20
// handlerTimeout caps a single Telegram update handler. Telegram retries after
// 60s of no 2xx; 10s leaves headroom for outbound API calls inside handlers
// without holding a Cloud Run instance long enough to block other updates.
const handlerTimeout = 10 * time.Second
// WebhookHandler returns an http.HandlerFunc that validates Telegram's secret
// token (constant-time) and dispatches the update synchronously to the bot.
//
// Dispatch is synchronous because the bot is constructed with
// bot.WithNotAsyncHandlers — handlers run inside this goroutine, so r.Context()
// stays live and bounded by handlerTimeout.
//
// secret must be non-empty; main is responsible for failing-fast at startup.
func WebhookHandler(b *bot.Bot, secret string) http.HandlerFunc {
secretBytes := []byte(secret)
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
got := []byte(r.Header.Get(secretTokenHeader))
if subtle.ConstantTimeCompare(got, secretBytes) != 1 {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxWebhookBody)
var update models.Update
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), handlerTimeout)
defer cancel()
b.ProcessUpdate(ctx, &update)
w.WriteHeader(http.StatusOK)
}
}
+104
View File
@@ -0,0 +1,104 @@
package telegram
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-telegram/bot"
)
const testSecret = "super-secret-token"
// validUpdate is a minimal Telegram update payload that decodes cleanly. The
// bot has no handlers registered so ProcessUpdate is a no-op match.
const validUpdate = `{"update_id": 1}`
func mustBot(t *testing.T) *bot.Bot {
t.Helper()
b, err := NewBot("TEST:TOKEN")
if err != nil {
t.Fatalf("NewBot: %v", err)
}
return b
}
func TestWebhookHandler_RejectsNonPost(t *testing.T) {
h := WebhookHandler(mustBot(t), testSecret)
req := httptest.NewRequest(http.MethodGet, "/webhook", nil)
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want 405", rec.Code)
}
}
func TestWebhookHandler_RejectsMissingSecret(t *testing.T) {
h := WebhookHandler(mustBot(t), testSecret)
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(validUpdate))
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rec.Code)
}
}
func TestWebhookHandler_RejectsWrongSecret(t *testing.T) {
h := WebhookHandler(mustBot(t), testSecret)
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(validUpdate))
req.Header.Set(secretTokenHeader, "wrong")
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rec.Code)
}
}
func TestWebhookHandler_RejectsWrongSecretSamePrefix(t *testing.T) {
// Locks the constant-time compare: a value sharing a prefix must still
// 401, not silently succeed.
h := WebhookHandler(mustBot(t), testSecret)
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(validUpdate))
req.Header.Set(secretTokenHeader, testSecret[:len(testSecret)-1]+"X")
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rec.Code)
}
}
func TestWebhookHandler_RejectsMalformedJSON(t *testing.T) {
h := WebhookHandler(mustBot(t), testSecret)
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader("not-json"))
req.Header.Set(secretTokenHeader, testSecret)
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
func TestWebhookHandler_RejectsOversizedBody(t *testing.T) {
h := WebhookHandler(mustBot(t), testSecret)
body := bytes.Repeat([]byte("a"), maxWebhookBody+1)
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
req.Header.Set(secretTokenHeader, testSecret)
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code == http.StatusOK {
t.Errorf("oversized body should not return 200; got %d", rec.Code)
}
}
func TestWebhookHandler_AcceptsValidUpdate(t *testing.T) {
h := WebhookHandler(mustBot(t), testSecret)
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(validUpdate))
req.Header.Set(secretTokenHeader, testSecret)
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
}