happyDomain/internal/app/plugins_provider_test.go
Pierre-Olivier Mercier e58a585e2f providers: load external provider plugins from .so files
Add a NewProviderPlugin loader alongside the existing checker plugin
loader. The factory returns a ProviderCreatorFunc / ProviderInfos pair,
which is registered through internal/provider.RegisterProvider so plugin
providers appear in the registry like the built-in ones.
2026-04-10 17:01:09 +07:00

145 lines
5.2 KiB
Go

// 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/>.
//go:build linux || darwin || freebsd
package app
import (
"errors"
"plugin"
"strings"
"testing"
providerReg "git.happydns.org/happyDomain/internal/provider"
"git.happydns.org/happyDomain/model"
)
// dummyProviderBody is a minimal happydns.ProviderBody used by the tests
// below; we only care that loadProviderPlugin can register it without
// touching real DNS code.
type dummyProviderBody struct {
Endpoint string
}
func (d *dummyProviderBody) InstantiateProvider() (happydns.ProviderActuator, error) {
return nil, errors.New("not implemented in tests")
}
func newDummyProviderFactory() func() (happydns.ProviderCreatorFunc, happydns.ProviderInfos, error) {
return func() (happydns.ProviderCreatorFunc, happydns.ProviderInfos, error) {
creator := func() happydns.ProviderBody { return &dummyProviderBody{} }
return creator, happydns.ProviderInfos{Name: "Dummy"}, nil
}
}
func TestLoadProviderPlugin_SymbolMissing(t *testing.T) {
found, err := loadProviderPlugin(&fakeSymbols{}, "missing.so")
if found || err != nil {
t.Fatalf("expected (false, nil) when symbol is absent, got (%v, %v)", found, err)
}
}
func TestLoadProviderPlugin_WrongSymbolType(t *testing.T) {
fs := &fakeSymbols{syms: map[string]plugin.Symbol{
"NewProviderPlugin": 42, // not a function
}}
found, err := loadProviderPlugin(fs, "wrongtype.so")
if !found || err == nil {
t.Fatalf("expected (true, err) for wrong symbol type, got (%v, %v)", found, err)
}
if !strings.Contains(err.Error(), "unexpected type") {
t.Errorf("expected error to mention unexpected type, got %v", err)
}
}
func TestLoadProviderPlugin_FactoryError(t *testing.T) {
factory := func() (happydns.ProviderCreatorFunc, happydns.ProviderInfos, error) {
return nil, happydns.ProviderInfos{}, errors.New("boom")
}
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewProviderPlugin": factory}}
found, err := loadProviderPlugin(fs, "factoryerr.so")
if !found || err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("expected factory error to propagate, got (%v, %v)", found, err)
}
}
func TestLoadProviderPlugin_NilCreator(t *testing.T) {
factory := func() (happydns.ProviderCreatorFunc, happydns.ProviderInfos, error) {
return nil, happydns.ProviderInfos{Name: "Dummy"}, nil
}
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewProviderPlugin": factory}}
found, err := loadProviderPlugin(fs, "nilcreator.so")
if !found || err == nil || !strings.Contains(err.Error(), "nil ProviderCreatorFunc") {
t.Fatalf("expected nil creator to be rejected, got (%v, %v)", found, err)
}
}
func TestLoadProviderPlugin_FactoryPanics(t *testing.T) {
factory := func() (happydns.ProviderCreatorFunc, happydns.ProviderInfos, error) {
panic("kaboom")
}
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewProviderPlugin": factory}}
found, err := loadProviderPlugin(fs, "panic.so")
if !found || err == nil {
t.Fatalf("expected panic to be converted to error, got (%v, %v)", found, err)
}
if !strings.Contains(err.Error(), "panicked") || !strings.Contains(err.Error(), "kaboom") {
t.Errorf("expected wrapped panic error, got %v", err)
}
}
func TestLoadProviderPlugin_SuccessAndDuplicate(t *testing.T) {
factory := newDummyProviderFactory()
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewProviderPlugin": factory}}
// First registration should succeed and use a fully-qualified name
// (package.Type) so it cannot collide with a built-in or another plugin
// shipping a "dummyProviderBody" struct in a different package.
found, err := loadProviderPlugin(fs, "first.so")
if !found || err != nil {
t.Fatalf("expected first load to succeed, got (%v, %v)", found, err)
}
const expectedKey = "app.dummyProviderBody"
if _, ok := providerReg.GetProviders()[expectedKey]; !ok {
t.Fatalf("expected provider to be registered as %q, registry has: %v",
expectedKey, keysOf(providerReg.GetProviders()))
}
// Second registration of the same qualified name must be a no-op (just
// a warning); the existing entry should still be there afterwards.
found, err = loadProviderPlugin(fs, "second.so")
if !found || err != nil {
t.Fatalf("expected second load to be silently ignored, got (%v, %v)", found, err)
}
}
func keysOf[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}