Skip to content

Commit b57b8be

Browse files
committed
review fixes: system logs survive the level gate, writable comes from the device, args parse inside the guard
1 parent 596efab commit b57b8be

6 files changed

Lines changed: 114 additions & 11 deletions

File tree

‎lib/src/config/logging_behaviour.dart‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ class LoggingBehavior {
5555
}
5656

5757
bool shouldLog(LogEntry entry) {
58+
// System logs are the SDK's own record — a dashboard edit to a local
59+
// database, not something the app called. minimumLevel exists to control
60+
// the app's volume, and system's value (500) sits below even verbose, so
61+
// without this exemption the default config silently drops every one and
62+
// an audit line is never written anywhere.
63+
if (entry.level == LogLevel.system) return true;
64+
5865
if (entry.level.value < minimumLevel.value) return false;
5966
if (enabledTags.contains('*')) return true;
6067

‎lib/src/db/db_dispatcher.dart‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import 'package:code_scout/src/code_scout.dart';
12
import 'package:code_scout/src/db/db_registry.dart';
23
import 'package:code_scout/src/db/db_source.dart';
4+
import 'package:code_scout/src/log/log_level.dart';
35

46
/// Answers the database questions the dashboard asks over a live session.
57
///
@@ -66,16 +68,39 @@ class DatabaseDispatcher {
6668
dbName,
6769
CodeScoutReadRequest.fromJson(args),
6870
);
69-
return {'ok': true, 'page': page.toJson()};
71+
// writable rides along so the dashboard's edit affordances come from
72+
// this registration rather than from anything the browser said about
73+
// itself. The enforcement is in the registry either way; this is about
74+
// not drawing buttons that would only ever be refused.
75+
return {'ok': true, 'page': page.toJson(), 'writable': entry.writable};
7076

7177
case 'update':
7278
// Straight to the registry, never to the source: the registry is where
7379
// writable is enforced, and routing round it would make that flag a
7480
// suggestion.
75-
final result = await DatabaseRegistry.i.write(
76-
dbName,
77-
CodeScoutWriteRequest.fromJson(args),
78-
);
81+
final request = CodeScoutWriteRequest.fromJson(args);
82+
final result = await DatabaseRegistry.i.write(dbName, request);
83+
84+
if (result.ok) {
85+
// A read leaves no trace and should not. A write changed this
86+
// device, so it goes through the ordinary logging pipeline: it lands
87+
// in the session timeline, syncs with everything else, and answers
88+
// "why is this device's data odd" three weeks later. No audit table.
89+
final by = args['by'] as String? ?? 'the dashboard';
90+
CodeScout.instance.log(
91+
level: LogLevel.system,
92+
message:
93+
'Dashboard edited $dbName ${request.namespace}.${request.column} on ${request.handle} · by $by',
94+
tags: {'codescout'},
95+
metadata: {
96+
'db': dbName,
97+
'namespace': request.namespace,
98+
'column': request.column,
99+
'handle': '${request.handle}',
100+
'by': by,
101+
},
102+
);
103+
}
79104
return {...result.toJson()};
80105

81106
default:

‎lib/src/db/sqflite_source.dart‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,13 @@ class CodeScoutSqflite implements CodeScoutSource {
120120
// CAST so one rule covers every type: a number filtered by "42" behaves
121121
// the way the person typing it expects, with no per-type branching and
122122
// no expression language to parse.
123-
where.add('CAST(${quoteIdentifier(column)} AS TEXT) LIKE ?');
124-
args.add('%$text%');
123+
//
124+
// The typed text is escaped so % and _ mean the characters, not LIKE's
125+
// wildcards. Somebody filtering a discounts column by "100%" wants rows
126+
// containing that string, not every row.
127+
final escaped = text.replaceAll(r'\', r'\\').replaceAll('%', r'\%').replaceAll('_', r'\_');
128+
where.add("CAST(${quoteIdentifier(column)} AS TEXT) LIKE ? ESCAPE '\\'");
129+
args.add('%$escaped%');
125130
});
126131

127132
final limit = request.limit.clamp(1, maxPageRows);

‎lib/src/live/live_session_client.dart‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,11 +197,14 @@ class LiveSessionClient extends ChangeNotifier {
197197
/// gets no reply waits out its full timeout and then cannot tell a broken
198198
/// query from a phone that went to sleep.
199199
Future<void> _answer(String req, Map<String, dynamic> message) async {
200-
final op = message['op'] as String? ?? '';
201-
final args = (message['args'] as Map?)?.cast<String, dynamic>() ?? <String, dynamic>{};
202-
200+
// Parsing happens inside the try. `args` of the wrong shape entirely — a
201+
// string where a map should be — would otherwise throw before the guard,
202+
// become an unhandled async error, and send no reply at all: the worst of
203+
// both worlds.
203204
Map<String, dynamic> body;
204205
try {
206+
final op = message['op'] as String? ?? '';
207+
final args = (message['args'] as Map?)?.cast<String, dynamic>() ?? <String, dynamic>{};
205208
body = await DatabaseDispatcher.handle(op, args);
206209
} catch (e) {
207210
body = {'ok': false, 'error': e.toString()};

‎test/db_dispatcher_test.dart‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'dart:convert';
22
import 'dart:typed_data';
33

44
import 'package:code_scout/code_scout.dart';
5+
import 'package:code_scout/src/csx_interface/log_buffer.dart';
56
import 'package:code_scout/src/db/db_dispatcher.dart';
67
import 'package:flutter_test/flutter_test.dart';
78
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
@@ -82,6 +83,14 @@ void main() {
8283
group('rows', () {
8384
setUp(() => DatabaseRegistry.i.register('shop.db', CodeScoutSqflite(db)));
8485

86+
test('rows carries writable from the registration, not from the caller', () async {
87+
// The dashboard draws edit affordances off this. It must come from what
88+
// the app agreed to, never from anything the browser said about itself.
89+
final reply = await DatabaseDispatcher.handle(
90+
'rows', {'db': 'shop.db', 'namespace': 'flags'});
91+
expect(reply['writable'], isFalse);
92+
});
93+
8594
test('a page comes back with columns, rows and handles', () async {
8695
final reply = await DatabaseDispatcher.handle(
8796
'rows', {'db': 'shop.db', 'namespace': 'flags'});
@@ -148,6 +157,47 @@ void main() {
148157
expect((await db.rawQuery('SELECT enabled FROM flags')).first['enabled'], 0);
149158
});
150159

160+
test('a successful write is logged on the device', () async {
161+
// A read leaves no trace and should not. A write changed the device, so
162+
// it goes through the ordinary pipeline and lands in the session
163+
// timeline — the answer to "why is this device's data odd" weeks later.
164+
DatabaseRegistry.i.register('shop.db', CodeScoutSqflite(db), writable: true);
165+
LogBuffer.i.clear();
166+
167+
await DatabaseDispatcher.handle('update', {
168+
'db': 'shop.db',
169+
'namespace': 'flags',
170+
'column': 'enabled',
171+
'handle': await rowid(),
172+
'value': '1',
173+
'was': 0,
174+
'by': 'ada@example.com',
175+
});
176+
177+
final audit = LogBuffer.i.entries.where((e) => e.level == LogLevel.system);
178+
expect(audit, hasLength(1), reason: 'the write left no trace');
179+
expect(audit.single.message, contains('flags.enabled'));
180+
expect(audit.single.message, contains('ada@example.com'));
181+
});
182+
183+
test('a refused write is not logged as a change', () async {
184+
// Nothing changed, so nothing happened worth recording. An audit line
185+
// for a refusal would read as "the dashboard edited this" when it did not.
186+
DatabaseRegistry.i.register('shop.db', CodeScoutSqflite(db));
187+
LogBuffer.i.clear();
188+
189+
await DatabaseDispatcher.handle('update', {
190+
'db': 'shop.db',
191+
'namespace': 'flags',
192+
'column': 'enabled',
193+
'handle': await rowid(),
194+
'value': '1',
195+
'was': 0,
196+
});
197+
198+
expect(LogBuffer.i.entries.where((e) => e.level == LogLevel.system), isEmpty);
199+
});
200+
151201
test('a conflict carries the current value back', () async {
152202
DatabaseRegistry.i.register('shop.db', CodeScoutSqflite(db), writable: true);
153203
await db.rawUpdate('UPDATE flags SET enabled = 9');

‎test/db_query_test.dart‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,19 @@ void main() {
198198
expect(page.rows.first.first.display, 'checkout_one_tap');
199199
});
200200

201+
test("LIKE's wildcards mean themselves in a filter", () async {
202+
// Somebody filtering by "100%" wants rows containing that string, not
203+
// every row with a 1 followed by two zeroes and anything at all.
204+
await db.insert('flags', {'key': 'discount_100%', 'enabled': 0});
205+
await db.insert('flags', {'key': 'discount_100x', 'enabled': 0});
206+
207+
final page = await source.read(
208+
const CodeScoutReadRequest(namespace: 'flags', filters: {'key': '100%'}),
209+
);
210+
expect(page.rows, hasLength(1));
211+
expect(page.rows.single.first.display, 'discount_100%');
212+
});
213+
201214
test('an empty filter is not a filter', () async {
202215
final page = await source.read(
203216
const CodeScoutReadRequest(namespace: 'flags', filters: {'key': ''}),
@@ -219,7 +232,7 @@ void main() {
219232
expect(second.hasMore, isFalse);
220233
});
221234

222-
test('the row ceiling is the deviceies, not the dashboard\'s', () async {
235+
test("the row ceiling is the device's, not the dashboard's", () async {
223236
// A limit the caller can raise is not a limit. Only the device knows what
224237
// it costs to build the page.
225238
for (var i = 0; i < 150; i++) {

0 commit comments

Comments
 (0)