feat(PROJ-17): Admin Dashboard Systemauslastung immer anzeigen
- Systemauslastungs-Sektion wird immer gerendert (nicht nur bei Erfolg) - Fehlermeldung wenn /api/admin/system/stats nicht erreichbar ist - Feature-Status auf In Review gesetzt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
package userstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
RoleUser = "user"
|
||||
RoleAdmin = "admin"
|
||||
RoleAuditor = "auditor"
|
||||
)
|
||||
|
||||
// User represents a user account in the system.
|
||||
type User struct {
|
||||
ID int64
|
||||
Username string
|
||||
Email string
|
||||
Role string
|
||||
Source string // "local" or "ldap"
|
||||
Active bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateUserRequest holds parameters for creating a new user.
|
||||
type CreateUserRequest struct {
|
||||
Username string
|
||||
Email string
|
||||
Password string
|
||||
Role string
|
||||
}
|
||||
|
||||
// UpdateUserRequest holds optional fields for updating a user.
|
||||
type UpdateUserRequest struct {
|
||||
Email *string
|
||||
Role *string
|
||||
Active *bool
|
||||
Password *string
|
||||
}
|
||||
|
||||
// Store is a PostgreSQL-backed user store.
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// New connects to PostgreSQL using the given DSN and initialises the schema.
|
||||
func New(dsn string) (*Store, error) {
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("userstore: connect: %w", err)
|
||||
}
|
||||
|
||||
s := &Store{pool: pool}
|
||||
if err := s.initSchema(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("userstore: init schema: %w", err)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) initSchema(ctx context.Context) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
username VARCHAR(100) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL DEFAULT '',
|
||||
role VARCHAR(20) NOT NULL CHECK (role IN ('user','auditor','admin')),
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'local',
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS token_blacklist (
|
||||
jti VARCHAR(255) PRIMARY KEY,
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
// Close closes the underlying connection pool.
|
||||
func (s *Store) Close() error {
|
||||
s.pool.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create inserts a new local user with a bcrypt-hashed password.
|
||||
func (s *Store) Create(req CreateUserRequest) (*User, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("userstore: bcrypt: %w", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
var id int64
|
||||
err = s.pool.QueryRow(ctx,
|
||||
`INSERT INTO users (username, email, password_hash, role, source, active, created_at)
|
||||
VALUES ($1, $2, $3, $4, 'local', true, NOW())
|
||||
RETURNING id`,
|
||||
req.Username, req.Email, string(hash), req.Role,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("userstore: create: %w", err)
|
||||
}
|
||||
|
||||
return s.GetByID(id)
|
||||
}
|
||||
|
||||
// GetByID retrieves a user by their numeric ID.
|
||||
func (s *Store) GetByID(id int64) (*User, error) {
|
||||
ctx := context.Background()
|
||||
row := s.pool.QueryRow(ctx,
|
||||
`SELECT id, username, email, role, source, active, created_at FROM users WHERE id = $1`, id,
|
||||
)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
// GetByUsername retrieves a user by their username.
|
||||
func (s *Store) GetByUsername(username string) (*User, error) {
|
||||
ctx := context.Background()
|
||||
row := s.pool.QueryRow(ctx,
|
||||
`SELECT id, username, email, role, source, active, created_at FROM users WHERE username = $1`, username,
|
||||
)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
// VerifyPassword checks credentials and returns the user, or an error if the
|
||||
// password is wrong or the account is disabled.
|
||||
func (s *Store) VerifyPassword(username, password string) (*User, error) {
|
||||
ctx := context.Background()
|
||||
row := s.pool.QueryRow(ctx,
|
||||
`SELECT id, username, email, role, source, active, created_at, password_hash FROM users WHERE username = $1`,
|
||||
username,
|
||||
)
|
||||
|
||||
var u User
|
||||
var hash string
|
||||
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt, &hash)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errors.New("userstore: user not found")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("userstore: scan: %w", err)
|
||||
}
|
||||
|
||||
if !u.Active {
|
||||
return nil, errors.New("userstore: account disabled")
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
|
||||
return nil, errors.New("userstore: wrong password")
|
||||
}
|
||||
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// Update applies a partial update to a user record.
|
||||
func (s *Store) Update(id int64, req UpdateUserRequest) (*User, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
if req.Email != nil {
|
||||
if _, err := s.pool.Exec(ctx, `UPDATE users SET email = $1 WHERE id = $2`, *req.Email, id); err != nil {
|
||||
return nil, fmt.Errorf("userstore: update email: %w", err)
|
||||
}
|
||||
}
|
||||
if req.Role != nil {
|
||||
if _, err := s.pool.Exec(ctx, `UPDATE users SET role = $1 WHERE id = $2`, *req.Role, id); err != nil {
|
||||
return nil, fmt.Errorf("userstore: update role: %w", err)
|
||||
}
|
||||
}
|
||||
if req.Active != nil {
|
||||
if _, err := s.pool.Exec(ctx, `UPDATE users SET active = $1 WHERE id = $2`, *req.Active, id); err != nil {
|
||||
return nil, fmt.Errorf("userstore: update active: %w", err)
|
||||
}
|
||||
}
|
||||
if req.Password != nil {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("userstore: bcrypt: %w", err)
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx, `UPDATE users SET password_hash = $1 WHERE id = $2`, string(hash), id); err != nil {
|
||||
return nil, fmt.Errorf("userstore: update password: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return s.GetByID(id)
|
||||
}
|
||||
|
||||
// Delete removes a user by ID. Returns an error if the user does not exist.
|
||||
func (s *Store) Delete(id int64) error {
|
||||
ctx := context.Background()
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("userstore: delete: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("userstore: user %d not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns all users, optionally filtered by role. Pass role="" to list all.
|
||||
func (s *Store) List(role string) ([]*User, error) {
|
||||
ctx := context.Background()
|
||||
var rows pgx.Rows
|
||||
var err error
|
||||
|
||||
if role == "" {
|
||||
rows, err = s.pool.Query(ctx,
|
||||
`SELECT id, username, email, role, source, active, created_at FROM users ORDER BY id`)
|
||||
} else {
|
||||
rows, err = s.pool.Query(ctx,
|
||||
`SELECT id, username, email, role, source, active, created_at FROM users WHERE role = $1 ORDER BY id`, role)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("userstore: list: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var users []*User
|
||||
for rows.Next() {
|
||||
u, err := scanUserRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
// BlacklistToken adds a JWT ID to the token blacklist.
|
||||
func (s *Store) BlacklistToken(jti string, expires time.Time) error {
|
||||
ctx := context.Background()
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO token_blacklist (jti, expires_at) VALUES ($1, $2)
|
||||
ON CONFLICT (jti) DO UPDATE SET expires_at = EXCLUDED.expires_at`,
|
||||
jti, expires.UTC(),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// IsBlacklisted returns true if the given JTI is in the blacklist.
|
||||
func (s *Store) IsBlacklisted(jti string) (bool, error) {
|
||||
ctx := context.Background()
|
||||
var count int
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM token_blacklist WHERE jti = $1`, jti,
|
||||
).Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// CleanExpiredTokens removes blacklist entries whose expiry has passed.
|
||||
func (s *Store) CleanExpiredTokens() error {
|
||||
ctx := context.Background()
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM token_blacklist WHERE expires_at < NOW()`)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpsertLDAPUser creates or updates an LDAP-sourced user.
|
||||
func (s *Store) UpsertLDAPUser(username, email, role string) (*User, error) {
|
||||
ctx := context.Background()
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO users (username, email, password_hash, role, source, active, created_at)
|
||||
VALUES ($1, $2, '', $3, 'ldap', true, NOW())
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
role = EXCLUDED.role,
|
||||
source = 'ldap'
|
||||
`, username, email, role)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("userstore: upsert ldap: %w", err)
|
||||
}
|
||||
return s.GetByUsername(username)
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func scanUser(row pgx.Row) (*User, error) {
|
||||
var u User
|
||||
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, fmt.Errorf("userstore: not found")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("userstore: scan: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func scanUserRow(rows pgx.Rows) (*User, error) {
|
||||
var u User
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("userstore: scan row: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package userstore_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/archivmail/internal/userstore"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) *userstore.Store {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping (needs PostgreSQL)")
|
||||
}
|
||||
// Use a unique schema per test to isolate
|
||||
schema := "test_" + strings.ReplaceAll(t.Name(), "/", "_")
|
||||
schema = strings.ToLower(schema)
|
||||
// Append schema to DSN
|
||||
sep := "?"
|
||||
if strings.Contains(dsn, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
schemaDSN := dsn + sep + "search_path=" + schema
|
||||
|
||||
// Create schema
|
||||
ctx := context.Background()
|
||||
conn, err := pgx.Connect(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
conn.Exec(ctx, "CREATE SCHEMA IF NOT EXISTS "+schema)
|
||||
conn.Close(ctx)
|
||||
|
||||
s, err := userstore.New(schemaDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("userstore.New: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
s.Close()
|
||||
conn2, _ := pgx.Connect(context.Background(), dsn)
|
||||
if conn2 != nil {
|
||||
conn2.Exec(context.Background(), "DROP SCHEMA "+schema+" CASCADE")
|
||||
conn2.Close(context.Background())
|
||||
}
|
||||
})
|
||||
return s
|
||||
}
|
||||
|
||||
func TestCreateAndGetUser(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
u, err := s.Create(userstore.CreateUserRequest{
|
||||
Username: "alice",
|
||||
Email: "alice@example.com",
|
||||
Password: "secret123",
|
||||
Role: userstore.RoleAdmin,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if u.ID == 0 {
|
||||
t.Error("expected non-zero ID")
|
||||
}
|
||||
if u.Username != "alice" {
|
||||
t.Errorf("Username = %q", u.Username)
|
||||
}
|
||||
if u.Role != userstore.RoleAdmin {
|
||||
t.Errorf("Role = %q", u.Role)
|
||||
}
|
||||
if u.Source != "local" {
|
||||
t.Errorf("Source = %q, want local", u.Source)
|
||||
}
|
||||
|
||||
got, err := s.GetByID(u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID: %v", err)
|
||||
}
|
||||
if got.Email != "alice@example.com" {
|
||||
t.Errorf("Email = %q", got.Email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPassword(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
_, err := s.Create(userstore.CreateUserRequest{
|
||||
Username: "bob", Email: "bob@example.com",
|
||||
Password: "correcthorse", Role: userstore.RoleUser,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Correct password
|
||||
u, err := s.VerifyPassword("bob", "correcthorse")
|
||||
if err != nil {
|
||||
t.Errorf("VerifyPassword correct: %v", err)
|
||||
}
|
||||
if u.Username != "bob" {
|
||||
t.Errorf("Username = %q", u.Username)
|
||||
}
|
||||
|
||||
// Wrong password
|
||||
if _, err := s.VerifyPassword("bob", "wrongpassword"); err == nil {
|
||||
t.Error("expected error for wrong password")
|
||||
}
|
||||
|
||||
// Non-existent user
|
||||
if _, err := s.VerifyPassword("nobody", "x"); err == nil {
|
||||
t.Error("expected error for unknown user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUser(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
u, _ := s.Create(userstore.CreateUserRequest{
|
||||
Username: "carol", Email: "carol@old.com",
|
||||
Password: "pw", Role: userstore.RoleUser,
|
||||
})
|
||||
|
||||
newEmail := "carol@new.com"
|
||||
newRole := userstore.RoleAuditor
|
||||
updated, err := s.Update(u.ID, userstore.UpdateUserRequest{
|
||||
Email: &newEmail,
|
||||
Role: &newRole,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
if updated.Email != "carol@new.com" {
|
||||
t.Errorf("Email after update = %q", updated.Email)
|
||||
}
|
||||
if updated.Role != userstore.RoleAuditor {
|
||||
t.Errorf("Role after update = %q", updated.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisableUser(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
u, _ := s.Create(userstore.CreateUserRequest{
|
||||
Username: "dave", Email: "dave@x.com",
|
||||
Password: "pw", Role: userstore.RoleUser,
|
||||
})
|
||||
|
||||
active := false
|
||||
s.Update(u.ID, userstore.UpdateUserRequest{Active: &active})
|
||||
|
||||
if _, err := s.VerifyPassword("dave", "pw"); err == nil {
|
||||
t.Error("disabled user should not be able to login")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUser(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
u, _ := s.Create(userstore.CreateUserRequest{
|
||||
Username: "eve", Email: "eve@x.com",
|
||||
Password: "pw", Role: userstore.RoleUser,
|
||||
})
|
||||
|
||||
if err := s.Delete(u.ID); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, err := s.GetByID(u.ID); err == nil {
|
||||
t.Error("GetByID should error after delete")
|
||||
}
|
||||
// Delete non-existent should error
|
||||
if err := s.Delete(u.ID); err == nil {
|
||||
t.Error("second delete should return error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListUsers(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
users := []userstore.CreateUserRequest{
|
||||
{Username: "u1", Email: "u1@x.com", Password: "pw", Role: userstore.RoleUser},
|
||||
{Username: "u2", Email: "u2@x.com", Password: "pw", Role: userstore.RoleAdmin},
|
||||
{Username: "u3", Email: "u3@x.com", Password: "pw", Role: userstore.RoleAuditor},
|
||||
{Username: "u4", Email: "u4@x.com", Password: "pw", Role: userstore.RoleUser},
|
||||
}
|
||||
for _, req := range users {
|
||||
s.Create(req)
|
||||
}
|
||||
|
||||
all, err := s.List("")
|
||||
if err != nil {
|
||||
t.Fatalf("List all: %v", err)
|
||||
}
|
||||
if len(all) != 4 {
|
||||
t.Errorf("List all: got %d, want 4", len(all))
|
||||
}
|
||||
|
||||
admins, _ := s.List(userstore.RoleAdmin)
|
||||
if len(admins) != 1 {
|
||||
t.Errorf("List admin: got %d, want 1", len(admins))
|
||||
}
|
||||
|
||||
regular, _ := s.List(userstore.RoleUser)
|
||||
if len(regular) != 2 {
|
||||
t.Errorf("List user: got %d, want 2", len(regular))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenBlacklist(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
jti := "test-jti-12345"
|
||||
expires := time.Now().Add(1 * time.Hour)
|
||||
|
||||
if err := s.BlacklistToken(jti, expires); err != nil {
|
||||
t.Fatalf("BlacklistToken: %v", err)
|
||||
}
|
||||
|
||||
blacklisted, err := s.IsBlacklisted(jti)
|
||||
if err != nil {
|
||||
t.Fatalf("IsBlacklisted: %v", err)
|
||||
}
|
||||
if !blacklisted {
|
||||
t.Error("token should be blacklisted")
|
||||
}
|
||||
|
||||
// Non-blacklisted token
|
||||
bl2, _ := s.IsBlacklisted("other-jti")
|
||||
if bl2 {
|
||||
t.Error("unknown token should not be blacklisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanExpiredTokens(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
// Add an already-expired token
|
||||
s.BlacklistToken("expired-jti", time.Now().Add(-1*time.Hour))
|
||||
// Add a valid token
|
||||
s.BlacklistToken("valid-jti", time.Now().Add(1*time.Hour))
|
||||
|
||||
if err := s.CleanExpiredTokens(); err != nil {
|
||||
t.Fatalf("CleanExpiredTokens: %v", err)
|
||||
}
|
||||
|
||||
bl, _ := s.IsBlacklisted("expired-jti")
|
||||
if bl {
|
||||
t.Error("expired token should be cleaned up")
|
||||
}
|
||||
bl2, _ := s.IsBlacklisted("valid-jti")
|
||||
if !bl2 {
|
||||
t.Error("valid token should still be blacklisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertLDAPUser(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
u, err := s.UpsertLDAPUser("ldapuser", "ldap@corp.com", userstore.RoleAuditor)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertLDAPUser: %v", err)
|
||||
}
|
||||
if u.Source != "ldap" {
|
||||
t.Errorf("Source = %q, want ldap", u.Source)
|
||||
}
|
||||
|
||||
// Second upsert should update, not duplicate
|
||||
u2, err := s.UpsertLDAPUser("ldapuser", "ldap@corp.com", userstore.RoleAuditor)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertLDAPUser second: %v", err)
|
||||
}
|
||||
if u2.ID != u.ID {
|
||||
t.Error("second upsert should not create a new record")
|
||||
}
|
||||
|
||||
all, _ := s.List("")
|
||||
if len(all) != 1 {
|
||||
t.Errorf("expected 1 user after double upsert, got %d", len(all))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user