feat: add secure settings foundation
This commit is contained in:
@@ -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;
|
||||
@@ -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());
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS admin_bootstrap_challenges;
|
||||
@@ -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');
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS setting_sessions;
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS user_preferences;
|
||||
@@ -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));
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user