Files
LambdaMail/services/protocols/internal/presentation/http/websession_test.go
T
Lucas RayanandClaude Opus 5 2b369ab373 fix(dns): o botao de reconciliar nao criava registo nenhum
O painel listava os registos em falta e oferecia um botao para os criar, mas
esse botao chegava a um endpoint que so escrevia uma linha de auditoria e
respondia "REQUESTED". Nunca tocou no DNS. Quem tinha o token da Cloudflare
era o servico de protocolos, e a unica coisa capaz de o usar corria num
temporizador -- para um dominio so, o de MAIL_DOMAIN.

Por isso um dominio adicionado pela consola, como o cienciaembarcada.com.br,
nunca era reconciliado: os registos nao apareciam e o unico sintoma visivel
era a consola dizer que faltavam, para sempre.

- POST /api/v1/admin/dns/reconcile publica os registos de um dominio, agora,
  e devolve o que foi criado, corrigido e o que ficou em conflito
- o temporizador passa a percorrer todos os dominios ativos
- conflitos sao reportados, nao resolvidos: um registo do mesmo tipo e nome
  com outro valor pode ser correio da zona ou um servico de que ninguem se
  lembra, e nao e deste servidor decidir sobrepo-lo
- o erro do fornecedor fica no log, nao na resposta: pode nomear a conta ou
  a zona

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:52:27 -03:00

145 lines
5.8 KiB
Go

package httppresentation
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"testing"
"time"
)
// These tokens were minted by the auth service itself (services/auth,
// session.ts) under the secret below. They are the real wire format: if the
// two implementations ever drift, every mail request from the webmail starts
// failing and this test says so first.
const (
interopSecret = "shared-secret-for-interop-test"
interopSession = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtYi0xIiwiZW1haWwiOiJ1c2VyQGV4YW1wbGUudGVzdCIsInJvbGUiOiJVU0VSIiwiZG9tYWluSWQiOiJkb20tMSIsInN1cmZhY2UiOiJ1c2VyIiwiYXVkIjoibGFtYmRhbWFpbDp1c2VyIiwibWZhU2F0aXNmaWVkIjp0cnVlLCJtZmFTYXRpc2ZpZWRBdCI6MTc4NTY3NDI0MzYzMiwicHVycG9zZSI6InNlc3Npb24iLCJpYXQiOjE3ODU2NzQyNDMsImV4cCI6MTc4NTcwMzA0M30.vrXM2T9HKfHDOThmx0LD_n05jfsr4iaKYpZrKTBa5NM"
interopChallenge = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtYi0xIiwiZW1haWwiOiJ1c2VyQGV4YW1wbGUudGVzdCIsInJvbGUiOiJVU0VSIiwiZG9tYWluSWQiOiJkb20tMSIsInN1cmZhY2UiOiJ1c2VyIiwiYXVkIjoibGFtYmRhbWFpbDp1c2VyIiwibWZhU2F0aXNmaWVkIjpmYWxzZSwicHVycG9zZSI6Im1mYV9jaGFsbGVuZ2UiLCJpYXQiOjE3ODU2NzQyNDMsImV4cCI6MTc4NTY3NDU0M30.7OqmI9A1L9Efbt6AX4lqojZZ8IKuWi-6oe_VJNgJAxM"
// The captured tokens carry a fixed validity window. Verification is
// pinned to an instant inside it, so these tests assert on the signature
// and the claims rather than quietly turning red once the tokens age out.
interopIssuedAt = 1785674243
)
func testVerifier(secret string) *WebSessionVerifier {
v := NewWebSessionVerifier(secret)
v.now = func() time.Time { return time.Unix(interopIssuedAt+60, 0) }
return v
}
func TestWebSessionVerifier_AcceptsTokenIssuedByTheAuthService(t *testing.T) {
session, err := testVerifier(interopSecret).Verify(interopSession)
if err != nil {
t.Fatalf("a token minted by the auth service was rejected: %v", err)
}
if session.Email != "user@example.test" {
t.Errorf("Email = %q, want user@example.test", session.Email)
}
if session.Surface != "user" || session.Audience != "lambdamail:user" {
t.Errorf("surface/aud = %q/%q, want user/lambdamail:user", session.Surface, session.Audience)
}
}
// The challenge token is issued after the password but before the second
// factor. Accepting it here would let the mail API be opened without MFA.
func TestWebSessionVerifier_RejectsChallengeToken(t *testing.T) {
_, err := testVerifier(interopSecret).Verify(interopChallenge)
if !errors.Is(err, ErrNotASession) {
t.Fatalf("error = %v, want ErrNotASession", err)
}
}
func TestWebSessionVerifier_RejectsWrongSecret(t *testing.T) {
_, err := testVerifier("not-the-secret").Verify(interopSession)
if !errors.Is(err, ErrBadSignature) {
t.Fatalf("error = %v, want ErrBadSignature", err)
}
}
// A tampered payload must not survive: this is the check that stops a user
// from promoting their own token to the admin surface.
func TestWebSessionVerifier_RejectsTamperedPayload(t *testing.T) {
tampered := interopSession[:len(interopSession)-6] + "AAAAAA"
if _, err := testVerifier(interopSecret).Verify(tampered); err == nil {
t.Fatal("a tampered token verified")
}
}
func TestWebSessionVerifier_RequireSurfaceRejectsOtherSurface(t *testing.T) {
_, err := testVerifier(interopSecret).RequireSurface(interopSession, "admin")
if !errors.Is(err, ErrWrongAudience) {
t.Fatalf("error = %v, want ErrWrongAudience", err)
}
if _, err := testVerifier(interopSecret).RequireSurface(interopSession, "user"); err != nil {
t.Fatalf("the matching surface was rejected: %v", err)
}
}
func TestWebSessionVerifier_RejectsMalformed(t *testing.T) {
v := testVerifier(interopSecret)
for _, token := range []string{"", "a.b", "a.b.c.d", "not-a-token"} {
if _, err := v.Verify(token); err == nil {
t.Errorf("token %q verified", token)
}
}
}
// Expiry still has to bite: pinning the clock must not disable the check.
func TestWebSessionVerifier_RejectsExpiredToken(t *testing.T) {
v := NewWebSessionVerifier(interopSecret)
v.now = func() time.Time { return time.Unix(interopIssuedAt+9*60*60, 0) }
if _, err := v.Verify(interopSession); !errors.Is(err, ErrTokenExpired) {
t.Fatalf("error = %v, want ErrTokenExpired", err)
}
}
// mintSession signs a session token the way the auth service does, so a test
// can build a claim set the captured tokens do not cover - another surface, an
// expired window, a different address.
//
// The captured tokens above stay the interop check: this only produces the
// variations, and produces them through the same algorithm.
func mintSession(t *testing.T, claims WebSession) string {
t.Helper()
return mintSessionRaw(claims)
}
// mintSessionRaw is the same without a *testing.T, for helpers that build a
// token outside a test body.
func mintSessionRaw(claims WebSession) string {
header := base64.RawURLEncoding.EncodeToString(
[]byte(`{"alg":"HS256","typ":"JWT"}`))
body, err := json.Marshal(claims)
if err != nil {
panic(err)
}
payload := base64.RawURLEncoding.EncodeToString(body)
mac := hmac.New(sha256.New, []byte(interopSecret))
mac.Write([]byte(header + "." + payload))
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return header + "." + payload + "." + signature
}
// The minted tokens have to verify, or every test built on them is asserting
// against a signature this server would reject for the wrong reason.
func TestMintedSessionVerifiesLikeARealOne(t *testing.T) {
token := mintSession(t, WebSession{
Subject: "mb-1", Email: "user@example.test", Surface: "user",
Audience: "lambdamail:user", Purpose: "session", MfaSatisfied: true,
IssuedAt: interopIssuedAt, ExpiresAt: interopIssuedAt + 3600,
})
session, err := testVerifier(interopSecret).RequireSurface(token, "user")
if err != nil {
t.Fatalf("a locally minted session was rejected: %v", err)
}
if session.Email != "user@example.test" {
t.Errorf("Email = %q", session.Email)
}
}