feat: create standalone native fnOS repository
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/havline/havline/internal/validate"
|
||||
)
|
||||
|
||||
const sessionTTL = 7 * 24 * time.Hour
|
||||
|
||||
var ErrUnauthorized = errors.New("unauthorized")
|
||||
var ErrNotInitialized = errors.New("not initialized")
|
||||
|
||||
type Service struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func New(db *sql.DB) *Service {
|
||||
return &Service{db: db}
|
||||
}
|
||||
|
||||
func (s *Service) IsInitialized(ctx context.Context) (bool, error) {
|
||||
var count int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM admins`).Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
const defaultAdminUsername = "admin"
|
||||
|
||||
func (s *Service) EnsureDefaultAdmin(ctx context.Context, log *slog.Logger) error {
|
||||
initialized, err := s.IsInitialized(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if initialized {
|
||||
return nil
|
||||
}
|
||||
|
||||
password := strings.TrimSpace(os.Getenv("HAVLINE_INITIAL_ADMIN_PASSWORD"))
|
||||
generated := false
|
||||
if password == "" {
|
||||
var err error
|
||||
password, err = generatePassword(16)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
generated = true
|
||||
}
|
||||
if err := s.Setup(ctx, defaultAdminUsername, password); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if generated {
|
||||
log.Info(
|
||||
"首次启动已创建管理员账户,请使用以下凭据登录,并在「设置」中尽快修改密码",
|
||||
"module", "SYSTEM",
|
||||
"username", defaultAdminUsername,
|
||||
"password", password,
|
||||
)
|
||||
} else {
|
||||
log.Info(
|
||||
"首次启动已使用 HAVLINE_INITIAL_ADMIN_PASSWORD 创建管理员账户,请在登录后尽快修改密码",
|
||||
"module", "SYSTEM",
|
||||
"username", defaultAdminUsername,
|
||||
"password_source", "environment",
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Setup(ctx context.Context, username, password string) error {
|
||||
if err := validate.Username(username); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validate.Password(password); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
initialized, err := s.IsInitialized(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if initialized {
|
||||
return fmt.Errorf("管理员已初始化")
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.db.ExecContext(ctx, `INSERT INTO admins(username, password_hash) VALUES (?, ?)`, username, string(hash))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, username, password string) (string, error) {
|
||||
var adminID int
|
||||
var hash string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT id, password_hash FROM admins WHERE username = ?`, username).Scan(&adminID, &hash)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrUnauthorized
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
|
||||
return "", ErrUnauthorized
|
||||
}
|
||||
return s.createSession(ctx, adminID)
|
||||
}
|
||||
|
||||
func (s *Service) AdminIDForSession(ctx context.Context, sessionID string) (int, error) {
|
||||
if sessionID == "" {
|
||||
return 0, ErrUnauthorized
|
||||
}
|
||||
var adminID int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT admin_id FROM sessions WHERE id = ?`, sessionID).Scan(&adminID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, ErrUnauthorized
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return adminID, nil
|
||||
}
|
||||
|
||||
func (s *Service) ChangePassword(ctx context.Context, adminID int, oldPassword, newPassword, keepSessionID string) error {
|
||||
if err := validate.Password(newPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
var hash string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT password_hash FROM admins WHERE id = ?`, adminID).Scan(&hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(oldPassword)); err != nil {
|
||||
return fmt.Errorf("当前密码不正确")
|
||||
}
|
||||
newHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `UPDATE admins SET password_hash = ? WHERE id = ?`, string(newHash), adminID); err != nil {
|
||||
return err
|
||||
}
|
||||
// 改密后失效该账号的其他会话(保留当前这次,否则用户会被自己踢下线)
|
||||
_, err = s.db.ExecContext(ctx, `DELETE FROM sessions WHERE admin_id = ? AND id <> ?`, adminID, keepSessionID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) Logout(ctx context.Context, sessionID string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, sessionID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) ValidateSession(ctx context.Context, sessionID string) error {
|
||||
if sessionID == "" {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
var expiresAt string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT expires_at FROM sessions WHERE id = ?`, sessionID).Scan(&expiresAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
return err
|
||||
}
|
||||
expires, err := time.Parse(time.RFC3339, expiresAt)
|
||||
if err != nil {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
if time.Now().After(expires) {
|
||||
_, _ = s.db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, sessionID)
|
||||
return ErrUnauthorized
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) createSession(ctx context.Context, adminID int) (string, error) {
|
||||
id, err := randomToken(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
expires := time.Now().Add(sessionTTL).UTC().Format(time.RFC3339)
|
||||
_, err = s.db.ExecContext(ctx, `INSERT INTO sessions(id, admin_id, expires_at) VALUES (?, ?, ?)`, id, adminID, expires)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func randomToken(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func generatePassword(length int) (string, error) {
|
||||
const letters = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
if length < 8 {
|
||||
length = 8
|
||||
}
|
||||
out := make([]byte, length)
|
||||
randBytes := make([]byte, length)
|
||||
if _, err := rand.Read(randBytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for i := range out {
|
||||
out[i] = letters[int(randBytes[i])%len(letters)]
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/havline/havline/internal/db"
|
||||
)
|
||||
|
||||
func TestEnsureDefaultAdminCreatesOnce(t *testing.T) {
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
svc := New(conn)
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
|
||||
if err := svc.EnsureDefaultAdmin(context.Background(), logger); err != nil {
|
||||
t.Fatalf("first bootstrap: %v", err)
|
||||
}
|
||||
initialized, err := svc.IsInitialized(context.Background())
|
||||
if err != nil || !initialized {
|
||||
t.Fatalf("expected initialized admin, got initialized=%v err=%v", initialized, err)
|
||||
}
|
||||
|
||||
if err := svc.EnsureDefaultAdmin(context.Background(), logger); err != nil {
|
||||
t.Fatalf("second bootstrap: %v", err)
|
||||
}
|
||||
if _, err := svc.Login(context.Background(), "admin", "wrong-password"); err != ErrUnauthorized {
|
||||
t.Fatalf("expected unauthorized for wrong password, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDefaultAdminUsesEnvPassword(t *testing.T) {
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("HAVLINE_INITIAL_ADMIN_PASSWORD", "env-password-123")
|
||||
svc := New(conn)
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
|
||||
if err := svc.EnsureDefaultAdmin(context.Background(), logger); err != nil {
|
||||
t.Fatalf("bootstrap with env password: %v", err)
|
||||
}
|
||||
if _, err := svc.Login(context.Background(), "admin", "env-password-123"); err != nil {
|
||||
t.Fatalf("expected env password login to succeed, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// API Token 给无法保存会话 cookie 的调用方(脚本、HomeAssistant、手机快捷指令)用。
|
||||
//
|
||||
// 明文只在创建响应里返回一次,库里只存 SHA-256:令牌由 crypto/rand 生成、熵足够高,
|
||||
// 不像人类密码那样需要 bcrypt 这类慢哈希(慢哈希在这里只会拖慢每个 API 请求)。
|
||||
const (
|
||||
TokenScopeRead = "read"
|
||||
TokenScopeWrite = "write"
|
||||
|
||||
// lastUsedWriteInterval 限制回写频率:单连接 SQLite 上每个请求都写一次不划算,
|
||||
// 同一分钟内重复使用不再写。
|
||||
lastUsedWriteInterval = time.Minute
|
||||
|
||||
maxTokenNameLength = 64
|
||||
)
|
||||
|
||||
type APIToken struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
LastUsedAt string `json:"last_used_at,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// CreatedAPIToken 只在创建响应里出现:Token 是明文,之后无法再取回。
|
||||
type CreatedAPIToken struct {
|
||||
APIToken
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func (s *Service) ListAPITokens(ctx context.Context) ([]APIToken, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, name, scope, COALESCE(expires_at, ''), COALESCE(last_used_at, ''), created_at
|
||||
FROM api_tokens ORDER BY id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tokens := make([]APIToken, 0, 8)
|
||||
for rows.Next() {
|
||||
var token APIToken
|
||||
if err := rows.Scan(&token.ID, &token.Name, &token.Scope, &token.ExpiresAt, &token.LastUsedAt, &token.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokens = append(tokens, token)
|
||||
}
|
||||
return tokens, rows.Err()
|
||||
}
|
||||
|
||||
// CreateAPIToken 新建令牌并返回一次明文;expiresAt 为零值表示永不过期。
|
||||
func (s *Service) CreateAPIToken(ctx context.Context, name, scope string, expiresAt time.Time) (CreatedAPIToken, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return CreatedAPIToken{}, fmt.Errorf("请填写令牌名称")
|
||||
}
|
||||
if len([]rune(name)) > maxTokenNameLength {
|
||||
return CreatedAPIToken{}, fmt.Errorf("令牌名称最长 %d 个字", maxTokenNameLength)
|
||||
}
|
||||
if scope != TokenScopeRead && scope != TokenScopeWrite {
|
||||
return CreatedAPIToken{}, fmt.Errorf("无效的权限范围")
|
||||
}
|
||||
|
||||
plain, err := randomToken(32)
|
||||
if err != nil {
|
||||
return CreatedAPIToken{}, err
|
||||
}
|
||||
expires := ""
|
||||
if !expiresAt.IsZero() {
|
||||
expires = expiresAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO api_tokens(name, token_hash, scope, expires_at) VALUES (?, ?, ?, ?)`,
|
||||
name, hashToken(plain), scope, expires)
|
||||
if err != nil {
|
||||
return CreatedAPIToken{}, err
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return CreatedAPIToken{}, err
|
||||
}
|
||||
token, err := s.apiTokenByID(ctx, id)
|
||||
if err != nil {
|
||||
return CreatedAPIToken{}, err
|
||||
}
|
||||
return CreatedAPIToken{APIToken: token, Token: plain}, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteAPIToken(ctx context.Context, id int64) error {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM api_tokens WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return fmt.Errorf("令牌不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateAPIToken 校验明文令牌并返回权限范围;校验通过后限频回写最后使用时间。
|
||||
func (s *Service) ValidateAPIToken(ctx context.Context, plain string) (string, error) {
|
||||
plain = strings.TrimSpace(plain)
|
||||
if plain == "" {
|
||||
return "", ErrUnauthorized
|
||||
}
|
||||
|
||||
var (
|
||||
id int64
|
||||
scope string
|
||||
expiresAt string
|
||||
)
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, scope, COALESCE(expires_at, '') FROM api_tokens WHERE token_hash = ?`, hashToken(plain)).
|
||||
Scan(&id, &scope, &expiresAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrUnauthorized
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if expiresAt != "" {
|
||||
expires, err := time.Parse(time.RFC3339, expiresAt)
|
||||
if err != nil || time.Now().After(expires) {
|
||||
return "", ErrUnauthorized
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
cutoff := now.Add(-lastUsedWriteInterval).Format(time.RFC3339)
|
||||
// 回写失败不影响本次调用:last_used_at 只是审计信息
|
||||
_, _ = s.db.ExecContext(ctx,
|
||||
`UPDATE api_tokens SET last_used_at = ? WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)`,
|
||||
now.Format(time.RFC3339), id, cutoff)
|
||||
return scope, nil
|
||||
}
|
||||
|
||||
func (s *Service) apiTokenByID(ctx context.Context, id int64) (APIToken, error) {
|
||||
var token APIToken
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, scope, COALESCE(expires_at, ''), COALESCE(last_used_at, ''), created_at
|
||||
FROM api_tokens WHERE id = ?`, id).
|
||||
Scan(&token.ID, &token.Name, &token.Scope, &token.ExpiresAt, &token.LastUsedAt, &token.CreatedAt)
|
||||
return token, err
|
||||
}
|
||||
|
||||
func hashToken(plain string) string {
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/havline/havline/internal/db"
|
||||
)
|
||||
|
||||
func newTestService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return New(conn)
|
||||
}
|
||||
|
||||
func TestAPITokenLifecycle(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
created, err := svc.CreateAPIToken(ctx, "homeassistant", TokenScopeRead, time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if created.Token == "" || created.Scope != TokenScopeRead || created.ID == 0 {
|
||||
t.Fatalf("unexpected created token: %+v", created)
|
||||
}
|
||||
|
||||
// 库里只存 SHA-256:明文不落盘
|
||||
var stored string
|
||||
if err := svc.db.QueryRowContext(ctx, `SELECT token_hash FROM api_tokens WHERE id = ?`, created.ID).Scan(&stored); err != nil {
|
||||
t.Fatalf("read hash: %v", err)
|
||||
}
|
||||
if stored == created.Token || len(stored) != 64 {
|
||||
t.Fatalf("令牌应以 SHA-256 存储,实际 %q", stored)
|
||||
}
|
||||
|
||||
scope, err := svc.ValidateAPIToken(ctx, created.Token)
|
||||
if err != nil || scope != TokenScopeRead {
|
||||
t.Fatalf("validate: scope=%q err=%v", scope, err)
|
||||
}
|
||||
|
||||
var lastUsed string
|
||||
if err := svc.db.QueryRowContext(ctx, `SELECT COALESCE(last_used_at, '') FROM api_tokens WHERE id = ?`, created.ID).Scan(&lastUsed); err != nil {
|
||||
t.Fatalf("read last_used_at: %v", err)
|
||||
}
|
||||
if lastUsed == "" {
|
||||
t.Fatal("校验通过后应回写 last_used_at")
|
||||
}
|
||||
|
||||
if _, err := svc.ValidateAPIToken(ctx, "not-a-real-token"); err != ErrUnauthorized {
|
||||
t.Fatalf("未知令牌应拒绝,实际 %v", err)
|
||||
}
|
||||
if err := svc.DeleteAPIToken(ctx, created.ID); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if _, err := svc.ValidateAPIToken(ctx, created.Token); err != ErrUnauthorized {
|
||||
t.Fatalf("删除后令牌应失效,实际 %v", err)
|
||||
}
|
||||
if err := svc.DeleteAPIToken(ctx, created.ID); err == nil {
|
||||
t.Fatal("重复删除应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPITokenExpiry(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
expired, err := svc.CreateAPIToken(ctx, "old-script", TokenScopeWrite, time.Now().Add(-time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if _, err := svc.ValidateAPIToken(ctx, expired.Token); err != ErrUnauthorized {
|
||||
t.Fatalf("过期令牌应拒绝,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPITokenRejectsBadInput(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := svc.CreateAPIToken(ctx, " ", TokenScopeRead, time.Time{}); err == nil {
|
||||
t.Fatal("空名称应报错")
|
||||
}
|
||||
if _, err := svc.CreateAPIToken(ctx, "bad-scope", "admin", time.Time{}); err == nil {
|
||||
t.Fatal("未知权限范围应报错")
|
||||
}
|
||||
if _, err := svc.ValidateAPIToken(ctx, " "); err != ErrUnauthorized {
|
||||
t.Fatalf("空令牌应拒绝,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAPITokensReturnsNewestFirst(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := svc.CreateAPIToken(ctx, "first", TokenScopeRead, time.Time{}); err != nil {
|
||||
t.Fatalf("create first: %v", err)
|
||||
}
|
||||
if _, err := svc.CreateAPIToken(ctx, "second", TokenScopeWrite, time.Time{}); err != nil {
|
||||
t.Fatalf("create second: %v", err)
|
||||
}
|
||||
tokens, err := svc.ListAPITokens(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(tokens) != 2 || tokens[0].Name != "second" {
|
||||
t.Fatalf("应按 id 倒序返回:%+v", tokens)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user