dsh-session-rewind 1.2.7 → 1.2.8
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/README.md +163 -163
- package/package.json +1 -1
- package/test/client-smoke.mjs +440 -440
- package/test/route-smoke.mjs +272 -272
- package/test/selftest.mjs +262 -262
package/test/route-smoke.mjs
CHANGED
|
@@ -1,272 +1,272 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Route-level test: the two Host routes through their real fences and shape,
|
|
3
|
-
* with a real archive and a stubbed Turn lookup.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import assert from 'node:assert/strict';
|
|
7
|
-
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
8
|
-
import { tmpdir } from 'node:os';
|
|
9
|
-
import { join } from 'node:path';
|
|
10
|
-
|
|
11
|
-
import { createArchive } from '../src/lib/archive.js';
|
|
12
|
-
import {
|
|
13
|
-
CANDIDATES_PATH,
|
|
14
|
-
PLAN_PATH,
|
|
15
|
-
RESTORE_PATH,
|
|
16
|
-
createCandidatesHandler,
|
|
17
|
-
createCutsHandler,
|
|
18
|
-
createInplaceHandler,
|
|
19
|
-
createPlanHandler,
|
|
20
|
-
createRestoreHandler,
|
|
21
|
-
} from '../src/lib/route.js';
|
|
22
|
-
|
|
23
|
-
const root = await mkdtemp(join(tmpdir(), 'rewind-route-'));
|
|
24
|
-
const archive = createArchive({ directory: join(root, 'archive') });
|
|
25
|
-
const file = join(root, 'src', 'main.ts');
|
|
26
|
-
const { mkdir } = await import('node:fs/promises');
|
|
27
|
-
await mkdir(join(root, 'src'), { recursive: true });
|
|
28
|
-
await writeFile(file, 'const a = 1;\n', 'utf8');
|
|
29
|
-
|
|
30
|
-
const sessionId = 'session-route-1';
|
|
31
|
-
await archive.record({ sessionId, turn: 2, absolute: file, displayPath: 'src/main.ts' });
|
|
32
|
-
|
|
33
|
-
const describeTurn = async (id, options) => {
|
|
34
|
-
if (id !== sessionId) return null;
|
|
35
|
-
const turn = Number.isInteger(options.turn) ? options.turn : 2;
|
|
36
|
-
if (turn !== 2) return null;
|
|
37
|
-
return { turn, endSeq: 41 };
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
const restored = [];
|
|
41
|
-
const planHandler = createPlanHandler({
|
|
42
|
-
archive,
|
|
43
|
-
describeTurn,
|
|
44
|
-
userPromptOf: async () => 'make it dark',
|
|
45
|
-
trustedHosts: () => [],
|
|
46
|
-
});
|
|
47
|
-
const restoreHandler = createRestoreHandler({
|
|
48
|
-
archive,
|
|
49
|
-
describeTurn,
|
|
50
|
-
restoreFile: async (entry) => {
|
|
51
|
-
restored.push(entry.path);
|
|
52
|
-
return { path: entry.displayPath, status: 'restored' };
|
|
53
|
-
},
|
|
54
|
-
trustedHosts: () => [],
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
const HOST = '127.0.0.1:64031';
|
|
58
|
-
|
|
59
|
-
function request(body, options = {}) {
|
|
60
|
-
const text = body === undefined ? '' : JSON.stringify(body);
|
|
61
|
-
return {
|
|
62
|
-
method: options.method ?? 'POST',
|
|
63
|
-
headers: {
|
|
64
|
-
host: options.host ?? HOST,
|
|
65
|
-
origin: options.origin ?? (options.host === undefined ? `http://${HOST}` : options.origin),
|
|
66
|
-
'content-type': options.contentType ?? 'application/json',
|
|
67
|
-
...(options.headers ?? {}),
|
|
68
|
-
},
|
|
69
|
-
async *[Symbol.asyncIterator]() {
|
|
70
|
-
if (text !== '') yield Buffer.from(text, 'utf8');
|
|
71
|
-
},
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function response() {
|
|
76
|
-
return {
|
|
77
|
-
status: 0,
|
|
78
|
-
headers: {},
|
|
79
|
-
body: '',
|
|
80
|
-
writeHead(status, headers) {
|
|
81
|
-
this.status = status;
|
|
82
|
-
this.headers = headers ?? {};
|
|
83
|
-
},
|
|
84
|
-
end(text) {
|
|
85
|
-
this.body = text ?? '';
|
|
86
|
-
},
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// --- plan ----------------------------------------------------------------
|
|
91
|
-
let res = response();
|
|
92
|
-
await planHandler(request({ sessionId, turn: 2 }), res);
|
|
93
|
-
assert.equal(res.status, 200);
|
|
94
|
-
const plan = JSON.parse(res.body);
|
|
95
|
-
assert.deepEqual(plan, {
|
|
96
|
-
ok: true,
|
|
97
|
-
turn: 2,
|
|
98
|
-
seq: 41,
|
|
99
|
-
prompt: 'make it dark',
|
|
100
|
-
files: [{ path: file, displayPath: 'src/main.ts', action: 'restore', size: 13 }],
|
|
101
|
-
});
|
|
102
|
-
assert.equal(res.headers['Cache-Control'], 'no-store');
|
|
103
|
-
assert.equal(res.headers['X-Content-Type-Options'], 'nosniff');
|
|
104
|
-
|
|
105
|
-
// seq-only targeting works too
|
|
106
|
-
res = response();
|
|
107
|
-
await planHandler(request({ sessionId, seq: 40 }), res);
|
|
108
|
-
assert.equal(res.status, 200);
|
|
109
|
-
|
|
110
|
-
// --- plan gates ----------------------------------------------------------
|
|
111
|
-
res = response();
|
|
112
|
-
await planHandler(request({ sessionId, turn: 2 }, { method: 'GET' }), res);
|
|
113
|
-
assert.equal(res.status, 405);
|
|
114
|
-
|
|
115
|
-
res = response();
|
|
116
|
-
await planHandler(request({ sessionId, turn: 2 }, { host: 'example.com', origin: 'http://example.com' }), res);
|
|
117
|
-
assert.equal(res.status, 403);
|
|
118
|
-
|
|
119
|
-
res = response();
|
|
120
|
-
await planHandler(request({ sessionId, turn: 2 }, { headers: { 'sec-fetch-site': 'cross-site' } }), res);
|
|
121
|
-
assert.equal(res.status, 403);
|
|
122
|
-
|
|
123
|
-
res = response();
|
|
124
|
-
await planHandler(request({ sessionId, turn: 2 }, { origin: 'http://evil.example' }), res);
|
|
125
|
-
assert.equal(res.status, 403);
|
|
126
|
-
|
|
127
|
-
res = response();
|
|
128
|
-
await planHandler(request({ sessionId, turn: 2 }, { contentType: 'text/plain' }), res);
|
|
129
|
-
assert.equal(res.status, 415);
|
|
130
|
-
|
|
131
|
-
res = response();
|
|
132
|
-
await planHandler(request({ sessionId: '' }), res);
|
|
133
|
-
assert.equal(res.status, 400);
|
|
134
|
-
|
|
135
|
-
res = response();
|
|
136
|
-
await planHandler(request({ sessionId }), res);
|
|
137
|
-
assert.equal(res.status, 400);
|
|
138
|
-
|
|
139
|
-
res = response();
|
|
140
|
-
await planHandler(request({ sessionId: 'other', turn: 2 }), res);
|
|
141
|
-
assert.equal(res.status, 409);
|
|
142
|
-
|
|
143
|
-
// --- candidates ----------------------------------------------------------
|
|
144
|
-
const listTurns = async (id, limit) => {
|
|
145
|
-
if (id !== sessionId) return [];
|
|
146
|
-
return [{ turn: 2, seq: 41, prompt: 'make it dark', files: { count: 1, restorable: 1 } }].slice(0, limit);
|
|
147
|
-
};
|
|
148
|
-
const candidatesHandler = createCandidatesHandler({ listTurns, trustedHosts: () => [] });
|
|
149
|
-
|
|
150
|
-
res = response();
|
|
151
|
-
await candidatesHandler(request({ sessionId }), res);
|
|
152
|
-
assert.equal(res.status, 200);
|
|
153
|
-
assert.deepEqual(JSON.parse(res.body), {
|
|
154
|
-
ok: true,
|
|
155
|
-
turns: [{ turn: 2, seq: 41, prompt: 'make it dark', files: { count: 1, restorable: 1 } }],
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
res = response();
|
|
159
|
-
await candidatesHandler(request({ sessionId }, { method: 'GET' }), res);
|
|
160
|
-
assert.equal(res.status, 405);
|
|
161
|
-
|
|
162
|
-
res = response();
|
|
163
|
-
await candidatesHandler(request({ sessionId: 'x' }), res);
|
|
164
|
-
assert.equal(res.status, 200);
|
|
165
|
-
assert.deepEqual(JSON.parse(res.body).turns, []);
|
|
166
|
-
|
|
167
|
-
res = response();
|
|
168
|
-
await candidatesHandler(request({}), res);
|
|
169
|
-
assert.equal(res.status, 400);
|
|
170
|
-
|
|
171
|
-
res = response();
|
|
172
|
-
await candidatesHandler(request({ sessionId }, { host: 'example.com', origin: 'http://example.com' }), res);
|
|
173
|
-
assert.equal(res.status, 403);
|
|
174
|
-
|
|
175
|
-
// --- in-place rewind ----------------------------------------------------
|
|
176
|
-
const executed = [];
|
|
177
|
-
const inplaceHandler = createInplaceHandler({
|
|
178
|
-
describe: async (id, seq) => {
|
|
179
|
-
if (id !== sessionId) throw Object.assign(new Error('not live'), { status: 409 });
|
|
180
|
-
if (seq !== 11) throw Object.assign(new Error('only your own messages'), { code: 'not-a-user-message' });
|
|
181
|
-
return { targetSeq: 11, surfaceStart: 11, surfaceEnd: 41, prompt: 'make it dark', turn: 2 };
|
|
182
|
-
},
|
|
183
|
-
execute: async (id, seq, restoreFiles) => {
|
|
184
|
-
if (seq !== 11) throw Object.assign(new Error('only your own messages'), { code: 'not-a-user-message' });
|
|
185
|
-
executed.push({ id, seq, restoreFiles });
|
|
186
|
-
return { markerSeq: 50, targetSeq: seq, prompt: 'make it dark', turn: 2, hiddenFromSeq: 11, hiddenFromTurn: 2 };
|
|
187
|
-
},
|
|
188
|
-
trustedHosts: () => [],
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
// dryRun plans without executing
|
|
192
|
-
res = response();
|
|
193
|
-
await inplaceHandler(request({ sessionId, seq: 11, dryRun: true }), res);
|
|
194
|
-
assert.equal(res.status, 200);
|
|
195
|
-
assert.equal(JSON.parse(res.body).dryRun, true);
|
|
196
|
-
assert.deepEqual(executed, []);
|
|
197
|
-
|
|
198
|
-
// execute appends + reports the cut
|
|
199
|
-
res = response();
|
|
200
|
-
await inplaceHandler(request({ sessionId, seq: 11 }), res);
|
|
201
|
-
assert.equal(res.status, 200);
|
|
202
|
-
assert.deepEqual(JSON.parse(res.body), {
|
|
203
|
-
ok: true, markerSeq: 50, targetSeq: 11, prompt: 'make it dark', turn: 2, hiddenFromSeq: 11, hiddenFromTurn: 2,
|
|
204
|
-
});
|
|
205
|
-
assert.deepEqual(executed, [{ id: sessionId, seq: 11, restoreFiles: true }]);
|
|
206
|
-
|
|
207
|
-
// a planning refusal is a 409 with a code, not a 500
|
|
208
|
-
executed.length = 0;
|
|
209
|
-
res = response();
|
|
210
|
-
await inplaceHandler(request({ sessionId, seq: 12 }), res);
|
|
211
|
-
assert.equal(res.status, 409);
|
|
212
|
-
assert.equal(JSON.parse(res.body).code, 'not-a-user-message');
|
|
213
|
-
|
|
214
|
-
// gates
|
|
215
|
-
res = response();
|
|
216
|
-
await inplaceHandler(request({ sessionId }, {}), res);
|
|
217
|
-
assert.equal(res.status, 400);
|
|
218
|
-
res = response();
|
|
219
|
-
await inplaceHandler(request({ sessionId, seq: -1 }), res);
|
|
220
|
-
assert.equal(res.status, 400);
|
|
221
|
-
res = response();
|
|
222
|
-
await inplaceHandler(request({ sessionId, seq: 11 }, { host: 'evil.example', origin: 'http://evil.example' }), res);
|
|
223
|
-
assert.equal(res.status, 403);
|
|
224
|
-
|
|
225
|
-
// --- cuts ----------------------------------------------------------------
|
|
226
|
-
const cutsHandler = createCutsHandler({
|
|
227
|
-
cutsOf: async (id) => (id === sessionId ? { hiddenFromSeq: 11, hiddenFromTurn: 2 } : { hiddenFromSeq: null, hiddenFromTurn: null }),
|
|
228
|
-
trustedHosts: () => [],
|
|
229
|
-
});
|
|
230
|
-
res = response();
|
|
231
|
-
await cutsHandler(request({ sessionId }), res);
|
|
232
|
-
assert.equal(res.status, 200);
|
|
233
|
-
assert.deepEqual(JSON.parse(res.body), { ok: true, hiddenFromSeq: 11, hiddenFromTurn: 2 });
|
|
234
|
-
res = response();
|
|
235
|
-
await cutsHandler(request({}), res);
|
|
236
|
-
assert.equal(res.status, 400);
|
|
237
|
-
|
|
238
|
-
// --- restore -------------------------------------------------------------
|
|
239
|
-
res = response();
|
|
240
|
-
await restoreHandler(request({ sessionId, turn: 2 }), res);
|
|
241
|
-
assert.equal(res.status, 200);
|
|
242
|
-
assert.deepEqual(JSON.parse(res.body), {
|
|
243
|
-
ok: true,
|
|
244
|
-
turn: 2,
|
|
245
|
-
results: [{ path: 'src/main.ts', status: 'restored' }],
|
|
246
|
-
});
|
|
247
|
-
assert.deepEqual(restored, [file]);
|
|
248
|
-
|
|
249
|
-
// a client cannot name a path that the archive did not record
|
|
250
|
-
restored.length = 0;
|
|
251
|
-
res = response();
|
|
252
|
-
await restoreHandler(request({ sessionId, turn: 2, paths: ['C:/Windows/System32/config/SAM'] }), res);
|
|
253
|
-
assert.equal(res.status, 200);
|
|
254
|
-
assert.deepEqual(JSON.parse(res.body).results, []);
|
|
255
|
-
assert.deepEqual(restored, []);
|
|
256
|
-
|
|
257
|
-
// an unknown turn is refused, and no file is touched
|
|
258
|
-
restored.length = 0;
|
|
259
|
-
res = response();
|
|
260
|
-
await restoreHandler(request({ sessionId, turn: 9 }), res);
|
|
261
|
-
assert.equal(res.status, 409);
|
|
262
|
-
assert.deepEqual(restored, []);
|
|
263
|
-
|
|
264
|
-
// a Turn with no captured files answers with an empty result, not an error
|
|
265
|
-
res = response();
|
|
266
|
-
await restoreHandler(request({ sessionId, turn: 2, paths: [] }), res);
|
|
267
|
-
assert.equal(res.status, 200);
|
|
268
|
-
|
|
269
|
-
console.log(`route smoke: all assertions passed (${PLAN_PATH}, ${RESTORE_PATH})`);
|
|
270
|
-
|
|
271
|
-
await archive.close();
|
|
272
|
-
await rm(root, { recursive: true, force: true });
|
|
1
|
+
/**
|
|
2
|
+
* Route-level test: the two Host routes through their real fences and shape,
|
|
3
|
+
* with a real archive and a stubbed Turn lookup.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { createArchive } from '../src/lib/archive.js';
|
|
12
|
+
import {
|
|
13
|
+
CANDIDATES_PATH,
|
|
14
|
+
PLAN_PATH,
|
|
15
|
+
RESTORE_PATH,
|
|
16
|
+
createCandidatesHandler,
|
|
17
|
+
createCutsHandler,
|
|
18
|
+
createInplaceHandler,
|
|
19
|
+
createPlanHandler,
|
|
20
|
+
createRestoreHandler,
|
|
21
|
+
} from '../src/lib/route.js';
|
|
22
|
+
|
|
23
|
+
const root = await mkdtemp(join(tmpdir(), 'rewind-route-'));
|
|
24
|
+
const archive = createArchive({ directory: join(root, 'archive') });
|
|
25
|
+
const file = join(root, 'src', 'main.ts');
|
|
26
|
+
const { mkdir } = await import('node:fs/promises');
|
|
27
|
+
await mkdir(join(root, 'src'), { recursive: true });
|
|
28
|
+
await writeFile(file, 'const a = 1;\n', 'utf8');
|
|
29
|
+
|
|
30
|
+
const sessionId = 'session-route-1';
|
|
31
|
+
await archive.record({ sessionId, turn: 2, absolute: file, displayPath: 'src/main.ts' });
|
|
32
|
+
|
|
33
|
+
const describeTurn = async (id, options) => {
|
|
34
|
+
if (id !== sessionId) return null;
|
|
35
|
+
const turn = Number.isInteger(options.turn) ? options.turn : 2;
|
|
36
|
+
if (turn !== 2) return null;
|
|
37
|
+
return { turn, endSeq: 41 };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const restored = [];
|
|
41
|
+
const planHandler = createPlanHandler({
|
|
42
|
+
archive,
|
|
43
|
+
describeTurn,
|
|
44
|
+
userPromptOf: async () => 'make it dark',
|
|
45
|
+
trustedHosts: () => [],
|
|
46
|
+
});
|
|
47
|
+
const restoreHandler = createRestoreHandler({
|
|
48
|
+
archive,
|
|
49
|
+
describeTurn,
|
|
50
|
+
restoreFile: async (entry) => {
|
|
51
|
+
restored.push(entry.path);
|
|
52
|
+
return { path: entry.displayPath, status: 'restored' };
|
|
53
|
+
},
|
|
54
|
+
trustedHosts: () => [],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const HOST = '127.0.0.1:64031';
|
|
58
|
+
|
|
59
|
+
function request(body, options = {}) {
|
|
60
|
+
const text = body === undefined ? '' : JSON.stringify(body);
|
|
61
|
+
return {
|
|
62
|
+
method: options.method ?? 'POST',
|
|
63
|
+
headers: {
|
|
64
|
+
host: options.host ?? HOST,
|
|
65
|
+
origin: options.origin ?? (options.host === undefined ? `http://${HOST}` : options.origin),
|
|
66
|
+
'content-type': options.contentType ?? 'application/json',
|
|
67
|
+
...(options.headers ?? {}),
|
|
68
|
+
},
|
|
69
|
+
async *[Symbol.asyncIterator]() {
|
|
70
|
+
if (text !== '') yield Buffer.from(text, 'utf8');
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function response() {
|
|
76
|
+
return {
|
|
77
|
+
status: 0,
|
|
78
|
+
headers: {},
|
|
79
|
+
body: '',
|
|
80
|
+
writeHead(status, headers) {
|
|
81
|
+
this.status = status;
|
|
82
|
+
this.headers = headers ?? {};
|
|
83
|
+
},
|
|
84
|
+
end(text) {
|
|
85
|
+
this.body = text ?? '';
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- plan ----------------------------------------------------------------
|
|
91
|
+
let res = response();
|
|
92
|
+
await planHandler(request({ sessionId, turn: 2 }), res);
|
|
93
|
+
assert.equal(res.status, 200);
|
|
94
|
+
const plan = JSON.parse(res.body);
|
|
95
|
+
assert.deepEqual(plan, {
|
|
96
|
+
ok: true,
|
|
97
|
+
turn: 2,
|
|
98
|
+
seq: 41,
|
|
99
|
+
prompt: 'make it dark',
|
|
100
|
+
files: [{ path: file, displayPath: 'src/main.ts', action: 'restore', size: 13 }],
|
|
101
|
+
});
|
|
102
|
+
assert.equal(res.headers['Cache-Control'], 'no-store');
|
|
103
|
+
assert.equal(res.headers['X-Content-Type-Options'], 'nosniff');
|
|
104
|
+
|
|
105
|
+
// seq-only targeting works too
|
|
106
|
+
res = response();
|
|
107
|
+
await planHandler(request({ sessionId, seq: 40 }), res);
|
|
108
|
+
assert.equal(res.status, 200);
|
|
109
|
+
|
|
110
|
+
// --- plan gates ----------------------------------------------------------
|
|
111
|
+
res = response();
|
|
112
|
+
await planHandler(request({ sessionId, turn: 2 }, { method: 'GET' }), res);
|
|
113
|
+
assert.equal(res.status, 405);
|
|
114
|
+
|
|
115
|
+
res = response();
|
|
116
|
+
await planHandler(request({ sessionId, turn: 2 }, { host: 'example.com', origin: 'http://example.com' }), res);
|
|
117
|
+
assert.equal(res.status, 403);
|
|
118
|
+
|
|
119
|
+
res = response();
|
|
120
|
+
await planHandler(request({ sessionId, turn: 2 }, { headers: { 'sec-fetch-site': 'cross-site' } }), res);
|
|
121
|
+
assert.equal(res.status, 403);
|
|
122
|
+
|
|
123
|
+
res = response();
|
|
124
|
+
await planHandler(request({ sessionId, turn: 2 }, { origin: 'http://evil.example' }), res);
|
|
125
|
+
assert.equal(res.status, 403);
|
|
126
|
+
|
|
127
|
+
res = response();
|
|
128
|
+
await planHandler(request({ sessionId, turn: 2 }, { contentType: 'text/plain' }), res);
|
|
129
|
+
assert.equal(res.status, 415);
|
|
130
|
+
|
|
131
|
+
res = response();
|
|
132
|
+
await planHandler(request({ sessionId: '' }), res);
|
|
133
|
+
assert.equal(res.status, 400);
|
|
134
|
+
|
|
135
|
+
res = response();
|
|
136
|
+
await planHandler(request({ sessionId }), res);
|
|
137
|
+
assert.equal(res.status, 400);
|
|
138
|
+
|
|
139
|
+
res = response();
|
|
140
|
+
await planHandler(request({ sessionId: 'other', turn: 2 }), res);
|
|
141
|
+
assert.equal(res.status, 409);
|
|
142
|
+
|
|
143
|
+
// --- candidates ----------------------------------------------------------
|
|
144
|
+
const listTurns = async (id, limit) => {
|
|
145
|
+
if (id !== sessionId) return [];
|
|
146
|
+
return [{ turn: 2, seq: 41, prompt: 'make it dark', files: { count: 1, restorable: 1 } }].slice(0, limit);
|
|
147
|
+
};
|
|
148
|
+
const candidatesHandler = createCandidatesHandler({ listTurns, trustedHosts: () => [] });
|
|
149
|
+
|
|
150
|
+
res = response();
|
|
151
|
+
await candidatesHandler(request({ sessionId }), res);
|
|
152
|
+
assert.equal(res.status, 200);
|
|
153
|
+
assert.deepEqual(JSON.parse(res.body), {
|
|
154
|
+
ok: true,
|
|
155
|
+
turns: [{ turn: 2, seq: 41, prompt: 'make it dark', files: { count: 1, restorable: 1 } }],
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
res = response();
|
|
159
|
+
await candidatesHandler(request({ sessionId }, { method: 'GET' }), res);
|
|
160
|
+
assert.equal(res.status, 405);
|
|
161
|
+
|
|
162
|
+
res = response();
|
|
163
|
+
await candidatesHandler(request({ sessionId: 'x' }), res);
|
|
164
|
+
assert.equal(res.status, 200);
|
|
165
|
+
assert.deepEqual(JSON.parse(res.body).turns, []);
|
|
166
|
+
|
|
167
|
+
res = response();
|
|
168
|
+
await candidatesHandler(request({}), res);
|
|
169
|
+
assert.equal(res.status, 400);
|
|
170
|
+
|
|
171
|
+
res = response();
|
|
172
|
+
await candidatesHandler(request({ sessionId }, { host: 'example.com', origin: 'http://example.com' }), res);
|
|
173
|
+
assert.equal(res.status, 403);
|
|
174
|
+
|
|
175
|
+
// --- in-place rewind ----------------------------------------------------
|
|
176
|
+
const executed = [];
|
|
177
|
+
const inplaceHandler = createInplaceHandler({
|
|
178
|
+
describe: async (id, seq) => {
|
|
179
|
+
if (id !== sessionId) throw Object.assign(new Error('not live'), { status: 409 });
|
|
180
|
+
if (seq !== 11) throw Object.assign(new Error('only your own messages'), { code: 'not-a-user-message' });
|
|
181
|
+
return { targetSeq: 11, surfaceStart: 11, surfaceEnd: 41, prompt: 'make it dark', turn: 2 };
|
|
182
|
+
},
|
|
183
|
+
execute: async (id, seq, restoreFiles) => {
|
|
184
|
+
if (seq !== 11) throw Object.assign(new Error('only your own messages'), { code: 'not-a-user-message' });
|
|
185
|
+
executed.push({ id, seq, restoreFiles });
|
|
186
|
+
return { markerSeq: 50, targetSeq: seq, prompt: 'make it dark', turn: 2, hiddenFromSeq: 11, hiddenFromTurn: 2 };
|
|
187
|
+
},
|
|
188
|
+
trustedHosts: () => [],
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// dryRun plans without executing
|
|
192
|
+
res = response();
|
|
193
|
+
await inplaceHandler(request({ sessionId, seq: 11, dryRun: true }), res);
|
|
194
|
+
assert.equal(res.status, 200);
|
|
195
|
+
assert.equal(JSON.parse(res.body).dryRun, true);
|
|
196
|
+
assert.deepEqual(executed, []);
|
|
197
|
+
|
|
198
|
+
// execute appends + reports the cut
|
|
199
|
+
res = response();
|
|
200
|
+
await inplaceHandler(request({ sessionId, seq: 11 }), res);
|
|
201
|
+
assert.equal(res.status, 200);
|
|
202
|
+
assert.deepEqual(JSON.parse(res.body), {
|
|
203
|
+
ok: true, markerSeq: 50, targetSeq: 11, prompt: 'make it dark', turn: 2, hiddenFromSeq: 11, hiddenFromTurn: 2,
|
|
204
|
+
});
|
|
205
|
+
assert.deepEqual(executed, [{ id: sessionId, seq: 11, restoreFiles: true }]);
|
|
206
|
+
|
|
207
|
+
// a planning refusal is a 409 with a code, not a 500
|
|
208
|
+
executed.length = 0;
|
|
209
|
+
res = response();
|
|
210
|
+
await inplaceHandler(request({ sessionId, seq: 12 }), res);
|
|
211
|
+
assert.equal(res.status, 409);
|
|
212
|
+
assert.equal(JSON.parse(res.body).code, 'not-a-user-message');
|
|
213
|
+
|
|
214
|
+
// gates
|
|
215
|
+
res = response();
|
|
216
|
+
await inplaceHandler(request({ sessionId }, {}), res);
|
|
217
|
+
assert.equal(res.status, 400);
|
|
218
|
+
res = response();
|
|
219
|
+
await inplaceHandler(request({ sessionId, seq: -1 }), res);
|
|
220
|
+
assert.equal(res.status, 400);
|
|
221
|
+
res = response();
|
|
222
|
+
await inplaceHandler(request({ sessionId, seq: 11 }, { host: 'evil.example', origin: 'http://evil.example' }), res);
|
|
223
|
+
assert.equal(res.status, 403);
|
|
224
|
+
|
|
225
|
+
// --- cuts ----------------------------------------------------------------
|
|
226
|
+
const cutsHandler = createCutsHandler({
|
|
227
|
+
cutsOf: async (id) => (id === sessionId ? { hiddenFromSeq: 11, hiddenFromTurn: 2 } : { hiddenFromSeq: null, hiddenFromTurn: null }),
|
|
228
|
+
trustedHosts: () => [],
|
|
229
|
+
});
|
|
230
|
+
res = response();
|
|
231
|
+
await cutsHandler(request({ sessionId }), res);
|
|
232
|
+
assert.equal(res.status, 200);
|
|
233
|
+
assert.deepEqual(JSON.parse(res.body), { ok: true, hiddenFromSeq: 11, hiddenFromTurn: 2 });
|
|
234
|
+
res = response();
|
|
235
|
+
await cutsHandler(request({}), res);
|
|
236
|
+
assert.equal(res.status, 400);
|
|
237
|
+
|
|
238
|
+
// --- restore -------------------------------------------------------------
|
|
239
|
+
res = response();
|
|
240
|
+
await restoreHandler(request({ sessionId, turn: 2 }), res);
|
|
241
|
+
assert.equal(res.status, 200);
|
|
242
|
+
assert.deepEqual(JSON.parse(res.body), {
|
|
243
|
+
ok: true,
|
|
244
|
+
turn: 2,
|
|
245
|
+
results: [{ path: 'src/main.ts', status: 'restored' }],
|
|
246
|
+
});
|
|
247
|
+
assert.deepEqual(restored, [file]);
|
|
248
|
+
|
|
249
|
+
// a client cannot name a path that the archive did not record
|
|
250
|
+
restored.length = 0;
|
|
251
|
+
res = response();
|
|
252
|
+
await restoreHandler(request({ sessionId, turn: 2, paths: ['C:/Windows/System32/config/SAM'] }), res);
|
|
253
|
+
assert.equal(res.status, 200);
|
|
254
|
+
assert.deepEqual(JSON.parse(res.body).results, []);
|
|
255
|
+
assert.deepEqual(restored, []);
|
|
256
|
+
|
|
257
|
+
// an unknown turn is refused, and no file is touched
|
|
258
|
+
restored.length = 0;
|
|
259
|
+
res = response();
|
|
260
|
+
await restoreHandler(request({ sessionId, turn: 9 }), res);
|
|
261
|
+
assert.equal(res.status, 409);
|
|
262
|
+
assert.deepEqual(restored, []);
|
|
263
|
+
|
|
264
|
+
// a Turn with no captured files answers with an empty result, not an error
|
|
265
|
+
res = response();
|
|
266
|
+
await restoreHandler(request({ sessionId, turn: 2, paths: [] }), res);
|
|
267
|
+
assert.equal(res.status, 200);
|
|
268
|
+
|
|
269
|
+
console.log(`route smoke: all assertions passed (${PLAN_PATH}, ${RESTORE_PATH})`);
|
|
270
|
+
|
|
271
|
+
await archive.close();
|
|
272
|
+
await rm(root, { recursive: true, force: true });
|