• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / tg-spam / 15672803049

16 Jun 2025 05:42AM UTC coverage: 79.352% (-2.1%) from 81.499%
15672803049

Pull #294

github

umputun
Add CLI override functionality for auth credentials in database mode

- Created applyCLIOverrides function to handle selective CLI parameter overrides
- Currently handles --server.auth and --server.auth-hash overrides
- Only applies overrides when values differ from defaults
- Auth hash takes precedence over password when both are provided
- Added comprehensive unit tests covering all override scenarios
- Function is extensible for future CLI override needs (documented in comments)

This fixes the issue where users couldn't change auth credentials when using
database configuration mode (--confdb), as the save-config command would
overwrite all settings rather than just the auth credentials.
Pull Request #294: Implement database configuration support

891 of 1298 new or added lines in 9 files covered. (68.64%)

174 existing lines in 4 files now uncovered.

5734 of 7226 relevant lines covered (79.35%)

57.45 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

79.55
/app/config/store.go
1
package config
2

3
import (
4
        "context"
5
        "encoding/json"
6
        "fmt"
7
        "log"
8
        "time"
9

10
        "github.com/jmoiron/sqlx"
11

12
        "github.com/umputun/tg-spam/app/storage/engine"
13
)
14

15
// Store provides access to settings stored in database
16
type Store struct {
17
        *engine.SQL
18
        engine.RWLocker
19
        crypter         *Crypter
20
        sensitiveFields []string
21
}
22

23
// all config queries
24
const (
25
        CmdCreateConfigTable engine.DBCmd = iota + 1000
26
        CmdCreateConfigIndexes
27
        CmdUpsertConfig
28
        CmdSelectConfig
29
        CmdDeleteConfig
30
        CmdSelectConfigUpdatedAt
31
        CmdCountConfig
32
)
33

34
// queries holds all config queries
35
var configQueries = engine.NewQueryMap().
36
        Add(CmdCreateConfigTable, engine.Query{
37
                Sqlite: `CREATE TABLE IF NOT EXISTS config (
38
                        id INTEGER PRIMARY KEY,
39
                        gid TEXT NOT NULL,
40
                        data TEXT NOT NULL,
41
                        created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
42
                        updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
43
                        UNIQUE(gid)
44
                )`,
45
                Postgres: `CREATE TABLE IF NOT EXISTS config (
46
                        id SERIAL PRIMARY KEY,
47
                        gid TEXT NOT NULL,
48
                        data TEXT NOT NULL,
49
                        created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
50
                        updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
51
                        UNIQUE(gid)
52
                )`,
53
        }).
54
        AddSame(CmdCreateConfigIndexes, `CREATE INDEX IF NOT EXISTS idx_config_gid ON config(gid)`).
55
        Add(CmdUpsertConfig, engine.Query{
56
                Sqlite: `INSERT INTO config (gid, data, updated_at) 
57
                        VALUES (?, ?, ?) 
58
                        ON CONFLICT (gid) DO UPDATE 
59
                        SET data = excluded.data, updated_at = excluded.updated_at`,
60
                Postgres: `INSERT INTO config (gid, data, updated_at) 
61
                        VALUES ($1, $2, $3) 
62
                        ON CONFLICT (gid) DO UPDATE 
63
                        SET data = EXCLUDED.data, updated_at = EXCLUDED.updated_at`,
64
        }).
65
        AddSame(CmdSelectConfig, `SELECT data FROM config WHERE gid = ?`).
66
        AddSame(CmdDeleteConfig, `DELETE FROM config WHERE gid = ?`).
67
        AddSame(CmdSelectConfigUpdatedAt, `SELECT updated_at FROM config WHERE gid = ?`).
68
        AddSame(CmdCountConfig, `SELECT COUNT(*) FROM config WHERE gid = ?`)
69

70
// NewStore creates a new settings store
71
func NewStore(ctx context.Context, db *engine.SQL, opts ...StoreOption) (*Store, error) {
21✔
72
        if db == nil {
22✔
73
                return nil, fmt.Errorf("no db provided")
1✔
74
        }
1✔
75

76
        // create store with default options
77
        res := &Store{
20✔
78
                SQL:             db,
20✔
79
                RWLocker:        db.MakeLock(),
20✔
80
                sensitiveFields: defaultSensitiveFields(),
20✔
81
        }
20✔
82

20✔
83
        // apply options
20✔
84
        for _, opt := range opts {
26✔
85
                opt(res)
6✔
86
        }
6✔
87

88
        // initialize the database table using the TableConfig pattern
89
        cfg := engine.TableConfig{
20✔
90
                Name:          "config",
20✔
91
                CreateTable:   CmdCreateConfigTable,
20✔
92
                CreateIndexes: CmdCreateConfigIndexes,
20✔
93
                MigrateFunc:   noopMigrate, // no migration needed for config table
20✔
94
                QueriesMap:    configQueries,
20✔
95
        }
20✔
96

20✔
97
        if err := engine.InitTable(ctx, db, cfg); err != nil {
20✔
NEW
98
                return nil, fmt.Errorf("failed to init config table: %w", err)
×
NEW
99
        }
×
100

101
        return res, nil
20✔
102
}
103

104
// StoreOption defines functional options for Store
105
type StoreOption func(*Store)
106

107
// WithCrypter adds a crypter to the store for field encryption
108
func WithCrypter(crypter *Crypter) StoreOption {
4✔
109
        return func(s *Store) {
8✔
110
                s.crypter = crypter
4✔
111
        }
4✔
112
}
113

114
// WithSensitiveFields sets the list of sensitive fields to encrypt/decrypt
115
func WithSensitiveFields(fields []string) StoreOption {
2✔
116
        return func(s *Store) {
4✔
117
                s.sensitiveFields = fields
2✔
118
        }
2✔
119
}
120

121
// defaultSensitiveFields returns the default list of sensitive fields
122
func defaultSensitiveFields() []string {
22✔
123
        return []string{
22✔
124
                FieldTelegramToken,  // telegram bot token
22✔
125
                FieldOpenAIToken,    // openAI API token
22✔
126
                FieldServerAuthHash, // server auth hash
22✔
127
        }
22✔
128
}
22✔
129

130
// Load retrieves the settings from the database
131
func (s *Store) Load(ctx context.Context) (*Settings, error) {
18✔
132
        s.RLock()
18✔
133
        defer s.RUnlock()
18✔
134

18✔
135
        var record struct {
18✔
136
                Data string `db:"data"`
18✔
137
        }
18✔
138

18✔
139
        query, err := configQueries.Pick(s.Type(), CmdSelectConfig)
18✔
140
        if err != nil {
18✔
NEW
141
                return nil, fmt.Errorf("failed to get select query: %w", err)
×
NEW
142
        }
×
143

144
        query = s.Adopt(query)
18✔
145
        err = s.GetContext(ctx, &record, query, s.GID())
18✔
146
        if err != nil {
22✔
147
                if err.Error() == "sql: no rows in result set" {
8✔
148
                        return nil, fmt.Errorf("no settings found in database")
4✔
149
                }
4✔
NEW
150
                return nil, fmt.Errorf("failed to get settings: %w", err)
×
151
        }
152

153
        result := New()
14✔
154
        if err := json.Unmarshal([]byte(record.Data), result); err != nil {
16✔
155
                return nil, fmt.Errorf("failed to unmarshal settings: %w", err)
2✔
156
        }
2✔
157

158
        // decrypt sensitive fields if crypter is configured
159
        if s.crypter != nil {
16✔
160
                if err := s.crypter.DecryptSensitiveFields(result, s.sensitiveFields...); err != nil {
4✔
NEW
161
                        return nil, fmt.Errorf("failed to decrypt sensitive fields: %w", err)
×
NEW
162
                }
×
163
        }
164

165
        return result, nil
12✔
166
}
167

168
// Save stores the settings to the database
169
func (s *Store) Save(ctx context.Context, settings *Settings) error {
16✔
170
        if s == nil {
16✔
NEW
171
                return fmt.Errorf("store is nil")
×
NEW
172
        }
×
173
        if settings == nil {
18✔
174
                return fmt.Errorf("nil settings")
2✔
175
        }
2✔
176

177
        s.Lock()
14✔
178
        defer s.Unlock()
14✔
179

14✔
180
        // create a safe copy without sensitive information
14✔
181
        safeCopy := *settings // make a shallow copy
14✔
182

14✔
183
        // clear transient fields that shouldn't be persisted
14✔
184
        safeCopy.Transient = TransientSettings{}
14✔
185

14✔
186
        // ensure credentials are properly saved in domain models
14✔
187
        // they are already stored in the proper domain fields like:
14✔
188
        // - Telegram.Token
14✔
189
        // - OpenAI.Token
14✔
190
        // - Server.AuthHash
14✔
191

14✔
192
        // marshal the settings to JSON
14✔
193
        data, err := json.Marshal(&safeCopy)
14✔
194
        if err != nil {
14✔
NEW
195
                return fmt.Errorf("failed to marshal settings: %w", err)
×
NEW
196
        }
×
197

198
        // encrypt sensitive fields if crypter is configured
199
        if s.crypter != nil {
18✔
200
                if encErr := s.crypter.EncryptSensitiveFields(&safeCopy, s.sensitiveFields...); encErr != nil {
4✔
NEW
201
                        return fmt.Errorf("failed to encrypt sensitive fields: %w", encErr)
×
NEW
202
                }
×
203

204
                // re-marshal after encryption
205
                data, err = json.Marshal(&safeCopy)
4✔
206
                if err != nil {
4✔
NEW
207
                        return fmt.Errorf("failed to marshal settings after encryption: %w", err)
×
NEW
208
                }
×
209
        }
210

211
        query, err := configQueries.Pick(s.Type(), CmdUpsertConfig)
14✔
212
        if err != nil {
14✔
NEW
213
                return fmt.Errorf("failed to get upsert query: %w", err)
×
NEW
214
        }
×
215

216
        query = s.Adopt(query)
14✔
217
        _, err = s.ExecContext(ctx, query, s.GID(), string(data), time.Now())
14✔
218
        if err != nil {
14✔
NEW
219
                return fmt.Errorf("failed to save settings: %w", err)
×
NEW
220
        }
×
221

222
        return nil
14✔
223
}
224

225
// Delete removes the settings from the database
226
func (s *Store) Delete(ctx context.Context) error {
2✔
227
        if s == nil {
2✔
NEW
228
                return fmt.Errorf("store is nil")
×
NEW
229
        }
×
230
        s.Lock()
2✔
231
        defer s.Unlock()
2✔
232

2✔
233
        query, err := configQueries.Pick(s.Type(), CmdDeleteConfig)
2✔
234
        if err != nil {
2✔
NEW
235
                return fmt.Errorf("failed to get delete query: %w", err)
×
NEW
236
        }
×
237

238
        query = s.Adopt(query)
2✔
239
        _, err = s.ExecContext(ctx, query, s.GID())
2✔
240
        if err != nil {
2✔
NEW
241
                return fmt.Errorf("failed to delete settings: %w", err)
×
NEW
242
        }
×
243

244
        return nil
2✔
245
}
246

247
// LastUpdated returns the last update time of the settings
248
func (s *Store) LastUpdated(ctx context.Context) (time.Time, error) {
8✔
249
        if s == nil {
8✔
NEW
250
                return time.Time{}, fmt.Errorf("store is nil")
×
NEW
251
        }
×
252
        s.RLock()
8✔
253
        defer s.RUnlock()
8✔
254

8✔
255
        var record struct {
8✔
256
                UpdatedAt time.Time `db:"updated_at"`
8✔
257
        }
8✔
258

8✔
259
        query, err := configQueries.Pick(s.Type(), CmdSelectConfigUpdatedAt)
8✔
260
        if err != nil {
8✔
NEW
261
                return time.Time{}, fmt.Errorf("failed to get updated_at query: %w", err)
×
NEW
262
        }
×
263

264
        query = s.Adopt(query)
8✔
265
        err = s.GetContext(ctx, &record, query, s.GID())
8✔
266
        if err != nil {
10✔
267
                if err.Error() == "sql: no rows in result set" {
4✔
268
                        return time.Time{}, fmt.Errorf("no settings found in database")
2✔
269
                }
2✔
NEW
270
                return time.Time{}, fmt.Errorf("failed to get settings update time: %w", err)
×
271
        }
272

273
        return record.UpdatedAt, nil
6✔
274
}
275

276
// Exists checks if settings exist in the database
277
func (s *Store) Exists(ctx context.Context) (bool, error) {
4✔
278
        if s == nil {
4✔
NEW
279
                return false, fmt.Errorf("store is nil")
×
NEW
280
        }
×
281
        s.RLock()
4✔
282
        defer s.RUnlock()
4✔
283

4✔
284
        var count int
4✔
285
        query, err := configQueries.Pick(s.Type(), CmdCountConfig)
4✔
286
        if err != nil {
4✔
NEW
287
                return false, fmt.Errorf("failed to get count query: %w", err)
×
NEW
288
        }
×
289

290
        query = s.Adopt(query)
4✔
291
        err = s.GetContext(ctx, &count, query, s.GID())
4✔
292
        if err != nil {
4✔
NEW
293
                return false, fmt.Errorf("failed to check if settings exist: %w", err)
×
NEW
294
        }
×
295

296
        return count > 0, nil
4✔
297
}
298

299
// noopMigrate is a no-op migration function for the config table
300
// since there's no need for migrations currently
301
func noopMigrate(_ context.Context, _ *sqlx.Tx, _ string) error {
20✔
302
        log.Printf("[DEBUG] no migration needed for config table")
20✔
303
        return nil
20✔
304
}
20✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc