From 40141120d289c2dfc4c0a998cb577babf0d9b7ef Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 23 Sep 2025 12:45:40 +0900 Subject: [PATCH] Implement checker options retrieval --- .../api-admin/controller/check_controller.go | 148 ++++++ internal/api-admin/route/check.go | 9 + internal/app/app.go | 2 +- internal/storage/inmemory/database.go | 2 + internal/storage/interface.go | 2 + internal/storage/kvtpl/check.go | 185 +++++++ internal/usecase/check/check_options_test.go | 450 ++++++++++++++++++ internal/usecase/check/check_storage.go | 46 ++ internal/usecase/check/check_usecase.go | 85 +++- internal/usecase/check/check_usecase_test.go | 85 ++++ model/checker.go | 38 +- model/identifier.go | 5 + 12 files changed, 1051 insertions(+), 6 deletions(-) create mode 100644 internal/storage/kvtpl/check.go create mode 100644 internal/usecase/check/check_options_test.go create mode 100644 internal/usecase/check/check_storage.go create mode 100644 internal/usecase/check/check_usecase_test.go diff --git a/internal/api-admin/controller/check_controller.go b/internal/api-admin/controller/check_controller.go index 76ec3d49..afa954be 100644 --- a/internal/api-admin/controller/check_controller.go +++ b/internal/api-admin/controller/check_controller.go @@ -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) +} diff --git a/internal/api-admin/route/check.go b/internal/api-admin/route/check.go index 2d6da24a..139f4c50 100644 --- a/internal/api-admin/route/check.go +++ b/internal/api-admin/route/check.go @@ -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) } diff --git a/internal/app/app.go b/internal/app/app.go index d7881d3e..927f1bc6 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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, diff --git a/internal/storage/inmemory/database.go b/internal/storage/inmemory/database.go index a0bd9e4e..7d05925b 100644 --- a/internal/storage/inmemory/database.go +++ b/internal/storage/inmemory/database.go @@ -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), diff --git a/internal/storage/interface.go b/internal/storage/interface.go index cf4c6646..1300da38 100644 --- a/internal/storage/interface.go +++ b/internal/storage/interface.go @@ -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 diff --git a/internal/storage/kvtpl/check.go b/internal/storage/kvtpl/check.go new file mode 100644 index 00000000..49e2b33c --- /dev/null +++ b/internal/storage/kvtpl/check.go @@ -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 . +// +// 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 . + +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 +} diff --git a/internal/usecase/check/check_options_test.go b/internal/usecase/check/check_options_test.go new file mode 100644 index 00000000..6291b4b2 --- /dev/null +++ b/internal/usecase/check/check_options_test.go @@ -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 . +// +// 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 . + +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"]) + } +} diff --git a/internal/usecase/check/check_storage.go b/internal/usecase/check/check_storage.go new file mode 100644 index 00000000..dcf3c793 --- /dev/null +++ b/internal/usecase/check/check_storage.go @@ -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 . +// +// 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 . + +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 +} diff --git a/internal/usecase/check/check_usecase.go b/internal/usecase/check/check_usecase.go index e1d0e7c6..9f2f3a4f 100644 --- a/internal/usecase/check/check_usecase.go +++ b/internal/usecase/check/check_usecase.go @@ -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) +} diff --git a/internal/usecase/check/check_usecase_test.go b/internal/usecase/check/check_usecase_test.go new file mode 100644 index 00000000..e5e779f9 --- /dev/null +++ b/internal/usecase/check/check_usecase_test.go @@ -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]) + } +} diff --git a/model/checker.go b/model/checker.go index b4bb4038..58282d86 100644 --- a/model/checker.go +++ b/model/checker.go @@ -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 } diff --git a/model/identifier.go b/model/identifier.go index 32031d94..f8fdb094 100644 --- a/model/identifier.go +++ b/model/identifier.go @@ -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) }