Phase 2c — testing infrastructure (43 tests, CI gating, docker-compose)
Some checks failed
Docker Build / build-and-push-image (push) Has been cancelled
Some checks failed
Docker Build / build-and-push-image (push) Has been cancelled
Brings the project to a TDD/BDD-friendly state — apologies for shipping Phase 1.5 + Phase 2 code-first, that violated feedback_tdd_first_bdd_required. What's added : - helpers_test.go : FakeTelegram (httptest server that records sendMessage / deleteMessage / setWebhook / etc.), miniredis bootstrap, MakeUpdate / PostWebhook helpers. The same harness simulates 'a user DMing the bot' end-to-end without hitting Telegram cloud — answer to the user question. - 43 tests covering : allowlist parsing, telegram type helpers (UserID / ChatID / Text / messageID), secret_token constant-time compare, Backoff schedule, Auth (login wrong/right/logout/TTL/nil-receiver), EchoHandler, HTTPHandler (forward / timeout / non-2xx / empty body), AuthHandler (start / auth / whoami / logout / replay defense delete), Server (bad secret 401, unknown bot 404, allowlist drop, gated bot prompt, full /auth → echo → /logout flow, healthz/readyz). - All tests pass with -race in 1.6s, no external deps (miniredis + httptest in-process). Infra : - Updated .gitea/workflows/dockerimage.yaml : new 'test' job (go vet + go test -race) gates the build-and-push-image job. CI now also runs on pull_request. - docker-compose.yml : redis + postgres for full local stack. - Makefile : test-race, compose-up/down targets. - README updated with test + local-dev sections. Refs ~/.claude/plans/pour-les-notifications-on-inherited-seal.md § Phase 2.
This commit is contained in:
200
server_test.go
Normal file
200
server_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// e2eHarness wires a full Server (Registry + Auth + Allowlist + FakeTelegram)
|
||||
// for integration tests covering the webhook → handler dispatch path.
|
||||
type e2eHarness struct {
|
||||
ft *FakeTelegram
|
||||
auth *Auth
|
||||
server *Server
|
||||
}
|
||||
|
||||
func newE2EHarness(t *testing.T, allowedUsers string) *e2eHarness {
|
||||
t.Helper()
|
||||
ft := NewFakeTelegram(t)
|
||||
m := StartMiniRedis(t)
|
||||
auth := NewTestAuth(t, m, testAuthCode, time.Hour)
|
||||
|
||||
bots := map[string]BotConfig{
|
||||
"factory": {
|
||||
Handler: "auth",
|
||||
Token: "factory-tok",
|
||||
Secret: "factory-secret",
|
||||
},
|
||||
"echobot": {
|
||||
Handler: "echo",
|
||||
Token: "echo-tok",
|
||||
Secret: "echo-secret",
|
||||
},
|
||||
"publicbot": {
|
||||
Handler: "echo",
|
||||
RequireAuth: boolPtr(false),
|
||||
Token: "pub-tok",
|
||||
Secret: "pub-secret",
|
||||
},
|
||||
}
|
||||
cfg := &Config{Bots: bots}
|
||||
reg, err := NewRegistry(cfg, ft.Client(), auth, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRegistry: %v", err)
|
||||
}
|
||||
allow := NewAllowlist(allowedUsers)
|
||||
srv := NewServer(reg, auth, allow, ft.Client(), nil)
|
||||
return &e2eHarness{ft: ft, auth: auth, server: srv}
|
||||
}
|
||||
|
||||
func (h *e2eHarness) routes() http.Handler { return h.server.Routes() }
|
||||
|
||||
// authenticate does the /auth <code> dance for the given user.
|
||||
func (h *e2eHarness) authenticate(t *testing.T, userID int64) {
|
||||
t.Helper()
|
||||
ok, err := h.auth.Login(context.Background(), userID, testAuthCode)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("authenticate user %d: ok=%v err=%v", userID, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_BadSecretToken_401(t *testing.T) {
|
||||
h := newE2EHarness(t, "")
|
||||
upd := MakeUpdate(1, 7497777082, 7497777082, 1, "hi")
|
||||
|
||||
status, _ := PostWebhook(t, h.routes(), "echobot", "WRONG", upd)
|
||||
if status != 401 {
|
||||
t.Fatalf("status = %d, want 401", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_UnknownBot_404(t *testing.T) {
|
||||
h := newE2EHarness(t, "")
|
||||
upd := MakeUpdate(1, 7497777082, 7497777082, 1, "hi")
|
||||
|
||||
status, _ := PostWebhook(t, h.routes(), "ghost", "anything", upd)
|
||||
if status != 404 {
|
||||
t.Fatalf("status = %d, want 404", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_AllowlistDrop_SilentNoReply(t *testing.T) {
|
||||
h := newE2EHarness(t, "7497777082") // only this user is allowed
|
||||
upd := MakeUpdate(1, 999, 999, 1, "hi") // stranger
|
||||
|
||||
status, _ := PostWebhook(t, h.routes(), "echobot", "echo-secret", upd)
|
||||
if status != 200 {
|
||||
t.Fatalf("status = %d, want 200", status)
|
||||
}
|
||||
if len(h.ft.Sent()) != 0 {
|
||||
t.Fatalf("stranger should be silent-dropped, got %d sends", len(h.ft.Sent()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_GatedBot_NotAuthed_PromptsAuth(t *testing.T) {
|
||||
h := newE2EHarness(t, "")
|
||||
upd := MakeUpdate(1, 100, 100, 1, "hi")
|
||||
|
||||
status, _ := PostWebhook(t, h.routes(), "echobot", "echo-secret", upd)
|
||||
if status != 200 {
|
||||
t.Fatalf("status = %d, want 200", status)
|
||||
}
|
||||
last := h.ft.LastSentTextTo(100)
|
||||
if !strings.Contains(last, "🔒") || !strings.Contains(last, "@arcodange_factory_bot") {
|
||||
t.Fatalf("should prompt /auth, got %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_GatedBot_Authed_HandlerRuns(t *testing.T) {
|
||||
h := newE2EHarness(t, "")
|
||||
h.authenticate(t, 100)
|
||||
upd := MakeUpdate(1, 100, 100, 1, "ping")
|
||||
|
||||
status, _ := PostWebhook(t, h.routes(), "echobot", "echo-secret", upd)
|
||||
if status != 200 {
|
||||
t.Fatalf("status = %d, want 200", status)
|
||||
}
|
||||
if got := h.ft.LastSentTextTo(100); got != "ping" {
|
||||
t.Fatalf("authed user should get echo, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_PublicBot_NoAuthNeeded(t *testing.T) {
|
||||
h := newE2EHarness(t, "")
|
||||
upd := MakeUpdate(1, 100, 100, 1, "hi public")
|
||||
|
||||
status, _ := PostWebhook(t, h.routes(), "publicbot", "pub-secret", upd)
|
||||
if status != 200 {
|
||||
t.Fatalf("status = %d, want 200", status)
|
||||
}
|
||||
if got := h.ft.LastSentTextTo(100); got != "hi public" {
|
||||
t.Fatalf("public bot must echo without auth, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_FactoryAuth_FullFlow(t *testing.T) {
|
||||
h := newE2EHarness(t, "")
|
||||
|
||||
// Step 1 : /auth wrong → "Mauvais code"
|
||||
wrong := MakeUpdate(1, 100, 100, 11, "/auth wrong")
|
||||
_, _ = PostWebhook(t, h.routes(), "factory", "factory-secret", wrong)
|
||||
if got := h.ft.LastSentTextTo(100); !strings.Contains(got, "Mauvais") {
|
||||
t.Fatalf("step 1 wrong code reply : %q", got)
|
||||
}
|
||||
authed, _ := h.auth.IsAuthed(context.Background(), 100)
|
||||
if authed {
|
||||
t.Fatal("session should not exist after wrong code")
|
||||
}
|
||||
|
||||
// Step 2 : /auth right → "Authentifié" + deleteMessage
|
||||
right := MakeUpdate(2, 100, 100, 22, "/auth "+testAuthCode)
|
||||
_, _ = PostWebhook(t, h.routes(), "factory", "factory-secret", right)
|
||||
if got := h.ft.LastSentTextTo(100); !strings.Contains(got, "Authentifié") {
|
||||
t.Fatalf("step 2 right code reply : %q", got)
|
||||
}
|
||||
deleted := h.ft.Deleted()
|
||||
if len(deleted) != 1 || deleted[0].MessageID != 22 {
|
||||
t.Fatalf("right code should deleteMessage 22, got %+v", deleted)
|
||||
}
|
||||
authed, _ = h.auth.IsAuthed(context.Background(), 100)
|
||||
if !authed {
|
||||
t.Fatal("session must exist after right code")
|
||||
}
|
||||
|
||||
// Step 3 : the gated bot now responds normally
|
||||
ping := MakeUpdate(3, 100, 100, 33, "ping after auth")
|
||||
_, _ = PostWebhook(t, h.routes(), "echobot", "echo-secret", ping)
|
||||
if got := h.ft.LastSentTextTo(100); got != "ping after auth" {
|
||||
t.Fatalf("step 3 echo reply : %q", got)
|
||||
}
|
||||
|
||||
// Step 4 : /logout closes the session
|
||||
logout := MakeUpdate(4, 100, 100, 44, "/logout")
|
||||
_, _ = PostWebhook(t, h.routes(), "factory", "factory-secret", logout)
|
||||
authed, _ = h.auth.IsAuthed(context.Background(), 100)
|
||||
if authed {
|
||||
t.Fatal("logout should drop the session")
|
||||
}
|
||||
|
||||
// Step 5 : the gated bot is gated again
|
||||
again := MakeUpdate(5, 100, 100, 55, "after logout")
|
||||
_, _ = PostWebhook(t, h.routes(), "echobot", "echo-secret", again)
|
||||
if got := h.ft.LastSentTextTo(100); !strings.Contains(got, "🔒") {
|
||||
t.Fatalf("step 5 should be gated again, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_HealthAndReady(t *testing.T) {
|
||||
h := newE2EHarness(t, "")
|
||||
for _, path := range []string{"/healthz", "/readyz"} {
|
||||
req := httpRequest("GET", path, "")
|
||||
rr := httpRecorder()
|
||||
h.routes().ServeHTTP(rr, req)
|
||||
if rr.Code != 200 || rr.Body.String() != "OK" {
|
||||
t.Errorf("%s = %d %q", path, rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user