From 8d2d4b469b8b7b792e5188eb8a4954b482b514fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E7=AB=A0=E6=B5=A9?= Date: Thu, 13 Aug 2026 12:38:01 +0800 Subject: [PATCH] feat: add secure settings foundation --- db/migrations/000012_system_settings.down.sql | 1 + db/migrations/000012_system_settings.up.sql | 19 +++ .../000012_system_settings_contract_test.go | 25 ++++ db/migrations/000013_admin_bootstrap.down.sql | 1 + db/migrations/000013_admin_bootstrap.up.sql | 5 + .../000013_admin_bootstrap_contract_test.go | 18 +++ .../000014_setting_sessions.down.sql | 1 + db/migrations/000014_setting_sessions.up.sql | 5 + .../000014_setting_sessions_contract_test.go | 20 +++ .../000015_user_preferences.down.sql | 1 + db/migrations/000015_user_preferences.up.sql | 3 + .../000015_user_preferences_contract_test.go | 20 +++ internal/dingtalkconfig/verifier.go | 69 ++++++++++ internal/dingtalkconfig/verifier_test.go | 35 ++++++ internal/secretstore/file.go | 84 +++++++++++++ internal/secretstore/file_test.go | 39 ++++++ internal/settings/settings.go | 118 ++++++++++++++++++ internal/settings/settings_test.go | 85 +++++++++++++ 18 files changed, 549 insertions(+) create mode 100644 db/migrations/000012_system_settings.down.sql create mode 100644 db/migrations/000012_system_settings.up.sql create mode 100644 db/migrations/000012_system_settings_contract_test.go create mode 100644 db/migrations/000013_admin_bootstrap.down.sql create mode 100644 db/migrations/000013_admin_bootstrap.up.sql create mode 100644 db/migrations/000013_admin_bootstrap_contract_test.go create mode 100644 db/migrations/000014_setting_sessions.down.sql create mode 100644 db/migrations/000014_setting_sessions.up.sql create mode 100644 db/migrations/000014_setting_sessions_contract_test.go create mode 100644 db/migrations/000015_user_preferences.down.sql create mode 100644 db/migrations/000015_user_preferences.up.sql create mode 100644 db/migrations/000015_user_preferences_contract_test.go create mode 100644 internal/dingtalkconfig/verifier.go create mode 100644 internal/dingtalkconfig/verifier_test.go create mode 100644 internal/secretstore/file.go create mode 100644 internal/secretstore/file_test.go create mode 100644 internal/settings/settings.go create mode 100644 internal/settings/settings_test.go diff --git a/db/migrations/000012_system_settings.down.sql b/db/migrations/000012_system_settings.down.sql new file mode 100644 index 0000000..3dc3d3a --- /dev/null +++ b/db/migrations/000012_system_settings.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS config_audit_events; DROP TABLE IF EXISTS system_resource_bindings; DROP TABLE IF EXISTS administrators; DROP TABLE IF EXISTS system_config_revisions; \ No newline at end of file diff --git a/db/migrations/000012_system_settings.up.sql b/db/migrations/000012_system_settings.up.sql new file mode 100644 index 0000000..386b053 --- /dev/null +++ b/db/migrations/000012_system_settings.up.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS system_config_revisions ( + id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, kind TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>0), + state TEXT NOT NULL CHECK(state IN ('draft','validated','active','retired','failed')), public_config JSONB NOT NULL DEFAULT '{}'::JSONB, + secret_ref TEXT NOT NULL DEFAULT '', validation_status TEXT NOT NULL DEFAULT 'pending', validation_code TEXT NOT NULL DEFAULT '', + validated_at TIMESTAMPTZ, activated_at TIMESTAMPTZ, created_by_staff_id TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(tenant_key,kind,revision)); +CREATE UNIQUE INDEX IF NOT EXISTS uq_system_config_active ON system_config_revisions(tenant_key,kind) WHERE state='active'; +CREATE TABLE IF NOT EXISTS administrators ( + id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, staff_id TEXT NOT NULL, union_id TEXT NOT NULL DEFAULT '', display_name TEXT NOT NULL DEFAULT '', + department TEXT NOT NULL DEFAULT '', role TEXT NOT NULL CHECK(role IN ('super_admin','admin')), status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','disabled')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(tenant_key,staff_id)); +CREATE TABLE IF NOT EXISTS system_resource_bindings ( + id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, kind TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>0), state TEXT NOT NULL CHECK(state IN ('draft','validated','active','retired','failed')), + public_config JSONB NOT NULL DEFAULT '{}'::JSONB, validation_code TEXT NOT NULL DEFAULT '', validated_at TIMESTAMPTZ, activated_at TIMESTAMPTZ, + created_by_staff_id TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(tenant_key,kind,revision)); +CREATE UNIQUE INDEX IF NOT EXISTS uq_resource_binding_active ON system_resource_bindings(tenant_key,kind) WHERE state='active'; +CREATE TABLE IF NOT EXISTS config_audit_events ( + id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, actor_staff_id TEXT NOT NULL DEFAULT '', action TEXT NOT NULL, target_kind TEXT NOT NULL, + target_revision INTEGER NOT NULL DEFAULT 0, result_code TEXT NOT NULL, metadata JSONB NOT NULL DEFAULT '{}'::JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()); diff --git a/db/migrations/000012_system_settings_contract_test.go b/db/migrations/000012_system_settings_contract_test.go new file mode 100644 index 0000000..04c1f3d --- /dev/null +++ b/db/migrations/000012_system_settings_contract_test.go @@ -0,0 +1,25 @@ +package migrations_test + +import ( + "os" + "strings" + "testing" +) + +func TestUp012CreatesSettingsWithoutSecrets(t *testing.T) { + b, e := os.ReadFile("000012_system_settings.up.sql") + if e != nil { + t.Fatal(e) + } + s := strings.ToUpper(string(b)) + for _, x := range []string{"SYSTEM_CONFIG_REVISIONS", "ADMINISTRATORS", "SYSTEM_RESOURCE_BINDINGS", "CONFIG_AUDIT_EVENTS", "PUBLIC_CONFIG"} { + if !strings.Contains(s, x) { + t.Errorf("missing %s", x) + } + } + for _, x := range []string{"CLIENT_SECRET JSON", "ACCESS_TOKEN JSON", "API_KEY JSON"} { + if strings.Contains(s, x) { + t.Errorf("forbidden %s", x) + } + } +} diff --git a/db/migrations/000013_admin_bootstrap.down.sql b/db/migrations/000013_admin_bootstrap.down.sql new file mode 100644 index 0000000..61dfb21 --- /dev/null +++ b/db/migrations/000013_admin_bootstrap.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS admin_bootstrap_challenges; \ No newline at end of file diff --git a/db/migrations/000013_admin_bootstrap.up.sql b/db/migrations/000013_admin_bootstrap.up.sql new file mode 100644 index 0000000..d6dd2ad --- /dev/null +++ b/db/migrations/000013_admin_bootstrap.up.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS admin_bootstrap_challenges ( + id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, code_hash TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL, attempts INTEGER NOT NULL DEFAULT 0 CHECK(attempts>=0), max_attempts INTEGER NOT NULL DEFAULT 5 CHECK(max_attempts BETWEEN 1 AND 10), + candidate_staff_id TEXT NOT NULL DEFAULT '', candidate_union_id TEXT NOT NULL DEFAULT '', candidate_display_name TEXT NOT NULL DEFAULT '', candidate_department TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL CHECK(status IN ('pending','candidate_detected','confirmed','expired','locked')), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()); +CREATE UNIQUE INDEX IF NOT EXISTS uq_admin_bootstrap_pending ON admin_bootstrap_challenges(tenant_key) WHERE status IN ('pending','candidate_detected'); \ No newline at end of file diff --git a/db/migrations/000013_admin_bootstrap_contract_test.go b/db/migrations/000013_admin_bootstrap_contract_test.go new file mode 100644 index 0000000..8b771c5 --- /dev/null +++ b/db/migrations/000013_admin_bootstrap_contract_test.go @@ -0,0 +1,18 @@ +package migrations_test + +import ( + "os" + "strings" + "testing" +) + +func TestUp013StoresOnlyBootstrapHash(t *testing.T) { + b, e := os.ReadFile("000013_admin_bootstrap.up.sql") + if e != nil { + t.Fatal(e) + } + s := strings.ToUpper(string(b)) + if !strings.Contains(s, "CODE_HASH") || strings.Contains(s, " CODE TEXT") { + t.Fatal("bootstrap must store hash only") + } +} diff --git a/db/migrations/000014_setting_sessions.down.sql b/db/migrations/000014_setting_sessions.down.sql new file mode 100644 index 0000000..12497c3 --- /dev/null +++ b/db/migrations/000014_setting_sessions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS setting_sessions; \ No newline at end of file diff --git a/db/migrations/000014_setting_sessions.up.sql b/db/migrations/000014_setting_sessions.up.sql new file mode 100644 index 0000000..39b9bbe --- /dev/null +++ b/db/migrations/000014_setting_sessions.up.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS setting_sessions ( + tenant_key TEXT NOT NULL, staff_id TEXT NOT NULL, conversation_id TEXT NOT NULL, conversation_type TEXT NOT NULL CHECK(conversation_type IN ('direct','group')), + current_step TEXT NOT NULL, pending_action TEXT NOT NULL DEFAULT '', draft_config JSONB NOT NULL DEFAULT '{}'::JSONB, expires_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY(tenant_key,staff_id)); +CREATE INDEX IF NOT EXISTS idx_setting_sessions_expiry ON setting_sessions(expires_at); \ No newline at end of file diff --git a/db/migrations/000014_setting_sessions_contract_test.go b/db/migrations/000014_setting_sessions_contract_test.go new file mode 100644 index 0000000..75aa317 --- /dev/null +++ b/db/migrations/000014_setting_sessions_contract_test.go @@ -0,0 +1,20 @@ +package migrations_test + +import ( + "os" + "strings" + "testing" +) + +func TestUp014CreatesExpiringSettingSessions(t *testing.T) { + b, e := os.ReadFile("000014_setting_sessions.up.sql") + if e != nil { + t.Fatal(e) + } + s := strings.ToUpper(string(b)) + for _, x := range []string{"SETTING_SESSIONS", "EXPIRES_AT", "DRAFT_CONFIG"} { + if !strings.Contains(s, x) { + t.Errorf("missing %s", x) + } + } +} diff --git a/db/migrations/000015_user_preferences.down.sql b/db/migrations/000015_user_preferences.down.sql new file mode 100644 index 0000000..688c162 --- /dev/null +++ b/db/migrations/000015_user_preferences.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS user_preferences; \ No newline at end of file diff --git a/db/migrations/000015_user_preferences.up.sql b/db/migrations/000015_user_preferences.up.sql new file mode 100644 index 0000000..e18f85f --- /dev/null +++ b/db/migrations/000015_user_preferences.up.sql @@ -0,0 +1,3 @@ +CREATE TABLE IF NOT EXISTS user_preferences ( + tenant_key TEXT NOT NULL, channel TEXT NOT NULL, external_actor_id TEXT NOT NULL, language_mode TEXT NOT NULL DEFAULT 'auto' CHECK(language_mode IN ('auto','fixed')), + locale TEXT NOT NULL DEFAULT 'auto' CHECK(locale IN ('auto','zh-CN','en','ja','ko','es')), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY(tenant_key,channel,external_actor_id)); \ No newline at end of file diff --git a/db/migrations/000015_user_preferences_contract_test.go b/db/migrations/000015_user_preferences_contract_test.go new file mode 100644 index 0000000..4e92ba8 --- /dev/null +++ b/db/migrations/000015_user_preferences_contract_test.go @@ -0,0 +1,20 @@ +package migrations_test + +import ( + "os" + "strings" + "testing" +) + +func TestUp015ScopesPreferencesByTenantAndActor(t *testing.T) { + b, e := os.ReadFile("000015_user_preferences.up.sql") + if e != nil { + t.Fatal(e) + } + s := strings.ToUpper(string(b)) + for _, x := range []string{"USER_PREFERENCES", "TENANT_KEY", "EXTERNAL_ACTOR_ID", "LANGUAGE_MODE", "LOCALE"} { + if !strings.Contains(s, x) { + t.Errorf("missing %s", x) + } + } +} diff --git a/internal/dingtalkconfig/verifier.go b/internal/dingtalkconfig/verifier.go new file mode 100644 index 0000000..1d1540b --- /dev/null +++ b/internal/dingtalkconfig/verifier.go @@ -0,0 +1,69 @@ +package dingtalkconfig + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +type Result struct { + OK bool + Code string + CheckedAt time.Time +} +type Verifier struct { + endpoint string + client *http.Client +} + +func NewVerifier(endpoint string, client *http.Client) *Verifier { + if client == nil { + client = &http.Client{Timeout: 10 * time.Second} + } + return &Verifier{endpoint: strings.TrimSpace(endpoint), client: client} +} +func (v *Verifier) Verify(ctx context.Context, id, secret string) Result { + r := Result{Code: "verification_failed", CheckedAt: time.Now().UTC()} + if strings.TrimSpace(id) == "" || strings.TrimSpace(secret) == "" { + r.Code = "credential_incomplete" + return r + } + u, err := url.Parse(v.endpoint) + if err != nil { + r.Code = "endpoint_invalid" + return r + } + q := u.Query() + q.Set("appkey", id) + q.Set("appsecret", secret) + u.RawQuery = q.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return r + } + resp, err := v.client.Do(req) + if err != nil { + r.Code = "network_error" + return r + } + defer resp.Body.Close() + var body struct { + ErrCode int `json:"errcode"` + AccessToken string `json:"access_token"` + } + if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body) != nil { + r.Code = "invalid_response" + return r + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 || body.ErrCode != 0 || strings.TrimSpace(body.AccessToken) == "" { + r.Code = "credential_rejected" + return r + } + r.OK = true + r.Code = "ok" + return r +} diff --git a/internal/dingtalkconfig/verifier_test.go b/internal/dingtalkconfig/verifier_test.go new file mode 100644 index 0000000..dff6b94 --- /dev/null +++ b/internal/dingtalkconfig/verifier_test.go @@ -0,0 +1,35 @@ +package dingtalkconfig + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestVerifierNeverReturnsToken(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"errcode":0,"access_token":"secret-token"}`)) + })) + defer ts.Close() + v := NewVerifier(ts.URL, ts.Client()) + result := v.Verify(context.Background(), "id", "secret") + if !result.OK { + t.Fatalf("result=%+v", result) + } + if strings.Contains(result.Code, "secret-token") { + t.Fatal("token leaked") + } +} +func TestVerifierRejectsBadCredential(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"errcode":40014,"errmsg":"bad secret"}`)) + })) + defer ts.Close() + result := NewVerifier(ts.URL, ts.Client()).Verify(context.Background(), "id", "wrong") + if result.OK || result.Code != "credential_rejected" { + t.Fatalf("result=%+v", result) + } +} diff --git a/internal/secretstore/file.go b/internal/secretstore/file.go new file mode 100644 index 0000000..e848145 --- /dev/null +++ b/internal/secretstore/file.go @@ -0,0 +1,84 @@ +package secretstore + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "os" + "path/filepath" + "strings" +) + +type Store struct{ root string } + +func New(root string) (*Store, error) { + root = strings.TrimSpace(root) + if root == "" { + return nil, errors.New("secret root is required") + } + if err := os.MkdirAll(root, 0700); err != nil { + return nil, err + } + if err := os.Chmod(root, 0700); err != nil { + return nil, err + } + return &Store{root: root}, nil +} +func (s *Store) Put(kind, value string) (string, error) { + kind = strings.TrimSpace(kind) + value = strings.TrimSpace(value) + if kind == "" || value == "" { + return "", errors.New("secret kind and value are required") + } + if strings.ContainsAny(kind, "/\\") || kind == "." || kind == ".." { + return "", errors.New("invalid secret kind") + } + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + ref := kind + "-" + hex.EncodeToString(b) + tmp, err := os.CreateTemp(s.root, ".new-") + if err != nil { + return "", err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if err = tmp.Chmod(0600); err == nil { + _, err = tmp.WriteString(value) + } + if err == nil { + err = tmp.Sync() + } + closeErr := tmp.Close() + if err == nil { + err = closeErr + } + if err != nil { + return "", err + } + if err = os.Rename(tmpName, filepath.Join(s.root, ref)); err != nil { + return "", err + } + return ref, nil +} +func (s *Store) Get(ref string) (string, error) { + if ref == "" || filepath.Base(ref) != ref { + return "", errors.New("invalid secret reference") + } + b, err := os.ReadFile(filepath.Join(s.root, ref)) + if err != nil { + return "", err + } + return string(b), nil +} +func (s *Store) Configured(ref string) bool { + _, err := os.Stat(filepath.Join(s.root, filepath.Base(ref))) + return ref != "" && filepath.Base(ref) == ref && err == nil +} +func (s *Store) Delete(ref string) error { + if ref == "" || filepath.Base(ref) != ref { + return errors.New("invalid secret reference") + } + return os.Remove(filepath.Join(s.root, ref)) +} diff --git a/internal/secretstore/file_test.go b/internal/secretstore/file_test.go new file mode 100644 index 0000000..d063780 --- /dev/null +++ b/internal/secretstore/file_test.go @@ -0,0 +1,39 @@ +package secretstore + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFileStoreWritesSecretWithPrivatePermissions(t *testing.T) { + root := t.TempDir() + s, err := New(root) + if err != nil { + t.Fatal(err) + } + ref, err := s.Put("dingtalk", "top-secret") + if err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Join(root, ref)) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("mode=%o", info.Mode().Perm()) + } + value, err := s.Get(ref) + if err != nil || value != "top-secret" { + t.Fatalf("value=%q err=%v", value, err) + } +} +func TestFileStoreRejectsTraversalAndBlank(t *testing.T) { + s, _ := New(t.TempDir()) + if _, err := s.Put("../bad", "x"); err == nil { + t.Fatal("expected traversal rejection") + } + if _, err := s.Put("ok", ""); err == nil { + t.Fatal("expected blank rejection") + } +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go new file mode 100644 index 0000000..02f2011 --- /dev/null +++ b/internal/settings/settings.go @@ -0,0 +1,118 @@ +package settings + +import ( + "context" + "errors" + "net/url" + "path" + "strings" +) + +type SystemState string + +const ( + StateUninitialized SystemState = "uninitialized" + StateCredentialsSaved SystemState = "credentials_saved" + StateDingTalkVerified SystemState = "dingtalk_verified" + StateWaitingAdmin SystemState = "waiting_first_admin" + StateActive SystemState = "active" + StateCredentialError SystemState = "credential_error" +) + +type Snapshot struct { + ClientID string + SecretConfigured bool + CredentialsVerified bool + AdministratorCount int +} + +func DetermineState(s Snapshot) SystemState { + id := strings.TrimSpace(s.ClientID) != "" + if !id && !s.SecretConfigured { + return StateUninitialized + } + if !id || !s.SecretConfigured { + return StateCredentialError + } + if !s.CredentialsVerified { + return StateCredentialsSaved + } + if s.AdministratorCount < 1 { + return StateWaitingAdmin + } + return StateActive +} + +type Command struct { + Name string + Args []string +} + +func ParseCommand(raw string) (Command, bool) { + fields := strings.Fields(strings.TrimSpace(raw)) + if len(fields) == 0 { + return Command{}, false + } + name := strings.ToLower(fields[0]) + switch name { + case "/setting", "/settings", "/设置": + default: + return Command{}, false + } + return Command{Name: name, Args: fields[1:]}, true +} + +type NotableLink struct{ BaseID, SheetID, ViewID string } + +func ParseNotableLink(raw string) (NotableLink, error) { + if len(raw) > 4096 { + return NotableLink{}, errors.New("AI Table link is too long") + } + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || u.Scheme != "https" { + return NotableLink{}, errors.New("invalid AI Table link") + } + host := strings.ToLower(u.Hostname()) + if host != "alidocs.dingtalk.com" && host != "docs.dingtalk.com" { + return NotableLink{}, errors.New("untrusted AI Table host") + } + base := path.Base(strings.TrimRight(u.Path, "/")) + q := u.Query() + result := NotableLink{BaseID: base, SheetID: q.Get("sheetId"), ViewID: q.Get("viewId")} + if result.BaseID == "" || result.BaseID == "." || result.SheetID == "" { + return NotableLink{}, errors.New("AI Table link is missing base or sheet") + } + return result, nil +} + +type Runtime interface { + Start(context.Context, string) error + Stop(context.Context) error +} +type Switcher struct { + runtime Runtime + active string +} + +func NewSwitcher(r Runtime, active string) *Switcher { return &Switcher{runtime: r, active: active} } +func (s *Switcher) Switch(ctx context.Context, candidate string) error { + if s == nil || s.runtime == nil { + return errors.New("runtime is required") + } + candidate = strings.TrimSpace(candidate) + if candidate == "" { + return errors.New("candidate is required") + } + old := s.active + if err := s.runtime.Stop(ctx); err != nil { + return err + } + if err := s.runtime.Start(ctx, candidate); err != nil { + if old != "" { + _ = s.runtime.Start(ctx, old) + } + return err + } + s.active = candidate + return nil +} diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go new file mode 100644 index 0000000..ef57e70 --- /dev/null +++ b/internal/settings/settings_test.go @@ -0,0 +1,85 @@ +package settings + +import ( + "context" + "errors" + "testing" +) + +func TestDetermineState(t *testing.T) { + cases := []struct { + name string + in Snapshot + want SystemState + }{ + {"empty", Snapshot{}, StateUninitialized}, + {"partial", Snapshot{ClientID: "id"}, StateCredentialError}, + {"saved", Snapshot{ClientID: "id", SecretConfigured: true}, StateCredentialsSaved}, + {"verified", Snapshot{ClientID: "id", SecretConfigured: true, CredentialsVerified: true}, StateWaitingAdmin}, + {"active", Snapshot{ClientID: "id", SecretConfigured: true, CredentialsVerified: true, AdministratorCount: 1}, StateActive}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := DetermineState(tc.in); got != tc.want { + t.Fatalf("got %s want %s", got, tc.want) + } + }) + } +} + +func TestParseCommandStrict(t *testing.T) { + yes := []string{"/setting", "/settings", "/设置", "/setting 123456", "/setting language ja", "/setting support", "/setting question https://alidocs.dingtalk.com/i/nodes/abc"} + for _, raw := range yes { + if c, ok := ParseCommand(raw); !ok || c.Name == "" { + t.Fatalf("must match %q", raw) + } + } + no := []string{"如何设置机器人", "abc /setting", "请解释/setting", "/settiing"} + for _, raw := range no { + if _, ok := ParseCommand(raw); ok { + t.Fatalf("must not match %q", raw) + } + } +} + +func TestParseNotableLink(t *testing.T) { + got, err := ParseNotableLink("https://alidocs.dingtalk.com/i/nodes/base123?sheetId=sheet456&viewId=view789&recordId=row") + if err != nil { + t.Fatal(err) + } + if got.BaseID != "base123" || got.SheetID != "sheet456" || got.ViewID != "view789" { + t.Fatalf("got %+v", got) + } + if _, err = ParseNotableLink("http://evil.example/x?sheetId=s"); err == nil { + t.Fatal("expected rejection") + } +} + +type fakeRuntime struct { + active string + starts []string + fail string +} + +func (f *fakeRuntime) Start(_ context.Context, id string) error { + f.starts = append(f.starts, id) + if id == f.fail { + return errors.New("boom") + } + f.active = id + return nil +} +func (f *fakeRuntime) Stop(_ context.Context) error { f.active = ""; return nil } +func TestSwitcherRollsBack(t *testing.T) { + f := &fakeRuntime{active: "old", fail: "new"} + s := NewSwitcher(f, "old") + if err := s.Switch(context.Background(), "new"); err == nil { + t.Fatal("expected error") + } + if f.active != "old" { + t.Fatalf("active=%q", f.active) + } + if len(f.starts) != 2 || f.starts[0] != "new" || f.starts[1] != "old" { + t.Fatalf("starts=%v", f.starts) + } +}