New checker: domain contact consistency

Add ContactInfo struct to DomainInfo and extract contact data (registrant,
admin, tech) from both RDAP and WHOIS responses. Introduce a new
domain_contact checker that compares actual contact fields against
user-specified expected values, with redaction detection for
privacy-protected domains. The WHOIS observation provider now also exposes
contact data so the new rule can reuse the same lookup as domain_expiry.
This commit is contained in:
nemunaire 2026-04-08 09:57:23 +07:00
commit 3fe1566370
8 changed files with 889 additions and 9 deletions

256
checkers/domain_contact.go Normal file
View file

@ -0,0 +1,256 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checkers
import (
"context"
"fmt"
"strings"
"time"
"git.happydns.org/happyDomain/internal/checker"
"git.happydns.org/happyDomain/model"
)
// redactedPatterns is the list of substrings that, when found in a contact
// field, indicate the data is privacy-protected rather than meaningful.
var redactedPatterns = []string{
"redacted",
"privacy",
"not disclosed",
"whoisguard",
"withheld",
"data protected",
"contact privacy",
}
// domainContactRule compares the registered domain contacts (registrant,
// admin, tech) against user-supplied expected values, with redaction
// detection for privacy-protected domains.
type domainContactRule struct{}
func (r *domainContactRule) Name() string {
return "domain_contact_check"
}
func (r *domainContactRule) Description() string {
return "Verifies that domain contacts (name/organization/email) match expected values"
}
// validRoles enumerates the contact roles supported by the WHOIS observation.
var validRoles = map[string]bool{
"registrant": true,
"admin": true,
"tech": true,
}
func (r *domainContactRule) ValidateOptions(opts happydns.CheckerOptions) error {
for _, key := range []string{"expectedName", "expectedOrganization", "expectedEmail", "checkRoles"} {
if v, ok := opts[key]; ok {
if _, ok := v.(string); !ok {
return fmt.Errorf("%s must be a string", key)
}
}
}
if v, ok := opts["checkRoles"].(string); ok && v != "" {
hasOne := false
for _, p := range strings.Split(v, ",") {
role := strings.TrimSpace(p)
if role == "" {
continue
}
if !validRoles[role] {
return fmt.Errorf("checkRoles: unknown role %q (allowed: registrant, admin, tech)", role)
}
hasOne = true
}
if !hasOne {
return fmt.Errorf("checkRoles must contain at least one role")
}
}
return nil
}
func (r *domainContactRule) Evaluate(ctx context.Context, obs happydns.ObservationGetter, opts happydns.CheckerOptions) happydns.CheckState {
var whois WHOISData
if err := obs.Get(ctx, ObservationKeyWhois, &whois); err != nil {
return happydns.CheckState{
Status: happydns.StatusError,
Message: fmt.Sprintf("Failed to get WHOIS data: %v", err),
Code: "contact_error",
}
}
expectedName, _ := opts["expectedName"].(string)
expectedOrg, _ := opts["expectedOrganization"].(string)
expectedEmail, _ := opts["expectedEmail"].(string)
if expectedName == "" && expectedOrg == "" && expectedEmail == "" {
return happydns.CheckState{
Status: happydns.StatusUnknown,
Message: "No expected contact values configured",
Code: "contact_skipped",
}
}
checkRolesStr := "registrant"
if v, ok := opts["checkRoles"].(string); ok && v != "" {
checkRolesStr = v
}
var roles []string
for s := range strings.SplitSeq(checkRolesStr, ",") {
s = strings.TrimSpace(s)
if s != "" {
roles = append(roles, s)
}
}
if len(roles) == 0 {
return happydns.CheckState{
Status: happydns.StatusUnknown,
Message: "No contact roles to check",
Code: "contact_skipped",
}
}
worst := happydns.StatusOK
var lines []string
for _, role := range roles {
contact, found := whois.Contacts[role]
if !found || contact == nil {
lines = append(lines, fmt.Sprintf("%s: contact not found", role))
worst = worseStatus(worst, happydns.StatusWarn)
continue
}
if isRedacted(contact) {
lines = append(lines, fmt.Sprintf("%s: contact info is redacted/private", role))
worst = worseStatus(worst, happydns.StatusInfo)
continue
}
var mismatches []string
if expectedName != "" && !strings.EqualFold(expectedName, contact.Name) {
mismatches = append(mismatches, fmt.Sprintf("name: got %q, expected %q", contact.Name, expectedName))
}
if expectedOrg != "" && !strings.EqualFold(expectedOrg, contact.Organization) {
mismatches = append(mismatches, fmt.Sprintf("organization: got %q, expected %q", contact.Organization, expectedOrg))
}
if expectedEmail != "" && !strings.EqualFold(expectedEmail, contact.Email) {
mismatches = append(mismatches, fmt.Sprintf("email: got %q, expected %q", contact.Email, expectedEmail))
}
if len(mismatches) > 0 {
lines = append(lines, fmt.Sprintf("%s: %s", role, strings.Join(mismatches, ", ")))
worst = worseStatus(worst, happydns.StatusWarn)
} else {
lines = append(lines, fmt.Sprintf("%s: contact info matches", role))
}
}
return happydns.CheckState{
Status: worst,
Message: strings.Join(lines, "; "),
Code: "contact_result",
}
}
// isRedacted reports whether a contact's fields look privacy-protected.
func isRedacted(c *happydns.ContactInfo) bool {
for _, field := range []string{c.Name, c.Organization, c.Email} {
lower := strings.ToLower(field)
for _, pattern := range redactedPatterns {
if strings.Contains(lower, pattern) {
return true
}
}
}
return false
}
// worseStatus returns the more severe of two statuses. The SDK orders
// statuses as Unknown < OK < Info < Warn < Crit < Error, so the higher
// numeric value is the more severe one.
func worseStatus(a, b happydns.Status) happydns.Status {
if b > a {
return b
}
return a
}
func init() {
checker.RegisterChecker(&happydns.CheckerDefinition{
ID: "domain_contact",
Name: "Domain Contact Consistency",
Availability: happydns.CheckerAvailability{
ApplyToDomain: true,
},
ObservationKeys: []happydns.ObservationKey{ObservationKeyWhois},
Options: happydns.CheckerOptionsDocumentation{
DomainOpts: []happydns.CheckerOptionDocumentation{
{
Id: "domainName",
Type: "string",
AutoFill: happydns.AutoFillDomainName,
Hide: true,
},
{
Id: "expectedName",
Type: "string",
Label: "Expected registrant name",
Description: "If set, the configured roles must report this exact name (case-insensitive).",
},
{
Id: "expectedOrganization",
Type: "string",
Label: "Expected organization",
Description: "If set, the configured roles must report this exact organization (case-insensitive).",
},
{
Id: "expectedEmail",
Type: "string",
Label: "Expected email",
Description: "If set, the configured roles must report this exact email (case-insensitive).",
},
{
Id: "checkRoles",
Type: "string",
Label: "Contact roles to check",
Description: "Comma-separated list of roles among: registrant, admin, tech.",
Default: "registrant",
Placeholder: "registrant",
},
},
},
Rules: []happydns.CheckRule{
&domainContactRule{},
},
Interval: &happydns.CheckIntervalSpec{
Min: 1 * time.Hour,
Max: 7 * 24 * time.Hour,
Default: 24 * time.Hour,
},
})
}

View file

@ -0,0 +1,211 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checkers
import (
"context"
"strings"
"testing"
"git.happydns.org/happyDomain/model"
)
func contactsFixture() map[string]*happydns.ContactInfo {
return map[string]*happydns.ContactInfo{
"registrant": {
Name: "Alice Example",
Organization: "Example Inc",
Email: "alice@example.com",
},
"admin": {
Name: "REDACTED FOR PRIVACY",
Organization: "REDACTED FOR PRIVACY",
Email: "redacted@example.com",
},
"tech": {
Name: "Bob Tech",
Organization: "Example Inc",
Email: "bob@example.com",
},
}
}
func TestDomainContactRule_Evaluate(t *testing.T) {
rule := &domainContactRule{}
obs := newWhoisObs(&WHOISData{Contacts: contactsFixture()})
cases := []struct {
name string
opts happydns.CheckerOptions
want happydns.Status
code string
}{
{
name: "no expectations",
opts: nil,
want: happydns.StatusUnknown,
code: "contact_skipped",
},
{
name: "registrant matches",
opts: happydns.CheckerOptions{
"expectedName": "Alice Example",
"expectedEmail": "alice@example.com",
},
want: happydns.StatusOK,
code: "contact_result",
},
{
name: "registrant name mismatch",
opts: happydns.CheckerOptions{
"expectedName": "Carol Other",
},
want: happydns.StatusWarn,
code: "contact_result",
},
{
name: "admin role is redacted",
opts: happydns.CheckerOptions{
"checkRoles": "admin",
"expectedName": "Alice Example",
},
want: happydns.StatusInfo,
code: "contact_result",
},
{
name: "missing role",
opts: happydns.CheckerOptions{
"checkRoles": "billing",
"expectedName": "Alice Example",
},
want: happydns.StatusWarn,
code: "contact_result",
},
{
name: "multi-role mixed (worst wins)",
opts: happydns.CheckerOptions{
"checkRoles": "registrant,admin",
"expectedName": "Alice Example",
},
// admin is redacted (Info) — Info is worse than OK from registrant.
want: happydns.StatusInfo,
code: "contact_result",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
st := rule.Evaluate(context.Background(), obs, tc.opts)
if st.Status != tc.want {
t.Errorf("status = %v, want %v (msg=%q)", st.Status, tc.want, st.Message)
}
if st.Code != tc.code {
t.Errorf("code = %q, want %q", st.Code, tc.code)
}
})
}
}
func TestDomainContactRule_EvaluateObservationError(t *testing.T) {
rule := &domainContactRule{}
obs := &stubObservationGetter{key: ObservationKeyWhois, err: errString("nope")}
st := rule.Evaluate(context.Background(), obs, happydns.CheckerOptions{"expectedName": "x"})
if st.Status != happydns.StatusError || st.Code != "contact_error" {
t.Errorf("got %v / %q", st.Status, st.Code)
}
}
func TestDomainContactRule_ValidateOptions(t *testing.T) {
rule := &domainContactRule{}
cases := []struct {
name string
opts happydns.CheckerOptions
wantErr bool
}{
{"empty", nil, false},
{"all valid", happydns.CheckerOptions{
"expectedName": "x",
"checkRoles": "registrant,tech",
}, false},
{"unknown role", happydns.CheckerOptions{"checkRoles": "billing"}, true},
{"empty roles after split", happydns.CheckerOptions{"checkRoles": " , , "}, true},
{"wrong type", happydns.CheckerOptions{"expectedEmail": 42}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := rule.ValidateOptions(tc.opts)
if (err != nil) != tc.wantErr {
t.Errorf("err=%v wantErr=%v", err, tc.wantErr)
}
})
}
}
func TestIsRedacted(t *testing.T) {
cases := []struct {
c *happydns.ContactInfo
want bool
}{
{&happydns.ContactInfo{Name: "REDACTED FOR PRIVACY"}, true},
{&happydns.ContactInfo{Organization: "Contact Privacy Inc"}, true},
{&happydns.ContactInfo{Email: "withheld@example.com"}, true},
{&happydns.ContactInfo{Name: "Alice", Email: "alice@example.com"}, false},
}
for _, tc := range cases {
if got := isRedacted(tc.c); got != tc.want {
t.Errorf("isRedacted(%+v) = %v, want %v", tc.c, got, tc.want)
}
}
}
func TestWorseStatus(t *testing.T) {
cases := []struct {
a, b, want happydns.Status
}{
{happydns.StatusOK, happydns.StatusInfo, happydns.StatusInfo},
{happydns.StatusInfo, happydns.StatusWarn, happydns.StatusWarn},
{happydns.StatusCrit, happydns.StatusWarn, happydns.StatusCrit},
{happydns.StatusOK, happydns.StatusUnknown, happydns.StatusOK},
{happydns.StatusError, happydns.StatusCrit, happydns.StatusError},
}
for _, tc := range cases {
if got := worseStatus(tc.a, tc.b); got != tc.want {
t.Errorf("worseStatus(%v,%v) = %v, want %v", tc.a, tc.b, got, tc.want)
}
}
}
// Sanity: WHOIS data with no contacts must not panic when a role is requested.
func TestDomainContactRule_NilContacts(t *testing.T) {
rule := &domainContactRule{}
obs := newWhoisObs(&WHOISData{})
st := rule.Evaluate(context.Background(), obs, happydns.CheckerOptions{
"expectedName": "Alice",
})
if st.Status != happydns.StatusWarn {
t.Errorf("status = %v, want Warn", st.Status)
}
if !strings.Contains(st.Message, "not found") {
t.Errorf("message = %q", st.Message)
}
}

View file

@ -44,8 +44,9 @@ const (
// WHOISData represents WHOIS observation data.
type WHOISData struct {
ExpiryDate time.Time `json:"expiryDate"`
Registrar string `json:"registrar"`
ExpiryDate time.Time `json:"expiryDate"`
Registrar string `json:"registrar"`
Contacts map[string]*happydns.ContactInfo `json:"contacts,omitempty"`
}
// whoisProvider is a placeholder WHOIS observation provider.
@ -78,6 +79,7 @@ func (p *whoisProvider) Collect(ctx context.Context, opts happydns.CheckerOption
return &WHOISData{
ExpiryDate: *info.ExpirationDate,
Registrar: registrar,
Contacts: info.Contacts,
}, nil
}

View file

@ -26,14 +26,27 @@ import (
"time"
)
type ContactInfo struct {
Name string `json:"name,omitempty"`
Organization string `json:"organization,omitempty"`
Email string `json:"email,omitempty"`
Street string `json:"street,omitempty"`
City string `json:"city,omitempty"`
Province string `json:"province,omitempty"`
PostalCode string `json:"postal_code,omitempty"`
Country string `json:"country,omitempty"`
Phone string `json:"phone,omitempty"`
}
type DomainInfo struct {
Name string `json:"name"`
Nameservers []string `json:"nameservers"`
CreationDate *time.Time `json:"creation"`
ExpirationDate *time.Time `json:"expiration"`
Registrar string `json:"registrar"`
RegistrarURL *string `json:"registrar_url"`
Status []string `json:"status"`
Name string `json:"name"`
Nameservers []string `json:"nameservers"`
CreationDate *time.Time `json:"creation"`
ExpirationDate *time.Time `json:"expiration"`
Registrar string `json:"registrar"`
RegistrarURL *string `json:"registrar_url"`
Status []string `json:"status"`
Contacts map[string]*ContactInfo `json:"contacts,omitempty"`
}
type DomainInfoGetter func(context.Context, Origin) (*DomainInfo, error)

View file

@ -106,6 +106,46 @@ func mapRDAPDomain(domainInfo *rdap.Domain) (*happydns.DomainInfo, error) {
name = domainInfo.LDHName
}
// Contacts
rdapRoleMap := map[string]string{
"registrant": "registrant",
"administrative": "admin",
"technical": "tech",
}
contacts := make(map[string]*happydns.ContactInfo)
for _, ent := range domainInfo.Entities {
if ent.VCard == nil || ent.Roles == nil {
continue
}
for _, role := range ent.Roles {
key, ok := rdapRoleMap[role]
if !ok {
continue
}
ci := &happydns.ContactInfo{
Name: ent.VCard.Name(),
Email: ent.VCard.Email(),
Street: ent.VCard.StreetAddress(),
City: ent.VCard.Locality(),
Province: ent.VCard.Region(),
PostalCode: ent.VCard.PostalCode(),
Country: ent.VCard.Country(),
Phone: ent.VCard.Tel(),
}
if props := ent.VCard.Get("org"); len(props) > 0 {
if s, ok := props[0].Value.(string); ok {
ci.Organization = s
}
}
contacts[key] = ci
}
}
var contactsPtr map[string]*happydns.ContactInfo
if len(contacts) > 0 {
contactsPtr = contacts
}
return &happydns.DomainInfo{
Name: name,
Nameservers: nameservers,
@ -114,5 +154,6 @@ func mapRDAPDomain(domainInfo *rdap.Domain) (*happydns.DomainInfo, error) {
Registrar: registrar,
RegistrarURL: registrarURL,
Status: domainInfo.Status,
Contacts: contactsPtr,
}, nil
}

View file

@ -0,0 +1,211 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package domaininfo
import (
"testing"
"github.com/openrdap/rdap"
)
// mustVCard parses a jCard JSON literal and fails the test on error.
func mustVCard(t *testing.T, jcard string) *rdap.VCard {
t.Helper()
v, err := rdap.NewVCard([]byte(jcard))
if err != nil {
t.Fatalf("NewVCard: %v", err)
}
return v
}
const registrarVCard = `["vcard",[
["version",{},"text","4.0"],
["fn",{},"text","Acme Registrar"],
["url",{},"uri","https://acme.example"]
]]`
const registrantVCard = `["vcard",[
["version",{},"text","4.0"],
["fn",{},"text","Alice Example"],
["org",{},"text","Example Inc"],
["email",{},"text","alice@example.com"],
["adr",{},"text",["","","123 Main St","Springfield","IL","62701","US"]],
["tel",{"type":["voice"]},"uri","tel:+1-555-0100"]
]]`
func TestMapRDAPDomain_Full(t *testing.T) {
in := &rdap.Domain{
LDHName: "example.com",
UnicodeName: "",
Status: []string{"clientTransferProhibited"},
Nameservers: []rdap.Nameserver{
{LDHName: "ns1.example.com"},
{UnicodeName: "ns2.exämple.com", LDHName: "ns2.xn--exmple-cua.com"},
},
Events: []rdap.Event{
{Action: "registration", Date: "2000-01-01T00:00:00Z"},
{Action: "expiration", Date: "2027-06-01T00:00:00Z"},
{Action: "last changed", Date: "2024-01-01T00:00:00Z"},
},
Entities: []rdap.Entity{
{
Roles: []string{"registrar"},
VCard: mustVCard(t, registrarVCard),
},
{
Roles: []string{"registrant"},
VCard: mustVCard(t, registrantVCard),
},
},
}
out, err := mapRDAPDomain(in)
if err != nil {
t.Fatal(err)
}
if out.Name != "example.com" {
t.Errorf("Name = %q", out.Name)
}
if out.Registrar != "Acme Registrar" {
t.Errorf("Registrar = %q", out.Registrar)
}
if out.RegistrarURL == nil || *out.RegistrarURL != "https://acme.example" {
t.Errorf("RegistrarURL = %v", out.RegistrarURL)
}
if out.ExpirationDate == nil || out.ExpirationDate.Year() != 2027 {
t.Errorf("ExpirationDate = %v", out.ExpirationDate)
}
if out.CreationDate == nil || out.CreationDate.Year() != 2000 {
t.Errorf("CreationDate = %v", out.CreationDate)
}
if len(out.Nameservers) != 2 || out.Nameservers[0] != "ns1.example.com" || out.Nameservers[1] != "ns2.exämple.com" {
t.Errorf("Nameservers = %v (expected unicode preference)", out.Nameservers)
}
if len(out.Status) != 1 || out.Status[0] != "clientTransferProhibited" {
t.Errorf("Status = %v", out.Status)
}
if out.Contacts == nil {
t.Fatal("Contacts is nil")
}
r := out.Contacts["registrant"]
if r == nil {
t.Fatal("registrant missing")
}
if r.Name != "Alice Example" {
t.Errorf("registrant Name = %q", r.Name)
}
if r.Organization != "Example Inc" {
t.Errorf("registrant Organization = %q", r.Organization)
}
if r.Email != "alice@example.com" {
t.Errorf("registrant Email = %q", r.Email)
}
if r.Country != "US" {
t.Errorf("registrant Country = %q", r.Country)
}
if r.Phone == "" {
t.Errorf("registrant Phone empty")
}
}
func TestMapRDAPDomain_RoleMapping(t *testing.T) {
mk := func(role string) rdap.Entity {
return rdap.Entity{
Roles: []string{role},
VCard: mustVCard(t, `["vcard",[["version",{},"text","4.0"],["fn",{},"text","`+role+`"]]]`),
}
}
in := &rdap.Domain{
LDHName: "example.com",
Entities: []rdap.Entity{
mk("registrant"),
mk("administrative"),
mk("technical"),
mk("abuse"), // unmapped: should be skipped
},
}
out, err := mapRDAPDomain(in)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"registrant", "admin", "tech"} {
if _, ok := out.Contacts[want]; !ok {
t.Errorf("missing contact role %q", want)
}
}
if _, ok := out.Contacts["abuse"]; ok {
t.Error("abuse role should not be present")
}
if len(out.Contacts) != 3 {
t.Errorf("got %d contacts, want 3", len(out.Contacts))
}
}
func TestMapRDAPDomain_NoEntities(t *testing.T) {
in := &rdap.Domain{LDHName: "bare.example"}
out, err := mapRDAPDomain(in)
if err != nil {
t.Fatal(err)
}
if out.Registrar != "Unknown" {
t.Errorf("Registrar = %q, want Unknown", out.Registrar)
}
if out.RegistrarURL != nil {
t.Errorf("RegistrarURL = %v, want nil", out.RegistrarURL)
}
if out.Contacts != nil {
t.Errorf("Contacts = %v, want nil", out.Contacts)
}
if out.ExpirationDate != nil || out.CreationDate != nil {
t.Error("dates should be nil with no events")
}
}
func TestMapRDAPDomain_UnicodeNamePreference(t *testing.T) {
in := &rdap.Domain{
LDHName: "xn--bcher-kva.example",
UnicodeName: "bücher.example",
}
out, err := mapRDAPDomain(in)
if err != nil {
t.Fatal(err)
}
if out.Name != "bücher.example" {
t.Errorf("Name = %q, want unicode form", out.Name)
}
}
func TestMapRDAPDomain_BadEventDate(t *testing.T) {
in := &rdap.Domain{
LDHName: "example.com",
Events: []rdap.Event{
{Action: "expiration", Date: "not a date"},
},
}
if _, err := mapRDAPDomain(in); err == nil {
t.Error("expected error on malformed event date")
}
}

View file

@ -85,6 +85,35 @@ func mapWhoisResult(result *whoisparser.WhoisInfo) *happydns.DomainInfo {
status = result.Domain.Status
}
// Contacts
contacts := make(map[string]*happydns.ContactInfo)
whoisContacts := map[string]*whoisparser.Contact{
"registrant": result.Registrant,
"admin": result.Administrative,
"tech": result.Technical,
}
for key, wc := range whoisContacts {
if wc == nil {
continue
}
contacts[key] = &happydns.ContactInfo{
Name: wc.Name,
Organization: wc.Organization,
Email: wc.Email,
Street: wc.Street,
City: wc.City,
Province: wc.Province,
PostalCode: wc.PostalCode,
Country: wc.Country,
Phone: wc.Phone,
}
}
var contactsPtr map[string]*happydns.ContactInfo
if len(contacts) > 0 {
contactsPtr = contacts
}
return &happydns.DomainInfo{
Name: name,
Nameservers: nameservers,
@ -93,5 +122,6 @@ func mapWhoisResult(result *whoisparser.WhoisInfo) *happydns.DomainInfo {
Registrar: registrar,
RegistrarURL: registrarURL,
Status: status,
Contacts: contactsPtr,
}
}

View file

@ -0,0 +1,116 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package domaininfo
import (
"testing"
"time"
whoisparser "github.com/likexian/whois-parser"
)
func TestMapWhoisResult_Full(t *testing.T) {
exp := time.Date(2027, 6, 1, 0, 0, 0, 0, time.UTC)
created := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
in := &whoisparser.WhoisInfo{
Domain: &whoisparser.Domain{
Domain: "example.com",
NameServers: []string{"ns1.example.com", "ns2.example.com"},
Status: []string{"clientTransferProhibited"},
CreatedDateInTime: &created,
ExpirationDateInTime: &exp,
},
Registrar: &whoisparser.Contact{
Name: "Acme Registrar",
ReferralURL: "https://acme.example",
},
Registrant: &whoisparser.Contact{
Name: "Alice",
Organization: "Example Inc",
Email: "alice@example.com",
Country: "US",
},
Administrative: &whoisparser.Contact{Name: "Admin"},
Technical: nil,
}
out := mapWhoisResult(in)
if out.Name != "example.com" {
t.Errorf("Name = %q", out.Name)
}
if len(out.Nameservers) != 2 {
t.Errorf("Nameservers = %v", out.Nameservers)
}
if out.ExpirationDate == nil || !out.ExpirationDate.Equal(exp) {
t.Errorf("ExpirationDate = %v", out.ExpirationDate)
}
if out.CreationDate == nil || !out.CreationDate.Equal(created) {
t.Errorf("CreationDate = %v", out.CreationDate)
}
if out.Registrar != "Acme Registrar" {
t.Errorf("Registrar = %q", out.Registrar)
}
if out.RegistrarURL == nil || *out.RegistrarURL != "https://acme.example" {
t.Errorf("RegistrarURL = %v", out.RegistrarURL)
}
if len(out.Status) != 1 || out.Status[0] != "clientTransferProhibited" {
t.Errorf("Status = %v", out.Status)
}
if out.Contacts == nil {
t.Fatal("expected contacts map")
}
if r := out.Contacts["registrant"]; r == nil || r.Name != "Alice" || r.Email != "alice@example.com" || r.Organization != "Example Inc" || r.Country != "US" {
t.Errorf("registrant = %+v", r)
}
if a := out.Contacts["admin"]; a == nil || a.Name != "Admin" {
t.Errorf("admin = %+v", a)
}
if _, ok := out.Contacts["tech"]; ok {
t.Error("tech should be absent when nil")
}
}
func TestMapWhoisResult_NilRegistrarAndContacts(t *testing.T) {
in := &whoisparser.WhoisInfo{
Domain: &whoisparser.Domain{Domain: "bare.example"},
}
out := mapWhoisResult(in)
if out.Registrar != "Unknown" {
t.Errorf("Registrar = %q, want Unknown", out.Registrar)
}
if out.RegistrarURL != nil {
t.Errorf("RegistrarURL = %v, want nil", out.RegistrarURL)
}
if out.Contacts != nil {
t.Errorf("Contacts = %v, want nil", out.Contacts)
}
}
func TestMapWhoisResult_NilDomain(t *testing.T) {
// Defensive: parser shouldn't normally produce this, but the mapper
// must not panic on a nil Domain.
out := mapWhoisResult(&whoisparser.WhoisInfo{})
if out.Name != "" || out.Nameservers != nil || out.ExpirationDate != nil {
t.Errorf("expected zero-valued domain fields, got %+v", out)
}
}