Files
auto-team-custom/server/child-delete-integration.test.mjs

602 lines
38 KiB
JavaScript

import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { createDecipheriv, createHash } from 'node:crypto';
import { createServer } from 'node:http';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
const serverScript = fileURLToPath(new URL('./index.mjs', import.meta.url));
const dataKey = 'isolated-child-delete-key';
const email = 'removed-child@example.test';
const owner = { id: 'owner-user', email: 'owner@example.test', role: 'account-owner', seat_type: 'default' };
function token(accountId, identity = 'owner-user', tokenEmail = owner.email) {
const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({
exp: Math.floor(Date.now() / 1000) + 3600, sub: identity,
'https://api.openai.com/auth': { chatgpt_account_id: accountId, chatgpt_plan_type: 'team', chatgpt_user_id: identity },
'https://api.openai.com/profile': { email: tokenEmail },
})).toString('base64url');
return `${header}.${payload}.private-fixture-signature`;
}
function child(fields = {}) {
return { id: 'child-delete', email, status: 'ready', plan: 'free', workspaceHistory: [], ...fields };
}
function mother(workspaceId = 'delete-team', fields = {}) {
return { id: `mother-${workspaceId}`, team: workspaceId, accountId: workspaceId, email: owner.email,
primaryOwnerEmail: owner.email, rotationMode: 'fixed', rotationEnabled: false,
accessToken: token(workspaceId), members: [owner], ...fields };
}
async function listen(server) {
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
return server.address().port;
}
async function unusedPort() {
const server = createServer();
const port = await listen(server);
await new Promise((resolve) => server.close(resolve));
return port;
}
async function fixture(t, { children = [child()], mothers = [mother()], remote = {}, holdRefresh = false, callback = false, onMembers, concurrency = 1 } = {}) {
const requests = [];
let releaseRefresh;
let refreshStarted;
const refreshObserved = new Promise((resolve) => { refreshStarted = resolve; });
const refreshGate = holdRefresh ? new Promise((resolve) => { releaseRefresh = resolve; }) : Promise.resolve();
const upstream = createServer(async (req, res) => {
const url = new URL(req.url || '/', 'http://127.0.0.1');
requests.push({ method: req.method, path: url.pathname, offset: Number(url.searchParams.get('offset') || 0), query: url.searchParams.get('query') });
const json = (payload, status = 200) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(payload)); };
if (url.pathname === '/mock-token') {
refreshStarted();
await refreshGate;
return json({ access_token: token('personal-child-delete', 'child-user', email), refresh_token: 'private-fixture-new-refresh-token' });
}
if (req.method !== 'GET') return json({ error: 'unexpected_fixture_mutation' }, 503);
const match = url.pathname.match(/^\/backend-api\/accounts\/([^/]+)\/(users|invites)$/);
if (match) {
let entry = remote[match[1]] || {};
if (entry.byIdentity) {
const payload = String(req.headers.authorization || '').split('.')[1];
const identity = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')).sub;
entry = { ...entry, ...(entry.byIdentity[identity] || {}) };
}
if (match[2] === 'users') {
await onMembers?.(match[1]);
const offset = Number(url.searchParams.get('offset') || 0);
const pageStatus = entry.pageStatuses?.[offset];
if (pageStatus && pageStatus !== 200) return json({ error: 'private-upstream-page-error-token' }, pageStatus);
if (entry.status && entry.status !== 200) return json({ error: 'private-upstream-error-token' }, entry.status);
if (entry.payload !== undefined) return json(entry.payload);
const members = entry.members || [owner];
return json(entry.pages?.[offset] ?? { items: members.slice(offset, offset + 100), total: members.length, offset, limit: 100 });
}
if (entry.inviteStatus && entry.inviteStatus !== 200) return json({ error: 'private-invite-error-token' }, entry.inviteStatus);
if (entry.invitePayload !== undefined) return json(entry.invitePayload);
const invitations = (entry.invites || []).filter((invite) => !url.searchParams.get('query')
|| String(invite.email_address || invite.email).toLowerCase() === url.searchParams.get('query'));
return json({ items: invitations, total: invitations.length, offset: 0, limit: 100 });
}
if (url.pathname === '/backend-api/subscriptions') return json({ seats_in_use: 1, seats_entitled: 2,
seat_capacity: [{ type: 'default', paid: 2, held: 0 }], assigned: { default: 1 } });
return json({ error: 'unexpected_fixture_read' }, 503);
});
const upstreamPort = await listen(upstream);
const dataDir = await mkdtemp(path.join(tmpdir(), 'child-delete-integration-'));
const port = await unusedPort();
const callbackPort = await unusedPort();
await writeFile(path.join(dataDir, 'state.json'), JSON.stringify({ version: 1, settings: { autoRefill: false, concurrency }, mothers, children, history: [] }));
const preload = `
const nativeFetch = globalThis.fetch;
globalThis.fetch = async (input, options) => {
const url = new URL(typeof input === 'string' || input instanceof URL ? String(input) : input.url);
if (url.hostname === 'auth.openai.com' && url.pathname.endsWith('/token')) return nativeFetch(${JSON.stringify(`http://127.0.0.1:${upstreamPort}/mock-token`)}, options);
if (url.hostname !== '127.0.0.1') throw new Error('isolated_external_network_disabled');
return nativeFetch(input, options);
};
`;
const app = spawn(process.execPath, ['--import', `data:text/javascript,${encodeURIComponent(preload)}`, serverScript], {
env: { ...process.env, HOST: '127.0.0.1', PORT: String(port), CHATGPT_BASE_URL: `http://127.0.0.1:${upstreamPort}`,
TEAM_ROTATION_DATA_DIR: dataDir, TEAM_ROTATION_DATA_KEY: dataKey, TEAM_ROTATION_API_TOKEN: '', TEAM_ROTATION_LOGIN_PASSWORD: 'isolated-delete-password',
DISABLE_MAINTENANCE: 'true', OPENAI_CALLBACK_ENABLED: String(callback), OPENAI_CALLBACK_PORT: String(callbackPort), OPENAI_REQUEST_TIMEOUT_MS: '10000' },
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
app.stdout.on('data', (chunk) => { output += chunk; });
app.stderr.on('data', (chunk) => { output += chunk; });
t.after(async () => {
releaseRefresh?.();
if (app.exitCode === null && app.signalCode === null) { const exited = new Promise((resolve) => app.once('exit', resolve)); app.kill(); await exited; }
upstream.closeAllConnections?.();
await new Promise((resolve) => upstream.close(resolve));
await rm(dataDir, { recursive: true, force: true });
});
const baseUrl = `http://127.0.0.1:${port}`;
for (let attempt = 0; attempt < 100; attempt += 1) {
if (app.exitCode !== null) throw new Error(`fixture server exited: ${output}`);
try { if ((await fetch(`${baseUrl}/api/auth/session`, { signal: AbortSignal.timeout(500) })).ok) break; } catch { /* Listener starting. */ }
await delay(25);
}
const login = await fetch(`${baseUrl}/api/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ password: 'isolated-delete-password' }) });
assert.equal(login.status, 200);
const cookie = login.headers.get('set-cookie').split(';', 1)[0];
return {
requests, remote, refreshObserved, releaseRefresh: () => releaseRefresh?.(),
async request(route, { method = 'GET', body } = {}) {
const response = await fetch(`${baseUrl}${route}`, { method, headers: { cookie, 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(5000) });
return { status: response.status, data: await response.json() };
},
async saved() {
const saved = JSON.parse(await readFile(path.join(dataDir, 'state.json'), 'utf8'));
if (!saved.encrypted) return saved;
const decipher = createDecipheriv('aes-256-gcm', createHash('sha256').update(dataKey).digest(), Buffer.from(saved.iv, 'base64'));
decipher.setAuthTag(Buffer.from(saved.tag, 'base64'));
return JSON.parse(Buffer.concat([decipher.update(Buffer.from(saved.ciphertext, 'base64')), decipher.final()]).toString());
},
async callback(state) {
return fetch(`http://127.0.0.1:${callbackPort}/auth/callback?code=private-fixture-code&state=${state}`);
},
};
}
function assertReadOnly(requests) { assert.ok(requests.every((request) => request.method === 'GET'), JSON.stringify(requests)); }
function confirmedDeparture(fields = {}) {
return { team: 'delete-team', status: 'kicked', reason: 'manual', source: 'member_sync',
joinedAt: '2025-01-01T00:00:00.000Z', removedAt: '2025-01-01T01:00:00.000Z', ...fields };
}
for (const mode of ['single', 'bulk']) {
test(`${mode} local deletion accepts a confirmed legacy departure when the Team API returns 403 and retains its pending invitation`, async (t) => {
const invite = { id: 'pending-invite', email_address: email, role: 'standard-user' };
const app = await fixture(t, {
children: [child({ status: 'login_pending', team: null, chatgptUserId: 'removed-child-user',
workspaceHistory: [confirmedDeparture()],
memberSnapshot: { id: 'removed-child-user', email },
teamInvitations: [{ workspaceId: 'delete-team', status: 'invited' }] })],
mothers: [mother('delete-team', { seatClaims: [{ id: 'local-claim', childId: 'child-delete', email,
workspaceId: 'delete-team', inviteId: invite.id, seatType: 'default', phase: 'invited' }] })],
remote: { 'delete-team': { status: 403, invites: [invite] } },
});
const result = mode === 'single' ? await app.request('/api/children/child-delete', { method: 'DELETE' })
: await app.request('/api/children/batch-delete', { method: 'POST', body: { ids: ['child-delete'], bannedOnly: false } });
assert.equal(result.status, 200);
if (mode === 'bulk') {
assert.deepEqual(result.data.deleted.map((entry) => entry.id), ['child-delete']);
assert.deepEqual(result.data.skipped, []);
}
const saved = await app.saved();
assert.equal(saved.children.length, 0);
const claim = saved.mothers[0].seatClaims[0];
assert.equal(claim.childId, null);
assert.equal(claim.phase, 'detached_invitation');
assert.equal(claim.email, email);
assert.equal(claim.inviteId, invite.id);
assert.equal(claim.seatType, 'default');
assert.deepEqual(app.remote['delete-team'].invites, [invite]);
assert.equal(app.requests.length, 1);
assertReadOnly(app.requests);
});
}
for (const [label, departure] of [
['missing removal time', confirmedDeparture({ removedAt: null })],
['invalid removal time', confirmedDeparture({ removedAt: 'invalid-date' })],
['future removal time', confirmedDeparture({ removedAt: '2999-01-01T01:00:00.000Z' })],
['removal predating the join', confirmedDeparture({ joinedAt: '2025-01-01T02:00:00.000Z' })],
['missing removal reason', confirmedDeparture({ reason: null })],
['a locally deleted Team', confirmedDeparture({ status: 'team_removed', reason: 'team_deleted_local' })],
]) {
test(`a Team API 403 does not authorize local deletion with ${label}`, async (t) => {
const app = await fixture(t, { children: [child({ team: null, workspaceHistory: [departure] })],
remote: { 'delete-team': { status: 403 } } });
const result = await app.request('/api/children/child-delete', { method: 'DELETE' });
assert.equal(result.status, 409);
assert.equal(result.data.message, 'membership_verification_failed');
assert.equal((await app.saved()).children.length, 1);
assertReadOnly(app.requests);
});
}
for (const [label, fields] of [
['a later active membership', { team: null, workspaceHistory: [confirmedDeparture(),
{ team: 'delete-team', status: 'active', joinedAt: '2025-01-01T02:00:00.000Z' }] }],
['a current Team link', { team: 'delete-team', workspaceHistory: [confirmedDeparture()] }],
]) {
test(`a past departure cannot authorize deletion during a Team API 403 when the account has ${label}`, async (t) => {
const app = await fixture(t, { children: [child(fields)], remote: { 'delete-team': { status: 403 } } });
const result = await app.request('/api/children/child-delete', { method: 'DELETE' });
assert.equal(result.status, 409);
assert.equal(result.data.message, 'membership_verification_failed');
assert.equal((await app.saved()).children.length, 1);
assertReadOnly(app.requests);
});
}
test('a conflicting cached member keeps a departed account during a Team API 403 even after a failed sync', async (t) => {
const app = await fixture(t, { children: [child({ team: null, workspaceHistory: [confirmedDeparture()] })],
mothers: [mother('delete-team', { members: [owner, { id: 'cached-child', email }],
lastMembersProbe: { ok: false, status: 403 }, lastWorkspaceSyncAt: '2025-01-01T02:00:00.000Z' })],
remote: { 'delete-team': { status: 403 } } });
const result = await app.request('/api/children/child-delete', { method: 'DELETE' });
assert.equal(result.status, 409);
assert.equal(result.data.message, 'membership_verification_failed');
assert.equal((await app.saved()).children.length, 1);
assertReadOnly(app.requests);
});
test('an official current member overrides an old confirmed departure', async (t) => {
const app = await fixture(t, { children: [child({ team: null, workspaceHistory: [confirmedDeparture()] })],
remote: { 'delete-team': { members: [owner, { id: 'rejoined-child', email }] } } });
const result = await app.request('/api/children/child-delete', { method: 'DELETE' });
assert.equal(result.status, 409);
assert.equal(result.data.message, 'child_has_active_team_memberships');
assert.equal((await app.saved()).children.length, 1);
assertReadOnly(app.requests);
});
test('a confirmed departure in one Team cannot authorize deletion when another Team returns 403 without departure evidence', async (t) => {
const app = await fixture(t, { children: [child({ team: null, workspaceHistory: [confirmedDeparture()] })],
mothers: [mother('delete-team'), mother('other-team')],
remote: { 'delete-team': { status: 403 }, 'other-team': { status: 403 } } });
const result = await app.request('/api/children/child-delete', { method: 'DELETE' });
assert.equal(result.status, 409);
assert.equal(result.data.message, 'membership_verification_failed');
assert.deepEqual(result.data.verificationTeams.map((entry) => entry.workspaceId), ['other-team']);
assert.equal((await app.saved()).children.length, 1);
assert.equal(app.requests.length, 2);
assertReadOnly(app.requests);
});
test('a confirmed departure cannot excuse an incomplete or explicitly failed official list inside HTTP 200', async (t) => {
const app = await fixture(t, { children: [child({ team: null, workspaceHistory: [confirmedDeparture()] })],
remote: { 'delete-team': {} } });
for (const payload of [{}, { items: [], total: 2 }, { success: false, items: [], total: 0 }]) {
app.remote['delete-team'].payload = payload;
const result = await app.request('/api/children/child-delete', { method: 'DELETE' });
assert.equal(result.status, 409, JSON.stringify(payload));
assert.equal(result.data.message, 'membership_verification_failed');
assert.equal((await app.saved()).children.length, 1);
}
assertReadOnly(app.requests);
});
test('a confirmed departure cannot excuse a 403 on a later member page after an actual member was returned', async (t) => {
const first = [{ id: 'rejoined-child', email },
...Array.from({ length: 99 }, (_, index) => ({ id: `unrelated-${index}`, email: `unrelated-${index}@example.test` }))];
const app = await fixture(t, { children: [child({ team: null, workspaceHistory: [confirmedDeparture()] })],
remote: { 'delete-team': { pages: { 0: { items: first, total: 101, offset: 0 } }, pageStatuses: { 100: 403 } } } });
const result = await app.request('/api/children/child-delete', { method: 'DELETE' });
assert.equal(result.status, 409);
assert.equal(result.data.message, 'membership_verification_failed');
assert.equal(result.data.verificationTeams[0].reason, 'members_list_incomplete');
assert.equal((await app.saved()).children.length, 1);
assert.deepEqual(app.requests.map((request) => request.offset), [0, 100]);
assertReadOnly(app.requests);
});
test('another manager failing on its first page cannot erase an incomplete member list for the same Team', async (t) => {
const first = [{ id: 'rejoined-child', email },
...Array.from({ length: 99 }, (_, index) => ({ id: `unrelated-${index}`, email: `unrelated-${index}@example.test` }))];
const app = await fixture(t, { children: [child({ team: null, workspaceHistory: [confirmedDeparture()] })],
mothers: [mother('delete-team', { id: 'mother-first', accessToken: token('delete-team', 'first-manager') }),
mother('delete-team', { id: 'mother-second', accessToken: token('delete-team', 'second-manager') })],
remote: { 'delete-team': { byIdentity: {
'first-manager': { pages: { 0: { items: first, total: 101, offset: 0 } }, pageStatuses: { 100: 403 } },
'second-manager': { status: 403 },
} } } });
const result = await app.request('/api/children/child-delete', { method: 'DELETE' });
assert.equal(result.status, 409);
assert.equal(result.data.message, 'membership_verification_failed');
assert.equal(result.data.verificationTeams[0].workspaceId, 'delete-team');
assert.equal(result.data.verificationTeams[0].reason, 'members_list_incomplete');
assert.equal((await app.saved()).children.length, 1);
assert.deepEqual(app.requests.map((request) => request.offset), [0, 100, 0]);
assertReadOnly(app.requests);
});
test('a removed child with a pending invitation can be deleted locally while its external seat reservation stays counted', async (t) => {
const invite = { id: 'pending-invite', email_address: email, role: 'standard-user' };
const app = await fixture(t, {
children: [child({ status: 'login_pending', team: null, joinStatus: 'member_confirmed',
workspaceHistory: [{ team: 'delete-team', status: 'kicked', reason: 'manual', removedAt: new Date().toISOString() }],
teamInvitations: [{ workspaceId: 'delete-team', status: 'invited' }] })],
mothers: [mother('delete-team', { seatClaims: [{ id: 'local-claim', childId: 'child-delete', email, workspaceId: 'delete-team', inviteId: invite.id, seatType: 'default', phase: 'invited' }] })],
remote: { 'delete-team': { invites: [invite] } },
});
const deleted = await app.request('/api/children/child-delete', { method: 'DELETE' });
assert.equal(deleted.status, 200);
let saved = await app.saved();
assert.equal(saved.children.length, 0);
const claim = saved.mothers[0].seatClaims[0];
assert.equal(claim.childId, null);
assert.equal(claim.email, email);
assert.equal(claim.inviteId, invite.id);
assert.equal(claim.phase, 'detached_invitation');
assert.deepEqual(app.remote['delete-team'].invites, [invite]);
await app.request('/api/mothers/mother-delete-team/sync', { method: 'POST' });
saved = await app.saved();
assert.equal(saved.mothers[0].seatClaims.length, 1);
app.remote['delete-team'].inviteStatus = 403;
await app.request('/api/mothers/mother-delete-team/sync', { method: 'POST' });
assert.equal((await app.saved()).mothers[0].seatClaims.length, 1, 'failed invitation verification cannot release capacity');
app.remote['delete-team'].inviteStatus = 200;
app.remote['delete-team'].invites = [];
await app.request('/api/mothers/mother-delete-team/sync', { method: 'POST' });
assert.equal((await app.saved()).mothers[0].seatClaims.length, 0, 'confirmed vanished invitation releases the detached reservation');
assertReadOnly(app.requests);
});
test('an incomplete empty invitation page cannot release a detached reservation', async (t) => {
const app = await fixture(t, { children: [],
mothers: [mother('delete-team', { seatClaims: [{ id: 'detached-claim', email, workspaceId: 'delete-team', inviteId: 'pending-invite', phase: 'detached_invitation' }] })],
remote: { 'delete-team': {} },
});
for (const payload of [{ items: [], total: 1 }, { items: [], total: -1 }, { items: [], total: 0.5 }, { items: [], total: 0, offset: 1 }]) {
app.remote['delete-team'].invitePayload = payload;
await app.request('/api/mothers/mother-delete-team/sync', { method: 'POST' });
assert.equal((await app.saved()).mothers[0].seatClaims.length, 1, JSON.stringify(payload));
}
app.remote['delete-team'].invitePayload = { items: [], total: 0, offset: 0 };
await app.request('/api/mothers/mother-delete-team/sync', { method: 'POST' });
assert.equal((await app.saved()).mothers[0].seatClaims.length, 0);
assertReadOnly(app.requests);
});
test('an explicit invitation API failure inside HTTP 200 keeps the detached reservation', async (t) => {
const app = await fixture(t, { children: [],
mothers: [mother('delete-team', { seatClaims: [{ id: 'detached-claim', email, workspaceId: 'delete-team', inviteId: 'pending-invite', phase: 'detached_invitation' }] })],
remote: { 'delete-team': {} },
});
for (const payload of [{ success: false, items: [] }, { ok: false, items: [] },
{ error: 'private-upstream-error-token', items: [] }, { errors: ['private-upstream-error-token'], items: [] }]) {
app.remote['delete-team'].invitePayload = payload;
await app.request('/api/mothers/mother-delete-team/sync', { method: 'POST' });
assert.equal((await app.saved()).mothers[0].seatClaims.length, 1, JSON.stringify(payload));
}
assertReadOnly(app.requests);
});
for (const mode of ['single', 'bulk']) {
test(`${mode} deletion ignores stale active history and member snapshots after complete official absence`, async (t) => {
const stale = child({ team: 'delete-team', status: 'active', memberSnapshot: { id: 'old-member', email },
workspaceHistory: [{ team: 'delete-team', status: 'active', memberId: 'old-member', manualKickEnabled: true, manualKickAt: new Date().toISOString() }] });
const app = await fixture(t, { children: [stale], mothers: [mother('delete-team', { members: [owner, stale.memberSnapshot] })] });
const result = mode === 'single' ? await app.request('/api/children/child-delete', { method: 'DELETE' })
: await app.request('/api/children/batch-delete', { method: 'POST', body: { ids: ['child-delete'], bannedOnly: false } });
assert.equal(result.status, 200);
assert.equal((await app.saved()).children.length, 0);
if (mode === 'bulk') assert.deepEqual(result.data.deleted.map((entry) => entry.id), ['child-delete']);
assertReadOnly(app.requests);
});
}
test('a real active member in another current Team blocks single and bulk deletion even when no membership was stored locally', async (t) => {
const app = await fixture(t, { mothers: [mother('team-a'), mother('team-b')],
remote: { 'team-b': { members: [owner, { id: 'actual-member', email: email.toUpperCase(), role: 'standard-user' }] } } });
const single = await app.request('/api/children/child-delete', { method: 'DELETE' });
assert.equal(single.status, 409);
assert.equal(single.data.message, 'child_has_active_team_memberships');
const bulk = await app.request('/api/children/batch-delete', { method: 'POST', body: { ids: ['child-delete'], bannedOnly: false } });
assert.equal(bulk.data.deleted.length, 0);
assert.equal(bulk.data.skipped[0].reason, single.data.message);
assert.equal((await app.saved()).children.length, 1);
assert.ok(app.requests.some((request) => request.path.includes('/team-a/users')));
assert.ok(app.requests.some((request) => request.path.includes('/team-b/users')));
assertReadOnly(app.requests);
});
test('an active member without email still blocks deletion through its recorded exact user identity', async (t) => {
const app = await fixture(t, { children: [child({ chatgptUserId: 'child-user' })],
remote: { 'delete-team': { members: [owner, { id: 'child-user', role: 'standard-user' }] } } });
const result = await app.request('/api/children/child-delete', { method: 'DELETE' });
assert.equal(result.status, 409);
assert.equal(result.data.message, 'child_has_active_team_memberships');
assertReadOnly(app.requests);
});
for (const failure of [{ status: 403 }, { payload: {} }, { payload: { items: [], total: 2 } },
{ payload: { success: false, items: [], total: 0 } }, { payload: { ok: false, items: [] } },
{ payload: { error: 'private-upstream-error-token', items: [] } }, { payload: { errors: ['private-upstream-error-token'], items: [] } }]) {
test(`unknown official membership (${JSON.stringify(failure)}) keeps the child, credentials, and reservations`, async (t) => {
const record = child({ password: 'private-fixture-password', workspaceHistory: [{ team: 'delete-team', status: 'kicked' }] });
const app = await fixture(t, { children: [record], mothers: [mother('delete-team', { seatClaims: [{ id: 'private-claim', childId: record.id, email, phase: 'invited' }] })],
remote: { 'delete-team': failure } });
const result = await app.request('/api/children/child-delete', { method: 'DELETE' });
assert.equal(result.status, 409);
assert.equal(result.data.message, 'membership_verification_failed');
const saved = await app.saved();
assert.equal(saved.children[0].password, record.password);
assert.equal(saved.mothers[0].seatClaims[0].childId, record.id);
assert.doesNotMatch(JSON.stringify(result.data), /private-fixture-password|private-upstream-error-token|accessToken|refreshToken/);
assertReadOnly(app.requests);
});
}
test('complete member pagination sees an active member after the first hundred records', async (t) => {
const first = Array.from({ length: 100 }, (_, index) => ({ id: `unrelated-${index}`, email: `unrelated-${index}@example.test` }));
const app = await fixture(t, { remote: { 'delete-team': { pages: { 0: { items: first, total: 101 }, 100: { items: [{ id: 'late-member', email }], total: 101 } } } } });
const result = await app.request('/api/children/child-delete', { method: 'DELETE' });
assert.equal(result.status, 409);
assert.equal(result.data.message, 'child_has_active_team_memberships');
assert.deepEqual(app.requests.map((request) => request.offset), [0, 100]);
assertReadOnly(app.requests);
});
test('bulk deletion shares official reads while retaining active and unverifiable records', async (t) => {
const app = await fixture(t, { children: [child({ id: 'stale-a', email: 'stale-a@example.test', team: 'delete-team' }),
child({ id: 'stale-b', email: 'stale-b@example.test', workspaceHistory: [{ team: 'delete-team', status: 'active' }] }),
child({ id: 'active', email: 'active@example.test' }),
child({ id: 'unknown', workspaceHistory: [{ workspaceId: 'unconfigured-team', status: 'active' }] })],
remote: { 'delete-team': { members: [owner, { id: 'active', email: 'active@example.test' }] } } });
const result = await app.request('/api/children/batch-delete', { method: 'POST', body: { ids: ['stale-a', 'stale-b', 'active', 'unknown'], bannedOnly: false } });
assert.equal(result.status, 200);
assert.deepEqual(result.data.deleted.map((entry) => entry.id), ['stale-a', 'stale-b']);
assert.deepEqual(result.data.skipped.map((entry) => entry.reason), ['child_has_active_team_memberships', 'membership_verification_failed']);
assert.equal(app.requests.length, 1, 'bulk uses one complete current list per Team');
assert.deepEqual((await app.saved()).children.map((entry) => entry.id), ['active', 'unknown']);
assertReadOnly(app.requests);
});
test('active history whose Team is not configured remains unverifiable rather than presumed removed', async (t) => {
const app = await fixture(t, { mothers: [], children: [child({ team: 'external-team', workspaceHistory: [{ team: 'external-team', status: 'active' }] })] });
const result = await app.request('/api/children/child-delete', { method: 'DELETE' });
assert.equal(result.status, 409);
assert.equal(result.data.message, 'membership_verification_failed');
assert.equal(result.data.verificationTeams[0].reason, 'workspace_not_configured');
assert.equal(app.requests.length, 0);
});
test('a departed account with no current Teams can be deleted and its saved OAuth callback cannot revive it', async (t) => {
const state = 'fixture-deleted-state';
const app = await fixture(t, { mothers: [], callback: true, children: [child({
workspaceHistory: [{ team: 'old-team', status: 'kicked' }],
authSession: { state, codeVerifier: 'private-fixture-verifier' },
})] });
assert.equal((await app.callback(state)).status, 200);
assert.equal((await app.request('/api/children/child-delete', { method: 'DELETE' })).status, 200);
assert.equal((await app.callback(state)).status, 400);
assert.equal((await app.saved()).children.length, 0);
assert.equal(app.requests.length, 0);
});
test('an ongoing refresh and its completed Free authorization both protect an account without departure history', async (t) => {
const app = await fixture(t, { mothers: [], holdRefresh: true, children: [child({ refreshToken: 'private-fixture-old-refresh-token' })] });
const started = await app.request('/api/children/auto-json', { method: 'POST', body: { scope: 'free', ids: ['child-delete'] } });
assert.equal(started.status, 202);
await app.refreshObserved;
const single = await app.request('/api/children/child-delete', { method: 'DELETE' });
assert.equal(single.status, 409);
assert.equal(single.data.message, 'account_operation_in_progress');
const bulk = await app.request('/api/children/batch-delete', { method: 'POST', body: { ids: ['child-delete'], bannedOnly: false } });
assert.equal(bulk.data.deleted.length, 0);
assert.equal(bulk.data.skipped[0].reason, 'account_operation_in_progress');
app.releaseRefresh();
for (let attempt = 0; attempt < 100; attempt += 1) {
const polled = await app.request(`/api/children/auto-json/${started.data.job.id}`);
if (polled.data.job.status === 'complete') break;
await delay(20);
}
assert.equal((await app.saved()).children.length, 1);
const deleted = await app.request('/api/children/child-delete', { method: 'DELETE' });
assert.equal(deleted.status, 409);
assert.equal(deleted.data.message, 'authorized_free_account');
assert.equal((await app.saved()).children.length, 1);
});
test('local deletion metadata is safe and classifies historical authorization independently of token validity', async (t) => {
const fixtures = [
['fresh', { password: 'private-password', totp: 'private-totp' }, true, 'never_authorized'],
['pending', { authSession: { state: 'pending-state', codeVerifier: 'private-verifier' }, loginBrowserRequired: true }, true, 'never_authorized'],
['access', { accessToken: 'private-expired-access', expiresAt: '2001-01-01T00:00:00Z' }, false, 'authorized_free_account'],
['refresh', { refreshToken: 'private-expired-refresh' }, false, 'authorized_free_account'],
['id-token', { idToken: 'private-old-id-token' }, false, 'authorized_free_account'],
['legacy', { token: 'private...token' }, false, 'authorized_free_account'],
['nested', { credentials: { access_token: 'private-nested-access' } }, false, 'authorized_free_account'],
['workspace', { workspaceTokens: { 'old-team': { accessToken: 'private-old-team-token', expiresAt: '2001-01-01T00:00:00Z' } } }, false, 'authorized_free_account'],
['authorized-at', { authAt: '2001-01-01T00:00:00Z' }, false, 'authorized_free_account'],
['sub2api', { sub2apiImported: true }, false, 'authorized_free_account'],
['nested-sub2api', { sub2api: { imported: true } }, false, 'authorized_free_account'],
['departed', { accessToken: 'private-historical-access', workspaceHistory: [confirmedDeparture()] }, true, 'departed_account'],
['banned', { status: 'banned', accessToken: 'private-banned-access' }, true, 'banned_account'],
['banned-current', { status: 'banned', team: 'delete-team' }, false, 'child_has_active_team_memberships'],
['history-member', { status: 'banned', workspaceHistory: [{ team: 'delete-team', status: 'active' }] }, false, 'child_has_active_team_memberships'],
['cached-member', { status: 'banned' }, false, 'child_has_active_team_memberships'],
];
const children = fixtures.map(([id, fields]) => child({ id, email: `${id}@example.test`, ...fields }));
const app = await fixture(t, { children, mothers: [mother('delete-team', { members: [owner, { id: 'cached-user', email: 'cached-member@example.test' }] })] });
const state = (await app.request('/api/state')).data;
for (const [id, , eligible, reason] of fixtures) {
const actual = state.children.find((record) => record.id === id).localDeletion;
assert.equal(actual.eligible, eligible, id);
assert.equal(actual.reason, reason, id);
assert.deepEqual(Object.keys(actual).sort(), ['eligible', 'message', 'reason']);
assert.equal(typeof actual.message, 'string');
assert.doesNotMatch(JSON.stringify(actual), /private-|token|secret/i);
}
assert.equal(app.requests.length, 0, 'metadata must never query upstream');
});
test('explicit general batch deletion removes only unacquired, banned and departed accounts and retains actual members', async (t) => {
const children = [
child({ id: 'fresh', email: 'fresh@example.test', password: 'private-unacquired-password' }),
child({ id: 'banned', email: 'banned@example.test', status: 'banned', accessToken: 'private-banned-token' }),
child({ id: 'departed', email: 'departed@example.test', refreshToken: 'private-historical-rt', workspaceHistory: [confirmedDeparture()] }),
child({ id: 'authorized-free', email: 'authorized-free@example.test', accessToken: 'private-free-token', expiresAt: '2000-01-01T00:00:00Z' }),
child({ id: 'current-banned', email: 'current-banned@example.test', status: 'banned', team: 'delete-team' }),
child({ id: 'remote-current', email: 'remote-current@example.test', status: 'banned', workspaceHistory: [confirmedDeparture()] }),
];
const app = await fixture(t, { children, remote: { 'delete-team': { members: [owner,
{ id: 'actual-current-banned', email: 'current-banned@example.test' }, { id: 'actual-remote-current', email: 'remote-current@example.test' },
] } } });
const single = await app.request('/api/children/authorized-free', { method: 'DELETE' });
assert.equal(single.status, 409);
assert.equal(single.data.message, 'authorized_free_account');
assert.equal(app.requests.length, 0, 'ineligible standalone Free authorization needs no membership requests');
const result = await app.request('/api/children/batch-delete', { method: 'POST', body: { ids: children.map((record) => record.id), bannedOnly: false } });
assert.equal(result.status, 200);
assert.deepEqual(result.data.deleted.map((record) => record.id), ['fresh', 'banned', 'departed']);
assert.deepEqual(result.data.skipped.map((record) => [record.id, record.reason]), [
['authorized-free', 'authorized_free_account'], ['current-banned', 'child_has_active_team_memberships'], ['remote-current', 'child_has_active_team_memberships'],
]);
const saved = await app.saved();
assert.deepEqual(saved.children.map((record) => record.id), ['authorized-free', 'current-banned', 'remote-current']);
assert.equal(saved.children[0].accessToken, 'private-free-token');
assert.equal(app.requests.length, 1);
assertReadOnly(app.requests);
});
for (const bannedOnly of [true, undefined]) test(`legacy banned-only batch selection (${String(bannedOnly)}) never expands to other candidates`, async (t) => {
const records = [child({ id: 'fresh', email: 'fresh@example.test' }),
child({ id: 'banned', email: 'banned@example.test', banStatus: 'banned' }),
child({ id: 'departed', email: 'departed@example.test', workspaceHistory: [confirmedDeparture()] })];
const app = await fixture(t, { mothers: [], children: records });
const result = await app.request('/api/children/batch-delete', { method: 'POST', body: { ids: records.map((record) => record.id), ...(bannedOnly === undefined ? {} : { bannedOnly }) } });
assert.deepEqual(result.data.deleted.map((record) => record.id), ['banned']);
assert.deepEqual(result.data.skipped.map((record) => record.reason), ['child_not_banned', 'child_not_banned']);
assert.deepEqual((await app.saved()).children.map((record) => record.id), ['fresh', 'departed']);
});
for (const mode of ['single', 'bulk']) test(`${mode} deletion preserves a newly refreshed token even after the authorization queue completes`, async (t) => {
let lookupEntered; let releaseLookup;
const entered = new Promise((resolve) => { lookupEntered = resolve; });
const gate = new Promise((resolve) => { releaseLookup = resolve; });
t.after(() => releaseLookup());
const app = await fixture(t, { concurrency: 2, children: [child({ refreshToken: 'private-old-rt', workspaceHistory: [confirmedDeparture()] })],
onMembers: async () => { lookupEntered(); await gate; } });
const deleting = mode === 'single' ? app.request('/api/children/child-delete', { method: 'DELETE' })
: app.request('/api/children/batch-delete', { method: 'POST', body: { ids: ['child-delete'], bannedOnly: false } });
await entered;
const started = await app.request('/api/children/auto-json', { method: 'POST', body: { scope: 'free', ids: ['child-delete'] } });
assert.equal(started.status, 202);
let complete = false;
for (let attempt = 0; attempt < 100; attempt += 1) {
const polled = await app.request(`/api/children/auto-json/${started.data.job.id}`);
if (polled.data.job.status === 'complete') { complete = true; break; }
await delay(20);
}
assert.equal(complete, true);
releaseLookup();
const result = await deleting;
if (mode === 'single') {
assert.equal(result.status, 409);
assert.equal(result.data.message, 'account_changed_during_deletion');
} else {
assert.deepEqual(result.data.deleted, []);
assert.equal(result.data.skipped[0].reason, 'account_changed_during_deletion');
}
const saved = await app.saved();
assert.equal(saved.children.length, 1);
assert.equal(saved.children[0].refreshToken, 'private-fixture-new-refresh-token');
});