Implement checker options retrieval

This commit is contained in:
nemunaire 2025-09-23 12:45:40 +09:00
commit 40141120d2
12 changed files with 1051 additions and 6 deletions

View file

@ -55,6 +55,22 @@ func (uc *CheckerController) CheckerHandler(c *gin.Context) {
c.Next()
}
// CheckerOptionHandler is a middleware that retrieves a specific option and sets it in the context.
func (uc *CheckerController) CheckerOptionHandler(c *gin.Context) {
cname := c.Param("cname")
optname := c.Param("optname")
opts, err := uc.checkerService.GetCheckerOptions(cname, nil, nil, nil)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
return
}
c.Set("option", (*opts)[optname])
c.Next()
}
// ListCheckers retrieves all available checks.
//
// @Summary List checkers (admin)
@ -109,3 +125,135 @@ func (uc *CheckerController) GetCheckerStatus(c *gin.Context) {
c.JSON(http.StatusOK, res)
}
// GetCheckerOptions retrieves all options for a check.
//
// @Summary Get check options (admin)
// @Schemes
// @Description Retrieves all configuration options for a specific check.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Checker name"
// @Success 200 {object} happydns.CheckerOptions "Checker options as key-value pairs"
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options [get]
func (uc *CheckerController) GetCheckerOptions(c *gin.Context) {
cname := c.Param("cname")
opts, err := uc.checkerService.GetCheckerOptions(cname, nil, nil, nil)
happydns.ApiResponse(c, opts, err)
}
// AddCheckerOptions adds or overwrites specific admin-level options for a check.
//
// @Summary Add checker options
// @Schemes
// @Description Adds or overwrites specific configuration options for a checker without affecting other options.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Checker name"
// @Param body body happydns.SetCheckerOptionsRequest true "Options to add or overwrite"
// @Success 200 {object} bool "Success status"
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options [post]
func (uc *CheckerController) AddCheckerOptions(c *gin.Context) {
cname := c.Param("cname")
var req happydns.SetCheckerOptionsRequest
err := c.ShouldBindJSON(&req)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
err = uc.checkerService.OverwriteSomeCheckerOptions(cname, nil, nil, nil, req.Options)
happydns.ApiResponse(c, true, err)
}
// ChangeCheckerOptions replaces all options for a checker.
//
// @Summary Replace checker options (admin)
// @Schemes
// @Description Replaces all configuration options for a check with the provided options.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Checker name"
// @Param body body happydns.SetCheckerOptionsRequest true "New complete set of options"
// @Success 200 {object} bool "Success status"
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options [put]
func (uc *CheckerController) ChangeCheckerOptions(c *gin.Context) {
cname := c.Param("cname")
var req happydns.SetCheckerOptionsRequest
err := c.ShouldBindJSON(&req)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
err = uc.checkerService.SetCheckerOptions(cname, nil, nil, nil, req.Options)
happydns.ApiResponse(c, true, err)
}
// GetCheckerOption retrieves a specific option value for a checker.
//
// @Summary Get checker option (admin)
// @Schemes
// @Description Retrieves the value of a specific configuration option for a checker.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Checker name"
// @Param optname path string true "Option name"
// @Success 200 {object} object "Option value (type varies)"
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options/{optname} [get]
func (uc *CheckerController) GetCheckerOption(c *gin.Context) {
opt := c.MustGet("option")
happydns.ApiResponse(c, opt, nil)
}
// SetCheckerOption sets or updates a specific option value for a checker.
//
// @Summary Set checker option (admin)
// @Schemes
// @Description Sets or updates the value of a specific configuration option for a checker.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Checker name"
// @Param optname path string true "Option name"
// @Param body body object true "Option value (type varies by option)"
// @Success 200 {object} bool "Success status"
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options/{optname} [put]
func (uc *CheckerController) SetCheckerOption(c *gin.Context) {
cname := c.Param("cname")
optname := c.Param("optname")
var req any
err := c.ShouldBindJSON(&req)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
po := happydns.CheckerOptions{}
po[optname] = req
err = uc.checkerService.OverwriteSomeCheckerOptions(cname, nil, nil, nil, po)
happydns.ApiResponse(c, true, err)
}

View file

@ -39,4 +39,13 @@ func declareChecksRoutes(router *gin.RouterGroup, dep Dependencies) {
apiCheckerRoutes.GET("", cc.GetCheckerStatus)
//apiCheckerRoutes.POST("", tpc.ChangeCheckerStatus)
apiCheckerRoutes.GET("/options", cc.GetCheckerOptions)
apiCheckerRoutes.POST("/options", cc.AddCheckerOptions)
apiCheckerRoutes.PUT("/options", cc.ChangeCheckerOptions)
apiCheckerOptionsRoutes := apiCheckerRoutes.Group("/options/:optname")
apiCheckerOptionsRoutes.Use(cc.CheckerOptionHandler)
apiCheckerOptionsRoutes.GET("", cc.GetCheckerOption)
apiCheckerOptionsRoutes.PUT("", cc.SetCheckerOption)
}

View file

@ -242,7 +242,7 @@ func (app *App) initUsecases() {
app.usecases.authUser = authUserService
app.usecases.resolver = usecase.NewResolverUsecase(app.cfg)
app.usecases.session = sessionService
app.usecases.checker = checkUC.NewCheckerUsecase(app.cfg)
app.usecases.checker = checkUC.NewCheckerUsecase(app.cfg, app.store)
app.usecases.orchestrator = orchestrator.NewOrchestrator(
domainLogService,

View file

@ -40,6 +40,7 @@ type InMemoryStorage struct {
data map[string][]byte // Generic key-value store for KVStorage interface
authUsers map[string]*happydns.UserAuth
authUsersByEmail map[string]happydns.Identifier
checksCfg map[string]*happydns.CheckerOptions
domains map[string]*happydns.Domain
domainLogs map[string]*happydns.DomainLogWithDomainId
domainLogsByDomains map[string][]*happydns.Identifier
@ -58,6 +59,7 @@ func NewInMemoryStorage() (*InMemoryStorage, error) {
data: make(map[string][]byte),
authUsers: make(map[string]*happydns.UserAuth),
authUsersByEmail: make(map[string]happydns.Identifier),
checksCfg: make(map[string]*happydns.CheckerOptions),
domains: make(map[string]*happydns.Domain),
domainLogs: make(map[string]*happydns.DomainLogWithDomainId),
domainLogsByDomains: make(map[string][]*happydns.Identifier),

View file

@ -23,6 +23,7 @@ package storage // import "git.happydns.org/happyDomain/internal/storage"
import (
"git.happydns.org/happyDomain/internal/usecase/authuser"
"git.happydns.org/happyDomain/internal/usecase/check"
"git.happydns.org/happyDomain/internal/usecase/domain"
"git.happydns.org/happyDomain/internal/usecase/domain_log"
"git.happydns.org/happyDomain/internal/usecase/insight"
@ -43,6 +44,7 @@ type Storage interface {
domain.DomainStorage
domainlog.DomainLogStorage
insight.InsightStorage
check.CheckerStorage
provider.ProviderStorage
session.SessionStorage
user.UserStorage

View file

@ -0,0 +1,185 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 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 database
import (
"errors"
"fmt"
"strings"
"git.happydns.org/happyDomain/model"
)
func (s *KVStorage) ListAllCheckerConfigurations() (happydns.Iterator[happydns.CheckerOptions], error) {
iter := s.db.Search("chckrcfg-")
return NewKVIterator[happydns.CheckerOptions](s.db, iter), nil
}
func buildCheckerKey(cname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier) string {
u := ""
if user != nil {
u = user.String()
}
d := ""
if domain != nil {
d = domain.String()
}
s := ""
if service != nil {
s = service.String()
}
return strings.Join([]string{cname, u, d, s}, "/")
}
func keyToPositional(key string, opts *happydns.CheckerOptions) (*happydns.CheckerOptionsPositional, error) {
tmp := strings.Split(key, "/")
if len(tmp) < 4 {
return nil, fmt.Errorf("malformed plugin configuration key, got %q", key)
}
cname := tmp[0]
var userid *happydns.Identifier
if len(tmp[1]) > 0 {
u, err := happydns.NewIdentifierFromString(tmp[1])
if err != nil {
return nil, err
}
userid = &u
}
var domainid *happydns.Identifier
if len(tmp[2]) > 0 {
d, err := happydns.NewIdentifierFromString(tmp[2])
if err != nil {
return nil, err
}
domainid = &d
}
var serviceid *happydns.Identifier
if len(tmp[3]) > 0 {
s, err := happydns.NewIdentifierFromString(tmp[3])
if err != nil {
return nil, err
}
serviceid = &s
}
return &happydns.CheckerOptionsPositional{
CheckName: cname,
UserId: userid,
DomainId: domainid,
ServiceId: serviceid,
Options: *opts,
}, nil
}
func (s *KVStorage) ListCheckerConfiguration(cname string) (configs []*happydns.CheckerOptionsPositional, err error) {
iter := s.db.Search("chckrcfg-" + cname + "/")
defer iter.Release()
for iter.Next() {
var p happydns.CheckerOptions
e := s.db.DecodeData(iter.Value(), &p)
if e != nil {
err = errors.Join(err, e)
continue
}
opts, e := keyToPositional(strings.TrimPrefix(iter.Key(), "chckrcfg-"), &p)
if e != nil {
err = errors.Join(err, e)
continue
}
configs = append(configs, opts)
}
return
}
func (s *KVStorage) GetCheckerConfiguration(cname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier) (configs []*happydns.CheckerOptionsPositional, err error) {
iter := s.db.Search("chckrcfg-" + cname + "/")
defer iter.Release()
for iter.Next() {
var p happydns.CheckerOptions
e := s.db.DecodeData(iter.Value(), &p)
if e != nil {
err = errors.Join(err, e)
continue
}
opts, e := keyToPositional(strings.TrimPrefix(iter.Key(), "chckrcfg-"), &p)
if e != nil {
err = errors.Join(err, e)
continue
}
// Match logic:
// - When parameter is nil: match ONLY configs with nil ID (requesting specific scope)
// - When parameter is not nil: match configs with nil ID (admin-level) OR matching ID
matchUser := (user == nil && opts.UserId == nil) ||
(user != nil && (opts.UserId == nil || opts.UserId.Equals(*user)))
matchDomain := (domain == nil && opts.DomainId == nil) ||
(domain != nil && (opts.DomainId == nil || opts.DomainId.Equals(*domain)))
matchService := (service == nil && opts.ServiceId == nil) ||
(service != nil && (opts.ServiceId == nil || opts.ServiceId.Equals(*service)))
if matchUser && matchDomain && matchService {
configs = append(configs, opts)
}
}
return
}
func (s *KVStorage) UpdateCheckerConfiguration(cname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier, opts happydns.CheckerOptions) error {
return s.db.Put(fmt.Sprintf("chckrcfg-%s", buildCheckerKey(cname, user, domain, service)), opts)
}
func (s *KVStorage) DeleteCheckerConfiguration(cname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier) error {
return s.db.Delete(fmt.Sprintf("chckrcfg-%s", buildCheckerKey(cname, user, domain, service)))
}
func (s *KVStorage) ClearCheckerConfigurations() error {
iter := s.db.Search("chckrcfg-")
defer iter.Release()
for iter.Next() {
err := s.db.Delete(iter.Key())
if err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,450 @@
// 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 check_test
import (
"context"
"testing"
"git.happydns.org/happyDomain/checks"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/internal/storage/inmemory"
kv "git.happydns.org/happyDomain/internal/storage/kvtpl"
uc "git.happydns.org/happyDomain/internal/usecase/check"
"git.happydns.org/happyDomain/model"
)
// ---------------------------------------------------------------------------
// mockCheckerForOptions registered once at package init.
// ---------------------------------------------------------------------------
const testCheckerName = "test-mock-checker-options"
type mockCheckerForOptions struct{}
func (m *mockCheckerForOptions) ID() string { return testCheckerName }
func (m *mockCheckerForOptions) Name() string { return testCheckerName }
func (m *mockCheckerForOptions) Availability() happydns.CheckerAvailability {
return happydns.CheckerAvailability{ApplyToDomain: true}
}
func (m *mockCheckerForOptions) Options() happydns.CheckerOptionsDocumentation {
return happydns.CheckerOptionsDocumentation{
RunOpts: []happydns.CheckerOptionDocumentation{
{Id: "run-param", Default: "run-default"},
},
DomainOpts: []happydns.CheckerOptionDocumentation{
{Id: "domain-autofill", AutoFill: happydns.AutoFillDomainName},
{Id: "domain-param", Default: "domain-default"},
},
UserOpts: []happydns.CheckerOptionDocumentation{
{Id: "user-param"},
},
ServiceOpts: []happydns.CheckerOptionDocumentation{
{Id: "service-param"},
},
}
}
func (m *mockCheckerForOptions) RunCheck(_ context.Context, opts happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) {
return nil, nil
}
func init() {
checks.RegisterChecker(testCheckerName, &mockCheckerForOptions{})
}
// ---------------------------------------------------------------------------
// Helper: create a fresh in-memory database for each test.
// ---------------------------------------------------------------------------
func newOptionsTestDB(t *testing.T) storage.Storage {
t.Helper()
mem, err := inmemory.NewInMemoryStorage()
if err != nil {
t.Fatalf("failed to create in-memory storage: %v", err)
}
db, err := kv.NewKVDatabase(mem)
if err != nil {
t.Fatalf("failed to create KV database: %v", err)
}
return db
}
func newTestCheckerUsecase(db storage.Storage) happydns.CheckerUsecase {
return uc.NewCheckerUsecase(&happydns.Options{}, db, db)
}
// ---------------------------------------------------------------------------
// GetStoredCheckerOptionsNoDefault tests
// ---------------------------------------------------------------------------
func Test_GetStoredOptions_EmptyStore(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
opts, err := checkerUC.GetStoredCheckerOptionsNoDefault(testCheckerName, nil, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(opts) != 0 {
t.Errorf("expected empty options from empty store, got %v", opts)
}
}
func Test_GetStoredOptions_MergesStored(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
userId, _ := happydns.NewRandomIdentifier()
// Store user-level option.
if err := db.UpdateCheckerConfiguration(testCheckerName, &userId, nil, nil, happydns.CheckerOptions{"user-param": "val"}); err != nil {
t.Fatalf("failed to seed option: %v", err)
}
opts, err := checkerUC.GetStoredCheckerOptionsNoDefault(testCheckerName, &userId, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if opts["user-param"] != "val" {
t.Errorf("expected user-param='val', got %v", opts["user-param"])
}
}
func Test_GetStoredOptions_AutoFillInjects(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
// Create a domain in the db.
domain := &happydns.Domain{
DomainName: "example.com.",
}
if err := db.CreateDomain(domain); err != nil {
t.Fatalf("failed to create domain: %v", err)
}
opts, err := checkerUC.GetStoredCheckerOptionsNoDefault(testCheckerName, nil, &domain.Id, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if opts["domain-autofill"] != "example.com." {
t.Errorf("expected domain-autofill='example.com.', got %v", opts["domain-autofill"])
}
}
func Test_GetStoredOptions_UnknownCheckerReturnsStored(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
// Store options for an unknown checker.
if err := db.UpdateCheckerConfiguration("unknown-checker", nil, nil, nil, happydns.CheckerOptions{"some-param": "some-value"}); err != nil {
t.Fatalf("failed to seed option: %v", err)
}
opts, err := checkerUC.GetStoredCheckerOptionsNoDefault("unknown-checker", nil, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if opts["some-param"] != "some-value" {
t.Errorf("expected some-param='some-value', got %v", opts["some-param"])
}
}
// ---------------------------------------------------------------------------
// BuildMergedCheckerOptions tests
// ---------------------------------------------------------------------------
func Test_BuildMerged_DefaultsFirst(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
merged, err := checkerUC.BuildMergedCheckerOptions(testCheckerName, nil, nil, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if merged["run-param"] != "run-default" {
t.Errorf("expected run-param='run-default', got %v", merged["run-param"])
}
if merged["domain-param"] != "domain-default" {
t.Errorf("expected domain-param='domain-default', got %v", merged["domain-param"])
}
}
func Test_BuildMerged_StoredOverridesDefault(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
domainId, _ := happydns.NewRandomIdentifier()
// Store domain-level option that overrides the default.
if err := db.UpdateCheckerConfiguration(testCheckerName, nil, &domainId, nil, happydns.CheckerOptions{"domain-param": "custom"}); err != nil {
t.Fatalf("failed to seed option: %v", err)
}
merged, err := checkerUC.BuildMergedCheckerOptions(testCheckerName, nil, &domainId, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if merged["domain-param"] != "custom" {
t.Errorf("expected domain-param='custom' (stored overrides default), got %v", merged["domain-param"])
}
}
func Test_BuildMerged_RunOptsOverrideStored(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
// Store an admin-level value for run-param.
if err := db.UpdateCheckerConfiguration(testCheckerName, nil, nil, nil, happydns.CheckerOptions{"run-param": "stored-value"}); err != nil {
t.Fatalf("failed to seed option: %v", err)
}
runOpts := happydns.CheckerOptions{"run-param": "runtime"}
merged, err := checkerUC.BuildMergedCheckerOptions(testCheckerName, nil, nil, nil, runOpts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if merged["run-param"] != "runtime" {
t.Errorf("expected run-param='runtime' (runOpts wins), got %v", merged["run-param"])
}
}
func Test_BuildMerged_AutoFillWinsOverAll(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
// Create domain in db.
domain := &happydns.Domain{
DomainName: "example.com.",
}
if err := db.CreateDomain(domain); err != nil {
t.Fatalf("failed to create domain: %v", err)
}
// Both stored and runOpts attempt to set domain-autofill.
if err := db.UpdateCheckerConfiguration(testCheckerName, nil, nil, nil, happydns.CheckerOptions{"domain-autofill": "manual-value"}); err != nil {
t.Fatalf("failed to seed option: %v", err)
}
runOpts := happydns.CheckerOptions{"domain-autofill": "runtime-value"}
merged, err := checkerUC.BuildMergedCheckerOptions(testCheckerName, nil, &domain.Id, nil, runOpts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Auto-fill always wins.
if merged["domain-autofill"] != "example.com." {
t.Errorf("expected domain-autofill='example.com.' (auto-fill wins), got %v", merged["domain-autofill"])
}
}
func Test_BuildMerged_NilAutoFillStoreSkips(t *testing.T) {
db := newOptionsTestDB(t)
// Pass nil as the CheckAutoFillStorage interface (not a typed nil).
checkerUC := uc.NewCheckerUsecase(&happydns.Options{}, db, nil)
domainId, _ := happydns.NewRandomIdentifier()
// Should not panic even when autoFillStore is nil.
merged, err := checkerUC.BuildMergedCheckerOptions(testCheckerName, nil, &domainId, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// domain-autofill should NOT be set (no auto-fill storage available).
if _, ok := merged["domain-autofill"]; ok {
t.Errorf("expected domain-autofill to be absent when autoFillStore is nil, got %v", merged["domain-autofill"])
}
}
// ---------------------------------------------------------------------------
// SetCheckerOptions tests
// ---------------------------------------------------------------------------
func Test_SetOptions_ServiceLevel(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
userId, _ := happydns.NewRandomIdentifier()
domainId, _ := happydns.NewRandomIdentifier()
serviceId, _ := happydns.NewRandomIdentifier()
opts := happydns.CheckerOptions{"service-param": "val"}
if err := checkerUC.SetCheckerOptions(testCheckerName, &userId, &domainId, &serviceId, opts); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify the configuration was stored at service scope.
configs, err := db.GetCheckerConfiguration(testCheckerName, &userId, &domainId, &serviceId)
if err != nil {
t.Fatalf("failed to retrieve config: %v", err)
}
// Find the service-level entry (UserId, DomainId, ServiceId all set).
found := false
for _, c := range configs {
if c.UserId != nil && c.DomainId != nil && c.ServiceId != nil {
found = true
break
}
}
if !found {
t.Error("expected a service-level configuration entry to be stored")
}
}
func Test_SetOptions_DomainLevel(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
userId, _ := happydns.NewRandomIdentifier()
domainId, _ := happydns.NewRandomIdentifier()
opts := happydns.CheckerOptions{"domain-param": "val"}
if err := checkerUC.SetCheckerOptions(testCheckerName, &userId, &domainId, nil, opts); err != nil {
t.Fatalf("unexpected error: %v", err)
}
configs, err := db.GetCheckerConfiguration(testCheckerName, &userId, &domainId, nil)
if err != nil {
t.Fatalf("failed to retrieve config: %v", err)
}
found := false
for _, c := range configs {
if c.UserId != nil && c.DomainId != nil && c.ServiceId == nil {
found = true
break
}
}
if !found {
t.Error("expected a domain-level configuration entry to be stored")
}
}
func Test_SetOptions_UserLevel(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
userId, _ := happydns.NewRandomIdentifier()
opts := happydns.CheckerOptions{"user-param": "val"}
if err := checkerUC.SetCheckerOptions(testCheckerName, &userId, nil, nil, opts); err != nil {
t.Fatalf("unexpected error: %v", err)
}
configs, err := db.GetCheckerConfiguration(testCheckerName, &userId, nil, nil)
if err != nil {
t.Fatalf("failed to retrieve config: %v", err)
}
found := false
for _, c := range configs {
if c.UserId != nil && c.DomainId == nil && c.ServiceId == nil {
found = true
break
}
}
if !found {
t.Error("expected a user-level configuration entry to be stored")
}
}
func Test_SetOptions_AdminLevel(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
opts := happydns.CheckerOptions{"run-param": "admin-val"}
if err := checkerUC.SetCheckerOptions(testCheckerName, nil, nil, nil, opts); err != nil {
t.Fatalf("unexpected error: %v", err)
}
configs, err := db.GetCheckerConfiguration(testCheckerName, nil, nil, nil)
if err != nil {
t.Fatalf("failed to retrieve config: %v", err)
}
if len(configs) == 0 {
t.Error("expected at least one admin-level configuration entry to be stored")
}
}
func Test_SetOptions_UnknownCheckerErrors(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
opts := happydns.CheckerOptions{"param": "val"}
if err := checkerUC.SetCheckerOptions("unknown-checker-xyz", nil, nil, nil, opts); err == nil {
t.Fatal("expected error for unknown checker")
}
}
// ---------------------------------------------------------------------------
// OverwriteSomeCheckerOptions tests
// ---------------------------------------------------------------------------
func Test_Overwrite_MergesWithExisting(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
// Pre-seed existing options at admin scope.
if err := db.UpdateCheckerConfiguration(testCheckerName, nil, nil, nil, happydns.CheckerOptions{"a": "1"}); err != nil {
t.Fatalf("failed to seed option: %v", err)
}
if err := checkerUC.OverwriteSomeCheckerOptions(testCheckerName, nil, nil, nil, happydns.CheckerOptions{"b": "2"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Retrieve the stored options and verify both keys are present.
configs, err := db.GetCheckerConfiguration(testCheckerName, nil, nil, nil)
if err != nil {
t.Fatalf("failed to retrieve config: %v", err)
}
if len(configs) == 0 {
t.Fatal("expected at least one config entry")
}
merged := configs[0].Options
if merged["a"] != "1" {
t.Errorf("expected a='1' to be preserved, got %v", merged["a"])
}
if merged["b"] != "2" {
t.Errorf("expected b='2' to be added, got %v", merged["b"])
}
}
func Test_Overwrite_OverridesExistingKey(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
// Pre-seed existing options.
if err := db.UpdateCheckerConfiguration(testCheckerName, nil, nil, nil, happydns.CheckerOptions{"a": "1"}); err != nil {
t.Fatalf("failed to seed option: %v", err)
}
if err := checkerUC.OverwriteSomeCheckerOptions(testCheckerName, nil, nil, nil, happydns.CheckerOptions{"a": "99"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
configs, err := db.GetCheckerConfiguration(testCheckerName, nil, nil, nil)
if err != nil {
t.Fatalf("failed to retrieve config: %v", err)
}
if len(configs) == 0 {
t.Fatal("expected at least one config entry")
}
if configs[0].Options["a"] != "99" {
t.Errorf("expected a='99' after overwrite, got %v", configs[0].Options["a"])
}
}

View file

@ -0,0 +1,46 @@
// 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 check
import (
"git.happydns.org/happyDomain/model"
)
type CheckerStorage interface {
// ListAllCheckConfigurations retrieves the list of known Providers.
ListAllCheckerConfigurations() (happydns.Iterator[happydns.CheckerOptions], error)
// ListCheckerConfiguration retrieves all providers own by the given User.
ListCheckerConfiguration(string) ([]*happydns.CheckerOptionsPositional, error)
// GetCheckerConfiguration retrieves the full Provider with the given identifier and owner.
GetCheckerConfiguration(string, *happydns.Identifier, *happydns.Identifier, *happydns.Identifier) ([]*happydns.CheckerOptionsPositional, error)
// UpdateCheckerConfiguration updates the fields of the given Provider.
UpdateCheckerConfiguration(string, *happydns.Identifier, *happydns.Identifier, *happydns.Identifier, happydns.CheckerOptions) error
// DeleteCheckerConfiguration removes the given Provider from the database.
DeleteCheckerConfiguration(string, *happydns.Identifier, *happydns.Identifier, *happydns.Identifier) error
// ClearCheckerConfigurations deletes all Providers present in the database.
ClearCheckerConfigurations() error
}

View file

@ -22,7 +22,10 @@
package check
import (
"cmp"
"fmt"
"maps"
"slices"
"git.happydns.org/happyDomain/checks"
"git.happydns.org/happyDomain/model"
@ -30,11 +33,13 @@ import (
type checkerUsecase struct {
config *happydns.Options
store CheckerStorage
}
func NewCheckerUsecase(cfg *happydns.Options) happydns.CheckerUsecase {
func NewCheckerUsecase(cfg *happydns.Options, store CheckerStorage) happydns.CheckerUsecase {
return &checkerUsecase{
config: cfg,
store: store,
}
}
@ -47,6 +52,84 @@ func (tu *checkerUsecase) GetChecker(cname string) (happydns.Checker, error) {
return checker, nil
}
// copyNonEmpty copies key/value pairs from src into dst, skipping nil or empty-string values.
func copyNonEmpty(dst, src happydns.CheckerOptions) {
for k, v := range src {
if v == nil {
continue
}
if s, ok := v.(string); ok && s == "" {
continue
}
dst[k] = v
}
}
func compareIdentifiers(a, b *happydns.Identifier) int {
if a == nil && b == nil {
return 0
}
if a == nil {
return -1
}
if b == nil {
return 1
}
if a.Equals(*b) {
return 0
}
return a.Compare(*b)
}
// CompareCheckerOptionsPositional defines the merge precedence ordering for
// checker option configs: admin < user < domain < service.
func CompareCheckerOptionsPositional(a, b *happydns.CheckerOptionsPositional) int {
if a.CheckName != b.CheckName {
return cmp.Compare(a.CheckName, b.CheckName)
}
if res := compareIdentifiers(a.UserId, b.UserId); res != 0 {
return res
}
if res := compareIdentifiers(a.DomainId, b.DomainId); res != 0 {
return res
}
return compareIdentifiers(a.ServiceId, b.ServiceId)
}
func (tu *checkerUsecase) GetCheckerOptions(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier) (*happydns.CheckerOptions, error) {
configs, err := tu.store.GetCheckerConfiguration(cname, userid, domainid, serviceid)
if err != nil {
return nil, err
}
slices.SortFunc(configs, CompareCheckerOptionsPositional)
opts := make(happydns.CheckerOptions)
for _, c := range configs {
maps.Copy(opts, c.Options)
}
return &opts, nil
}
func (tu *checkerUsecase) ListCheckers() (*map[string]happydns.Checker, error) {
return checks.GetCheckers(), nil
}
func (tu *checkerUsecase) SetCheckerOptions(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, opts happydns.CheckerOptions) error {
return tu.store.UpdateCheckerConfiguration(cname, userid, domainid, serviceid, opts)
}
func (tu *checkerUsecase) OverwriteSomeCheckerOptions(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, opts happydns.CheckerOptions) error {
current, err := tu.GetCheckerOptions(cname, userid, domainid, serviceid)
if err != nil {
return err
}
maps.Copy(*current, opts)
return tu.store.UpdateCheckerConfiguration(cname, userid, domainid, serviceid, *current)
}

View file

@ -0,0 +1,85 @@
package check_test
import (
"slices"
"testing"
uc "git.happydns.org/happyDomain/internal/usecase/check"
"git.happydns.org/happyDomain/model"
)
func TestSortByCheckName(t *testing.T) {
slice := []*happydns.CheckerOptionsPositional{
{CheckName: "zeta"},
{CheckName: "alpha"},
{CheckName: "beta"},
}
slices.SortFunc(slice, uc.CompareCheckerOptionsPositional)
got := []string{slice[0].CheckName, slice[1].CheckName, slice[2].CheckName}
want := []string{"alpha", "beta", "zeta"}
for i := range want {
if got[i] != want[i] {
t.Errorf("expected %v, got %v", want, got)
break
}
}
}
func TestNilBeforeNonNil(t *testing.T) {
uid, _ := happydns.NewRandomIdentifier()
slice := []*happydns.CheckerOptionsPositional{
{CheckName: "alpha", UserId: &uid},
{CheckName: "alpha", UserId: nil},
}
slices.SortFunc(slice, uc.CompareCheckerOptionsPositional)
if slice[0].UserId != nil {
t.Errorf("expected nil UserId first, got %+v", slice[0].UserId)
}
}
func TestDomainIdOrder(t *testing.T) {
did, _ := happydns.NewRandomIdentifier()
slice := []*happydns.CheckerOptionsPositional{
{CheckName: "alpha", UserId: nil, DomainId: &did},
{CheckName: "alpha", UserId: nil, DomainId: nil},
}
slices.SortFunc(slice, uc.CompareCheckerOptionsPositional)
if slice[0].DomainId != nil {
t.Errorf("expected nil DomainId first, got %+v", slice[0].DomainId)
}
}
func TestServiceIdOrder(t *testing.T) {
sid, _ := happydns.NewRandomIdentifier()
slice := []*happydns.CheckerOptionsPositional{
{CheckName: "alpha", UserId: nil, DomainId: nil, ServiceId: &sid},
{CheckName: "alpha", UserId: nil, DomainId: nil, ServiceId: nil},
}
slices.SortFunc(slice, uc.CompareCheckerOptionsPositional)
if slice[0].ServiceId != nil {
t.Errorf("expected nil ServiceId first, got %+v", slice[0].ServiceId)
}
}
func TestStableGrouping(t *testing.T) {
uid, _ := happydns.NewRandomIdentifier()
slice := []*happydns.CheckerOptionsPositional{
{CheckName: "alpha", UserId: &uid},
{CheckName: "alpha", UserId: &uid},
}
slices.SortFunc(slice, uc.CompareCheckerOptionsPositional)
if slice[0].CheckName != slice[1].CheckName {
t.Errorf("expected grouping, got %+v vs %+v", slice[0], slice[1])
}
}

View file

@ -36,11 +36,14 @@ const (
type CheckResultStatus int
type CheckerOptions map[string]any
type Checker interface {
ID() string
Name() string
Availability() CheckerAvailability
RunCheck(ctx context.Context, options map[string]any, meta map[string]string) (*CheckResult, error)
Options() CheckerOptionsDocumentation
RunCheck(ctx context.Context, options CheckerOptions, meta map[string]string) (*CheckResult, error)
}
// CheckIntervalSpec describes the scheduling bounds for a checker.
@ -58,9 +61,23 @@ type CheckerIntervalProvider interface {
}
type CheckerResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Availability CheckerAvailability `json:"availability"`
ID string `json:"id"`
Name string `json:"name"`
Availability CheckerAvailability `json:"availability"`
Options CheckerOptionsDocumentation `json:"options"`
}
type SetCheckerOptionsRequest struct {
Options CheckerOptions `json:"options"`
}
type CheckerOptionsPositional struct {
CheckName string
UserId *Identifier
DomainId *Identifier
ServiceId *Identifier
Options CheckerOptions
}
type CheckerAvailability struct {
@ -70,7 +87,20 @@ type CheckerAvailability struct {
LimitToServices []string `json:"limitToServices,omitempty"`
}
type CheckerOptionsDocumentation struct {
RunOpts []CheckerOptionDocumentation `json:"runOpts,omitempty"`
ServiceOpts []CheckerOptionDocumentation `json:"serviceOpts,omitempty"`
DomainOpts []CheckerOptionDocumentation `json:"domainOpts,omitempty"`
UserOpts []CheckerOptionDocumentation `json:"userOpts,omitempty"`
AdminOpts []CheckerOptionDocumentation `json:"adminOpts,omitempty"`
}
type CheckerOptionDocumentation Field
type CheckerUsecase interface {
GetChecker(string) (Checker, error)
GetCheckerOptions(string, *Identifier, *Identifier, *Identifier) (*CheckerOptions, error)
ListCheckers() (*map[string]Checker, error)
OverwriteSomeCheckerOptions(string, *Identifier, *Identifier, *Identifier, CheckerOptions) error
SetCheckerOptions(string, *Identifier, *Identifier, *Identifier, CheckerOptions) error
}

View file

@ -27,6 +27,7 @@ import (
"encoding/base64"
"encoding/gob"
"errors"
"slices"
)
const IDENTIFIER_LEN = 16
@ -55,6 +56,10 @@ func (i Identifier) Equals(other Identifier) bool {
return bytes.Equal(i, other)
}
func (i Identifier) Compare(other Identifier) int {
return slices.Compare(i, other)
}
func (i *Identifier) String() string {
return base64.RawURLEncoding.EncodeToString(*i)
}