@illli-studio/dsh-mory 0.0.0-stage → 0.1.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.
@@ -0,0 +1,278 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { MorySettingsService, appearanceIds, parseSyncSnapshot } from '../settings.mjs';
4
+ import { MoryService, MoryError } from '../service.mjs';
5
+ import { normalizeMemory, mergeSnapshots } from '../protocol.mjs';
6
+ import { apply } from '../index.js';
7
+
8
+ const json = (value, status = 200) => Response.json(value, { status });
9
+ const error = (code, status) => value => { assert.ok(value instanceof MoryError); assert.equal(value.code, code); if (status !== undefined) assert.equal(value.status, status); return true; };
10
+ function missing() { return Object.assign(new Error('missing'), { code: 'ENOENT' }); }
11
+ function filesystem() {
12
+ const files = new Map(), writes = [];
13
+ return { files, writes,
14
+ async mkdir() {},
15
+ async readFile(path) { if (!files.has(path)) throw missing(); return files.get(path); },
16
+ async open(path) { if (files.has(path)) throw Object.assign(new Error('locked'), { code: 'EEXIST' }); files.set(path, ''); return { async close() {} }; },
17
+ async writeFile(path, data, options) { if (options?.flag === 'wx' && files.has(path)) throw new Error('exists'); files.set(path, data); writes.push({ path, data, options }); },
18
+ async rename(from, to) { if (!files.has(from)) throw missing(); files.set(to, files.get(from)); files.delete(from); },
19
+ async unlink(path) { files.delete(path); },
20
+ };
21
+ }
22
+ function create(options = {}) {
23
+ const fs = options.fs || filesystem();
24
+ const settings = new MorySettingsService(options.config || {}, { env: {}, home: '/settings-test-home', fs, ...options.dependencies });
25
+ return { settings, fs };
26
+ }
27
+ async function memory(id, text = id, fields = {}) { return normalizeMemory({ id, text, title: id, capturedAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', ...fields }); }
28
+ function attachConnection(settings, options = {}) {
29
+ const service = new MoryService(options.config || {}, { settings, env: options.env || {}, home: '/settings-test-home', readFile: async () => '{"port":8787,"token":"local-memory-secret"}', fetch: options.fetch || (async () => json({ memories: [], total: 0, nextOffset: null })) });
30
+ settings.attach(service); return service;
31
+ }
32
+ async function configured(options = {}) {
33
+ const result = create(options);
34
+ await result.settings.update({ expectedRevision: 0, sync: { provider: options.provider || 'github', repositoryUrl: options.provider === 'gitee' ? 'https://gitee.com/user/memory' : 'https://github.com/user/memory', token: 'provider-secret', ...options.sync } });
35
+ return result;
36
+ }
37
+ async function syncFixture(options = {}) {
38
+ const local = { generation: 1, memories: options.local || [await memory('local')], tombstones: options.localTombstones || [] };
39
+ let remote = { schemaVersion: 2, memories: options.remote || [await memory('remote')], tombstones: options.remoteTombstones || [] };
40
+ let sha = options.missingFile ? undefined : 'sha-1'; let writes = 0, localWrites = 0; const requests = [];
41
+ const fetch = async (url, init) => {
42
+ const parsed = new URL(url); requests.push({ url: parsed, init });
43
+ assert.equal(init.redirect, 'error');
44
+ assert.ok(init.signal instanceof AbortSignal);
45
+ if (!parsed.pathname.includes('/contents/')) return json({ private: options.private !== false, default_branch: 'main' });
46
+ if (init.method === 'GET') {
47
+ if (options.invalidRemote) return json({ encoding: 'base64', sha: 'sha-1', content: Buffer.from('{bad json').toString('base64') });
48
+ return sha ? json({ encoding: 'base64', sha, content: Buffer.from(JSON.stringify(remote)).toString('base64') }) : json({ message: 'not found' }, 404);
49
+ }
50
+ const body = JSON.parse(init.body); writes++;
51
+ if (options.alwaysConflict) return json({ message: 'conflict provider-secret' }, 422);
52
+ if (options.conflict && writes === 1) {
53
+ remote.memories.push(await memory('concurrent-remote'));
54
+ sha = 'sha-2'; return json({ message: 'conflict' }, 409);
55
+ }
56
+ assert.equal(body.sha, sha);
57
+ remote = JSON.parse(Buffer.from(body.content, 'base64').toString('utf8'));
58
+ sha = `sha-${writes + 2}`;
59
+ if (options.localConflict) { local.generation++; local.memories.push(await memory('concurrent-local')); }
60
+ return options.emptySuccess ? new Response(null, { status: 204 }) : json({ content: { sha } }, 201);
61
+ };
62
+ const result = await configured({ provider: options.provider, sync: options.sync, dependencies: { fetch } });
63
+ const service = {
64
+ async connection() { return { baseUrl: 'http://127.0.0.1:8787', token: 'local-secret' }; },
65
+ async request(path, method, body, signal, pinned) {
66
+ assert.equal(pinned.baseUrl, 'http://127.0.0.1:8787');
67
+ if (path === '/v1/snapshot') return structuredClone(local);
68
+ assert.equal(path, '/v1/sync'); assert.equal(method, 'POST'); localWrites++;
69
+ if (body.expectedGeneration !== local.generation) throw new MoryError(409, 'REVISION_CONFLICT', 'Changed.');
70
+ const merged = mergeSnapshots(local, body); local.memories = merged.memories; local.tombstones = merged.tombstones; local.generation++;
71
+ return structuredClone(local);
72
+ },
73
+ };
74
+ result.settings.attach(service);
75
+ return { ...result, local, requests, remote: () => remote, writes: () => writes, localWrites: () => localWrites };
76
+ }
77
+
78
+ test('settings defaults are complete, redacted, and do not create a configuration file', async () => {
79
+ const { settings, fs } = create(); const value = await settings.execute('get');
80
+ assert.equal(value.revision, 0); assert.equal(value.appearance, 'default'); assert.equal(value.language, 'auto');
81
+ assert.equal(value.connection.tokenConfigured, false); assert.equal(value.sync.tokenConfigured, false);
82
+ assert.equal(value.sync.path, 'mory/memories.json'); assert.equal(value.sync.autoSync, false);
83
+ assert.equal(fs.files.size, 0); assert.equal(value.lastSync, null); assert.equal(appearanceIds.length, 25);
84
+ });
85
+
86
+ test('settings updates persist atomically with mode600, and blank secrets preserve while null clears', async () => {
87
+ const { settings, fs } = create(); attachConnection(settings);
88
+ let value = await settings.update({ expectedRevision: 0, appearance: 'ocean', language: 'zh', connection: { apiUrl: 'https://memory.example', token: 'memory-secret' }, sync: { repositoryUrl: 'https://github.com/user/memory.git', token: 'provider-secret', deviceName: 'Desktop', autoSync: true } });
89
+ assert.equal(value.revision, 1); assert.equal(value.connection.tokenConfigured, true); assert.equal(value.sync.tokenConfigured, true);
90
+ assert.doesNotMatch(JSON.stringify(value), /memory-secret|provider-secret/);
91
+ assert.equal(fs.writes[0].options.mode, 0o600); assert.equal(fs.files.size, 1);
92
+ value = await settings.update({ expectedRevision: 1, connection: { token: '' }, sync: { token: '' } });
93
+ assert.equal(value.connection.tokenConfigured, true); assert.equal(value.sync.tokenConfigured, true);
94
+ const reopened = create({ fs }).settings; attachConnection(reopened);
95
+ assert.equal((await reopened.execute('get')).appearance, 'ocean');
96
+ value = await reopened.update({ expectedRevision: 2, connection: { token: null }, sync: { token: null } });
97
+ assert.equal(value.connection.tokenConfigured, false); assert.equal(value.sync.tokenConfigured, false);
98
+ assert.equal(fs.files.size, 1);
99
+ });
100
+
101
+ test('stale settings revisions and concurrent updates preserve the winning settings', async () => {
102
+ const { settings, fs } = create(); const other = create({ fs }).settings;
103
+ const results = await Promise.allSettled([settings.update({ expectedRevision: 0, language: 'en' }), other.update({ expectedRevision: 0, appearance: 'midnight' })]);
104
+ assert.equal(results.filter(value => value.status === 'fulfilled').length, 1);
105
+ const stored = await settings.execute('get'); assert.equal(stored.revision, 1);
106
+ await assert.rejects(other.update({ expectedRevision: 0, language: 'zh' }), error('SETTINGS_CONFLICT', 409));
107
+ assert.equal((await settings.execute('get')).revision, 1); assert.equal(fs.files.size, 1);
108
+ });
109
+
110
+ test('invalid origins, repositories, paths, settings fields and themes never commit settings', async () => {
111
+ const { settings, fs } = create();
112
+ for (const patch of [
113
+ { appearance: 'unknown' }, { language: 'fr' }, { connection: { apiUrl: 'http://public.example' } },
114
+ { connection: { apiUrl: 'https://user:secret@memory.example' } }, { connection: { apiUrl: 'https://memory.example/path' } },
115
+ { sync: { repositoryUrl: 'https://github.com/user/repo/path' } }, { sync: { repositoryUrl: 'https://github.com@evil.example/user/repo' } },
116
+ { sync: { repositoryUrl: 'https://gitee.com/user/repo' } }, { sync: { path: '../memory.json' } },
117
+ { sync: { path: '/memory.json' } }, { sync: { autoSync: 'yes' } }, { connection: { extra: true } }, { extra: true },
118
+ ]) await assert.rejects(settings.update({ expectedRevision: 0, ...patch }), error('INVALID_INPUT', 400));
119
+ assert.equal(fs.files.size, 0);
120
+ });
121
+
122
+ test('filesystem failures are explicit and do not reveal host paths or stored secrets', async () => {
123
+ const { settings } = create({ dependencies: { fs: { async readFile() { throw new Error('C:/private-token/file'); } } } });
124
+ await assert.rejects(settings.execute('get'), failure => { error('SETTINGS_IO', 503)(failure); assert.doesNotMatch(failure.message, /private-token/); return true; });
125
+ const denied = create({ dependencies: { fs: { async mkdir() { throw new Error('path secret'); } } } }).settings;
126
+ await assert.rejects(denied.update({ expectedRevision: 0, language: 'en' }), error('SETTINGS_IO', 503));
127
+ });
128
+
129
+ test('saved memory tokens remain bound to their origin and environment overrides take precedence', async () => {
130
+ const { settings } = create(); let service = attachConnection(settings);
131
+ await settings.update({ expectedRevision: 0, connection: { apiUrl: 'https://one.example', token: 'saved-secret' } });
132
+ assert.equal((await service.connection()).token, 'saved-secret');
133
+ await settings.update({ expectedRevision: 1, connection: { apiUrl: 'https://two.example', token: '' } });
134
+ await assert.rejects(service.connection(), error('SETUP_REQUIRED', 503));
135
+ service = attachConnection(settings, { env: { MORY_API_URL: 'https://admin.example', MORY_API_TOKEN: 'admin-secret' } });
136
+ assert.deepEqual(await service.connection(), { baseUrl: 'https://admin.example', token: 'admin-secret' });
137
+ const value = await settings.execute('get'); assert.equal(value.connection.managedByEnvironment, true); assert.doesNotMatch(JSON.stringify(value), /saved-secret|admin-secret|local-memory-secret/);
138
+ });
139
+
140
+ test('the configured device name is stored on new memories with a stable device identity', async () => {
141
+ const { settings } = create({ dependencies: { hostname: () => 'host-machine' } }); let body;
142
+ const service = attachConnection(settings, { fetch: async (_url, init) => { body = JSON.parse(init.body); return json({ memory: await memory('new-memory', body.text, { origin: body.origin }) }); } });
143
+ await settings.update({ expectedRevision: 0, sync: { deviceName: 'Work desktop' } });
144
+ const first = await settings.deviceOrigin(); await service.execute('create', { text: 'remember me' });
145
+ assert.equal(body.origin.deviceName, 'Work desktop'); assert.equal(body.origin.deviceId, first.deviceId);
146
+ await settings.update({ expectedRevision: 1, sync: { deviceName: 'Renamed desktop' } });
147
+ assert.equal((await settings.deviceOrigin()).deviceId, first.deviceId);
148
+ assert.equal((await settings.deviceOrigin()).deviceName, 'Renamed desktop'); settings.dispose();
149
+ });
150
+
151
+ test('provider changes preserve a stored token without forwarding it to another provider', async () => {
152
+ let calls = 0;
153
+ const { settings } = await configured({ dependencies: { fetch: async () => { calls++; return json({ private: true }); } } });
154
+ const value = await settings.update({ expectedRevision: 1, sync: { provider: 'gitee', repositoryUrl: 'https://gitee.com/user/memory', token: '' } });
155
+ assert.equal(value.sync.tokenStored, true); assert.equal(value.sync.tokenConfigured, false);
156
+ await assert.rejects(settings.execute('check', { target: 'sync' }), error('SYNC_SETUP_REQUIRED', 400));
157
+ assert.equal(calls, 0);
158
+ });
159
+
160
+ test('connection checks use protected Mory data and redact upstream failures', async () => {
161
+ const { settings } = create(); let path;
162
+ attachConnection(settings, { fetch: async url => { path = new URL(url).pathname; return json({ memories: [], total: 7, nextOffset: null }); } });
163
+ assert.equal((await settings.execute('health')).total, 7); assert.equal(path, '/v1/memories');
164
+ attachConnection(settings, { fetch: async () => json({ error: 'secret upstream payload' }, 401) });
165
+ await assert.rejects(settings.execute('check', { target: 'mory' }), failure => { assert.doesNotMatch(failure.message, /secret/); assert.equal(failure.status, 401); return true; });
166
+ });
167
+
168
+ test('repository check discovers the branch and rejects public repositories before any write', async () => {
169
+ const f = await syncFixture({ private: false });
170
+ await assert.rejects(f.settings.execute('sync', { mode: 'push' }), error('PRIVATE_REPOSITORY_REQUIRED', 400));
171
+ assert.equal(f.writes(), 0); assert.equal(f.localWrites(), 0);
172
+ const ok = await syncFixture(); const result = await ok.settings.execute('check', { target: 'sync' });
173
+ assert.equal(result.private, true); assert.equal(result.branch, 'main'); assert.equal(result.connected, true);
174
+ assert.doesNotMatch(JSON.stringify(result), /provider-secret/);
175
+ });
176
+
177
+ test('pull merges remote records without dropping unrelated local memories or uploading', async () => {
178
+ const f = await syncFixture(); const result = await f.settings.execute('sync', { mode: 'pull' });
179
+ assert.equal(result.ok, true); assert.equal(result.remoteCommitted, false); assert.equal(f.writes(), 0);
180
+ assert.deepEqual(new Set(f.local.memories.map(value => value.id)), new Set(['local', 'remote']));
181
+ assert.equal(result.localApplied, true);
182
+ });
183
+
184
+ test('push and merge preserve divergent original records and tombstones on both sides', async () => {
185
+ const local = await memory('same', 'local edit', { isPrivate: true });
186
+ const remote = await memory('same', 'remote edit'); const deleted = await memory('deleted');
187
+ const f = await syncFixture({ local: [local, deleted], remote: [remote], remoteTombstones: ['deleted'] });
188
+ const result = await f.settings.execute('sync', { mode: 'merge' });
189
+ assert.equal(result.ok, true); assert.equal(result.memories, 2); assert.equal(result.tombstones, 1);
190
+ assert.ok(f.remote().memories.some(value => value.id.includes('~conflict~')));
191
+ assert.ok(f.remote().memories.some(value => value.payload.content === 'local edit' && value.isPrivate));
192
+ assert.ok(!f.local.memories.some(value => value.id === 'deleted')); assert.ok(f.local.tombstones.includes('deleted'));
193
+ });
194
+
195
+ test('a remote SHA conflict rereads and remerges before retrying the commit', async () => {
196
+ const f = await syncFixture({ conflict: true }); const result = await f.settings.execute('sync', { mode: 'push' });
197
+ assert.equal(result.retries, 1); assert.equal(f.writes(), 2);
198
+ assert.deepEqual(new Set(f.remote().memories.map(value => value.id)), new Set(['local', 'remote', 'concurrent-remote']));
199
+ assert.ok(f.local.memories.some(value => value.id === 'concurrent-remote'));
200
+ });
201
+
202
+ test('exhausted remote conflicts leave local data intact and record a redacted failure', async () => {
203
+ const f = await syncFixture({ alwaysConflict: true });
204
+ await assert.rejects(f.settings.execute('sync', { mode: 'merge' }), error('REMOTE_CONFLICT', 422));
205
+ assert.equal(f.writes(), 4); assert.equal(f.localWrites(), 0); assert.equal(f.local.memories.length, 1);
206
+ const last = (await f.settings.execute('get')).lastSync; assert.equal(last.status, 'error'); assert.doesNotMatch(JSON.stringify(last), /provider-secret/);
207
+ });
208
+
209
+ test('a local generation conflict after remote commit returns explicit partial status without overwriting', async () => {
210
+ const f = await syncFixture({ localConflict: true }); const result = await f.settings.execute('sync', { mode: 'merge' });
211
+ assert.equal(result.ok, false); assert.equal(result.status, 'partial'); assert.equal(result.remoteCommitted, true); assert.equal(result.localApplied, false);
212
+ assert.ok(f.local.memories.some(value => value.id === 'concurrent-local')); assert.equal(f.local.memories.length, 2);
213
+ assert.equal((await f.settings.execute('get')).lastSync.status, 'partial');
214
+ });
215
+
216
+ test('malformed remote snapshots never commit locally or remotely', async () => {
217
+ const f = await syncFixture({ invalidRemote: true });
218
+ await assert.rejects(f.settings.execute('sync', { mode: 'merge' }), error('INVALID_SNAPSHOT', 400));
219
+ assert.equal(f.localWrites(), 0); assert.equal(f.writes(), 0);
220
+ for (const value of [{ memories: [{ text: 'missing id' }] }, { memories: [], tombstones: [null] }, { schemaVersion: 99, memories: [] }]) await assert.rejects(parseSyncSnapshot(value), error('INVALID_SNAPSHOT', 400));
221
+ });
222
+
223
+ test('Gitee creates missing files with POST, encodes Unicode, and keeps secrets out of results', async () => {
224
+ const f = await syncFixture({ provider: 'gitee', missingFile: true, local: [await memory('中文记忆', '用户的记忆')] });
225
+ const result = await f.settings.execute('sync', { mode: 'push' });
226
+ const write = f.requests.find(value => value.init.method === 'POST'); assert.ok(write);
227
+ assert.equal(JSON.parse(write.init.body).access_token, 'provider-secret');
228
+ assert.equal(f.remote().memories[0].payload.content, '用户的记忆'); assert.equal(result.provider, 'gitee');
229
+ assert.doesNotMatch(JSON.stringify(result), /provider-secret/);
230
+ });
231
+
232
+ test('remote transport limits responses, refuses redirects, and sanitizes provider errors', async () => {
233
+ let cancelled = false;
234
+ const f = await configured({ dependencies: { fetch: async (_url, init) => {
235
+ assert.equal(init.redirect, 'error');
236
+ return new Response(new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array(8 * 1024 * 1024 + 1)); }, cancel() { cancelled = true; } }));
237
+ } } });
238
+ await assert.rejects(f.settings.execute('check', { target: 'sync' }), error('RESPONSE_TOO_LARGE', 502)); assert.equal(cancelled, true);
239
+ f.settings.fetch = async () => json({ message: 'provider-secret' }, 403);
240
+ await assert.rejects(f.settings.execute('check', { target: 'sync' }), failure => { assert.equal(failure.code, 'REMOTE_ERROR'); assert.doesNotMatch(failure.message, /provider-secret/); return true; });
241
+ const empty = await syncFixture({ emptySuccess: true });
242
+ const committed = await empty.settings.execute('sync', { mode: 'merge' });
243
+ assert.equal(committed.ok, true); assert.equal(committed.remoteCommitted, true); assert.equal(committed.localApplied, true);
244
+ assert.deepEqual(new Set(empty.local.memories.map(value => value.id)), new Set(['local', 'remote']));
245
+ });
246
+
247
+ test('saving or opening settings never uploads; automatic sync debounces later mutations and cleans up', async () => {
248
+ let nextId = 0, cancelled = 0; const timers = new Map();
249
+ const { settings } = await configured({ sync: { autoSync: true }, dependencies: { setTimeout: callback => { const id = ++nextId; timers.set(id, callback); return id; }, clearTimeout: id => { cancelled++; timers.delete(id); } } });
250
+ await settings.execute('get'); assert.equal(timers.size, 0);
251
+ let syncs = 0;
252
+ settings.synchronize = async mode => { syncs++; assert.equal(mode, 'merge'); };
253
+ await settings.afterMutation(); await settings.afterMutation(); assert.equal(timers.size, 1); assert.equal(cancelled, 1);
254
+ await [...timers.values()][0](); assert.equal(syncs, 1);
255
+ await settings.afterMutation(); settings.dispose(); assert.equal(settings.timer, null);
256
+ await settings.afterMutation(); assert.equal(syncs, 1);
257
+ });
258
+
259
+ test('plugin disposal aborts an active automatic sync', async () => {
260
+ let callback; let signal;
261
+ const { settings } = await configured({ sync: { autoSync: true }, dependencies: { setTimeout: next => { callback = next; return 1; }, clearTimeout() {} } });
262
+ let entered; const ready = new Promise(resolve => { entered = resolve; });
263
+ settings.synchronize = async (_mode, incoming) => { signal = incoming; entered(); await new Promise((_resolve, reject) => incoming.addEventListener('abort', () => reject(new Error('aborted')), { once: true })); };
264
+ await settings.afterMutation(); const pending = callback(); await ready; settings.dispose(); await pending; assert.equal(signal.aborted, true);
265
+ });
266
+
267
+ test('the settings tool and authenticated panel dispatcher call the same settings operation', async t => {
268
+ const calls = [], disposers = [], tools = new Map(); let route;
269
+ t.mock.method(MorySettingsService.prototype, 'update', async input => { calls.push(input); return { revision: 1, appearance: 'midnight', connection: { tokenConfigured: false }, sync: { tokenConfigured: false } }; });
270
+ const ctx = { effect(setup) { disposers.push(setup()); }, tools: { register(value) { tools.set(value.name, value); return () => tools.delete(value.name); } }, connection: { fetch: { register(value) { route = value; return async () => {}; } } } };
271
+ apply(ctx); t.after(async () => { for (const dispose of disposers.reverse()) await dispose?.(); });
272
+ const body = { expectedRevision: 0, appearance: 'midnight' };
273
+ const response = await route.fetch(new Request('http://dsh.test/api/mory', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'settings.update', ...body }) }));
274
+ const tool = tools.get('mory_settings'); const value = await tool.execute({ action: 'update', ...body }, { signal: new AbortController().signal });
275
+ assert.deepEqual(await response.json(), value); assert.deepEqual(calls, [body, body]);
276
+ assert.equal(tool.isConcurrencySafe({ action: 'get' }), true); assert.equal(tool.isConcurrencySafe({ action: 'sync' }), false);
277
+ assert.ok(tools.has('mory_memory'));
278
+ });