|
| 1 | +import assert from "node:assert"; |
| 2 | +import { Readable } from "node:stream"; |
| 3 | +import { |
| 4 | + CreateClientCertificateOperation, |
| 5 | + PutClientCertificateOperation, |
| 6 | + EditClientCertificateOperation, |
| 7 | + CreateDatabaseOperation, |
| 8 | + DeleteDatabasesOperation, |
| 9 | + DeleteCertificateOperation, |
| 10 | + GetCertificatesMetadataOperation, |
| 11 | + GetDatabaseNamesOperation, |
| 12 | + DatabaseAccess, |
| 13 | + DocumentConventions, |
| 14 | + IDocumentStore |
| 15 | +} from "../../src/index.js"; |
| 16 | +import { disposeTestDocumentStore, testContext } from "../Utils/TestUtil.js"; |
| 17 | + |
| 18 | +// RDBC-1085: the client's command-payload serializer PascalCases the first letter of every object |
| 19 | +// key, including the keys of the `permissions` map (which are database names). A lowercase database |
| 20 | +// name like "lowercase_db" was being sent as "Lowercase_db", and because the server matches permission |
| 21 | +// keys case-sensitively, certificate permissions no longer lined up with the database name. |
| 22 | +// The permission keys must travel to the server verbatim. |
| 23 | +describe("[RDBC-1085]", () => { |
| 24 | + |
| 25 | + const conventions = new DocumentConventions(); |
| 26 | + const node = { url: "http://localhost:8080", database: "test" } as any; |
| 27 | + |
| 28 | + const permissions = { |
| 29 | + "lowercase_db": "Admin", |
| 30 | + "mixedCaseDb": "ReadWrite", |
| 31 | + "UPPER_db": "Read" |
| 32 | + } as Record<string, DatabaseAccess>; |
| 33 | + |
| 34 | + function permissionKeysOf(body: string): string[] { |
| 35 | + return Object.keys(JSON.parse(body).Permissions ?? {}); |
| 36 | + } |
| 37 | + |
| 38 | + it("CreateClientCertificateOperation keeps permission (database-name) keys verbatim", () => { |
| 39 | + const command = new CreateClientCertificateOperation("cert", permissions, "ValidUser") |
| 40 | + .getCommand(conventions); |
| 41 | + const body = command.createRequest(node).body as string; |
| 42 | + |
| 43 | + assert.deepStrictEqual(permissionKeysOf(body), ["lowercase_db", "mixedCaseDb", "UPPER_db"]); |
| 44 | + }); |
| 45 | + |
| 46 | + it("PutClientCertificateOperation keeps permission (database-name) keys verbatim", () => { |
| 47 | + const command = new PutClientCertificateOperation("cert", "public-key", permissions, "ValidUser") |
| 48 | + .getCommand(conventions); |
| 49 | + const body = command.createRequest(node).body as string; |
| 50 | + |
| 51 | + assert.deepStrictEqual(permissionKeysOf(body), ["lowercase_db", "mixedCaseDb", "UPPER_db"]); |
| 52 | + }); |
| 53 | + |
| 54 | + it("EditClientCertificateOperation keeps permission (database-name) keys verbatim", () => { |
| 55 | + const command = new EditClientCertificateOperation({ |
| 56 | + thumbprint: "ABC123", |
| 57 | + name: "cert", |
| 58 | + clearance: "ValidUser", |
| 59 | + permissions |
| 60 | + }).getCommand(conventions); |
| 61 | + const body = command.createRequest(node).body as string; |
| 62 | + |
| 63 | + assert.deepStrictEqual(permissionKeysOf(body), ["lowercase_db", "mixedCaseDb", "UPPER_db"]); |
| 64 | + }); |
| 65 | + |
| 66 | + it("top-level certificate fields are still PascalCased on the wire", () => { |
| 67 | + const command = new CreateClientCertificateOperation("cert", permissions, "ValidUser") |
| 68 | + .getCommand(conventions); |
| 69 | + const parsed = JSON.parse(command.createRequest(node).body as string); |
| 70 | + |
| 71 | + assert.ok("Name" in parsed, "expected PascalCased 'Name' field"); |
| 72 | + assert.ok("SecurityClearance" in parsed, "expected PascalCased 'SecurityClearance' field"); |
| 73 | + assert.ok("Permissions" in parsed, "expected PascalCased 'Permissions' field"); |
| 74 | + }); |
| 75 | + |
| 76 | + it("GetCertificatesMetadataOperation reads permission (database-name) keys verbatim", async () => { |
| 77 | + // Server response shape, with permission keys of assorted casing. |
| 78 | + const serverResponse = JSON.stringify({ |
| 79 | + Results: [{ |
| 80 | + Name: "cert", |
| 81 | + SecurityClearance: "ValidUser", |
| 82 | + Thumbprint: "ABC123", |
| 83 | + Permissions: { |
| 84 | + "lowercase_db": "Admin", |
| 85 | + "mixedCaseDb": "ReadWrite", |
| 86 | + "UPPER_db": "Read" |
| 87 | + } |
| 88 | + }] |
| 89 | + }); |
| 90 | + |
| 91 | + const command = new GetCertificatesMetadataOperation("cert").getCommand(conventions); |
| 92 | + await command.setResponseAsync(Readable.from([serverResponse]), false); |
| 93 | + |
| 94 | + const keys = Object.keys(command.result[0].permissions ?? {}); |
| 95 | + assert.deepStrictEqual(keys, ["lowercase_db", "mixedCaseDb", "UPPER_db"]); |
| 96 | + }); |
| 97 | +}); |
| 98 | + |
| 99 | +// Full end-to-end reproduction of the ticket flow against a secured server. Certificate operations |
| 100 | +// require HTTPS + a client certificate, so this uses a secured document store. |
| 101 | +describe("[RDBC-1085] full flow", function () { |
| 102 | + |
| 103 | + let store: IDocumentStore; |
| 104 | + |
| 105 | + beforeEach(async function () { |
| 106 | + store = await testContext.getSecuredDocumentStore(); |
| 107 | + }); |
| 108 | + |
| 109 | + afterEach(async () => |
| 110 | + await disposeTestDocumentStore(store)); |
| 111 | + |
| 112 | + it("preserves lowercase database-name casing across delete/recreate and keeps certificate permissions aligned", async function () { |
| 113 | + this.timeout(60_000); |
| 114 | + |
| 115 | + // A mixed-case name exercises both the write path (permission key sent verbatim) and the |
| 116 | + // read path (permission key read back verbatim). A lowercase-first name would mask the read |
| 117 | + // path, since camelCasing a name that already starts lowercase is a no-op. |
| 118 | + const requestedName = "MyDb"; |
| 119 | + const certName = "RDBC-1085-full-flow-cert"; |
| 120 | + |
| 121 | + let thumbprint: string; |
| 122 | + |
| 123 | + try { |
| 124 | + // Register a client certificate whose permissions are keyed to the lowercase database name. |
| 125 | + await store.maintenance.server.send(new CreateClientCertificateOperation( |
| 126 | + certName, |
| 127 | + { [requestedName]: "Admin" } as Record<string, DatabaseAccess>, |
| 128 | + "ValidUser")); |
| 129 | + |
| 130 | + // Create the database with the lowercase name. |
| 131 | + await store.maintenance.server.send( |
| 132 | + new CreateDatabaseOperation({ databaseName: requestedName })); |
| 133 | + |
| 134 | + // 1. Capture the target database's certificate permissions. |
| 135 | + const metadataBefore = await store.maintenance.server.send( |
| 136 | + new GetCertificatesMetadataOperation(certName)); |
| 137 | + assert.strictEqual(metadataBefore.length, 1); |
| 138 | + thumbprint = metadataBefore[0].thumbprint; |
| 139 | + const capturedPermissions = metadataBefore[0].permissions ?? {}; |
| 140 | + |
| 141 | + // The captured permission key must match the requested (lowercase) database name. |
| 142 | + assert.deepStrictEqual(Object.keys(capturedPermissions), [requestedName]); |
| 143 | + |
| 144 | + // 2. Hard-delete the target database. |
| 145 | + await store.maintenance.server.send(new DeleteDatabasesOperation({ |
| 146 | + databaseNames: [requestedName], |
| 147 | + hardDelete: true |
| 148 | + })); |
| 149 | + |
| 150 | + // 3. Recreate it using the same lowercase name. |
| 151 | + await store.maintenance.server.send( |
| 152 | + new CreateDatabaseOperation({ databaseName: requestedName })); |
| 153 | + |
| 154 | + // 4. Restore the captured certificate permissions. |
| 155 | + await store.maintenance.server.send(new EditClientCertificateOperation({ |
| 156 | + thumbprint, |
| 157 | + name: metadataBefore[0].name, |
| 158 | + permissions: capturedPermissions, |
| 159 | + clearance: metadataBefore[0].securityClearance |
| 160 | + })); |
| 161 | + |
| 162 | + // The recreated database must keep the exact casing we requested. |
| 163 | + const databaseNames = await store.maintenance.server.send( |
| 164 | + new GetDatabaseNamesOperation(0, 100)); |
| 165 | + assert.ok(databaseNames.includes(requestedName), |
| 166 | + `expected database '${requestedName}' but got ${JSON.stringify(databaseNames)}`); |
| 167 | + |
| 168 | + // The restored permission key must still line up (case-sensitively) with the database name. |
| 169 | + const metadataAfter = await store.maintenance.server.send( |
| 170 | + new GetCertificatesMetadataOperation(certName)); |
| 171 | + assert.strictEqual(metadataAfter.length, 1); |
| 172 | + assert.deepStrictEqual(Object.keys(metadataAfter[0].permissions ?? {}), [requestedName]); |
| 173 | + } finally { |
| 174 | + // Clean up the recreated database and the test certificate. |
| 175 | + try { |
| 176 | + await store.maintenance.server.send(new DeleteDatabasesOperation({ |
| 177 | + databaseNames: [requestedName], |
| 178 | + hardDelete: true |
| 179 | + })); |
| 180 | + } catch { |
| 181 | + // ignore |
| 182 | + } |
| 183 | + if (thumbprint) { |
| 184 | + try { |
| 185 | + await store.maintenance.server.send(new DeleteCertificateOperation(thumbprint)); |
| 186 | + } catch { |
| 187 | + // ignore |
| 188 | + } |
| 189 | + } |
| 190 | + } |
| 191 | + }); |
| 192 | +}); |
0 commit comments