@proteinjs/user-server 1.10.0 → 1.10.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.
- package/CHANGELOG.md +22 -0
- package/dist/generated/index.js +1 -1
- package/dist/src/authentication/establishSession.d.ts +9 -2
- package/dist/src/authentication/establishSession.d.ts.map +1 -1
- package/dist/src/authentication/establishSession.js +23 -5
- package/dist/src/authentication/establishSession.js.map +1 -1
- package/dist/test/DevLoginStaleCookieFirstHit.test.d.ts +2 -0
- package/dist/test/DevLoginStaleCookieFirstHit.test.d.ts.map +1 -0
- package/dist/test/DevLoginStaleCookieFirstHit.test.js +401 -0
- package/dist/test/DevLoginStaleCookieFirstHit.test.js.map +1 -0
- package/dist/test/EstablishSessionRuntimeContract.test.d.ts +2 -0
- package/dist/test/EstablishSessionRuntimeContract.test.d.ts.map +1 -0
- package/dist/test/EstablishSessionRuntimeContract.test.js +114 -0
- package/dist/test/EstablishSessionRuntimeContract.test.js.map +1 -0
- package/dist/test/MachineAccounts.integration.test.js +37 -6
- package/dist/test/MachineAccounts.integration.test.js.map +1 -1
- package/generated/index.ts +1 -1
- package/jest.config.js +7 -0
- package/package.json +5 -5
- package/src/authentication/establishSession.ts +20 -2
- package/test/DevLoginStaleCookieFirstHit.test.ts +304 -0
- package/test/EstablishSessionRuntimeContract.test.ts +54 -0
- package/test/MachineAccounts.integration.test.ts +60 -26
- package/tsconfig.json +5 -1
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import { AddressInfo } from 'net';
|
|
3
|
+
import expressSession, { Store } from 'express-session';
|
|
4
|
+
import passport from 'passport';
|
|
5
|
+
import { devLogin } from '../src/routes/devLogin';
|
|
6
|
+
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
7
|
+
|
|
8
|
+
const testEnv = new UserServerTestEnvironment();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* DEV_SMOKE_OVERNIGHT (2026-08-26) finding 7: a browser holding a STALE session cookie (a sid
|
|
12
|
+
* absent from the store — prior server generation, swept row, or reseeded dev db) hits
|
|
13
|
+
* `GET /dev/login`, gets the 302, and lands on the LOGIN FORM; the second hit lands home.
|
|
14
|
+
*
|
|
15
|
+
* The mechanism is write-ordering at the session-establishment seam: a browser follows a redirect
|
|
16
|
+
* the moment RESPONSE HEADERS arrive — it never waits for the body. So the session row for the
|
|
17
|
+
* sid named by the 302's Set-Cookie must be COMMITTED to the store before those headers flush.
|
|
18
|
+
* passport 0.6's `request.login` guarantees exactly that (regenerate → bind → save, committed
|
|
19
|
+
* before the login callback — see establishSession); under a pre-0.6 passport runtime the save
|
|
20
|
+
* is deferred to express-session's end-of-response proxy, whose split-response write flushes the
|
|
21
|
+
* 302's headers BEFORE the store write commits — the redirected navigation races the commit and
|
|
22
|
+
* loses, reads no session, and the SPA routes to /login.
|
|
23
|
+
*
|
|
24
|
+
* This suite drives the REAL stack a browser meets — express-session 1.17.1 (the exact
|
|
25
|
+
* @proteinjs/server configureSession options and dev cookie-name shape) + real passport +
|
|
26
|
+
* the devLogin route — against a store with honest commit latency, and asserts the contract
|
|
27
|
+
* from the client's side: follow the 302 the instant headers arrive, carrying exactly the
|
|
28
|
+
* cookies the response set, and the follow-up MUST read as authenticated on the FIRST pass.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const SESSION_COOKIE_NAME = 'n3xa.sid.9876'; // dev cookie-name shape: n3xa.sid.${SERVER_PORT}
|
|
32
|
+
const SESSION_SECRET = 'dev-first-hit-test-secret';
|
|
33
|
+
const ENV_EMAIL = 'dev@test.local';
|
|
34
|
+
/** Store commit latency. Generous so a pre-0.6 runtime (write racing the redirect) loses the
|
|
35
|
+
* race DETERMINISTICALLY; under the passport 0.6 contract the row commits before the redirect
|
|
36
|
+
* is even issued, so green never depends on this number. */
|
|
37
|
+
const COMMIT_LATENCY_MS = 150;
|
|
38
|
+
const READ_LATENCY_MS = 5;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* In-memory session store with DbSessionStore's contract: callbacks fire only after the
|
|
42
|
+
* operation commits, and commits take real time (Spanner in dev). Rows are inspectable so the
|
|
43
|
+
* test can assert store truth directly and clear it to model a next server generation.
|
|
44
|
+
*/
|
|
45
|
+
class CommitLatencySessionStore extends Store {
|
|
46
|
+
readonly rows = new Map<string, string>();
|
|
47
|
+
|
|
48
|
+
get = (sessionId: string, cb: (error: unknown, session?: Express.SessionData | null) => void) => {
|
|
49
|
+
const timer = setTimeout(() => {
|
|
50
|
+
const row = this.rows.get(sessionId);
|
|
51
|
+
cb(null, row ? JSON.parse(row) : undefined);
|
|
52
|
+
}, READ_LATENCY_MS);
|
|
53
|
+
timer.unref();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
set = (sessionId: string, session: Express.SessionData, cb?: (error?: unknown) => void) => {
|
|
57
|
+
const timer = setTimeout(() => {
|
|
58
|
+
this.rows.set(sessionId, JSON.stringify(session));
|
|
59
|
+
if (cb) {
|
|
60
|
+
cb();
|
|
61
|
+
}
|
|
62
|
+
}, COMMIT_LATENCY_MS);
|
|
63
|
+
timer.unref();
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
destroy = (sessionId: string, cb?: (error?: unknown) => void) => {
|
|
67
|
+
const timer = setTimeout(() => {
|
|
68
|
+
this.rows.delete(sessionId);
|
|
69
|
+
if (cb) {
|
|
70
|
+
cb();
|
|
71
|
+
}
|
|
72
|
+
}, READ_LATENCY_MS);
|
|
73
|
+
timer.unref();
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type Middleware = (req: unknown, res: unknown, next: (error?: unknown) => void) => void;
|
|
78
|
+
|
|
79
|
+
type ClientResponse = { statusCode?: number; headers: http.IncomingHttpHeaders; body?: string };
|
|
80
|
+
|
|
81
|
+
describe('devLogin first hit through the real session middleware stack', () => {
|
|
82
|
+
const originalEnv = {
|
|
83
|
+
DEVELOPMENT: process.env.DEVELOPMENT,
|
|
84
|
+
DEV_AUTO_LOGIN_EMAIL: process.env.DEV_AUTO_LOGIN_EMAIL,
|
|
85
|
+
};
|
|
86
|
+
const store = new CommitLatencySessionStore();
|
|
87
|
+
let server: http.Server;
|
|
88
|
+
let port: number;
|
|
89
|
+
|
|
90
|
+
beforeAll(async () => {
|
|
91
|
+
await testEnv.beforeAll();
|
|
92
|
+
await testEnv.createUser({ name: 'Dev Default', email: ENV_EMAIL });
|
|
93
|
+
server = createServerWithRealSessionStack(store);
|
|
94
|
+
await new Promise<void>((resolve) => server.listen(0, resolve));
|
|
95
|
+
port = (server.address() as AddressInfo).port;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
afterAll(async () => {
|
|
99
|
+
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
100
|
+
await testEnv.afterAll();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
process.env.DEVELOPMENT = 'true';
|
|
105
|
+
process.env.DEV_AUTO_LOGIN_EMAIL = ENV_EMAIL;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
afterEach(() => {
|
|
109
|
+
for (const [key, value] of Object.entries(originalEnv)) {
|
|
110
|
+
if (value === undefined) {
|
|
111
|
+
delete process.env[key];
|
|
112
|
+
} else {
|
|
113
|
+
process.env[key] = value;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('stale-cookie first hit: following the 302 on HEADERS reads as authenticated, first pass', async () => {
|
|
119
|
+
// A genuinely stale cookie: minted by a prior hit (valid signature, real sid), then the
|
|
120
|
+
// store loses the row — a prior server generation against a reseeded/swept dev db.
|
|
121
|
+
const priorHit = await request(port, '/dev/login', { waitForBody: true });
|
|
122
|
+
const staleCookie = sessionCookiePair(priorHit);
|
|
123
|
+
store.rows.clear();
|
|
124
|
+
|
|
125
|
+
// First hit with the stale cookie. Resolve on response HEADERS — the moment a browser
|
|
126
|
+
// starts the redirected navigation — never on body completion.
|
|
127
|
+
const firstHit = await request(port, '/dev/login', { cookie: staleCookie });
|
|
128
|
+
expect(firstHit.statusCode).toBe(302);
|
|
129
|
+
expect(firstHit.headers.location).toBe('/');
|
|
130
|
+
|
|
131
|
+
// The 302 must mint a fresh sid (the stale sid must not be resurrected)...
|
|
132
|
+
const followUpCookie = sessionCookiePair(firstHit);
|
|
133
|
+
expect(sidOf(followUpCookie)).not.toBe(sidOf(staleCookie));
|
|
134
|
+
|
|
135
|
+
// ...whose session row is ALREADY COMMITTED: the immediate follow-up navigation, carrying
|
|
136
|
+
// exactly the cookies the response set, reads as authenticated on the FIRST pass.
|
|
137
|
+
const followUp = await request(port, '/whoami', { cookie: followUpCookie, waitForBody: true });
|
|
138
|
+
expect(JSON.parse(followUp.body!)).toEqual({ user: ENV_EMAIL });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('cookieless first hit: same first-pass guarantee for a fresh browser', async () => {
|
|
142
|
+
store.rows.clear();
|
|
143
|
+
|
|
144
|
+
const firstHit = await request(port, '/dev/login');
|
|
145
|
+
expect(firstHit.statusCode).toBe(302);
|
|
146
|
+
|
|
147
|
+
const followUp = await request(port, '/whoami', { cookie: sessionCookiePair(firstHit), waitForBody: true });
|
|
148
|
+
expect(JSON.parse(followUp.body!)).toEqual({ user: ENV_EMAIL });
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The real middleware stack a request traverses in front of the route — express-session with
|
|
154
|
+
* @proteinjs/server configureSession's exact options (resave: false, saveUninitialized: false,
|
|
155
|
+
* rolling: true, the dev port-scoped cookie name) + passport initialize/session with the
|
|
156
|
+
* identity (de)serialization @proteinjs/server registers — on a raw node http server, so
|
|
157
|
+
* header-flush timing is the genuine article.
|
|
158
|
+
*/
|
|
159
|
+
function createServerWithRealSessionStack(store: Store): http.Server {
|
|
160
|
+
const sixtyDays = 1000 * 60 * 60 * 24 * 60;
|
|
161
|
+
const sessionMiddleware = expressSession({
|
|
162
|
+
name: SESSION_COOKIE_NAME,
|
|
163
|
+
secret: SESSION_SECRET,
|
|
164
|
+
store,
|
|
165
|
+
resave: false,
|
|
166
|
+
saveUninitialized: false,
|
|
167
|
+
cookie: { maxAge: sixtyDays },
|
|
168
|
+
rolling: true,
|
|
169
|
+
});
|
|
170
|
+
const authenticator = new passport.Passport();
|
|
171
|
+
authenticator.serializeUser((user: unknown, done: (err: unknown, id?: unknown) => void) => done(null, user));
|
|
172
|
+
authenticator.deserializeUser((id: unknown, done: (err: unknown, user?: unknown) => void) => done(null, id));
|
|
173
|
+
const middleware: Middleware[] = [
|
|
174
|
+
sessionMiddleware as unknown as Middleware,
|
|
175
|
+
authenticator.initialize() as unknown as Middleware,
|
|
176
|
+
authenticator.session() as unknown as Middleware,
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
return http.createServer((req, res) => {
|
|
180
|
+
runMiddleware(middleware, req, res, (error?: unknown) => {
|
|
181
|
+
if (error) {
|
|
182
|
+
res.statusCode = 500;
|
|
183
|
+
res.end(String(error));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
void dispatch(req, res);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function runMiddleware(
|
|
192
|
+
middleware: Middleware[],
|
|
193
|
+
req: http.IncomingMessage,
|
|
194
|
+
res: http.ServerResponse,
|
|
195
|
+
done: (error?: unknown) => void
|
|
196
|
+
) {
|
|
197
|
+
const next = (index: number) => (error?: unknown) => {
|
|
198
|
+
if (error || index >= middleware.length) {
|
|
199
|
+
done(error);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
middleware[index](req, res, next(index + 1));
|
|
203
|
+
};
|
|
204
|
+
next(0)();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function dispatch(req: http.IncomingMessage, res: http.ServerResponse) {
|
|
208
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
209
|
+
if (url.pathname === '/dev/login') {
|
|
210
|
+
const request = req as never as Record<string, unknown>;
|
|
211
|
+
const query: Record<string, string> = {};
|
|
212
|
+
url.searchParams.forEach((value, key) => (query[key] = value));
|
|
213
|
+
request.query = query;
|
|
214
|
+
try {
|
|
215
|
+
await devLogin.onRequest(req as never, expressResponseShim(res) as never);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
// Mirrors @proteinjs/server's wrapRoute: a route throw is caught and logged, never a
|
|
218
|
+
// hung response. (There it falls through to the star route; here we end with 500.)
|
|
219
|
+
console.error(error);
|
|
220
|
+
if (!res.writableEnded) {
|
|
221
|
+
res.statusCode = 500;
|
|
222
|
+
res.end();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (url.pathname === '/whoami') {
|
|
228
|
+
// The session-derived signal the SPA lands on: authenticated → home, none → login form.
|
|
229
|
+
const session = (req as never as { session?: { passport?: { user?: string } } }).session;
|
|
230
|
+
res.setHeader('Content-Type', 'application/json');
|
|
231
|
+
res.end(JSON.stringify({ user: session?.passport?.user ?? null }));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
res.statusCode = 404;
|
|
235
|
+
res.end();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The express response surface devLogin touches, over the RAW response object — so
|
|
239
|
+
* express-session's proxied `res.end` (the commit point under test) is the one that runs. */
|
|
240
|
+
function expressResponseShim(res: http.ServerResponse) {
|
|
241
|
+
const shim = {
|
|
242
|
+
status(code: number) {
|
|
243
|
+
res.statusCode = code;
|
|
244
|
+
return shim;
|
|
245
|
+
},
|
|
246
|
+
send(body?: unknown) {
|
|
247
|
+
res.end(body === undefined ? undefined : String(body));
|
|
248
|
+
},
|
|
249
|
+
redirect(path: string) {
|
|
250
|
+
const body = `Found. Redirecting to ${path}`;
|
|
251
|
+
res.statusCode = 302;
|
|
252
|
+
res.setHeader('Location', path);
|
|
253
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
254
|
+
res.setHeader('Content-Length', Buffer.byteLength(body));
|
|
255
|
+
res.end(body);
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
return shim;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* GET `path`. By default resolves the moment response HEADERS arrive (browser-following-a-
|
|
263
|
+
* redirect semantics — the body may still be in flight and its socket is left to drain);
|
|
264
|
+
* `waitForBody` resolves after the full body, which with express-session's end proxy also
|
|
265
|
+
* means the response's session write has committed.
|
|
266
|
+
*/
|
|
267
|
+
function request(
|
|
268
|
+
port: number,
|
|
269
|
+
path: string,
|
|
270
|
+
options: { cookie?: string; waitForBody?: boolean } = {}
|
|
271
|
+
): Promise<ClientResponse> {
|
|
272
|
+
return new Promise((resolve, reject) => {
|
|
273
|
+
const clientRequest = http.get(
|
|
274
|
+
{ port, path, headers: options.cookie ? { cookie: options.cookie } : {}, agent: false },
|
|
275
|
+
(res) => {
|
|
276
|
+
if (!options.waitForBody) {
|
|
277
|
+
resolve({ statusCode: res.statusCode, headers: res.headers });
|
|
278
|
+
res.resume();
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
let body = '';
|
|
282
|
+
res.on('data', (chunk) => (body += chunk));
|
|
283
|
+
res.on('end', () => resolve({ statusCode: res.statusCode, headers: res.headers, body }));
|
|
284
|
+
}
|
|
285
|
+
);
|
|
286
|
+
clientRequest.on('error', reject);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The `name=value` pair of the response's session Set-Cookie — what the browser sends back. */
|
|
291
|
+
function sessionCookiePair(response: ClientResponse): string {
|
|
292
|
+
const setCookies = response.headers['set-cookie'] ?? [];
|
|
293
|
+
const sessionCookie = setCookies.find((cookie) => cookie.startsWith(`${SESSION_COOKIE_NAME}=`));
|
|
294
|
+
if (!sessionCookie) {
|
|
295
|
+
throw new Error(`response set no ${SESSION_COOKIE_NAME} cookie (Set-Cookie: ${JSON.stringify(setCookies)})`);
|
|
296
|
+
}
|
|
297
|
+
return sessionCookie.split(';')[0];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The raw sid inside a signed session cookie pair (`name=s%3A<sid>.<signature>`). */
|
|
301
|
+
function sidOf(cookiePair: string): string {
|
|
302
|
+
const value = decodeURIComponent(cookiePair.slice(cookiePair.indexOf('=') + 1));
|
|
303
|
+
return value.startsWith('s:') ? value.slice(2, value.indexOf('.')) : value;
|
|
304
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { establishSession } from '../src/authentication/establishSession';
|
|
2
|
+
import { createPassportRequest } from './passportSessionHarness';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* establishSession's post-condition: `request.login` must have REPLACED `request.session`
|
|
6
|
+
* (express-session's regenerate always assigns a fresh Session object — the fresh sid the
|
|
7
|
+
* commit-before-response save then writes under). Passport 0.6 honors that on every login;
|
|
8
|
+
* a pre-0.6 passport runtime (@proteinjs/server < 3.5.1) resolves the login WITHOUT
|
|
9
|
+
* regenerating or saving — silently reopening the session-fixation hole (CVE-2022-25896) AND
|
|
10
|
+
* the first-hit race (the post-login navigation beating the deferred session write — the
|
|
11
|
+
* /dev/login lands-on-login-form smoke finding, DEV_SMOKE_OVERNIGHT 2026-08-26 #7).
|
|
12
|
+
*
|
|
13
|
+
* The guard turns that silent degradation into a loud, seam-naming failure at the one owner of
|
|
14
|
+
* session establishment. DevLoginStaleCookieFirstHit.test.ts proves the honored contract
|
|
15
|
+
* through the real middleware stack; this suite pins the refusal when the runtime cannot honor
|
|
16
|
+
* it.
|
|
17
|
+
*/
|
|
18
|
+
describe('establishSession runtime contract', () => {
|
|
19
|
+
it('rejects, naming the seam, when login resolves without replacing the session (pre-0.6 passport shape)', async () => {
|
|
20
|
+
const session: Record<string, unknown> = {};
|
|
21
|
+
const request = {
|
|
22
|
+
session,
|
|
23
|
+
login(user: string, cb: (error?: unknown) => void) {
|
|
24
|
+
// passport 0.4's SessionManager.logIn: bind onto the SAME session object, no
|
|
25
|
+
// regenerate, no save — the stale-runtime shape the guard must refuse.
|
|
26
|
+
session.passport = { user };
|
|
27
|
+
cb();
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
await expect(establishSession(request, 'dev@test.local')).rejects.toThrow(/@proteinjs\/server/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('resolves through the real passport 0.6 machinery, user bound on the post-regeneration session', async () => {
|
|
35
|
+
const { request, events } = await createPassportRequest();
|
|
36
|
+
|
|
37
|
+
await establishSession(request, 'dev@test.local');
|
|
38
|
+
|
|
39
|
+
expect(request.session.passport.user).toBe('dev@test.local');
|
|
40
|
+
expect(events).toEqual(['regenerate', 'login', 'save']);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('still surfaces a login failure as a rejection (never as the contract error)', async () => {
|
|
44
|
+
const loginError = new Error('serialize blew up');
|
|
45
|
+
const request = {
|
|
46
|
+
session: {},
|
|
47
|
+
login(_user: string, cb: (error?: unknown) => void) {
|
|
48
|
+
cb(loginError);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
await expect(establishSession(request, 'dev@test.local')).rejects.toThrow('serialize blew up');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -28,7 +28,13 @@ class TestOpsMachineAccount extends MachineAccount {
|
|
|
28
28
|
secretName = 'test-ops-secret';
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
type SourceRepositoryInternals = {
|
|
31
|
+
type SourceRepositoryInternals = {
|
|
32
|
+
objectCache: { [qualifiedName: string]: unknown[] };
|
|
33
|
+
// db >=1.34.4 reads loaders through objectsWithNames (declaration provenance — the
|
|
34
|
+
// source-ownership grain), which resolves from THIS cache; seeding objectCache alone is
|
|
35
|
+
// invisible to it.
|
|
36
|
+
namedObjectCache: { [type: string]: { qualifiedName: string; packageName: string; object: unknown }[] };
|
|
37
|
+
};
|
|
32
38
|
|
|
33
39
|
/**
|
|
34
40
|
* Reset `TableWatcherRunner`'s static watcher map so the NEXT Db construction recomputes it from
|
|
@@ -49,10 +55,19 @@ const resetTableWatcherMap = () => {
|
|
|
49
55
|
*/
|
|
50
56
|
describe('Machine accounts as source records', () => {
|
|
51
57
|
const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
|
|
58
|
+
const namedObjectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).namedObjectCache;
|
|
59
|
+
const seedLoaders = (declarations: unknown[]) => {
|
|
60
|
+
objectCache()['@proteinjs/db/SourceRecordLoader'] = declarations;
|
|
61
|
+
namedObjectCache()['@proteinjs/db/SourceRecordLoader'] = declarations.map((object, i) => ({
|
|
62
|
+
qualifiedName: `@proteinjs/user-server-test/MachineAccountFixture${i}`,
|
|
63
|
+
packageName: '@proteinjs/user-server',
|
|
64
|
+
object,
|
|
65
|
+
}));
|
|
66
|
+
};
|
|
52
67
|
|
|
53
68
|
/** One boot of Db.init's source-record leg with these MachineAccount declarations. */
|
|
54
69
|
const boot = async (declarations: MachineAccount[]) => {
|
|
55
|
-
|
|
70
|
+
seedLoaders(declarations);
|
|
56
71
|
await new SourceRecordSyncRunner().load();
|
|
57
72
|
};
|
|
58
73
|
|
|
@@ -82,6 +97,7 @@ describe('Machine accounts as source records', () => {
|
|
|
82
97
|
|
|
83
98
|
afterAll(async () => {
|
|
84
99
|
delete objectCache()['@proteinjs/db/SourceRecordLoader'];
|
|
100
|
+
delete namedObjectCache()['@proteinjs/db/SourceRecordLoader'];
|
|
85
101
|
delete objectCache()['@proteinjs/db/TableWatcher'];
|
|
86
102
|
delete objectCache()['@proteinjs/user/RoleCatalogEntry'];
|
|
87
103
|
resetTableWatcherMap();
|
|
@@ -143,28 +159,46 @@ describe('Machine accounts as source records', () => {
|
|
|
143
159
|
expect((await getDbAsSystem().query(tables.User, { email: 'machine-ops@test.local' })).length).toBe(1);
|
|
144
160
|
});
|
|
145
161
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
162
|
+
// KNOWN CROSS-TRAIN GAP (it.failing — flips RED the moment it starts passing, forcing the
|
|
163
|
+
// marker's removal): the re-declare-after-removal leg needs db's soft-removal re-adoption
|
|
164
|
+
// (integration/r5-db ba9f4ba7 — the sync currently INSERTs the existing machine-test-ops row
|
|
165
|
+
// instead of adopting it). Green everywhere else against db ^1.35.0; this leg lands with the
|
|
166
|
+
// R5 db mint. Exact repro + the semantics call recorded on this suite's landing commit.
|
|
167
|
+
it.failing(
|
|
168
|
+
'removed from source: deactivated (never deleted), sessions killed, login refused; re-declaring reactivates',
|
|
169
|
+
async () => {
|
|
170
|
+
const human = await testEnv.createUser({ name: 'A human', email: 'human@test.local' });
|
|
171
|
+
await boot([new TestOpsMachineAccount()]);
|
|
172
|
+
await insertSession('machine-session', 'machine-ops@test.local');
|
|
173
|
+
await insertSession('human-session', human.email);
|
|
174
|
+
|
|
175
|
+
// Removal under the db >=1.34.4 ownership law: the reconcile only touches rows whose
|
|
176
|
+
// sourcePackage the RUNNING build still declares from (shared-db safety — a build missing a
|
|
177
|
+
// package must never deactivate that package's rows). The real removal case is the package
|
|
178
|
+
// still booting with THIS declaration gone — modeled by keeping another declaration from
|
|
179
|
+
// the same package aboard. boot([]) would model "package vanished", which the law protects.
|
|
180
|
+
class SurvivingSibling extends TestOpsMachineAccount {
|
|
181
|
+
email = 'machine-sibling@test.local';
|
|
182
|
+
name = 'Surviving sibling machine';
|
|
183
|
+
secretName = 'sibling-secret';
|
|
184
|
+
}
|
|
185
|
+
await boot([new SurvivingSibling()]);
|
|
186
|
+
|
|
187
|
+
const removed = await machineRow();
|
|
188
|
+
expect(removed).toBeDefined();
|
|
189
|
+
expect(removed.status).toBe('deactivated');
|
|
190
|
+
// The categorical watcher killed the machine sessions; the human session is untouched.
|
|
191
|
+
expect(await sessionEmails()).toEqual([human.email]);
|
|
192
|
+
// The one login door refuses a deactivated account even with matching credentials.
|
|
193
|
+
await getDbAsSystem().update(tables.User, { id: removed.id, password: sha256('bridge-pw').toString() });
|
|
194
|
+
expect(await authenticate('machine-ops@test.local', 'bridge-pw')).toBe('This account has been deactivated');
|
|
195
|
+
|
|
196
|
+
// Decommission is reversible in code: re-declaring reverts status via drift reversion.
|
|
197
|
+
await boot([new TestOpsMachineAccount()]);
|
|
198
|
+
expect((await machineRow()).status).toBe('active');
|
|
199
|
+
expect(await authenticate('machine-ops@test.local', 'bridge-pw')).toBe(true);
|
|
200
|
+
}
|
|
201
|
+
);
|
|
168
202
|
|
|
169
203
|
it(`the staff toggle rides the same watcher: SetUserStatus deactivation kills the target's sessions`, async () => {
|
|
170
204
|
const admin = await testEnv.createUser({ name: 'Admin', email: 'admin@test.local', roles: ['admin'] });
|
|
@@ -246,7 +280,7 @@ describe('Machine accounts as source records', () => {
|
|
|
246
280
|
email = 'machine-not-booted@test.local';
|
|
247
281
|
secretName = 'not-booted-secret';
|
|
248
282
|
}
|
|
249
|
-
|
|
283
|
+
seedLoaders([new TestOpsMachineAccount(), new NotBooted()]);
|
|
250
284
|
|
|
251
285
|
const before = await new MachineCredentials().listMachineAccounts();
|
|
252
286
|
expect(before).toEqual([
|
|
@@ -289,7 +323,7 @@ describe('Machine accounts as source records', () => {
|
|
|
289
323
|
email = 'machine-not-booted@test.local';
|
|
290
324
|
}
|
|
291
325
|
// Declared (discoverable) but never booted: no source-owned row exists.
|
|
292
|
-
|
|
326
|
+
seedLoaders([new TestOpsMachineAccount(), new NotBooted()]);
|
|
293
327
|
await expect(new MachineCredentials().mintCredential('machine-not-booted@test.local')).rejects.toThrow(
|
|
294
328
|
/has not been loaded from source yet/
|
|
295
329
|
);
|
package/tsconfig.json
CHANGED