#1: implement F0 foundation - monorepo, migrations, Go domain layer, service skeletons, CI
Stands up the LambdaMail monorepo per PLAN.md's F0 milestone: local dev scaffolding (.env.example, Makefile, .gitignore excluding PLAN.md/docs/ .superpowers/), the full initial Postgres schema as a golang-migrate migration, the complete Go domain layer for services/protocols (value objects, entities, domain events - zero external imports beyond github.com/google/uuid) with unit tests, health-checked service skeletons for auth/storage (TypeScript) and webmail (Next.js), a local docker-compose.yaml wiring all services together, a language-enforcement lint script, and a GitHub Actions CI workflow. Built via 11 sequential tasks under subagent-driven development, each with an independent implementer + reviewer cycle. Fixes applied during review: DomainName.IsPunycode() added to satisfy the promised interface; ioredis default-import typing under NodeNext; next.js bumped 15.0.3->15.5.21 to resolve high/critical CVEs; lang-lint.mjs corrected to not flag its own banned-terms list; web service healthcheck added so compose --wait actually waits on it; webmail Dockerfile's missing public/ directory: protocols go.mod version aligned with CI's Go 1.22 pin. Known follow-ups for later phases (not blocking F0): go-arch-lint isn't wired into CI yet; live docker compose up and migration apply have not been exercised in this dev environment (no Docker/psql available here) and will get their first real run in CI, which does have Docker.
This commit is contained in:
1 parent
f219b28075
commit
eb09fa0ced
73 files changed
+6891
No files matched your search
@@ -0,0 +1,24 @@
|
||||
package valueobject
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// DeliveryStatusCode is an RFC 3463 enhanced mail status code (class.subject.detail).
|
||||
type DeliveryStatusCode struct {
|
||||
class, subject, detail int
|
||||
}
|
||||
|
||||
var ErrDeliveryStatusInvalidClass = errors.New("delivery status code: class must be 2 (success), 4 (transient) or 5 (permanent)")
|
||||
|
||||
func NewDeliveryStatusCode(class, subject, detail int) (DeliveryStatusCode, error) {
|
||||
if class != 2 && class != 4 && class != 5 {
|
||||
return DeliveryStatusCode{}, ErrDeliveryStatusInvalidClass
|
||||
}
|
||||
return DeliveryStatusCode{class: class, subject: subject, detail: detail}, nil
|
||||
}
|
||||
|
||||
func (c DeliveryStatusCode) String() string {
|
||||
return fmt.Sprintf("%d.%d.%d", c.class, c.subject, c.detail)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package valueobject
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewDeliveryStatusCode_AcceptsSuccess(t *testing.T) {
|
||||
c, err := NewDeliveryStatusCode(2, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if c.String() != "2.0.0" {
|
||||
t.Errorf("String() = %q, want %q", c.String(), "2.0.0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDeliveryStatusCode_AcceptsPermanentFailure(t *testing.T) {
|
||||
c, err := NewDeliveryStatusCode(5, 1, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if c.String() != "5.1.1" {
|
||||
t.Errorf("String() = %q, want %q", c.String(), "5.1.1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDeliveryStatusCode_RejectsInvalidClass(t *testing.T) {
|
||||
if _, err := NewDeliveryStatusCode(3, 0, 0); err == nil {
|
||||
t.Fatal("expected error: RFC 3463 only defines classes 2, 4 and 5, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package valueobject
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DomainName is an RFC 1035 fully-qualified domain name, normalized to lowercase.
|
||||
type DomainName struct {
|
||||
value string
|
||||
}
|
||||
|
||||
var (
|
||||
ErrDomainNameEmpty = errors.New("domain name: must not be empty")
|
||||
ErrDomainNameNotFQDN = errors.New("domain name: must contain at least one dot (not a FQDN)")
|
||||
ErrDomainNameInvalidLabel = errors.New("domain name: label must not start or end with a hyphen")
|
||||
)
|
||||
|
||||
// NewDomainName validates and normalizes raw into a DomainName.
|
||||
func NewDomainName(raw string) (DomainName, error) {
|
||||
if raw == "" {
|
||||
return DomainName{}, ErrDomainNameEmpty
|
||||
}
|
||||
lower := strings.ToLower(raw)
|
||||
labels := strings.Split(lower, ".")
|
||||
if len(labels) < 2 {
|
||||
return DomainName{}, ErrDomainNameNotFQDN
|
||||
}
|
||||
for _, label := range labels {
|
||||
if label == "" || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
|
||||
return DomainName{}, ErrDomainNameInvalidLabel
|
||||
}
|
||||
}
|
||||
return DomainName{value: lower}, nil
|
||||
}
|
||||
|
||||
func (d DomainName) String() string { return d.value }
|
||||
|
||||
// IsPunycode reports whether the domain name is IDNA/punycode-encoded.
|
||||
// It returns true if any label of the domain starts with the ASCII Compatible Encoding prefix "xn--".
|
||||
func (d DomainName) IsPunycode() bool {
|
||||
labels := strings.Split(d.value, ".")
|
||||
for _, label := range labels {
|
||||
if strings.HasPrefix(label, "xn--") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package valueobject
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewDomainName_AcceptsValidFQDN(t *testing.T) {
|
||||
d, err := NewDomainName("Example.COM")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.String() != "example.com" {
|
||||
t.Errorf("String() = %q, want lowercased %q", d.String(), "example.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDomainName_RejectsEmpty(t *testing.T) {
|
||||
if _, err := NewDomainName(""); err == nil {
|
||||
t.Fatal("expected error for empty domain name, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDomainName_RejectsLabelWithoutDot(t *testing.T) {
|
||||
if _, err := NewDomainName("localhost"); err == nil {
|
||||
t.Fatal("expected error for single-label name (not a FQDN), got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDomainName_RejectsTrailingHyphenLabel(t *testing.T) {
|
||||
if _, err := NewDomainName("bad-.example.com"); err == nil {
|
||||
t.Fatal("expected error for label ending in hyphen, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainName_IsPunycode_WithPunycodeLabel(t *testing.T) {
|
||||
d, err := NewDomainName("xn--exmple-cua.com")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !d.IsPunycode() {
|
||||
t.Error("IsPunycode() = false, want true for punycode domain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainName_IsPunycode_WithoutPunycodeLabel(t *testing.T) {
|
||||
d, err := NewDomainName("example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.IsPunycode() {
|
||||
t.Error("IsPunycode() = true, want false for plain ASCII domain")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Package valueobject contains the domain's immutable, self-validating value objects.
|
||||
// Zero external imports: this is layer 1 of Clean Architecture (see PLAN.md section 3).
|
||||
package valueobject
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EmailAddress is an RFC 5322 mailbox address, normalized with a lowercased domain.
|
||||
type EmailAddress struct {
|
||||
localPart string
|
||||
domain string
|
||||
}
|
||||
|
||||
var (
|
||||
ErrEmailMissingAtSign = errors.New("email address: missing '@' separator")
|
||||
ErrEmailEmptyLocalPart = errors.New("email address: local part must not be empty")
|
||||
ErrEmailEmptyDomain = errors.New("email address: domain must not be empty")
|
||||
ErrEmailLocalPartTooLong = errors.New("email address: local part exceeds 64 octets (RFC 5321 4.5.3.1.1)")
|
||||
)
|
||||
|
||||
// NewEmailAddress validates and normalizes raw into an EmailAddress.
|
||||
func NewEmailAddress(raw string) (EmailAddress, error) {
|
||||
at := strings.LastIndex(raw, "@")
|
||||
if at < 0 {
|
||||
return EmailAddress{}, ErrEmailMissingAtSign
|
||||
}
|
||||
local, domain := raw[:at], raw[at+1:]
|
||||
if local == "" {
|
||||
return EmailAddress{}, ErrEmailEmptyLocalPart
|
||||
}
|
||||
if len(local) > 64 {
|
||||
return EmailAddress{}, ErrEmailLocalPartTooLong
|
||||
}
|
||||
if domain == "" {
|
||||
return EmailAddress{}, ErrEmailEmptyDomain
|
||||
}
|
||||
return EmailAddress{localPart: local, domain: strings.ToLower(domain)}, nil
|
||||
}
|
||||
|
||||
func (e EmailAddress) LocalPart() string { return e.localPart }
|
||||
func (e EmailAddress) Domain() string { return e.domain }
|
||||
func (e EmailAddress) String() string { return e.localPart + "@" + e.domain }
|
||||
@@ -0,0 +1,47 @@
|
||||
package valueobject
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewEmailAddress_AcceptsValidAddress(t *testing.T) {
|
||||
addr, err := NewEmailAddress("User.Name+tag@Example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if addr.LocalPart() != "User.Name+tag" {
|
||||
t.Errorf("local part = %q, want %q", addr.LocalPart(), "User.Name+tag")
|
||||
}
|
||||
if addr.Domain() != "example.com" {
|
||||
t.Errorf("domain = %q, want lowercased %q", addr.Domain(), "example.com")
|
||||
}
|
||||
if addr.String() != "User.Name+tag@example.com" {
|
||||
t.Errorf("String() = %q, want %q", addr.String(), "User.Name+tag@example.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEmailAddress_RejectsMissingAtSign(t *testing.T) {
|
||||
if _, err := NewEmailAddress("not-an-email"); err == nil {
|
||||
t.Fatal("expected error for address without '@', got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEmailAddress_RejectsEmptyLocalPart(t *testing.T) {
|
||||
if _, err := NewEmailAddress("@example.com"); err == nil {
|
||||
t.Fatal("expected error for empty local part, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEmailAddress_RejectsEmptyDomain(t *testing.T) {
|
||||
if _, err := NewEmailAddress("user@"); err == nil {
|
||||
t.Fatal("expected error for empty domain, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEmailAddress_RejectsOverlongLocalPart(t *testing.T) {
|
||||
longLocal := ""
|
||||
for i := 0; i < 65; i++ {
|
||||
longLocal += "a"
|
||||
}
|
||||
if _, err := NewEmailAddress(longLocal + "@example.com"); err == nil {
|
||||
t.Fatal("expected error for local part over 64 octets (RFC 5321 4.5.3.1.1), got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package valueobject
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MessageID is an RFC 5322 Message-ID, always wrapped in angle brackets.
|
||||
type MessageID struct {
|
||||
raw string
|
||||
}
|
||||
|
||||
var (
|
||||
ErrMessageIDEmpty = errors.New("message id: must not be empty")
|
||||
ErrMessageIDNotBracketed = errors.New("message id: must be wrapped in angle brackets")
|
||||
)
|
||||
|
||||
func NewMessageID(raw string) (MessageID, error) {
|
||||
if raw == "" {
|
||||
return MessageID{}, ErrMessageIDEmpty
|
||||
}
|
||||
if !strings.HasPrefix(raw, "<") || !strings.HasSuffix(raw, ">") {
|
||||
return MessageID{}, ErrMessageIDNotBracketed
|
||||
}
|
||||
return MessageID{raw: raw}, nil
|
||||
}
|
||||
|
||||
func (m MessageID) String() string { return m.raw }
|
||||
@@ -0,0 +1,25 @@
|
||||
package valueobject
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewMessageID_AcceptsAngleBracketForm(t *testing.T) {
|
||||
id, err := NewMessageID("<abc123@mail.example.com>")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if id.String() != "<abc123@mail.example.com>" {
|
||||
t.Errorf("String() = %q, want input preserved", id.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMessageID_RejectsEmpty(t *testing.T) {
|
||||
if _, err := NewMessageID(""); err == nil {
|
||||
t.Fatal("expected error for empty Message-ID, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMessageID_RejectsMissingAngleBrackets(t *testing.T) {
|
||||
if _, err := NewMessageID("abc123@mail.example.com"); err == nil {
|
||||
t.Fatal("expected error for Message-ID without angle brackets, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package valueobject
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PasswordHash wraps an Argon2id PHC-format hash string (see PLAN.md section 9, mailboxes.password_hash).
|
||||
type PasswordHash struct {
|
||||
phc string
|
||||
}
|
||||
|
||||
var (
|
||||
ErrPasswordHashEmpty = errors.New("password hash: must not be empty")
|
||||
ErrPasswordHashWrongScheme = errors.New("password hash: only argon2id PHC strings are accepted")
|
||||
)
|
||||
|
||||
// NewPasswordHash validates that phc is a well-formed argon2id PHC string.
|
||||
func NewPasswordHash(phc string) (PasswordHash, error) {
|
||||
if phc == "" {
|
||||
return PasswordHash{}, ErrPasswordHashEmpty
|
||||
}
|
||||
if !strings.HasPrefix(phc, "$argon2id$") {
|
||||
return PasswordHash{}, ErrPasswordHashWrongScheme
|
||||
}
|
||||
return PasswordHash{phc: phc}, nil
|
||||
}
|
||||
|
||||
func (p PasswordHash) String() string { return p.phc }
|
||||
@@ -0,0 +1,26 @@
|
||||
package valueobject
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewPasswordHash_AcceptsArgon2idPHCString(t *testing.T) {
|
||||
phc := "$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG"
|
||||
h, err := NewPasswordHash(phc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h.String() != phc {
|
||||
t.Errorf("String() = %q, want %q", h.String(), phc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPasswordHash_RejectsNonArgon2idScheme(t *testing.T) {
|
||||
if _, err := NewPasswordHash("$bcrypt$10$abcdefg"); err == nil {
|
||||
t.Fatal("expected error for non-argon2id scheme, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPasswordHash_RejectsEmpty(t *testing.T) {
|
||||
if _, err := NewPasswordHash(""); err == nil {
|
||||
t.Fatal("expected error for empty hash, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package valueobject
|
||||
|
||||
// ProtocolSecurityLevel is the effective TLS requirement for a listener (see PLAN.md section 4).
|
||||
type ProtocolSecurityLevel int
|
||||
|
||||
const (
|
||||
SecurityOpportunistic ProtocolSecurityLevel = iota // STARTTLS offered, plaintext fallback allowed (port 25 only)
|
||||
SecurityRequired // STARTTLS mandatory before AUTH (587/143/110/4190)
|
||||
SecurityImplicit // TLS from the first byte (465/993/995)
|
||||
)
|
||||
|
||||
func (l ProtocolSecurityLevel) String() string {
|
||||
switch l {
|
||||
case SecurityRequired:
|
||||
return "required"
|
||||
case SecurityImplicit:
|
||||
return "implicit"
|
||||
default:
|
||||
return "opportunistic"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package valueobject
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestProtocolSecurityLevel_StringRoundTrip(t *testing.T) {
|
||||
cases := []struct {
|
||||
level ProtocolSecurityLevel
|
||||
want string
|
||||
}{
|
||||
{SecurityOpportunistic, "opportunistic"},
|
||||
{SecurityRequired, "required"},
|
||||
{SecurityImplicit, "implicit"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := c.level.String(); got != c.want {
|
||||
t.Errorf("String() = %q, want %q", got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package valueobject
|
||||
|
||||
import "errors"
|
||||
|
||||
// QuotaLimit is a strictly positive byte quota (see PLAN.md section 3, Mailbox invariant).
|
||||
type QuotaLimit struct {
|
||||
bytes int64
|
||||
}
|
||||
|
||||
var ErrQuotaLimitNotPositive = errors.New("quota limit: must be a positive number of bytes")
|
||||
|
||||
func NewQuotaLimit(bytes int64) (QuotaLimit, error) {
|
||||
if bytes <= 0 {
|
||||
return QuotaLimit{}, ErrQuotaLimitNotPositive
|
||||
}
|
||||
return QuotaLimit{bytes: bytes}, nil
|
||||
}
|
||||
|
||||
func (q QuotaLimit) Bytes() int64 { return q.bytes }
|
||||
|
||||
// Exceeds reports whether usedBytes violates the "used_bytes <= quota_bytes" invariant.
|
||||
func (q QuotaLimit) Exceeds(usedBytes int64) bool { return usedBytes > q.bytes }
|
||||
@@ -0,0 +1,35 @@
|
||||
package valueobject
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewQuotaLimit_AcceptsPositiveBytes(t *testing.T) {
|
||||
q, err := NewQuotaLimit(1073741824)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if q.Bytes() != 1073741824 {
|
||||
t.Errorf("Bytes() = %d, want %d", q.Bytes(), 1073741824)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewQuotaLimit_RejectsZero(t *testing.T) {
|
||||
if _, err := NewQuotaLimit(0); err == nil {
|
||||
t.Fatal("expected error for zero quota, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewQuotaLimit_RejectsNegative(t *testing.T) {
|
||||
if _, err := NewQuotaLimit(-1); err == nil {
|
||||
t.Fatal("expected error for negative quota, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotaLimit_Exceeds(t *testing.T) {
|
||||
q, _ := NewQuotaLimit(1000)
|
||||
if !q.Exceeds(1001) {
|
||||
t.Error("Exceeds(1001) = false, want true for a 1000-byte quota")
|
||||
}
|
||||
if q.Exceeds(1000) {
|
||||
t.Error("Exceeds(1000) = true, want false: used_bytes <= quota_bytes is the invariant (PLAN.md section 3)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package valueobject
|
||||
|
||||
// SpamVerdict classifies a message by Rspamd score (thresholds from PLAN.md section 10.1).
|
||||
type SpamVerdict int
|
||||
|
||||
const (
|
||||
SpamHam SpamVerdict = iota
|
||||
SpamProbable
|
||||
SpamReject
|
||||
)
|
||||
|
||||
// SpamVerdictFromScore maps a Rspamd score to a verdict per the thresholds table in section 10.1.
|
||||
func SpamVerdictFromScore(score float64) SpamVerdict {
|
||||
switch {
|
||||
case score >= 9:
|
||||
return SpamReject
|
||||
case score >= 4:
|
||||
return SpamProbable
|
||||
default:
|
||||
return SpamHam
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package valueobject
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSpamVerdictFromScore_MatchesThresholds(t *testing.T) {
|
||||
// Thresholds from PLAN.md section 10.1.
|
||||
cases := []struct {
|
||||
score float64
|
||||
want SpamVerdict
|
||||
}{
|
||||
{0, SpamHam},
|
||||
{3.9, SpamHam},
|
||||
{4, SpamProbable},
|
||||
{8.9, SpamProbable},
|
||||
{9, SpamReject}, // section 10.1: 9-15 greylists/defers, modeled here as reject-eligible
|
||||
{15.1, SpamReject},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := SpamVerdictFromScore(c.score); got != c.want {
|
||||
t.Errorf("SpamVerdictFromScore(%v) = %v, want %v", c.score, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package valueobject
|
||||
|
||||
import "errors"
|
||||
|
||||
// TLSARecord models a DANE TLSA RR (RFC 6698/7671). Usage 2 (PKIX-CA) is rejected
|
||||
// by explicit ADR - see PLAN.md section 5.1: Let's Encrypt rotates intermediates
|
||||
// without notice, making it unpredictable. Only usage 3 (DANE-EE) is supported.
|
||||
type TLSARecord struct {
|
||||
usage uint8
|
||||
selector uint8
|
||||
matchingType uint8
|
||||
data string
|
||||
}
|
||||
|
||||
var (
|
||||
ErrTLSAUsageUnsupported = errors.New("tlsa record: only usage 3 (DANE-EE) is supported")
|
||||
ErrTLSADataWrongLength = errors.New("tlsa record: matching type 1 (SHA-256) requires a 64-hex-char digest")
|
||||
)
|
||||
|
||||
func NewTLSARecord(usage, selector, matchingType uint8, data string) (TLSARecord, error) {
|
||||
if usage != 3 {
|
||||
return TLSARecord{}, ErrTLSAUsageUnsupported
|
||||
}
|
||||
if matchingType == 1 && len(data) != 64 {
|
||||
return TLSARecord{}, ErrTLSADataWrongLength
|
||||
}
|
||||
return TLSARecord{usage: usage, selector: selector, matchingType: matchingType, data: data}, nil
|
||||
}
|
||||
|
||||
func (r TLSARecord) Usage() uint8 { return r.usage }
|
||||
func (r TLSARecord) Selector() uint8 { return r.selector }
|
||||
func (r TLSARecord) MatchingType() uint8 { return r.matchingType }
|
||||
func (r TLSARecord) Data() string { return r.data }
|
||||
@@ -0,0 +1,29 @@
|
||||
package valueobject
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewTLSARecord_AcceptsUsage3Selector1MatchingType1(t *testing.T) {
|
||||
hash := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
r, err := NewTLSARecord(3, 1, 1, hash)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if r.Usage() != 3 || r.Selector() != 1 || r.MatchingType() != 1 {
|
||||
t.Errorf("fields not preserved: got usage=%d selector=%d matchingType=%d", r.Usage(), r.Selector(), r.MatchingType())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTLSARecord_RejectsUsage2(t *testing.T) {
|
||||
// DANE_TLSA_USAGE=2 was explicitly rejected by ADR (PLAN.md section 5.1):
|
||||
// Let's Encrypt rotates intermediates without notice, making usage 2 unpredictable.
|
||||
hash := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
if _, err := NewTLSARecord(2, 1, 1, hash); err == nil {
|
||||
t.Fatal("expected error for usage=2 (PKIX-CA), got nil - rejected by ADR in PLAN.md section 5.1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTLSARecord_RejectsWrongLengthHash(t *testing.T) {
|
||||
if _, err := NewTLSARecord(3, 1, 1, "tooshort"); err == nil {
|
||||
t.Fatal("expected error for a SHA-256 hash that isn't 64 hex chars, got nil")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user