@vex-chat/libvex 8.0.0 → 9.1.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.
@@ -79,11 +79,46 @@ export function platformSuite(
79
79
  expect(true).toBe(true);
80
80
  });
81
81
 
82
- test("send and receive DM (self)", async () => {
82
+ test("existing-account device request requires password", async () => {
83
+ const SK2 = await e2eGenerateSecretKey();
84
+ const opts2: ClientOptions = e2eClientOptionsBase();
85
+ const storage2 = await makeStorage(SK2, opts2);
86
+ const client2 = await Client.create(SK2, opts2, storage2);
87
+
88
+ try {
89
+ const [duplicateUser, duplicateErr] = await client2.register(
90
+ username,
91
+ password,
92
+ );
93
+ expect(duplicateUser).toBeNull();
94
+ expect(duplicateErr?.message).toContain(
95
+ "Username is already registered. Sign in instead.",
96
+ );
97
+
98
+ const [missingPasswordUser, missingPasswordErr] =
99
+ await client2.requestDeviceEnrollmentWithPasskey(username);
100
+ expect(missingPasswordUser).toBeNull();
101
+ expect(missingPasswordErr?.message).toContain(
102
+ "Password is required to add this device.",
103
+ );
104
+
105
+ const [pendingUser, pendingErr] =
106
+ await client2.requestDeviceEnrollment(username, password);
107
+ expect(pendingUser).toBeNull();
108
+ expect(pendingErr?.name).toBe("DeviceApprovalRequiredError");
109
+ expect(
110
+ (pendingErr as null | { requestID?: unknown })?.requestID,
111
+ ).toEqual(expect.any(String));
112
+ } finally {
113
+ await client2.close().catch(() => {});
114
+ }
115
+ });
116
+
117
+ test("send self-DM without duplicating it as incoming", async () => {
83
118
  const me = client.me.user();
84
119
  const msgPromise = waitForMessage(
85
120
  client,
86
- (m) => m.direction === "incoming" && m.decrypted,
121
+ (m) => m.direction === "outgoing" && m.decrypted,
87
122
  `[${platformName}] self-DM`,
88
123
  );
89
124
  await client.messages.send(me.userID, "platform-test");
@@ -91,7 +126,7 @@ export function platformSuite(
91
126
  expect(msg.message).toBe("platform-test");
92
127
  });
93
128
 
94
- test("send and receive DM with encrypted extra", async () => {
129
+ test("send self-DM with encrypted extra", async () => {
95
130
  const me = client.me.user();
96
131
  const extra = JSON.stringify({
97
132
  reactionEvent: {
@@ -104,7 +139,7 @@ export function platformSuite(
104
139
  const msgPromise = waitForMessage(
105
140
  client,
106
141
  (m) =>
107
- m.direction === "incoming" &&
142
+ m.direction === "outgoing" &&
108
143
  m.decrypted &&
109
144
  m.extra === extra,
110
145
  `[${platformName}] self-DM extra`,
@@ -127,7 +162,7 @@ export function platformSuite(
127
162
  const msgPromise = waitForMessage(
128
163
  client,
129
164
  (m) =>
130
- m.direction === "incoming" &&
165
+ m.direction === "outgoing" &&
131
166
  m.decrypted &&
132
167
  m.message === body,
133
168
  "history DM",
@@ -156,11 +191,14 @@ export function platformSuite(
156
191
  try {
157
192
  const [user2, regErr] = await client2.register(
158
193
  username2,
159
- "test-pw-2",
194
+ "test password two",
160
195
  );
161
196
  expect(regErr).toBeNull();
162
197
 
163
- const loginErr = await client2.login(username2, "test-pw-2");
198
+ const loginErr = await client2.login(
199
+ username2,
200
+ "test password two",
201
+ );
164
202
  expect(loginErr.ok).toBe(true);
165
203
 
166
204
  await connectAndWait(client2, "client2");
@@ -186,14 +224,14 @@ export function platformSuite(
186
224
  const storage1 = await makeStorage(SK1, opts1);
187
225
  const client1 = await Client.create(SK1, opts1, storage1);
188
226
  const username1 = Client.randomUsername();
189
- const password1 = "test-pw-1";
227
+ const password1 = "test password one";
190
228
 
191
229
  const SK2 = await e2eGenerateSecretKey();
192
230
  const opts2: ClientOptions = e2eClientOptionsBase();
193
231
  const storage2 = await makeStorage(SK2, opts2);
194
232
  const client2 = await Client.create(SK2, opts2, storage2);
195
233
  const username2 = Client.randomUsername();
196
- const password2 = "test-pw-2";
234
+ const password2 = "test password two";
197
235
 
198
236
  try {
199
237
  const [_user1, regErr1] = await client1.register(
@@ -264,8 +302,16 @@ export function platformSuite(
264
302
 
265
303
  try {
266
304
  // Register + login + connect user2
267
- await client2.register(username2, "test-pw-2");
268
- await client2.login(username2, "test-pw-2");
305
+ const [_user2, registrationError] = await client2.register(
306
+ username2,
307
+ "test password two",
308
+ );
309
+ expect(registrationError).toBeNull();
310
+ const loginResult = await client2.login(
311
+ username2,
312
+ "test password two",
313
+ );
314
+ expect(loginResult.ok).toBe(true);
269
315
  await connectAndWait(client2, "client2");
270
316
 
271
317
  // user1 creates server + channel
@@ -366,6 +412,25 @@ export function platformSuite(
366
412
  const byID = await client.servers.retrieveByID(server.serverID);
367
413
  expect(byID?.serverID).toBe(server.serverID);
368
414
 
415
+ const renamed = await client.servers.update(
416
+ server.serverID,
417
+ "Renamed Server",
418
+ );
419
+ expect(renamed.name).toBe("Renamed Server");
420
+
421
+ const withIcon = await client.servers.setIcon(
422
+ server.serverID,
423
+ testImage,
424
+ );
425
+ expect(withIcon.icon).toEqual(expect.any(String));
426
+ expect(client.servers.iconURL(withIcon.icon!)).toContain(
427
+ `/server-icon/${withIcon.icon!}`,
428
+ );
429
+ const withoutIcon = await client.servers.removeIcon(
430
+ server.serverID,
431
+ );
432
+ expect(withoutIcon.icon).toBeUndefined();
433
+
369
434
  await client.servers.delete(server.serverID);
370
435
  const afterDelete = await client.servers.retrieve();
371
436
  expect(
@@ -383,6 +448,12 @@ export function platformSuite(
383
448
  const byID = await client.channels.retrieveByID(channel.channelID);
384
449
  expect(byID?.channelID).toBe(channel.channelID);
385
450
 
451
+ const renamed = await client.channels.update(
452
+ channel.channelID,
453
+ "Renamed Channel",
454
+ );
455
+ expect(renamed.name).toBe("Renamed Channel");
456
+
386
457
  await client.channels.delete(channel.channelID);
387
458
  const channels = await client.channels.retrieve(server.serverID);
388
459
  expect(
@@ -390,6 +461,9 @@ export function platformSuite(
390
461
  ).toBe(false);
391
462
  // Default channel still exists
392
463
  expect(channels.length).toBe(1);
464
+ await expect(
465
+ client.channels.delete(channels[0]!.channelID),
466
+ ).rejects.toThrow();
393
467
 
394
468
  await client.servers.delete(server.serverID);
395
469
  });
@@ -504,6 +578,31 @@ export function platformSuite(
504
578
  });
505
579
  }
506
580
  });
581
+
582
+ test("changes the account password with current-password proof", async () => {
583
+ const replacementPassword = "updated platform test password";
584
+
585
+ await expect(
586
+ client.me.changePassword(
587
+ "incorrect platform test password",
588
+ replacementPassword,
589
+ ),
590
+ ).rejects.toThrow();
591
+ await client.me.changePassword(password, replacementPassword);
592
+ await client.logout();
593
+
594
+ const oldLogin = await client.login(username, password);
595
+ expect(oldLogin.ok).toBe(false);
596
+ const newLogin = await client.login(username, replacementPassword);
597
+ expect(newLogin.ok).toBe(true);
598
+ });
599
+
600
+ test("logout clears local authentication", async () => {
601
+ await client.logout();
602
+ await expect(client.connect()).rejects.toThrow(
603
+ "No token — call login() or loginWithDeviceKey() first.",
604
+ );
605
+ });
507
606
  });
508
607
  }
509
608
 
@@ -68,6 +68,28 @@ describe("FetchHttpClient", () => {
68
68
  expect(err.response?.data).toBe("plain failure");
69
69
  });
70
70
 
71
+ it("redacts credentials from request metadata on errors", async () => {
72
+ globalThis.fetch = () =>
73
+ Promise.resolve(new Response(null, { status: 401 }));
74
+ const client = createFetchHttpClient();
75
+ client.defaults.headers.common.Authorization =
76
+ "Bearer secret-user-token";
77
+ client.defaults.headers.common["X-Device-Token"] =
78
+ "secret-device-token";
79
+
80
+ const err = await captureError(() =>
81
+ client.get("https://example.test/private"),
82
+ );
83
+ if (!isHttpError(err)) {
84
+ throw err;
85
+ }
86
+
87
+ expect(err.config.headers["authorization"]).toBe("[REDACTED]");
88
+ expect(err.config.headers["x-device-token"]).toBe("[REDACTED]");
89
+ expect(JSON.stringify(err)).not.toContain("secret-user-token");
90
+ expect(JSON.stringify(err)).not.toContain("secret-device-token");
91
+ });
92
+
71
93
  it("emits a final upload progress event for FormData payloads", async () => {
72
94
  globalThis.fetch = () =>
73
95
  Promise.resolve(new Response(new ArrayBuffer(0), { status: 200 }));
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Copyright (c) 2020-2026 Vex Heavy Industries LLC
3
+ * Licensed under AGPL-3.0. See LICENSE for details.
4
+ * Commercial licenses available at vex.wtf
5
+ */
6
+
7
+ import type { Device, User } from "@vex-chat/types";
8
+
9
+ import { describe, expect, it, vi } from "vitest";
10
+
11
+ import { Client } from "../Client.js";
12
+
13
+ type SendGroupMessage = (
14
+ this: unknown,
15
+ channelID: string,
16
+ message: string,
17
+ ) => Promise<void>;
18
+
19
+ type SendMessage = (
20
+ this: unknown,
21
+ userID: string,
22
+ message: string,
23
+ ) => Promise<void>;
24
+
25
+ const now = "2026-07-14T00:00:00.000Z";
26
+
27
+ function device(deviceID: string, owner: string): Device {
28
+ return {
29
+ deleted: false,
30
+ deviceID,
31
+ lastLogin: now,
32
+ name: deviceID,
33
+ owner,
34
+ signKey: `${deviceID}-sign-key`,
35
+ };
36
+ }
37
+
38
+ function user(userID: string, username: string): User {
39
+ return { lastSeen: now, userID, username };
40
+ }
41
+
42
+ describe("multi-device delivery", () => {
43
+ it("succeeds when one direct-message recipient device is unavailable", async () => {
44
+ const sender = user("user-a", "alice");
45
+ const recipient = user("user-b", "bob");
46
+ const unavailable = device("device-b-1", recipient.userID);
47
+ const available = device("device-b-2", recipient.userID);
48
+ const sendMailWithRecovery = vi.fn((target: Device) =>
49
+ target === unavailable
50
+ ? Promise.reject(new Error("Device is unavailable."))
51
+ : Promise.resolve(null),
52
+ );
53
+ const fakeClient = {
54
+ fetchUser: vi.fn(() => Promise.resolve([recipient, null])),
55
+ fetchUserDeviceListOnce: vi.fn(() =>
56
+ Promise.resolve([unavailable, available]),
57
+ ),
58
+ fetchUserDeviceListWithBackoff: vi.fn(() =>
59
+ Promise.resolve([unavailable, available]),
60
+ ),
61
+ getUser: () => sender,
62
+ sendMailWithRecovery,
63
+ };
64
+ const sendMessage = Reflect.get(
65
+ Client.prototype,
66
+ "sendMessage",
67
+ ) as SendMessage;
68
+
69
+ await expect(
70
+ sendMessage.call(fakeClient, recipient.userID, "hello"),
71
+ ).resolves.toBeUndefined();
72
+ expect(sendMailWithRecovery).toHaveBeenCalledTimes(2);
73
+ });
74
+
75
+ it("succeeds when another device for each group recipient receives the message", async () => {
76
+ const sender = user("user-a", "alice");
77
+ const recipient = user("user-b", "bob");
78
+ const ownDevice = device("device-a-1", sender.userID);
79
+ const unavailable = device("device-b-1", recipient.userID);
80
+ const available = device("device-b-2", recipient.userID);
81
+ const sendMailWithRecovery = vi.fn((target: Device) =>
82
+ target === unavailable
83
+ ? Promise.reject(new Error("Device is unavailable."))
84
+ : Promise.resolve(null),
85
+ );
86
+ const fakeClient = {
87
+ fetchUserDeviceListWithBackoff: vi.fn(() =>
88
+ Promise.resolve([ownDevice]),
89
+ ),
90
+ getMultiUserDeviceList: vi.fn(() =>
91
+ Promise.resolve([unavailable, available]),
92
+ ),
93
+ getUser: () => sender,
94
+ getUserList: vi.fn(() => Promise.resolve([sender, recipient])),
95
+ sendMailWithRecovery,
96
+ userRecords: {} as Record<string, User>,
97
+ };
98
+ const sendGroupMessage = Reflect.get(
99
+ Client.prototype,
100
+ "sendGroupMessage",
101
+ ) as SendGroupMessage;
102
+
103
+ await expect(
104
+ sendGroupMessage.call(
105
+ fakeClient,
106
+ "1b0a66e2-8275-4f8b-84d5-cb7309d41410",
107
+ "hello channel",
108
+ ),
109
+ ).resolves.toBeUndefined();
110
+ expect(sendMailWithRecovery).toHaveBeenCalledTimes(3);
111
+ });
112
+
113
+ it("still fails a group send when a recipient has no successful device delivery", async () => {
114
+ const sender = user("user-a", "alice");
115
+ const recipient = user("user-b", "bob");
116
+ const ownDevice = device("device-a-1", sender.userID);
117
+ const recipientDevice = device("device-b-1", recipient.userID);
118
+ const fakeClient = {
119
+ fetchUserDeviceListWithBackoff: vi.fn(() =>
120
+ Promise.resolve([ownDevice]),
121
+ ),
122
+ getMultiUserDeviceList: vi.fn(() =>
123
+ Promise.resolve([recipientDevice]),
124
+ ),
125
+ getUser: () => sender,
126
+ getUserList: vi.fn(() => Promise.resolve([sender, recipient])),
127
+ sendMailWithRecovery: vi.fn((target: Device) =>
128
+ target === recipientDevice
129
+ ? Promise.reject(new Error("Recipient is unavailable."))
130
+ : Promise.resolve(null),
131
+ ),
132
+ userRecords: {} as Record<string, User>,
133
+ };
134
+ const sendGroupMessage = Reflect.get(
135
+ Client.prototype,
136
+ "sendGroupMessage",
137
+ ) as SendGroupMessage;
138
+
139
+ await expect(
140
+ sendGroupMessage.call(
141
+ fakeClient,
142
+ "1b0a66e2-8275-4f8b-84d5-cb7309d41410",
143
+ "hello channel",
144
+ ),
145
+ ).rejects.toThrow("Recipient is unavailable.");
146
+ });
147
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Copyright (c) 2020-2026 Vex Heavy Industries LLC
3
+ * Licensed under AGPL-3.0. See LICENSE for details.
4
+ * Commercial licenses available at vex.wtf
5
+ */
6
+
7
+ import type { StoredCredentials } from "../types/index.js";
8
+
9
+ import * as fs from "node:fs";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+
13
+ import { afterEach, describe, expect, it } from "vitest";
14
+
15
+ import { NodeKeyStore } from "../keystore/node.js";
16
+
17
+ const dirs: string[] = [];
18
+
19
+ afterEach(() => {
20
+ for (const dir of dirs.splice(0)) {
21
+ fs.rmSync(dir, { force: true, recursive: true });
22
+ }
23
+ });
24
+
25
+ describe("NodeKeyStore", () => {
26
+ it("round-trips credentials through the encrypted file", async () => {
27
+ const dir = makeTempDir();
28
+ const store = new NodeKeyStore("correct horse battery staple", dir);
29
+ const credentials: StoredCredentials = {
30
+ deviceID: "device-id",
31
+ deviceKey: "ab".repeat(64),
32
+ preKey: "cd".repeat(32),
33
+ token: "saved-token",
34
+ username: "alice",
35
+ };
36
+
37
+ await store.save(credentials);
38
+
39
+ await expect(store.load("alice")).resolves.toEqual(credentials);
40
+ await expect(store.load()).resolves.toEqual(credentials);
41
+ const raw = fs.readFileSync(path.join(dir, "alice.vex"));
42
+ expect(raw.toString("utf8")).not.toContain("saved-token");
43
+ expect(fs.statSync(path.join(dir, "alice.vex")).mode & 0o777).toBe(
44
+ 0o600,
45
+ );
46
+ });
47
+
48
+ it("keeps usernames inside the configured directory", async () => {
49
+ const dir = makeTempDir();
50
+ const store = new NodeKeyStore("password", dir);
51
+
52
+ await store.save({
53
+ deviceID: "device-id",
54
+ deviceKey: "ab".repeat(64),
55
+ username: "../outside",
56
+ });
57
+
58
+ expect(fs.readdirSync(dir)).toEqual(["..%2Foutside.vex"]);
59
+ await expect(store.load("../outside")).resolves.toMatchObject({
60
+ username: "../outside",
61
+ });
62
+ });
63
+ });
64
+
65
+ function makeTempDir(): string {
66
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vex-keystore-"));
67
+ dirs.push(dir);
68
+ return dir;
69
+ }
@@ -10,10 +10,9 @@ import type { CryptoProfile, KeyPair } from "@vex-chat/crypto";
10
10
  import {
11
11
  setCryptoProfile,
12
12
  xBoxKeyPairAsync,
13
- xConstants,
14
13
  xEcdhKeyPairFromEcdsaKeyPairAsync,
15
- xEncode,
16
14
  XKeyConvert,
15
+ xPreKeySignaturePayload,
17
16
  xSignAsync,
18
17
  xSignKeyPair,
19
18
  xSignKeyPairAsync,
@@ -24,12 +23,11 @@ import {
24
23
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
25
24
 
26
25
  import { Client } from "../Client.js";
27
- import { fipsP256PreKeySignPayload } from "../utils/fipsMailExtra.js";
28
26
 
29
27
  import { MemoryStorage } from "./harness/memory-storage.js";
30
28
 
31
29
  interface KeyRingHarness {
32
- createPreKey: () => Promise<UnsavedPreKey>;
30
+ createPreKey: (kind: "one-time" | "signed") => Promise<UnsavedPreKey>;
33
31
  cryptoProfile: CryptoProfile;
34
32
  database: MemoryStorage;
35
33
  idKeys: KeyPair;
@@ -41,7 +39,7 @@ interface KeyRingHarness {
41
39
  }
42
40
 
43
41
  const clientMethods = Client.prototype as unknown as {
44
- createPreKey: () => Promise<UnsavedPreKey>;
42
+ createPreKey: (kind: "one-time" | "signed") => Promise<UnsavedPreKey>;
45
43
  isPreKeySignedByCurrentDevice: (preKey: PreKeysCrypto) => Promise<boolean>;
46
44
  populateKeyRing: () => Promise<void>;
47
45
  runWithThisCryptoProfile: <T>(fn: () => Promise<T>) => Promise<T>;
@@ -54,10 +52,11 @@ async function isValidFor(
54
52
  ): Promise<boolean> {
55
53
  setCryptoProfile(profile);
56
54
  const opened = await xSignOpenAsync(preKey.signature, signKeys.publicKey);
57
- const payload =
58
- profile === "fips"
59
- ? fipsP256PreKeySignPayload(preKey.keyPair.publicKey)
60
- : xEncode(xConstants.CURVE, preKey.keyPair.publicKey);
55
+ const payload = xPreKeySignaturePayload(
56
+ preKey.keyPair.publicKey,
57
+ "signed",
58
+ profile,
59
+ );
61
60
  return Boolean(opened && XUtils.bytesEqual(opened, payload));
62
61
  }
63
62
 
@@ -67,10 +66,11 @@ async function makeSignedPreKey(
67
66
  ): Promise<UnsavedPreKey> {
68
67
  setCryptoProfile(profile);
69
68
  const keyPair = await xBoxKeyPairAsync();
70
- const payload =
71
- profile === "fips"
72
- ? fipsP256PreKeySignPayload(keyPair.publicKey)
73
- : xEncode(xConstants.CURVE, keyPair.publicKey);
69
+ const payload = xPreKeySignaturePayload(
70
+ keyPair.publicKey,
71
+ "signed",
72
+ profile,
73
+ );
74
74
  return {
75
75
  keyPair,
76
76
  signature: await xSignAsync(payload, signKeys.secretKey),
@@ -1,5 +1,6 @@
1
1
  import type { Message } from "../index.js";
2
2
  import type { ClientDatabase } from "../storage/schema.js";
3
+ import type { SessionSQL } from "@vex-chat/types";
3
4
 
4
5
  import BetterSqlite3 from "better-sqlite3";
5
6
  import { Kysely, SqliteDialect } from "kysely";
@@ -74,6 +75,96 @@ describe("SqliteStorage message at-rest encryption", () => {
74
75
  await storage.close();
75
76
  }
76
77
  });
78
+
79
+ it("uses a fresh local nonce when a stored message is edited", async () => {
80
+ const { db, storage } = makeStorage();
81
+ try {
82
+ await storage.init();
83
+ const message = makeMessage({
84
+ mailID: "editable-mail",
85
+ message: "before",
86
+ nonce: nonceHex(3),
87
+ });
88
+ await storage.saveMessage(message);
89
+
90
+ const before = await messageRow(db, message.mailID);
91
+ expect(before?.message).toMatch(/^vex-storage-cipher:2:/);
92
+ const localNonce = before?.message
93
+ .slice("vex-storage-cipher:2:".length)
94
+ .slice(0, 48);
95
+ expect(localNonce).not.toBe(message.nonce);
96
+
97
+ await expect(
98
+ storage.updateMessage(message.mailID, { message: "after" }),
99
+ ).resolves.toBe(true);
100
+ const after = await messageRow(db, message.mailID);
101
+ expect(after?.message).toMatch(/^vex-storage-cipher:2:/);
102
+ expect(after?.message).not.toBe(before?.message);
103
+ expect(after?.nonce).toBe(message.nonce);
104
+
105
+ const history = await storage.getMessageHistory(message.authorID);
106
+ expect(history).toMatchObject([
107
+ { mailID: message.mailID, message: "after" },
108
+ ]);
109
+ } finally {
110
+ await storage.close();
111
+ }
112
+ });
113
+
114
+ it("encrypts skipped Double Ratchet keys at rest", async () => {
115
+ const { db, storage } = makeStorage();
116
+ try {
117
+ await storage.init();
118
+ const skippedKeys = JSON.stringify({
119
+ "ratchet-key:7": "ab".repeat(32),
120
+ });
121
+ const session = makeSession({ skippedKeys });
122
+ await storage.saveSession(session);
123
+
124
+ const row = await db
125
+ .selectFrom("sessions")
126
+ .select("skippedKeys")
127
+ .where("sessionID", "=", session.sessionID)
128
+ .executeTakeFirstOrThrow();
129
+ expect(row.skippedKeys).toMatch(/^vex-storage-secret:1:/);
130
+ expect(row.skippedKeys).not.toContain("ratchet-key");
131
+ expect(row.skippedKeys).not.toContain("ab".repeat(32));
132
+
133
+ const roundTripped = await storage.getAllSessions();
134
+ expect(roundTripped).toMatchObject([
135
+ { sessionID: session.sessionID, skippedKeys },
136
+ ]);
137
+ } finally {
138
+ await storage.close();
139
+ }
140
+ });
141
+
142
+ it("atomically updates an existing Double Ratchet session", async () => {
143
+ const { storage } = makeStorage();
144
+ try {
145
+ await storage.init();
146
+ const session = makeSession({ Ns: 1 });
147
+ await storage.saveSession(session);
148
+
149
+ await storage.saveSession({
150
+ ...session,
151
+ CKs: "77".repeat(32),
152
+ lastUsed: "2026-06-02T00:00:00.000Z",
153
+ Ns: 2,
154
+ });
155
+
156
+ const rows = await storage.getAllSessions();
157
+ expect(rows).toHaveLength(1);
158
+ expect(rows[0]).toMatchObject({
159
+ CKs: "77".repeat(32),
160
+ lastUsed: "2026-06-02T00:00:00.000Z",
161
+ Ns: 2,
162
+ sessionID: session.sessionID,
163
+ });
164
+ } finally {
165
+ await storage.close();
166
+ }
167
+ });
77
168
  });
78
169
 
79
170
  function makeMessage(overrides: Partial<Message>): Message {
@@ -94,6 +185,31 @@ function makeMessage(overrides: Partial<Message>): Message {
94
185
  };
95
186
  }
96
187
 
188
+ function makeSession(overrides: Partial<SessionSQL>): SessionSQL {
189
+ return {
190
+ CKr: null,
191
+ CKs: null,
192
+ deviceID: "peer-device",
193
+ DHr: null,
194
+ DHsPrivate: "11".repeat(32),
195
+ DHsPublic: "22".repeat(32),
196
+ fingerprint: "33".repeat(32),
197
+ lastUsed: "2026-06-01T00:00:00.000Z",
198
+ mode: "initiator",
199
+ Nr: 0,
200
+ Ns: 0,
201
+ PN: 0,
202
+ publicKey: "44".repeat(32),
203
+ RK: "55".repeat(32),
204
+ sessionID: "session-id",
205
+ SK: "66".repeat(32),
206
+ skippedKeys: "{}",
207
+ userID: "peer-user",
208
+ verified: false,
209
+ ...overrides,
210
+ };
211
+ }
212
+
97
213
  function makeStorage(): {
98
214
  db: Kysely<ClientDatabase>;
99
215
  storage: SqliteStorage;
@@ -112,7 +228,7 @@ function makeStorage(): {
112
228
  async function messageRow(db: Kysely<ClientDatabase>, mailID: string) {
113
229
  return await db
114
230
  .selectFrom("messages")
115
- .select(["decrypted", "extra", "message"])
231
+ .select(["decrypted", "extra", "message", "nonce"])
116
232
  .where("mailID", "=", mailID)
117
233
  .executeTakeFirst();
118
234
  }