sqlite-hub 0.17.0 → 0.17.2
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/frontend/js/api.js +6 -0
- package/frontend/js/app.js +119 -26
- package/frontend/js/components/modal.js +26 -6
- package/frontend/js/components/queryResults.js +18 -1
- package/frontend/js/components/rowEditorPanel.js +42 -34
- package/frontend/js/store.js +14 -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/styles/components.css +6 -0
- package/frontend/styles/tailwind.generated.css +1 -1
- package/package.json +1 -1
- package/server/middleware/localRequestSecurity.js +84 -0
- package/server/routes/connections.js +18 -1
- package/server/server.js +12 -1
- package/server/services/nativeFileDialogService.js +168 -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/utils/sqliteTypes.js +17 -8
- package/tests/connections-file-dialog-route.test.js +46 -0
- package/tests/copy-column-modal.test.js +14 -0
- package/tests/export-blob.test.js +46 -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 +78 -0
- package/tests/query-results-truncation.test.js +20 -0
- package/tests/row-editor-null-values.test.js +131 -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
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
const path = require("node:path");
|
|
2
|
+
const os = require("node:os");
|
|
3
|
+
const { execFile } = require("node:child_process");
|
|
4
|
+
const { promisify } = require("node:util");
|
|
5
|
+
const { AppError } = require("../utils/errors");
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
const DEFAULT_DATABASE_FILENAME = "new-database.sqlite";
|
|
9
|
+
|
|
10
|
+
function escapePowerShellSingleQuotedString(value) {
|
|
11
|
+
return String(value).replaceAll("'", "''");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function buildDialogAttempts({ platform = process.platform, homeDirectory = os.homedir() } = {}) {
|
|
15
|
+
const defaultPath = path.join(homeDirectory, DEFAULT_DATABASE_FILENAME);
|
|
16
|
+
|
|
17
|
+
if (platform === "darwin") {
|
|
18
|
+
return [
|
|
19
|
+
{
|
|
20
|
+
command: "osascript",
|
|
21
|
+
args: [
|
|
22
|
+
"-e",
|
|
23
|
+
"on run argv",
|
|
24
|
+
"-e",
|
|
25
|
+
'set selectedFile to choose file name with prompt "Create SQLite Database" default location POSIX file (item 1 of argv) default name (item 2 of argv)',
|
|
26
|
+
"-e",
|
|
27
|
+
"return POSIX path of selectedFile",
|
|
28
|
+
"-e",
|
|
29
|
+
"end run",
|
|
30
|
+
homeDirectory,
|
|
31
|
+
DEFAULT_DATABASE_FILENAME,
|
|
32
|
+
],
|
|
33
|
+
cancelledExitCodes: new Set([1]),
|
|
34
|
+
cancelledErrorPattern: /user canceled|-128/i,
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (platform === "win32") {
|
|
40
|
+
const initialDirectory = escapePowerShellSingleQuotedString(homeDirectory);
|
|
41
|
+
const script = [
|
|
42
|
+
"Add-Type -AssemblyName System.Windows.Forms",
|
|
43
|
+
"$dialog = New-Object System.Windows.Forms.SaveFileDialog",
|
|
44
|
+
"$dialog.Title = 'Create SQLite Database'",
|
|
45
|
+
"$dialog.Filter = 'SQLite databases (*.db;*.sqlite;*.sqlite3)|*.db;*.sqlite;*.sqlite3|All files (*.*)|*.*'",
|
|
46
|
+
"$dialog.DefaultExt = 'sqlite'",
|
|
47
|
+
"$dialog.AddExtension = $true",
|
|
48
|
+
`$dialog.InitialDirectory = '${initialDirectory}'`,
|
|
49
|
+
`$dialog.FileName = '${DEFAULT_DATABASE_FILENAME}'`,
|
|
50
|
+
"if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {",
|
|
51
|
+
" [Console]::Out.Write($dialog.FileName)",
|
|
52
|
+
"} else {",
|
|
53
|
+
" exit 2",
|
|
54
|
+
"}",
|
|
55
|
+
].join("; ");
|
|
56
|
+
|
|
57
|
+
return [
|
|
58
|
+
{
|
|
59
|
+
command: "powershell.exe",
|
|
60
|
+
args: ["-NoProfile", "-STA", "-Command", script],
|
|
61
|
+
cancelledExitCodes: new Set([2]),
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return [
|
|
67
|
+
{
|
|
68
|
+
command: "zenity",
|
|
69
|
+
args: [
|
|
70
|
+
"--file-selection",
|
|
71
|
+
"--save",
|
|
72
|
+
"--title=Create SQLite Database",
|
|
73
|
+
`--filename=${defaultPath}`,
|
|
74
|
+
"--file-filter=SQLite databases | *.db *.sqlite *.sqlite3",
|
|
75
|
+
"--file-filter=All files | *",
|
|
76
|
+
],
|
|
77
|
+
cancelledExitCodes: new Set([1]),
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
command: "kdialog",
|
|
81
|
+
args: [
|
|
82
|
+
"--getsavefilename",
|
|
83
|
+
defaultPath,
|
|
84
|
+
"SQLite databases (*.db *.sqlite *.sqlite3)",
|
|
85
|
+
"--title",
|
|
86
|
+
"Create SQLite Database",
|
|
87
|
+
],
|
|
88
|
+
cancelledExitCodes: new Set([1]),
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function normalizeSelectedDatabasePath(value) {
|
|
94
|
+
const selectedPath = String(value ?? "").trim();
|
|
95
|
+
|
|
96
|
+
if (!selectedPath) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return path.extname(selectedPath) ? selectedPath : `${selectedPath}.sqlite`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isMissingDialogCommand(error) {
|
|
104
|
+
return error?.code === "ENOENT";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isCancelledDialog(error, attempt) {
|
|
108
|
+
if (!attempt.cancelledExitCodes.has(Number(error?.code))) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!attempt.cancelledErrorPattern) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return attempt.cancelledErrorPattern.test(`${error?.message ?? ""}\n${error?.stderr ?? ""}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
class NativeFileDialogService {
|
|
120
|
+
constructor(options = {}) {
|
|
121
|
+
this.platform = options.platform ?? process.platform;
|
|
122
|
+
this.homeDirectory = options.homeDirectory ?? os.homedir();
|
|
123
|
+
this.executeFile = options.executeFile ?? execFileAsync;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async chooseCreateDatabasePath() {
|
|
127
|
+
const attempts = buildDialogAttempts({
|
|
128
|
+
platform: this.platform,
|
|
129
|
+
homeDirectory: this.homeDirectory,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
for (const attempt of attempts) {
|
|
133
|
+
try {
|
|
134
|
+
const result = await this.executeFile(attempt.command, attempt.args, {
|
|
135
|
+
maxBuffer: 64 * 1024,
|
|
136
|
+
windowsHide: true,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return normalizeSelectedDatabasePath(result?.stdout);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (isCancelledDialog(error, attempt)) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (isMissingDialogCommand(error)) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
throw new AppError("The native file dialog could not be opened.", 500, {
|
|
150
|
+
code: "NATIVE_FILE_DIALOG_FAILED",
|
|
151
|
+
details: { platform: this.platform },
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
throw new AppError("No supported native file dialog is available on this system.", 501, {
|
|
157
|
+
code: "NATIVE_FILE_DIALOG_UNAVAILABLE",
|
|
158
|
+
details: { platform: this.platform },
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = {
|
|
164
|
+
DEFAULT_DATABASE_FILENAME,
|
|
165
|
+
NativeFileDialogService,
|
|
166
|
+
buildDialogAttempts,
|
|
167
|
+
normalizeSelectedDatabasePath,
|
|
168
|
+
};
|
|
@@ -74,10 +74,6 @@ function formatPreviewValue(value) {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
function isUnchangedSubmittedValue(currentValue, submittedValue) {
|
|
77
|
-
if (currentValue === null && submittedValue === "") {
|
|
78
|
-
return true;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
77
|
return formatPreviewValue(currentValue) === formatPreviewValue(submittedValue);
|
|
82
78
|
}
|
|
83
79
|
|
|
@@ -91,6 +91,8 @@ class ExportService {
|
|
|
91
91
|
})
|
|
92
92
|
);
|
|
93
93
|
const result = this.sqlExecutor.execute(sql, {
|
|
94
|
+
blobMode: "full",
|
|
95
|
+
maxRows: null,
|
|
94
96
|
persistHistory: false,
|
|
95
97
|
requireReader: true,
|
|
96
98
|
});
|
|
@@ -132,7 +134,9 @@ class ExportService {
|
|
|
132
134
|
.filter(Boolean)
|
|
133
135
|
.join(" ")
|
|
134
136
|
);
|
|
135
|
-
const rows = serializeRows(statement.all(...(filter?.params ?? []))
|
|
137
|
+
const rows = serializeRows(statement.all(...(filter?.params ?? [])), {
|
|
138
|
+
blobMode: "full",
|
|
139
|
+
});
|
|
136
140
|
const columns = statement.columns().map((column) => column.name);
|
|
137
141
|
const content = renderExportContent({
|
|
138
142
|
columns,
|
|
@@ -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
|
};
|
|
@@ -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,46 @@
|
|
|
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
|
+
},
|
|
19
|
+
})
|
|
20
|
+
);
|
|
21
|
+
app.use(errorMiddleware);
|
|
22
|
+
|
|
23
|
+
const server = await new Promise((resolve) => {
|
|
24
|
+
const listener = app.listen(0, "127.0.0.1", () => resolve(listener));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const address = server.address();
|
|
29
|
+
const response = await fetch(
|
|
30
|
+
`http://127.0.0.1:${address.port}/api/connections/choose-create-path`,
|
|
31
|
+
{ method: "POST" }
|
|
32
|
+
);
|
|
33
|
+
const payload = await response.json();
|
|
34
|
+
|
|
35
|
+
assert.equal(response.status, 200);
|
|
36
|
+
assert.equal(payload.success, true);
|
|
37
|
+
assert.deepEqual(payload.data, {
|
|
38
|
+
cancelled: false,
|
|
39
|
+
path: "/tmp/new-database.sqlite",
|
|
40
|
+
});
|
|
41
|
+
} finally {
|
|
42
|
+
await new Promise((resolve, reject) => {
|
|
43
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
});
|
|
@@ -81,3 +81,17 @@ 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
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const Database = require("better-sqlite3");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const test = require("node:test");
|
|
4
|
+
const { ExportService } = require("../server/services/sqlite/exportService");
|
|
5
|
+
const { SqlExecutor } = require("../server/services/sqlite/sqlExecutor");
|
|
6
|
+
|
|
7
|
+
test("query and table exports include complete BLOB data", () => {
|
|
8
|
+
const db = new Database(":memory:");
|
|
9
|
+
const blob = Buffer.from(Array.from({ length: 100 }, (_, index) => index));
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
db.exec("CREATE TABLE files (id INTEGER PRIMARY KEY, payload BLOB)");
|
|
13
|
+
db.prepare("INSERT INTO files (payload) VALUES (?)").run(blob);
|
|
14
|
+
|
|
15
|
+
const connection = { id: "blob-export-test", label: "Blob export" };
|
|
16
|
+
const connectionManager = {
|
|
17
|
+
getActiveConnection: () => connection,
|
|
18
|
+
getActiveDatabase: () => db,
|
|
19
|
+
};
|
|
20
|
+
const appStateStore = {
|
|
21
|
+
findQueryHistoryItemBySql: () => null,
|
|
22
|
+
getSettings: () => ({ csvDelimiter: "," }),
|
|
23
|
+
recordQueryExecution: () => 1,
|
|
24
|
+
};
|
|
25
|
+
const sqlExecutor = new SqlExecutor({ connectionManager, appStateStore });
|
|
26
|
+
const exportService = new ExportService({
|
|
27
|
+
appStateStore,
|
|
28
|
+
connectionManager,
|
|
29
|
+
sqlExecutor,
|
|
30
|
+
});
|
|
31
|
+
const expectedBase64 = blob.toString("base64");
|
|
32
|
+
const queryExport = exportService.exportQuery(
|
|
33
|
+
"SELECT payload FROM files ORDER BY id",
|
|
34
|
+
{ format: "csv" }
|
|
35
|
+
);
|
|
36
|
+
const tableExport = exportService.exportTable("files", { format: "tsv" });
|
|
37
|
+
|
|
38
|
+
for (const content of [queryExport.content, tableExport.content]) {
|
|
39
|
+
assert.equal(content.includes(expectedBase64), true);
|
|
40
|
+
assert.match(content, /""encoding"":""base64""/);
|
|
41
|
+
assert.doesNotMatch(content, /base64Preview|hexPreview/);
|
|
42
|
+
}
|
|
43
|
+
} finally {
|
|
44
|
+
db.close();
|
|
45
|
+
}
|
|
46
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const { readFileSync } = require("node:fs");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const test = require("node:test");
|
|
5
|
+
|
|
6
|
+
let jsonPreviewModulePromise = null;
|
|
7
|
+
|
|
8
|
+
function loadJsonPreviewModule() {
|
|
9
|
+
if (!jsonPreviewModulePromise) {
|
|
10
|
+
const source = readFileSync(
|
|
11
|
+
path.resolve(__dirname, "../frontend/js/utils/jsonPreview.js"),
|
|
12
|
+
"utf8"
|
|
13
|
+
);
|
|
14
|
+
const url = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
|
|
15
|
+
|
|
16
|
+
jsonPreviewModulePromise = import(url);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return jsonPreviewModulePromise;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test("JSON preview formats objects and arrays with line breaks and indentation", async () => {
|
|
23
|
+
const { formatJsonPreview } = await loadJsonPreviewModule();
|
|
24
|
+
|
|
25
|
+
assert.equal(
|
|
26
|
+
formatJsonPreview('{"event":"created","metadata":{"source":"api"}}'),
|
|
27
|
+
[
|
|
28
|
+
"{",
|
|
29
|
+
' "event": "created",',
|
|
30
|
+
' "metadata": {',
|
|
31
|
+
' "source": "api"',
|
|
32
|
+
" }",
|
|
33
|
+
"}",
|
|
34
|
+
].join("\n")
|
|
35
|
+
);
|
|
36
|
+
assert.equal(
|
|
37
|
+
formatJsonPreview([1, { active: true }]),
|
|
38
|
+
["[", " 1,", " {", ' "active": true', " }", "]"].join("\n")
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("JSON preview ignores invalid JSON and scalar values", async () => {
|
|
43
|
+
const { formatJsonPreview } = await loadJsonPreviewModule();
|
|
44
|
+
|
|
45
|
+
assert.equal(formatJsonPreview("plain text"), null);
|
|
46
|
+
assert.equal(formatJsonPreview("{invalid"), null);
|
|
47
|
+
assert.equal(formatJsonPreview("42"), null);
|
|
48
|
+
assert.equal(formatJsonPreview(null), null);
|
|
49
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const test = require("node:test");
|
|
3
|
+
const {
|
|
4
|
+
LOOPBACK_HOST,
|
|
5
|
+
listenOnLoopback,
|
|
6
|
+
localRequestSecurity,
|
|
7
|
+
} = require("../server/middleware/localRequestSecurity");
|
|
8
|
+
|
|
9
|
+
function runMiddleware({ method = "GET", protocol = "http", headers = {} } = {}) {
|
|
10
|
+
const normalizedHeaders = Object.fromEntries(
|
|
11
|
+
Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value])
|
|
12
|
+
);
|
|
13
|
+
const req = {
|
|
14
|
+
method,
|
|
15
|
+
protocol,
|
|
16
|
+
get(name) {
|
|
17
|
+
return normalizedHeaders[String(name).toLowerCase()];
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
localRequestSecurity(req, {}, (error) => resolve(error ?? null));
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test("server listener binds explicitly to the IPv4 loopback address", () => {
|
|
27
|
+
let receivedArguments = null;
|
|
28
|
+
const listener = {};
|
|
29
|
+
const app = {
|
|
30
|
+
listen(...args) {
|
|
31
|
+
receivedArguments = args;
|
|
32
|
+
return listener;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
assert.equal(listenOnLoopback(app, 4173), listener);
|
|
37
|
+
assert.deepEqual(receivedArguments, [4173, LOOPBACK_HOST]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("local API security accepts same-origin browser and CLI requests", async () => {
|
|
41
|
+
assert.equal(
|
|
42
|
+
await runMiddleware({
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: {
|
|
45
|
+
host: "127.0.0.1:4173",
|
|
46
|
+
origin: "http://127.0.0.1:4173",
|
|
47
|
+
"sec-fetch-site": "same-origin",
|
|
48
|
+
},
|
|
49
|
+
}),
|
|
50
|
+
null
|
|
51
|
+
);
|
|
52
|
+
assert.equal(
|
|
53
|
+
await runMiddleware({
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: { host: "localhost:4173" },
|
|
56
|
+
}),
|
|
57
|
+
null
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("local API security rejects foreign hosts and cross-origin mutations", async () => {
|
|
62
|
+
const foreignHostError = await runMiddleware({
|
|
63
|
+
headers: { host: "example.test:4173" },
|
|
64
|
+
});
|
|
65
|
+
const crossOriginError = await runMiddleware({
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: {
|
|
68
|
+
host: "127.0.0.1:4173",
|
|
69
|
+
origin: "https://example.test",
|
|
70
|
+
"sec-fetch-site": "cross-site",
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
const wrongSchemeError = await runMiddleware({
|
|
74
|
+
method: "PATCH",
|
|
75
|
+
headers: {
|
|
76
|
+
host: "localhost:4173",
|
|
77
|
+
origin: "https://localhost:4173",
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
for (const error of [foreignHostError, crossOriginError, wrongSchemeError]) {
|
|
82
|
+
assert.equal(error?.statusCode, 403);
|
|
83
|
+
assert.equal(error?.code, "LOCAL_REQUEST_REQUIRED");
|
|
84
|
+
}
|
|
85
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const test = require("node:test");
|
|
3
|
+
const {
|
|
4
|
+
NativeFileDialogService,
|
|
5
|
+
buildDialogAttempts,
|
|
6
|
+
normalizeSelectedDatabasePath,
|
|
7
|
+
} = require("../server/services/nativeFileDialogService");
|
|
8
|
+
|
|
9
|
+
test("native database dialog normalizes paths and adds a default extension", () => {
|
|
10
|
+
assert.equal(normalizeSelectedDatabasePath("/tmp/customer-data"), "/tmp/customer-data.sqlite");
|
|
11
|
+
assert.equal(normalizeSelectedDatabasePath("/tmp/customer-data.db\n"), "/tmp/customer-data.db");
|
|
12
|
+
assert.equal(normalizeSelectedDatabasePath(""), null);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("native database dialog builds platform-specific save commands", () => {
|
|
16
|
+
const macAttempt = buildDialogAttempts({ platform: "darwin", homeDirectory: "/Users/test" })[0];
|
|
17
|
+
const windowsAttempt = buildDialogAttempts({ platform: "win32", homeDirectory: "C:\\Users\\test" })[0];
|
|
18
|
+
const linuxAttempts = buildDialogAttempts({ platform: "linux", homeDirectory: "/home/test" });
|
|
19
|
+
|
|
20
|
+
assert.equal(macAttempt.command, "osascript");
|
|
21
|
+
assert.match(macAttempt.args.join(" "), /choose file name/);
|
|
22
|
+
assert.equal(windowsAttempt.command, "powershell.exe");
|
|
23
|
+
assert.match(windowsAttempt.args.at(-1), /SaveFileDialog/);
|
|
24
|
+
assert.deepEqual(linuxAttempts.map((attempt) => attempt.command), ["zenity", "kdialog"]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("native database dialog returns null when the user cancels", async () => {
|
|
28
|
+
const service = new NativeFileDialogService({
|
|
29
|
+
platform: "darwin",
|
|
30
|
+
homeDirectory: "/Users/test",
|
|
31
|
+
executeFile: async () => {
|
|
32
|
+
const error = new Error("User canceled.");
|
|
33
|
+
error.code = 1;
|
|
34
|
+
throw error;
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
assert.equal(await service.chooseCreateDatabasePath(), null);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("native database dialog does not hide macOS script failures as cancellations", async () => {
|
|
42
|
+
const service = new NativeFileDialogService({
|
|
43
|
+
platform: "darwin",
|
|
44
|
+
homeDirectory: "/Users/test",
|
|
45
|
+
executeFile: async () => {
|
|
46
|
+
const error = new Error("AppleScript syntax error");
|
|
47
|
+
error.code = 1;
|
|
48
|
+
throw error;
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await assert.rejects(service.chooseCreateDatabasePath(), (error) => {
|
|
53
|
+
assert.equal(error.code, "NATIVE_FILE_DIALOG_FAILED");
|
|
54
|
+
return true;
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("native database dialog falls back from zenity to kdialog", async () => {
|
|
59
|
+
const commands = [];
|
|
60
|
+
const service = new NativeFileDialogService({
|
|
61
|
+
platform: "linux",
|
|
62
|
+
homeDirectory: "/home/test",
|
|
63
|
+
executeFile: async (command) => {
|
|
64
|
+
commands.push(command);
|
|
65
|
+
|
|
66
|
+
if (command === "zenity") {
|
|
67
|
+
const error = new Error("not found");
|
|
68
|
+
error.code = "ENOENT";
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { stdout: "/home/test/catalog.sqlite3\n" };
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
assert.equal(await service.chooseCreateDatabasePath(), "/home/test/catalog.sqlite3");
|
|
77
|
+
assert.deepEqual(commands, ["zenity", "kdialog"]);
|
|
78
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const test = require("node:test");
|
|
4
|
+
const { pathToFileURL } = require("node:url");
|
|
5
|
+
|
|
6
|
+
test("query results render an explicit truncation notice", async () => {
|
|
7
|
+
const moduleUrl = pathToFileURL(
|
|
8
|
+
path.resolve(__dirname, "../frontend/js/components/queryResults.js")
|
|
9
|
+
).href;
|
|
10
|
+
const { renderQueryResultsPane } = await import(moduleUrl);
|
|
11
|
+
const html = renderQueryResultsPane({
|
|
12
|
+
columns: ["id"],
|
|
13
|
+
rows: [{ id: 1 }, { id: 2 }],
|
|
14
|
+
truncated: true,
|
|
15
|
+
rowLimit: 2,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
assert.match(html, /Showing the first 2 rows/);
|
|
19
|
+
assert.match(html, /export it to process the complete result set/);
|
|
20
|
+
});
|