feat: Instance Endpoint 值对象与纯校验测试 #7

Merged
panxiao81 merged 1 commits from feature/instance-endpoint-values into main 2026-09-13 15:44:31 +00:00
2 changed files with 207 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package instance contains the pure domain model of a registered PostgreSQL instance.
// It does not depend on Kubernetes types, database drivers or credential providers.
package instance
import (
"errors"
"net/netip"
"regexp"
)
// TLSMode is an explicit transport policy, not a driver-specific default.
type TLSMode string
const (
TLSDisable TLSMode = "disable"
TLSRequire TLSMode = "require"
TLSVerifyCA TLSMode = "verify-ca"
TLSVerifyFull TLSMode = "verify-full"
)
// EndpointValues carries explicit, effective values across the application boundary.
// Defaults are supplied by the API/application mapping, never silently by the domain.
type EndpointValues struct {
Host string
HostAddr string
Port int
ManagementDatabase string
TLSMode TLSMode
}
// Endpoint is an immutable connection target. Equality compares its declared values,
// not physical server identity. Its zero value is invalid; aggregate construction
// must Validate incoming endpoints, even if callers bypass NewEndpoint.
type Endpoint struct {
values EndpointValues
}
var identifier = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`)
func NewEndpoint(values EndpointValues) (Endpoint, error) {
endpoint := Endpoint{values: values}
if err := endpoint.Validate(); err != nil {
return Endpoint{}, err
}
return endpoint, nil
}
// Values returns a copy, without exposing mutable state.
func (e Endpoint) Values() EndpointValues { return e.values }
// Validate checks local invariants only; it does not resolve DNS or perform IO.
// Errors intentionally omit input values.
func (e Endpoint) Validate() error {
if e.values.Host == "" {
return errors.New("endpoint host is required")
}
address, err := netip.ParseAddr(e.values.HostAddr)
if err != nil || address.Zone() != "" {
return errors.New("endpoint host address must be an IPv4 or IPv6 address")
}
if e.values.Port < 1 || e.values.Port > 65535 {
return errors.New("endpoint port must be between 1 and 65535")
}
if !identifier.MatchString(e.values.ManagementDatabase) {
return errors.New("endpoint management database must be a valid PostgreSQL identifier")
}
switch e.values.TLSMode {
case TLSDisable, TLSRequire, TLSVerifyCA, TLSVerifyFull:
return nil
default:
return errors.New("endpoint TLS mode must be explicitly supported")
}
}
+118
View File
@@ -0,0 +1,118 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package instance_test
import (
"strings"
"testing"
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/domain/instance"
)
func validEndpoint() instance.EndpointValues {
return instance.EndpointValues{
Host: "postgres.home.arpa", HostAddr: "192.0.2.10", Port: 5432,
ManagementDatabase: "postgres", TLSMode: instance.TLSVerifyFull,
}
}
// Acceptance: docs/domain-instance.md §2, explicit values and no implicit TLS downgrade.
func TestEndpointRejectsInvalidValues(t *testing.T) {
cases := []struct {
name string
change func(*instance.EndpointValues)
}{
{"empty host", func(v *instance.EndpointValues) { v.Host = "" }},
{"missing address", func(v *instance.EndpointValues) { v.HostAddr = "" }},
{"DNS instead of IP", func(v *instance.EndpointValues) { v.HostAddr = "postgres.home.arpa" }},
{"invalid IP", func(v *instance.EndpointValues) { v.HostAddr = "192.0.2.999" }},
{"address with port", func(v *instance.EndpointValues) { v.HostAddr = "192.0.2.10:5432" }},
{"scoped address", func(v *instance.EndpointValues) { v.HostAddr = "fe80::1%eth0" }},
{"zero port", func(v *instance.EndpointValues) { v.Port = 0 }},
{"negative port", func(v *instance.EndpointValues) { v.Port = -1 }},
{"large port", func(v *instance.EndpointValues) { v.Port = 65536 }},
{"empty database", func(v *instance.EndpointValues) { v.ManagementDatabase = "" }},
{"uppercase database", func(v *instance.EndpointValues) { v.ManagementDatabase = "Postgres" }},
{"leading digit", func(v *instance.EndpointValues) { v.ManagementDatabase = "1postgres" }},
{"punctuation", func(v *instance.EndpointValues) { v.ManagementDatabase = "post-gres" }},
{"NUL", func(v *instance.EndpointValues) { v.ManagementDatabase = "post\x00gres" }},
{"long identifier", func(v *instance.EndpointValues) { v.ManagementDatabase = strings.Repeat("a", 64) }},
{"missing TLS mode", func(v *instance.EndpointValues) { v.TLSMode = "" }},
{"unsupported TLS mode", func(v *instance.EndpointValues) { v.TLSMode = "prefer" }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
values := validEndpoint()
tc.change(&values)
endpoint, err := instance.NewEndpoint(values)
if err == nil {
t.Fatal("invalid endpoint accepted")
}
if endpoint != (instance.Endpoint{}) {
t.Fatal("constructor returned a partial endpoint on failure")
}
})
}
}
func TestEndpointPreservesValidValues(t *testing.T) {
for _, mode := range []instance.TLSMode{
instance.TLSDisable, instance.TLSRequire, instance.TLSVerifyCA, instance.TLSVerifyFull,
} {
for _, address := range []string{"192.0.2.10", "2001:db8::10"} {
for _, port := range []int{1, 65535} {
values := validEndpoint()
values.TLSMode, values.HostAddr, values.Port = mode, address, port
values.ManagementDatabase = "a" + strings.Repeat("_", 62)
endpoint, err := instance.NewEndpoint(values)
if err != nil {
t.Fatal(err)
}
if endpoint.Values() != values {
t.Fatal("constructor changed explicit values")
}
if err := endpoint.Validate(); err != nil {
t.Fatal(err)
}
}
}
}
}
func TestEndpointIsAnImmutableComparableValue(t *testing.T) {
values := validEndpoint()
endpoint, err := instance.NewEndpoint(values)
if err != nil {
t.Fatal(err)
}
same, err := instance.NewEndpoint(values)
if err != nil {
t.Fatal(err)
}
if endpoint != same {
t.Fatal("identical endpoint values must compare equal")
}
values.Host = "changed.example"
snapshot := endpoint.Values()
snapshot.Host = values.Host
if endpoint.Values().Host == snapshot.Host {
t.Fatal("caller mutated endpoint through a copy")
}
if err := (instance.Endpoint{}).Validate(); err == nil {
t.Fatal("zero endpoint must not be valid")
}
}