Repository navigation
Expand file tree
/
Copy pathkeyspace.test.ts
More file actions
137 lines (117 loc) · 4.82 KB
/
Copy pathkeyspace.test.ts
File metadata and controls
137 lines (117 loc) · 4.82 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
/**
* Couchbase keyspace mapping (issue #262, decision 4)
*
* The schema explorer is flat, so bucket > scope > collection is flattened the
* same way PostgreSQL flattens schema > table: the _default scope renders as a
* bare collection name, every other scope renders as "scope.collection".
*
* Quoting is a security boundary: SQL++ has no bind parameter for identifiers,
* so a keyspace path is built by string concatenation and an identifier that
* carries a backtick must not be able to escape its own quoting.
*/
import { describe, test, expect } from "bun:test";
import {
COUCHBASE_DEFAULT_SCOPE,
keyspaceDisplayName,
keyspaceFromDisplayName,
keyspacePath,
quoteIdentifier,
} from "@/lib/db/providers/document/couchbase/keyspace";
/**
* Number of backticks that are NOT part of an escaped (doubled) pair.
* A correctly quoted path has exactly two per segment and nothing else.
*/
function structuralBacktickCount(value: string): number {
return value
.replace(/``/g, "")
.split("")
.filter((char) => char === "`").length;
}
describe("keyspaceDisplayName", () => {
test("renders a _default scope collection as the bare collection name", () => {
expect(keyspaceDisplayName(COUCHBASE_DEFAULT_SCOPE, "hotel")).toBe("hotel");
});
test("renders any other scope as scope.collection", () => {
expect(keyspaceDisplayName("inventory", "hotel")).toBe("inventory.hotel");
});
});
describe("keyspaceFromDisplayName", () => {
test("maps a bare collection name onto the _default scope", () => {
expect(keyspaceFromDisplayName("travel", "hotel")).toEqual({
bucket: "travel",
scope: COUCHBASE_DEFAULT_SCOPE,
collection: "hotel",
});
});
test("splits a qualified display name into scope and collection", () => {
expect(keyspaceFromDisplayName("travel", "inventory.hotel")).toEqual({
bucket: "travel",
scope: "inventory",
collection: "hotel",
});
});
test("splits on the first separator only, so extra dots stay with the collection", () => {
expect(keyspaceFromDisplayName("travel", "inventory.hotel.eu")).toEqual({
bucket: "travel",
scope: "inventory",
collection: "hotel.eu",
});
});
test("round-trips a display name in both directions", () => {
const keyspace = keyspaceFromDisplayName("travel", "inventory.hotel");
expect(keyspaceDisplayName(keyspace.scope, keyspace.collection)).toBe("inventory.hotel");
const defaultKeyspace = keyspaceFromDisplayName("travel", "hotel");
expect(keyspaceDisplayName(defaultKeyspace.scope, defaultKeyspace.collection)).toBe("hotel");
});
});
describe("quoteIdentifier", () => {
test("wraps a plain identifier in backticks", () => {
expect(quoteIdentifier("hotel")).toBe("`hotel`");
});
test("quotes reserved words such as bucket and scope", () => {
// Verified against Couchbase Server 8.0.2: unquoted `bucket` / `scope` in a
// system:keyspaces projection fails with error 3000 (syntax error).
expect(quoteIdentifier("bucket")).toBe("`bucket`");
expect(quoteIdentifier("scope")).toBe("`scope`");
});
test("doubles an embedded backtick so it cannot close the quoting", () => {
expect(quoteIdentifier("ev`il")).toBe("`ev``il`");
});
test("doubles a trailing backtick", () => {
expect(quoteIdentifier("x`")).toBe("`x```");
});
test("leaves single quotes and backslashes untouched", () => {
expect(quoteIdentifier("o'brien\\x")).toBe("`o'brien\\x`");
});
});
describe("keyspacePath", () => {
test("quotes every segment of the executable path", () => {
expect(keyspacePath({ bucket: "travel-sample", scope: "inventory", collection: "hotel" })).toBe(
"`travel-sample`.`inventory`.`hotel`",
);
});
test("quotes the _default scope explicitly rather than omitting it", () => {
expect(keyspacePath({ bucket: "travel", scope: COUCHBASE_DEFAULT_SCOPE, collection: "hotel" })).toBe(
"`travel`.`_default`.`hotel`",
);
});
test("keeps a hostile collection name inside its quotes", () => {
const path = keyspacePath({ bucket: "travel", scope: "_default", collection: "x` OR 1=1 --" });
expect(path).toBe("`travel`.`_default`.`x`` OR 1=1 --`");
// Two structural backticks per segment and no stray one that could end the
// identifier early and turn the payload into executable SQL++.
expect(structuralBacktickCount(path)).toBe(6);
});
test("keeps a hostile bucket and scope inside their quotes", () => {
const path = keyspacePath({
bucket: "b`ucket",
scope: "s`cope",
collection: "c`ollection",
});
expect(path).toBe("`b``ucket`.`s``cope`.`c``ollection`");
expect(structuralBacktickCount(path)).toBe(6);
});
test("builds the path a display name maps to", () => {
expect(keyspacePath(keyspaceFromDisplayName("travel", "inventory.hotel"))).toBe("`travel`.`inventory`.`hotel`");
});
});