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>
145 lines
5.8 KiB
Go
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)
|
|
}
|
|
}
|