sqlite-hub 0.17.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -19
- package/bin/sqlite-hub.js +83 -401
- package/examples/api/queries.js +31 -0
- package/examples/api/rows.js +27 -0
- package/frontend/assets/mockups/charts_1_bars_1200.webp +0 -0
- package/frontend/assets/mockups/charts_2_pie_1200.webp +0 -0
- package/frontend/assets/mockups/charts_3_scatter_plot_1200.webp +0 -0
- package/frontend/assets/mockups/connections_1200.webp +0 -0
- package/frontend/assets/mockups/data_1_1200.webp +0 -0
- package/frontend/assets/mockups/data_2_row_editor_1200.webp +0 -0
- package/frontend/assets/mockups/documents_1200.webp +0 -0
- package/frontend/assets/mockups/media_tagging_1_setup_1200.webp +0 -0
- package/frontend/assets/mockups/media_tagging_2_tagging_queue_1200.webp +0 -0
- package/frontend/assets/mockups/media_tagging_3_media_viewer_1200.webp +0 -0
- package/frontend/assets/mockups/overview_1200.webp +0 -0
- package/frontend/assets/mockups/settings_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_1_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_2_query_details_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_3_export_column_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_4_export_column_as_markdown_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_5_export_query_result_1200.webp +0 -0
- package/frontend/assets/mockups/structure_1_1200.webp +0 -0
- package/frontend/assets/mockups/structure_2_inspector_1200.webp +0 -0
- package/frontend/assets/mockups/table_designer_1_1200.webp +0 -0
- package/frontend/assets/mockups/table_designer_2_checks_1200.webp +0 -0
- package/frontend/js/api.js +25 -0
- package/frontend/js/app.js +183 -26
- package/frontend/js/components/formControls.js +38 -0
- package/frontend/js/components/modal.js +79 -19
- package/frontend/js/components/queryResults.js +18 -1
- package/frontend/js/components/rowEditorPanel.js +42 -34
- package/frontend/js/store.js +129 -2
- package/frontend/js/utils/jsonPreview.js +31 -0
- package/frontend/js/utils/rowEditorValues.js +41 -0
- package/frontend/js/utils/tableScrollState.js +39 -0
- package/frontend/js/views/charts.js +8 -0
- package/frontend/js/views/data.js +4 -1
- package/frontend/js/views/editor.js +2 -0
- package/frontend/js/views/settings.js +141 -5
- package/frontend/styles/components.css +6 -0
- package/frontend/styles/tailwind.generated.css +2 -2
- package/package.json +6 -6
- package/server/middleware/apiTokenAuth.js +30 -0
- package/server/middleware/localRequestSecurity.js +84 -0
- package/server/routes/connections.js +35 -1
- package/server/routes/externalApi.js +222 -0
- package/server/routes/settings.js +64 -4
- package/server/server.js +34 -2
- package/server/services/apiTokenService.js +101 -0
- package/server/services/databaseCommandService.js +399 -0
- package/server/services/nativeFileDialogService.js +260 -0
- package/server/services/sqlite/dataBrowserService.js +0 -4
- package/server/services/sqlite/exportService.js +5 -1
- package/server/services/sqlite/sqlExecutor.js +51 -2
- package/server/services/storage/appStateStore.js +113 -0
- package/server/utils/errors.js +7 -0
- package/server/utils/sqliteTypes.js +17 -8
- package/tests/api-token-auth.test.js +127 -0
- package/tests/cli-service-delegation.test.js +43 -0
- package/tests/connections-file-dialog-route.test.js +89 -0
- package/tests/copy-column-modal.test.js +48 -0
- package/tests/database-command-service.test.js +102 -0
- package/tests/export-blob.test.js +46 -0
- package/tests/form-controls.test.js +34 -0
- package/tests/json-preview.test.js +49 -0
- package/tests/local-request-security.test.js +85 -0
- package/tests/native-file-dialog.test.js +105 -0
- package/tests/query-results-truncation.test.js +20 -0
- package/tests/row-editor-null-values.test.js +131 -0
- package/tests/settings-api-tokens-route.test.js +76 -0
- package/tests/settings-view.test.js +47 -0
- package/tests/sql-identifier-safety.test.js +9 -3
- package/tests/sql-result-limit.test.js +66 -0
- package/tests/table-scroll-state.test.js +70 -0
- package/frontend/assets/mockups/connections.png +0 -0
- package/frontend/assets/mockups/data.png +0 -0
- package/frontend/assets/mockups/data_row_editor.png +0 -0
- package/frontend/assets/mockups/home.png +0 -0
- package/frontend/assets/mockups/sql_editor.png +0 -0
- package/frontend/assets/mockups/sql_editor_croped.png +0 -0
- package/frontend/assets/mockups/sql_editor_querydetail.png +0 -0
- package/frontend/assets/mockups/structure.png +0 -0
- package/frontend/assets/mockups/structure_inspector.png +0 -0
|
@@ -2,6 +2,45 @@ const { ValidationError, mapSqliteError } = require("../../utils/errors");
|
|
|
2
2
|
const { serializeRows } = require("../../utils/sqliteTypes");
|
|
3
3
|
const { getTableDetail } = require("./introspection");
|
|
4
4
|
|
|
5
|
+
const DEFAULT_RESULT_ROW_LIMIT = 5000;
|
|
6
|
+
|
|
7
|
+
function normalizeResultRowLimit(value) {
|
|
8
|
+
if (value === null || value === false) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const normalized = value === undefined ? DEFAULT_RESULT_ROW_LIMIT : Number(value);
|
|
13
|
+
|
|
14
|
+
if (!Number.isInteger(normalized) || normalized < 1) {
|
|
15
|
+
throw new ValidationError("SQL result row limit must be a positive integer or null.");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return normalized;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readResultRows(prepared, rowLimit) {
|
|
22
|
+
if (rowLimit === null) {
|
|
23
|
+
return {
|
|
24
|
+
rows: prepared.all(),
|
|
25
|
+
truncated: false,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const rows = [];
|
|
30
|
+
let truncated = false;
|
|
31
|
+
|
|
32
|
+
for (const row of prepared.iterate()) {
|
|
33
|
+
if (rows.length >= rowLimit) {
|
|
34
|
+
truncated = true;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
rows.push(row);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { rows, truncated };
|
|
42
|
+
}
|
|
43
|
+
|
|
5
44
|
function getSerializedMemoryBytes(value) {
|
|
6
45
|
try {
|
|
7
46
|
return Buffer.byteLength(JSON.stringify(value ?? null), "utf8");
|
|
@@ -365,6 +404,7 @@ class SqlExecutor {
|
|
|
365
404
|
const db = this.connectionManager.getActiveDatabase();
|
|
366
405
|
const connection = this.connectionManager.getActiveConnection();
|
|
367
406
|
const statements = splitSqlStatements(sql);
|
|
407
|
+
const rowLimit = normalizeResultRowLimit(options.maxRows);
|
|
368
408
|
|
|
369
409
|
if (statements.length === 0) {
|
|
370
410
|
throw new ValidationError("No executable SQL statements were found.");
|
|
@@ -386,9 +426,11 @@ class SqlExecutor {
|
|
|
386
426
|
}
|
|
387
427
|
|
|
388
428
|
if (prepared.reader) {
|
|
389
|
-
const rows = prepared
|
|
429
|
+
const { rows, truncated } = readResultRows(prepared, rowLimit);
|
|
390
430
|
const columnDefinitions = prepared.columns();
|
|
391
|
-
const serializedRows = serializeRows(rows
|
|
431
|
+
const serializedRows = serializeRows(rows, {
|
|
432
|
+
blobMode: options.blobMode,
|
|
433
|
+
});
|
|
392
434
|
const editableResult = resolveEditableResult(db, columnDefinitions, serializedRows);
|
|
393
435
|
const columns = columnDefinitions.map((column) => column.name);
|
|
394
436
|
const result = {
|
|
@@ -397,6 +439,8 @@ class SqlExecutor {
|
|
|
397
439
|
keyword,
|
|
398
440
|
kind: "resultSet",
|
|
399
441
|
rowCount: serializedRows.length,
|
|
442
|
+
truncated,
|
|
443
|
+
rowLimit,
|
|
400
444
|
columns,
|
|
401
445
|
rows: editableResult?.rows ?? serializedRows,
|
|
402
446
|
editing: editableResult
|
|
@@ -463,6 +507,8 @@ class SqlExecutor {
|
|
|
463
507
|
rows: lastResultSet?.rows ?? [],
|
|
464
508
|
columns: lastResultSet?.columns ?? [],
|
|
465
509
|
editing: lastResultSet?.editing ?? null,
|
|
510
|
+
truncated: Boolean(lastResultSet?.truncated),
|
|
511
|
+
rowLimit: lastResultSet ? lastResultSet.rowLimit : null,
|
|
466
512
|
affectedRowCount: totalChanges,
|
|
467
513
|
resultKind: lastResultSet ? "resultSet" : results.at(-1)?.kind ?? "unknown",
|
|
468
514
|
};
|
|
@@ -490,6 +536,9 @@ class SqlExecutor {
|
|
|
490
536
|
}
|
|
491
537
|
|
|
492
538
|
module.exports = {
|
|
539
|
+
DEFAULT_RESULT_ROW_LIMIT,
|
|
493
540
|
SqlExecutor,
|
|
541
|
+
normalizeResultRowLimit,
|
|
542
|
+
readResultRows,
|
|
494
543
|
splitSqlStatements,
|
|
495
544
|
};
|
|
@@ -457,6 +457,19 @@ class AppStateStore {
|
|
|
457
457
|
|
|
458
458
|
CREATE INDEX IF NOT EXISTS idx_database_documents_database_updated
|
|
459
459
|
ON database_documents(database_key, updated_at DESC, id ASC);
|
|
460
|
+
|
|
461
|
+
CREATE TABLE IF NOT EXISTS api_tokens (
|
|
462
|
+
id TEXT PRIMARY KEY,
|
|
463
|
+
database_key TEXT NOT NULL,
|
|
464
|
+
name TEXT NOT NULL,
|
|
465
|
+
token_hash TEXT NOT NULL UNIQUE,
|
|
466
|
+
token_prefix TEXT NOT NULL,
|
|
467
|
+
created_at TEXT NOT NULL,
|
|
468
|
+
last_used_at TEXT
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
CREATE INDEX IF NOT EXISTS idx_api_tokens_database_created
|
|
472
|
+
ON api_tokens(database_key, created_at DESC, id ASC);
|
|
460
473
|
`);
|
|
461
474
|
|
|
462
475
|
const recentConnectionColumns = new Set(
|
|
@@ -2335,6 +2348,106 @@ class AppStateStore {
|
|
|
2335
2348
|
return this.getSettings();
|
|
2336
2349
|
}
|
|
2337
2350
|
|
|
2351
|
+
decorateApiTokenRow(row = {}) {
|
|
2352
|
+
return {
|
|
2353
|
+
id: String(row.id ?? ""),
|
|
2354
|
+
databaseKey: String(row.database_key ?? row.databaseKey ?? ""),
|
|
2355
|
+
name: String(row.name ?? ""),
|
|
2356
|
+
tokenPrefix: String(row.token_prefix ?? row.tokenPrefix ?? ""),
|
|
2357
|
+
createdAt: row.created_at ?? row.createdAt ?? null,
|
|
2358
|
+
lastUsedAt: row.last_used_at ?? row.lastUsedAt ?? null,
|
|
2359
|
+
};
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
listApiTokens(databaseKey) {
|
|
2363
|
+
const normalizedDatabaseKey = normalizeDocumentDatabaseKey(databaseKey);
|
|
2364
|
+
|
|
2365
|
+
return this.db
|
|
2366
|
+
.prepare(
|
|
2367
|
+
`
|
|
2368
|
+
SELECT id, database_key, name, token_prefix, created_at, last_used_at
|
|
2369
|
+
FROM api_tokens
|
|
2370
|
+
WHERE database_key = ?
|
|
2371
|
+
ORDER BY created_at DESC, id ASC
|
|
2372
|
+
`
|
|
2373
|
+
)
|
|
2374
|
+
.all(normalizedDatabaseKey)
|
|
2375
|
+
.map((row) => this.decorateApiTokenRow(row));
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
createApiToken({ databaseKey, name, tokenHash, tokenPrefix }) {
|
|
2379
|
+
const normalizedDatabaseKey = normalizeDocumentDatabaseKey(databaseKey);
|
|
2380
|
+
const id = crypto.randomUUID();
|
|
2381
|
+
const createdAt = new Date().toISOString();
|
|
2382
|
+
|
|
2383
|
+
this.db
|
|
2384
|
+
.prepare(
|
|
2385
|
+
`
|
|
2386
|
+
INSERT INTO api_tokens (
|
|
2387
|
+
id,
|
|
2388
|
+
database_key,
|
|
2389
|
+
name,
|
|
2390
|
+
token_hash,
|
|
2391
|
+
token_prefix,
|
|
2392
|
+
created_at
|
|
2393
|
+
)
|
|
2394
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
2395
|
+
`
|
|
2396
|
+
)
|
|
2397
|
+
.run(id, normalizedDatabaseKey, name, tokenHash, tokenPrefix, createdAt);
|
|
2398
|
+
|
|
2399
|
+
return this.listApiTokens(normalizedDatabaseKey).find((token) => token.id === id);
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
findApiTokenByHash(tokenHash) {
|
|
2403
|
+
const normalizedTokenHash = String(tokenHash ?? "").trim();
|
|
2404
|
+
|
|
2405
|
+
if (!normalizedTokenHash) {
|
|
2406
|
+
return null;
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
const row = this.db
|
|
2410
|
+
.prepare(
|
|
2411
|
+
`
|
|
2412
|
+
SELECT id, database_key, name, token_prefix, created_at, last_used_at
|
|
2413
|
+
FROM api_tokens
|
|
2414
|
+
WHERE token_hash = ?
|
|
2415
|
+
LIMIT 1
|
|
2416
|
+
`
|
|
2417
|
+
)
|
|
2418
|
+
.get(normalizedTokenHash);
|
|
2419
|
+
|
|
2420
|
+
return row ? this.decorateApiTokenRow(row) : null;
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
touchApiToken(tokenId) {
|
|
2424
|
+
this.db
|
|
2425
|
+
.prepare("UPDATE api_tokens SET last_used_at = ? WHERE id = ?")
|
|
2426
|
+
.run(new Date().toISOString(), String(tokenId ?? "").trim());
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
deleteApiToken(databaseKey, tokenId) {
|
|
2430
|
+
const normalizedDatabaseKey = normalizeDocumentDatabaseKey(databaseKey);
|
|
2431
|
+
const normalizedTokenId = String(tokenId ?? "").trim();
|
|
2432
|
+
|
|
2433
|
+
if (!normalizedTokenId) {
|
|
2434
|
+
throw new ValidationError("Token id is required.");
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
const result = this.db
|
|
2438
|
+
.prepare("DELETE FROM api_tokens WHERE database_key = ? AND id = ?")
|
|
2439
|
+
.run(normalizedDatabaseKey, normalizedTokenId);
|
|
2440
|
+
|
|
2441
|
+
if (result.changes < 1) {
|
|
2442
|
+
throw new NotFoundError("API token was not found.");
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
return {
|
|
2446
|
+
id: normalizedTokenId,
|
|
2447
|
+
deleted: true,
|
|
2448
|
+
};
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2338
2451
|
decorateDatabaseDocumentRow(row = {}) {
|
|
2339
2452
|
return {
|
|
2340
2453
|
id: String(row.id ?? ""),
|
package/server/utils/errors.js
CHANGED
|
@@ -29,6 +29,12 @@ class ConflictError extends AppError {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
class AuthenticationError extends AppError {
|
|
33
|
+
constructor(message = "Authentication required.", options = {}) {
|
|
34
|
+
super(message, 401, { code: "AUTHENTICATION_REQUIRED", ...options });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
32
38
|
class BusyError extends AppError {
|
|
33
39
|
constructor(message, options = {}) {
|
|
34
40
|
super(message, 423, { code: "SQLITE_BUSY", ...options });
|
|
@@ -161,6 +167,7 @@ function errorMiddleware(error, req, res, next) {
|
|
|
161
167
|
|
|
162
168
|
module.exports = {
|
|
163
169
|
AppError,
|
|
170
|
+
AuthenticationError,
|
|
164
171
|
BusyError,
|
|
165
172
|
ConflictError,
|
|
166
173
|
DatabaseRequiredError,
|
|
@@ -37,7 +37,16 @@ function normalizeDeclaredType(type) {
|
|
|
37
37
|
return { declaredType, affinity: "NUMERIC" };
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
function serializeBlob(buffer) {
|
|
40
|
+
function serializeBlob(buffer, options = {}) {
|
|
41
|
+
if (options.blobMode === "full") {
|
|
42
|
+
return {
|
|
43
|
+
__type: "blob",
|
|
44
|
+
sizeBytes: buffer.length,
|
|
45
|
+
encoding: "base64",
|
|
46
|
+
data: buffer.toString("base64"),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
41
50
|
return {
|
|
42
51
|
__type: "blob",
|
|
43
52
|
sizeBytes: buffer.length,
|
|
@@ -46,26 +55,26 @@ function serializeBlob(buffer) {
|
|
|
46
55
|
};
|
|
47
56
|
}
|
|
48
57
|
|
|
49
|
-
function serializeSqliteValue(value) {
|
|
58
|
+
function serializeSqliteValue(value, options = {}) {
|
|
50
59
|
if (Buffer.isBuffer(value)) {
|
|
51
|
-
return serializeBlob(value);
|
|
60
|
+
return serializeBlob(value, options);
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
if (value instanceof Uint8Array) {
|
|
55
|
-
return serializeBlob(Buffer.from(value));
|
|
64
|
+
return serializeBlob(Buffer.from(value), options);
|
|
56
65
|
}
|
|
57
66
|
|
|
58
67
|
return value;
|
|
59
68
|
}
|
|
60
69
|
|
|
61
|
-
function serializeRow(row) {
|
|
70
|
+
function serializeRow(row, options = {}) {
|
|
62
71
|
return Object.fromEntries(
|
|
63
|
-
Object.entries(row).map(([key, value]) => [key, serializeSqliteValue(value)])
|
|
72
|
+
Object.entries(row).map(([key, value]) => [key, serializeSqliteValue(value, options)])
|
|
64
73
|
);
|
|
65
74
|
}
|
|
66
75
|
|
|
67
|
-
function serializeRows(rows) {
|
|
68
|
-
return rows.map((row) => serializeRow(row));
|
|
76
|
+
function serializeRows(rows, options = {}) {
|
|
77
|
+
return rows.map((row) => serializeRow(row, options));
|
|
69
78
|
}
|
|
70
79
|
|
|
71
80
|
function decodeBlobPayload(payload) {
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const express = require("express");
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const os = require("node:os");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const test = require("node:test");
|
|
7
|
+
|
|
8
|
+
const { createExternalApiRouter } = require("../server/routes/externalApi");
|
|
9
|
+
const { ApiTokenService } = require("../server/services/apiTokenService");
|
|
10
|
+
const { AppStateStore } = require("../server/services/storage/appStateStore");
|
|
11
|
+
const { errorMiddleware } = require("../server/utils/errors");
|
|
12
|
+
|
|
13
|
+
function createConnection(id, label, databasePath) {
|
|
14
|
+
return {
|
|
15
|
+
id,
|
|
16
|
+
label,
|
|
17
|
+
path: databasePath,
|
|
18
|
+
lastOpenedAt: new Date().toISOString(),
|
|
19
|
+
lastModifiedAt: new Date().toISOString(),
|
|
20
|
+
sizeBytes: 0,
|
|
21
|
+
readOnly: false,
|
|
22
|
+
logoPath: null,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function startApi(t) {
|
|
27
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "sqlite-hub-api-token-"));
|
|
28
|
+
const store = new AppStateStore(path.join(directory, "state.db"));
|
|
29
|
+
const databaseA = createConnection("db-a", "Database A", path.join(directory, "a.db"));
|
|
30
|
+
const databaseB = createConnection("db-b", "Database B", path.join(directory, "b.db"));
|
|
31
|
+
|
|
32
|
+
store.upsertRecentConnection(databaseA);
|
|
33
|
+
store.upsertRecentConnection(databaseB, { makeActive: false });
|
|
34
|
+
|
|
35
|
+
const tokenService = new ApiTokenService({ appStateStore: store });
|
|
36
|
+
const serviceCalls = [];
|
|
37
|
+
const databaseService = {
|
|
38
|
+
listTables(databaseId) {
|
|
39
|
+
serviceCalls.push(databaseId);
|
|
40
|
+
return [{ name: "companies" }];
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
const app = express();
|
|
44
|
+
|
|
45
|
+
app.use(express.json());
|
|
46
|
+
app.use(
|
|
47
|
+
"/api/v1",
|
|
48
|
+
createExternalApiRouter({ databaseService, tokenService })
|
|
49
|
+
);
|
|
50
|
+
app.use(errorMiddleware);
|
|
51
|
+
|
|
52
|
+
const server = await new Promise((resolve) => {
|
|
53
|
+
const listener = app.listen(0, "127.0.0.1", () => resolve(listener));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
t.after(async () => {
|
|
57
|
+
await new Promise((resolve, reject) => {
|
|
58
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
59
|
+
});
|
|
60
|
+
store.db.close();
|
|
61
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
baseUrl: `http://127.0.0.1:${server.address().port}/api/v1`,
|
|
66
|
+
databaseA,
|
|
67
|
+
databaseB,
|
|
68
|
+
serviceCalls,
|
|
69
|
+
store,
|
|
70
|
+
tokenService,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
test("tokens are hashed, deletable, and isolated per database", async (t) => {
|
|
75
|
+
const fixture = await startApi(t);
|
|
76
|
+
const created = fixture.tokenService.createToken(fixture.databaseA.id, "Automation");
|
|
77
|
+
|
|
78
|
+
assert.match(created.token, /^shub_[A-Za-z0-9_-]+$/);
|
|
79
|
+
assert.equal(fixture.tokenService.listTokens(fixture.databaseA.id)[0].token, undefined);
|
|
80
|
+
|
|
81
|
+
const stored = fixture.store.db
|
|
82
|
+
.prepare("SELECT token_hash, token_prefix FROM api_tokens WHERE id = ?")
|
|
83
|
+
.get(created.id);
|
|
84
|
+
|
|
85
|
+
assert.notEqual(stored.token_hash, created.token);
|
|
86
|
+
assert.equal(stored.token_prefix, created.tokenPrefix);
|
|
87
|
+
assert.equal(fixture.tokenService.authenticate(fixture.databaseA.id, created.token).id, created.id);
|
|
88
|
+
assert.throws(
|
|
89
|
+
() => fixture.tokenService.authenticate(fixture.databaseB.id, created.token),
|
|
90
|
+
/invalid for this database/
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const validResponse = await fetch(
|
|
94
|
+
`${fixture.baseUrl}/databases/${fixture.databaseA.id}/tables`,
|
|
95
|
+
{ headers: { Authorization: `Bearer ${created.token}` } }
|
|
96
|
+
);
|
|
97
|
+
const validPayload = await validResponse.json();
|
|
98
|
+
|
|
99
|
+
assert.equal(validResponse.status, 200);
|
|
100
|
+
assert.deepEqual(validPayload.data.items, [{ name: "companies" }]);
|
|
101
|
+
assert.deepEqual(fixture.serviceCalls, [fixture.databaseA.id]);
|
|
102
|
+
|
|
103
|
+
const invalidResponse = await fetch(
|
|
104
|
+
`${fixture.baseUrl}/databases/${fixture.databaseA.id}/tables`,
|
|
105
|
+
{ headers: { Authorization: "Bearer invalid" } }
|
|
106
|
+
);
|
|
107
|
+
assert.equal(invalidResponse.status, 401);
|
|
108
|
+
|
|
109
|
+
const missingResponse = await fetch(
|
|
110
|
+
`${fixture.baseUrl}/databases/${fixture.databaseA.id}/tables`
|
|
111
|
+
);
|
|
112
|
+
assert.equal(missingResponse.status, 401);
|
|
113
|
+
|
|
114
|
+
const wrongDatabaseResponse = await fetch(
|
|
115
|
+
`${fixture.baseUrl}/databases/${fixture.databaseB.id}/tables`,
|
|
116
|
+
{ headers: { Authorization: `Bearer ${created.token}` } }
|
|
117
|
+
);
|
|
118
|
+
assert.equal(wrongDatabaseResponse.status, 401);
|
|
119
|
+
assert.deepEqual(fixture.serviceCalls, [fixture.databaseA.id]);
|
|
120
|
+
|
|
121
|
+
fixture.tokenService.deleteToken(fixture.databaseA.id, created.id);
|
|
122
|
+
assert.equal(fixture.tokenService.listTokens(fixture.databaseA.id).length, 0);
|
|
123
|
+
assert.throws(
|
|
124
|
+
() => fixture.tokenService.authenticate(fixture.databaseA.id, created.token),
|
|
125
|
+
/invalid for this database/
|
|
126
|
+
);
|
|
127
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const test = require("node:test");
|
|
3
|
+
|
|
4
|
+
const { main } = require("../bin/sqlite-hub");
|
|
5
|
+
|
|
6
|
+
test("CLI delegates database operations to the shared command service", async () => {
|
|
7
|
+
const calls = [];
|
|
8
|
+
const connection = {
|
|
9
|
+
id: "db-one",
|
|
10
|
+
label: "Database One",
|
|
11
|
+
};
|
|
12
|
+
const databaseService = {
|
|
13
|
+
getDatabase(reference) {
|
|
14
|
+
calls.push(["getDatabase", reference]);
|
|
15
|
+
return connection;
|
|
16
|
+
},
|
|
17
|
+
listDatabases() {
|
|
18
|
+
calls.push(["listDatabases"]);
|
|
19
|
+
return [connection];
|
|
20
|
+
},
|
|
21
|
+
listTables(reference) {
|
|
22
|
+
calls.push(["listTables", reference]);
|
|
23
|
+
return [{ name: "companies" }];
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
const output = [];
|
|
27
|
+
const originalLog = console.log;
|
|
28
|
+
|
|
29
|
+
console.log = (...values) => output.push(values.join(" "));
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
await main(["--database:Database One", "--tables"], { databaseService });
|
|
33
|
+
} finally {
|
|
34
|
+
console.log = originalLog;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
assert.deepEqual(calls, [
|
|
38
|
+
["listDatabases"],
|
|
39
|
+
["getDatabase", "Database One"],
|
|
40
|
+
["listTables", "db-one"],
|
|
41
|
+
]);
|
|
42
|
+
assert.match(output.join("\n"), /companies/);
|
|
43
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const express = require("express");
|
|
3
|
+
const test = require("node:test");
|
|
4
|
+
const { createConnectionsRouter } = require("../server/routes/connections");
|
|
5
|
+
const { errorMiddleware } = require("../server/utils/errors");
|
|
6
|
+
|
|
7
|
+
test("connections route returns the path selected by the native dialog", async () => {
|
|
8
|
+
const app = express();
|
|
9
|
+
app.use(express.json());
|
|
10
|
+
app.use(
|
|
11
|
+
"/api/connections",
|
|
12
|
+
createConnectionsRouter({
|
|
13
|
+
connectionManager: {},
|
|
14
|
+
importService: {},
|
|
15
|
+
backupService: {},
|
|
16
|
+
nativeFileDialogService: {
|
|
17
|
+
chooseCreateDatabasePath: async () => "/tmp/new-database.sqlite",
|
|
18
|
+
chooseOpenDatabasePath: async () => "/tmp/existing-database.db",
|
|
19
|
+
},
|
|
20
|
+
})
|
|
21
|
+
);
|
|
22
|
+
app.use(errorMiddleware);
|
|
23
|
+
|
|
24
|
+
const server = await new Promise((resolve) => {
|
|
25
|
+
const listener = app.listen(0, "127.0.0.1", () => resolve(listener));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const address = server.address();
|
|
30
|
+
const response = await fetch(
|
|
31
|
+
`http://127.0.0.1:${address.port}/api/connections/choose-create-path`,
|
|
32
|
+
{ method: "POST" }
|
|
33
|
+
);
|
|
34
|
+
const payload = await response.json();
|
|
35
|
+
|
|
36
|
+
assert.equal(response.status, 200);
|
|
37
|
+
assert.equal(payload.success, true);
|
|
38
|
+
assert.deepEqual(payload.data, {
|
|
39
|
+
cancelled: false,
|
|
40
|
+
path: "/tmp/new-database.sqlite",
|
|
41
|
+
});
|
|
42
|
+
} finally {
|
|
43
|
+
await new Promise((resolve, reject) => {
|
|
44
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("connections route returns the existing database selected by the native dialog", async () => {
|
|
50
|
+
const app = express();
|
|
51
|
+
app.use(express.json());
|
|
52
|
+
app.use(
|
|
53
|
+
"/api/connections",
|
|
54
|
+
createConnectionsRouter({
|
|
55
|
+
connectionManager: {},
|
|
56
|
+
importService: {},
|
|
57
|
+
backupService: {},
|
|
58
|
+
nativeFileDialogService: {
|
|
59
|
+
chooseCreateDatabasePath: async () => null,
|
|
60
|
+
chooseOpenDatabasePath: async () => "/tmp/existing-database.db",
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
);
|
|
64
|
+
app.use(errorMiddleware);
|
|
65
|
+
|
|
66
|
+
const server = await new Promise((resolve) => {
|
|
67
|
+
const listener = app.listen(0, "127.0.0.1", () => resolve(listener));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const address = server.address();
|
|
72
|
+
const response = await fetch(
|
|
73
|
+
`http://127.0.0.1:${address.port}/api/connections/choose-open-path`,
|
|
74
|
+
{ method: "POST" }
|
|
75
|
+
);
|
|
76
|
+
const payload = await response.json();
|
|
77
|
+
|
|
78
|
+
assert.equal(response.status, 200);
|
|
79
|
+
assert.equal(payload.success, true);
|
|
80
|
+
assert.deepEqual(payload.data, {
|
|
81
|
+
cancelled: false,
|
|
82
|
+
path: "/tmp/existing-database.db",
|
|
83
|
+
});
|
|
84
|
+
} finally {
|
|
85
|
+
await new Promise((resolve, reject) => {
|
|
86
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
});
|
|
@@ -81,3 +81,51 @@ test("modal footer close buttons are not right-aligned", () => {
|
|
|
81
81
|
/'<div class="[^']*justify-end[^']*pt-2[^']*>'[\s\S]{0,500}?data-action="close-modal"/
|
|
82
82
|
);
|
|
83
83
|
});
|
|
84
|
+
|
|
85
|
+
test("create database modal offers a native path picker and manual fallback", async () => {
|
|
86
|
+
const { renderCreateDatabaseForm } = await loadModalModule();
|
|
87
|
+
const html = renderCreateDatabaseForm({
|
|
88
|
+
kind: "create-connection",
|
|
89
|
+
error: null,
|
|
90
|
+
submitting: false,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
assert.match(html, /data-action="choose-create-database-path"/);
|
|
94
|
+
assert.match(html, /data-create-database-path/);
|
|
95
|
+
assert.match(html, /name="path"/);
|
|
96
|
+
assert.match(html, /enter an absolute path manually/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("open database modal offers a native file picker and manual fallback", async () => {
|
|
100
|
+
const { renderOpenConnectionForm } = await loadModalModule();
|
|
101
|
+
const html = renderOpenConnectionForm({
|
|
102
|
+
kind: "open-connection",
|
|
103
|
+
error: null,
|
|
104
|
+
submitting: false,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
assert.match(html, /data-action="choose-open-database-path"/);
|
|
108
|
+
assert.match(html, /data-open-database-path/);
|
|
109
|
+
assert.match(html, /name="path"/);
|
|
110
|
+
assert.match(html, /enter an absolute path manually/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("API token deletion uses the shared confirmation modal", async () => {
|
|
114
|
+
const { renderDeleteApiTokenForm } = await loadModalModule();
|
|
115
|
+
const html = renderDeleteApiTokenForm({
|
|
116
|
+
kind: "delete-api-token",
|
|
117
|
+
tokenId: "token-one",
|
|
118
|
+
tokenName: "Automation",
|
|
119
|
+
tokenPrefix: "shub_example",
|
|
120
|
+
databaseLabel: "Database One",
|
|
121
|
+
error: null,
|
|
122
|
+
submitting: false,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
assert.match(html, /Delete API token/);
|
|
126
|
+
assert.match(html, /Automation/);
|
|
127
|
+
assert.match(html, /Database One/);
|
|
128
|
+
assert.match(html, /shub_example\.\.\./);
|
|
129
|
+
assert.match(html, /data-form="delete-api-token-confirm"/);
|
|
130
|
+
assert.match(html, /Delete Token/);
|
|
131
|
+
});
|