Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
cbd65b1
feat(users): add re-authenticated account deletion
lilyshen0722 Oct 25, 2026
3552864
fix(users): fence deletion races and bound cleanup retries
lilyshen0722 Oct 25, 2026
7cd1d20
fix(account): claim deletion before billing and key quotas by user
lilyshen0722 Oct 25, 2026
70e86dd
feat(settings): add deliberate account deletion flow
lilyshen0722 Oct 25, 2026
4ec5651
fix(settings): disclose subscription cancellation on account deletion
lilyshen0722 Oct 25, 2026
34907a8
fix(settings): disclose runtime pause during account deletion
lilyshen0722 Oct 25, 2026
49ab23f
fix(account): recover cleanup intents after receipt write failure
lilyshen0722 Oct 25, 2026
58d932d
test(account): probe quotas on their configured identity
lilyshen0722 Oct 25, 2026
0027085
fix(account): restrict pending deletion sessions and isolate quotas
lilyshen0722 Oct 25, 2026
224d08d
test(account): rate limit session recovery probes
lilyshen0722 Oct 25, 2026
fe00b8d
Preserve historical DM membership during account deletion
lilyshen0722 Oct 25, 2026
6103f70
Render deleted DM members without reviving their accounts
lilyshen0722 Oct 25, 2026
83cbb46
Rate-limit pod lifecycle writes exposed by DM read review
lilyshen0722 Oct 25, 2026
60c5670
Merge main into account deletion branch for current CI
lilyshen0722 Oct 25, 2026
ddb363c
Merge main into account deletion for current-base review
lilyshen0722 Oct 25, 2026
3fa3793
Merge main and compose administrator role guards with account deletion
lilyshen0722 Oct 25, 2026
17a3a4d
Merge current main into account deletion for fresh review
lilyshen0722 Oct 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
596 changes: 596 additions & 0 deletions backend/__tests__/service/accountDeletion.test.js

Large diffs are not rendered by default.

11 changes: 6 additions & 5 deletions backend/__tests__/service/adminRoleConcurrency.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ describe('Administrator role concurrency', () => {
const [alice, bob] = await Promise.all([makeAdmin(`write-${kind}-alice`), makeAdmin(`write-${kind}-bob`)]);
const Lock = require('../../models/AdminUserMutationLock');
if (kind === 'ban') await User.updateOne({ _id: bob._id }, { $set: { role: 'user' } });
const write = jest.spyOn(User.prototype, kind === 'delete' ? 'deleteOne' : 'save')
const write = (kind === 'role' ? jest.spyOn(User, 'findOneAndUpdate')
: jest.spyOn(User.prototype, kind === 'delete' ? 'deleteOne' : 'save'))
.mockRejectedValueOnce(new Error('write outcome unknown'));
try {
const pending = kind === 'delete' ? request(app).delete(`/api/admin/users/${bob._id}`)
Expand Down Expand Up @@ -131,12 +132,12 @@ describe('Administrator role concurrency', () => {
const bob = await User.create({ username: 'promotion-bob', email: 'promotion-bob@example.test',
password: 'Role-concurrency-test-123!', role: 'user' });
const writing = deferred(); const resume = deferred();
const save = User.prototype.save;
jest.spyOn(User.prototype, 'save').mockImplementation(async function (...args) {
if (String(this._id) === String(bob._id) && this.role === 'admin') {
const update = User.findOneAndUpdate.bind(User);
jest.spyOn(User, 'findOneAndUpdate').mockImplementation(async (...args) => {
if (String(args[0]._id) === String(bob._id) && args[1].$set?.role === 'admin') {
writing.resolve(); await resume.promise;
}
return save.apply(this, args);
return update(...args);
});
const first = request(app).patch(`/api/admin/users/${bob._id}/role`)
.set('Authorization', `Bearer ${issueSessionToken(alice)}`).send({ role: 'admin' }).then((res) => res);
Expand Down
4 changes: 2 additions & 2 deletions backend/__tests__/unit/controllers/podController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ describe('podController', () => {

expect(save).toHaveBeenCalled();
expect(savedPod.populate).toHaveBeenCalledWith('createdBy', 'username profilePicture');
expect(savedPod.populate).toHaveBeenCalledWith('members', 'username profilePicture isBot');
expect(savedPod.populate).toHaveBeenCalledWith(expect.objectContaining({ path: 'members', select: 'username profilePicture isBot' }));
expect(AgentInstallation.install).toHaveBeenCalledWith('commonly-bot', 'p1', expect.objectContaining({
installedBy: 'creator',
instanceId: 'default',
Expand Down Expand Up @@ -290,7 +290,7 @@ describe('podController', () => {
expect(pod.members).toEqual(['creator']);
expect(pod.save).toHaveBeenCalled();
expect(pod.populate).toHaveBeenCalledWith('createdBy', 'username profilePicture');
expect(pod.populate).toHaveBeenCalledWith('members', 'username profilePicture isBot');
expect(pod.populate).toHaveBeenCalledWith(expect.objectContaining({ path: 'members', select: 'username profilePicture isBot' }));
expect(res.json).toHaveBeenCalledWith(pod);
});

Expand Down
3 changes: 3 additions & 0 deletions backend/__tests__/unit/middleware/agentRuntimeAuth.test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// These unit cases isolate runtime identity behavior. The deletion fence is
// exercised with real models in service/accountDeletion.test.js.
jest.mock('../../../services/accountDeletionState', () => ({ isAccountDeleting: jest.fn().mockResolvedValue(false) }));
// agentRuntimeAuth path 2 (legacy installation-token) needs to surface ALL
// active AgentInstallation rows for the same (agentName, instanceId), not
// just the one whose runtimeTokens.tokenHash matches. Otherwise the /events
Expand Down
19 changes: 19 additions & 0 deletions backend/__tests__/unit/middleware/ipRateLimit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,22 @@ describe('cloudflareIpRateLimitKeyGenerator', () => {
expect(key).toBe('anon');
});
});

const { authenticatedUserRateLimitKeyGenerator } = require('../../../middleware/ipRateLimit');

describe('account mutation quota keys', () => {
it('uses the authenticated account rather than bearer or forwarding headers', () => {
expect(authenticatedUserRateLimitKeyGenerator({ userId: 'one', headers: { authorization: 'rotated' } }))
.toBe('user:one');
expect(authenticatedUserRateLimitKeyGenerator({ userId: 'two' })).toBe('user:two');
});
});

const { presentedCredentialRateLimitKeyGenerator } = require('../../../middleware/ipRateLimit');
it('isolates anonymous traffic from credential buckets without retaining bearer text', () => {
const key = (authorization) => presentedCredentialRateLimitKeyGenerator({ header: (name) => name === 'Authorization' ? authorization : undefined });
expect(key()).toBe('no-credential');
expect(key('Bearer first-secret')).not.toBe(key());
expect(key('Bearer first-secret')).not.toBe(key('Bearer second-secret'));
expect(key('Bearer first-secret')).not.toContain('first-secret');
});
88 changes: 61 additions & 27 deletions backend/__tests__/unit/middleware/rateLimitIpKeySeparation.test.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
/**
* TASK-126 — behavioural probe: no rate limiter mounted in the app may put two
* callers that differ only in `cf-connecting-ip` into one bucket.
* callers that differ in the configured identity into one bucket. Existing
* CF policies vary only cf-connecting-ip; explicit account-deletion/upload
* policies vary only the presented credential or trusted user id.
* The new policies are pinned to their exact function identities and routes.
*
* Why each walked instance is re-mounted on a stub instead of being exercised
* through the loaded app — all four measured, not assumed:
* - Every entry in the loaded router stack is a Layer whose `handle.name` is
* `''` and whose `entry.name` is `<anonymous>`, for middleware generally and
* for limiters specifically. Identity (`resetKey`/`getKey` own properties)
* is the only census instrument; anything name-keyed mis-attributes.
* - Function names do not identify limiters. Express may wrap a limiter
* in a new function while copying resetKey/getKey. The router census uses
* those own properties; policy capture keys on the unchanged getKey method.
* - On a route carrying two limiters both write `RateLimit-Remaining` and the
* last one that runs wins. Proved on `POST /api/webhooks/groupme/:integrationId`:
* the 3000-max IP limiter reported `remaining` 599/598/597 (the 600-max
Expand All @@ -18,9 +20,10 @@
* `profileWriteUserLimit`) never runs for a header-free request.
* - Three instances never answer at all through the app: their handlers need
* Mongo and unit mode runs without it.
* On the stub there are no params and no auth header, so every key function
* takes its IP branch (each one falls back to the Cloudflare-aware helper), and
* the 204 terminal answers for all of them — no exemption list, no database.
* Existing policies receive no params, auth header or user id, so their IP
* branch remains the CF probe. Explicit new policies receive a presented
* credential or authenticated user id. Every limiter still runs
* a counted A,A,B sequence; no instance is exempted and no database is needed.
*
* The A,A,B sequence is deliberate: two requests cannot separate "different
* buckets" from "not counting at all", so A2 is the instrument's own control.
Expand All @@ -31,12 +34,31 @@
* everything handed to it would pass all 84 and establish nothing.
*/
/* eslint-disable import/no-unresolved, import/extensions */
// Express wraps middleware functions, but copies getKey unchanged. Capture
// policy by that store method while retaining the real limiter and store. New
// Authenticated-user quotas must be measured on their declared identity dimension.
const mockLimiterKeys = new WeakMap();
jest.mock('express-rate-limit', () => {
const actual = jest.requireActual('express-rate-limit');
const wrapped = (options) => {
const instance = actual(options);
mockLimiterKeys.set(instance.getKey, options?.keyGenerator);
return instance;
};
Object.assign(wrapped, actual);
wrapped.rateLimit = wrapped;
wrapped.default = wrapped;
return wrapped;
});

const fs = require('fs');
const express = require('express');
const request = require('supertest');
const rateLimit = require('express-rate-limit');
const { ipKeyGenerator } = require('express-rate-limit');
const { cloudflareIpRateLimitKeyGenerator } = require('../../../middleware/ipRateLimit');
const {
cloudflareIpRateLimitKeyGenerator, presentedCredentialRateLimitKeyGenerator, authenticatedUserRateLimitKeyGenerator,
} = require('../../../middleware/ipRateLimit');

// Captured at MODULE LOAD, before any test can flip it. A baseline re-read inside
// a test moves with a leak instead of catching it: measured, with the per-request
Expand Down Expand Up @@ -110,9 +132,13 @@ const remainingOf = (res) => {
return m ? Number(m[1]) : null;
};

const single = (instance) => {
const single = (instance, dimension = 'cf') => {
const stub = express();
stub.all('/', instance, (req, res) => {
stub.all('/', (req, _res, next) => {
if (dimension === 'user') req.userId = req.get('x-probe-identity');
if (dimension === 'credential') req.headers.authorization = `Bearer ${req.get('x-probe-identity')}`;
next();
}, instance, (req, res) => {
if (typeof req.rateLimit?.remaining === 'number') {
res.set('x-probe-remaining', String(req.rateLimit.remaining));
}
Expand All @@ -121,12 +147,12 @@ const single = (instance) => {
return stub;
};

const hit = async (stub, ip) => {
const hit = async (stub, ip, identity = ip) => {
const entry = process.env.NODE_ENV;
let outcome;
try {
process.env.NODE_ENV = 'development';
const res = await request(stub).get('/').set('cf-connecting-ip', ip)
const res = await request(stub).get('/').set('cf-connecting-ip', ip).set('x-probe-identity', identity)
.timeout({ response: 5000, deadline: 8000 });
outcome = { remaining: remainingOf(res), status: res.status };
} catch (err) {
Expand All @@ -139,13 +165,13 @@ const hit = async (stub, ip) => {
return outcome;
};

const observe = async (instance, tag) => {
const observe = async (instance, tag, dimension = 'cf') => {
const a = `198.51.100.${tag}`;
const b = `203.0.113.${tag}`;
const stub = single(instance);
const first = await hit(stub, a);
const second = await hit(stub, a);
const other = await hit(stub, b);
const stub = single(instance, dimension);
const first = await hit(stub, a, a);
const second = await hit(stub, a, a);
const other = await hit(stub, dimension === 'cf' ? b : a, b);
return {
r1: first.remaining, r2: second.remaining, r3: other.remaining, status: first.status, error: first.error,
};
Expand Down Expand Up @@ -179,6 +205,14 @@ describe('TASK-126 ip key separation', () => {
expect(distinct.size).toBe(walked.length);
});

it('pins the explicit credential/user policies without exempting them from counting', () => {
for (const key of [presentedCredentialRateLimitKeyGenerator, authenticatedUserRateLimitKeyGenerator]) {
expect(walked.filter((w) => mockLimiterKeys.get(w.instance.getKey) === key)
.map((w) => `${w.method.toUpperCase()} ${w.path}`).sort())
.toEqual(['DELETE /api/users/me', 'POST /api/uploads/']);
}
});

it('negative control: the instrument catches a limiter keyed the way the swapped sites were', async () => {
// The defect's own spelling, not a cousin of it (vera 73704): the 21 sites
// keyed `req.ip ? ipKeyGenerator(req.ip) : 'anon'`. A bare `req.ip` collapses
Expand Down Expand Up @@ -226,14 +260,11 @@ describe('TASK-126 ip key separation', () => {
expect(rec.r3).toBe(rec.r1);
});

// What this measures (wren 73671, ruling (b)), not what it might be read as:
// a request with no credential, no param and no body — built from nothing and
// carrying only `cf-connecting-ip` — is bucketed per `cf-connecting-ip` on
// every instance. That is stricter than a per-arm check: a limiter with no IP
// arm at all keys such a request on a CONSTANT, one bucket for every anonymous
// caller, which is the same defect as raw `req.ip` behind cloudflared — and it
// collapses A and B here exactly the same way.
it('every walked instance counts and separates; none collapses', async () => {
// Legacy policies keep the original anonymous CF-header probe. New policies
// use their presented-credential or authenticated-user dimension while
// holding the CF header constant. Pinning their routes prevents reclassifying
// a legacy raw-req.ip regression as an intentional per-user policy.
it('every walked instance counts and separates its configured identity; none collapses', async () => {
const results = [];
// Serial, not `Promise.all` (wren 73692): with 84 chains started in one tick,
// only the first reads the suite's NODE_ENV; the other 83 read the
Expand All @@ -246,7 +277,10 @@ describe('TASK-126 ip key separation', () => {
purpose: the NODE_ENV restore of one iteration must complete before the
next reads it, which is the property under test. */
for (const [i, w] of walked.entries()) {
results[i] = { path: w.path, method: w.method, ...(await observe(w.instance, i + 1)) };
const key = mockLimiterKeys.get(w.instance.getKey);
const dimension = key === authenticatedUserRateLimitKeyGenerator ? 'user'
: key === presentedCredentialRateLimitKeyGenerator ? 'credential' : 'cf';
results[i] = { path: w.path, method: w.method, dimension, ...(await observe(w.instance, i + 1, dimension)) };
}
/* eslint-enable no-await-in-loop, no-restricted-syntax */
expect(process.env.NODE_ENV).toBe(ENTRY_NODE_ENV);
Expand Down
9 changes: 7 additions & 2 deletions backend/__tests__/unit/routes/admin.users.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ jest.mock('../../../middleware/adminAuth', () => (_req, _res, next) => next());
jest.mock('../../../models/User', () => ({
find: jest.fn(),
findById: jest.fn(),
findOneAndUpdate: jest.fn(),
countDocuments: jest.fn(),
}));

Expand Down Expand Up @@ -147,11 +148,15 @@ describe('admin users routes', () => {
save: jest.fn().mockResolvedValue(true),
};
User.findById.mockResolvedValue(target);
User.findOneAndUpdate.mockResolvedValue({ ...target, role: 'admin' });

await handler(req, res);

expect(target.role).toBe('admin');
expect(target.save).toHaveBeenCalledTimes(1);
expect(User.findOneAndUpdate).toHaveBeenCalledWith(
{ _id: '000000000000000000000002', accountDeletionRequestedAt: { $exists: false } },
{ $set: { role: 'admin' } }, { new: true },
);
expect(target.save).not.toHaveBeenCalled();
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
message: 'User role updated successfully',
Expand Down
15 changes: 15 additions & 0 deletions backend/__tests__/unit/routes/pods.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,21 @@ describe('pods routes', () => {
expect(limited.body.msg).toMatch(/20 pod joins per 60s/);
});

it.each([
['post', '/api/pods', 201, 'createPod'],
['post', '/api/pods/123/leave', 200, 'leavePod'],
['delete', '/api/pods/123/members/456', 200, 'removeMember'],
])('bounds lifecycle writes on %s %s without consuming another token’s budget', async (method, url, status, handler) => {
const token = `Bearer lifecycle-${handler}`;
const callsBefore = controllers[handler].mock.calls.length;
for (let i = 0; i < 60; i += 1) {
await request(app)[method](url).set('Authorization', token).expect(status);
}
await request(app)[method](url).set('Authorization', token).expect(429);
expect(controllers[handler].mock.calls.length - callsBefore).toBe(60);
await request(app)[method](url).set('Authorization', `${token}-other`).expect(status);
});

it('DELETE /api/pods/123/members/456 calls removeMember', async () => {
await request(app).delete('/api/pods/123/members/456').expect(200);
expect(controllers.removeMember).toHaveBeenCalled();
Expand Down
3 changes: 3 additions & 0 deletions backend/__tests__/unit/routes/registry.runtime-tokens.test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// These unit cases isolate runtime identity behavior. The deletion fence is
// exercised with real models in service/accountDeletion.test.js.
jest.mock('../../../services/accountDeletionState', () => ({ isAccountDeleting: jest.fn().mockResolvedValue(false) }));
const request = require('supertest');
const express = require('express');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,6 @@
"routes/pg-status.ts GET / [unlimited]",
"routes/pg-status.ts POST /sync-user [unlimited]",
"routes/pods.ts DELETE /:id [unlimited]",
"routes/pods.ts DELETE /:id/members/:memberId [unlimited]",
"routes/pods.ts DELETE /announcement/:id [unlimited]",
"routes/pods.ts DELETE /external-link/:id [unlimited]",
"routes/pods.ts GET /:id/children [unlimited]",
Expand All @@ -112,8 +111,6 @@
"routes/pods.ts GET /:podId/files [unlimited]",
"routes/pods.ts GET /:type/:id [unlimited]",
"routes/pods.ts GET /external-link/:linkId/qrcode [unlimited]",
"routes/pods.ts POST / [unlimited]",
"routes/pods.ts POST /:id/leave [unlimited]",
"routes/pods.ts POST /announcement [unlimited]",
"routes/registry/admin.ts DELETE /admin/installations/:installationId [unlimited]",
"routes/registry/admin.ts DELETE /admin/installations/:installationId/runtime-tokens/:tokenId [unlimited]",
Expand Down Expand Up @@ -151,7 +148,6 @@
"routes/skills.ts POST /:skillId/rating [unlimited]",
"routes/skills.ts POST /import [unlimited]",
"routes/test-bot.ts POST /discord-summary [unlimited]",
"routes/uploads.ts POST / [unlimited]",
"routes/users.ts DELETE /:id/follow [unlimited]",
"routes/users.ts GET /:id [unlimited]",
"routes/users.ts GET /:id/public-activity [unlimited]",
Expand Down
18 changes: 17 additions & 1 deletion backend/__tests__/unit/routes/uploads.post.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,12 @@ jest.mock('../../../models/File', () => {
});
const File = require('../../../models/File');

jest.mock('../../../models/User', () => ({
findById: jest.fn(() => ({ select: () => ({ lean: async () => ({ _id: 'user1' }) }) })),
}));

jest.mock('../../../middleware/auth', () => (req, res, next) => {
req.userId = 'user1';
req.userId = /^Bearer quota-test-/.test(req.get('Authorization') || '') ? 'quota-user' : 'user1';
next();
});

Expand Down Expand Up @@ -68,6 +72,18 @@ describe('uploads POST / (ADR-002 Phase 1)', () => {
expect(res.body.url).not.toMatch(/^https?:\/\//);
});

it('limits one user despite forged forwarding headers and rotating sessions', async () => {
for (let attempt = 0; attempt < 60; attempt += 1) {
await request(app).post('/api/uploads')
.set('Authorization', `Bearer quota-test-${attempt % 2}`)
.set('cf-connecting-ip', `198.51.100.${attempt}`)
.set('X-Forwarded-For', `203.0.113.${attempt}`).expect(400);
}
await request(app).post('/api/uploads').set('Authorization', 'Bearer quota-test-new')
.set('cf-connecting-ip', '192.0.2.1').expect(429);
await request(app).post('/api/uploads').expect(400);
});

it('returns 400 when no file is provided', async () => {
const res = await request(app).post('/api/uploads').expect(400);
expect(res.body.msg).toBe('No file uploaded');
Expand Down
12 changes: 12 additions & 0 deletions backend/__tests__/unit/services/objectStore/mongoDriver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,18 @@ describe('MongoObjectStore (ADR-002 Phase 1)', () => {
expect(got).toBeNull();
});

it('deletes only an exact ownership namespace and refuses an empty prefix', async () => {
await store.put('user-abc-one.png', Buffer.from('a'), 'image/png');
await store.put('user-abc-two.png', Buffer.from('b'), 'image/png');
await store.put('user-abcd-keep.png', Buffer.from('c'), 'image/png');
await expect(store.deletePrefix('')).rejects.toThrow('prefix');
expect(await store.get('user-abc-one.png')).not.toBeNull();
await store.deletePrefix('user-abc-');
expect(await store.get('user-abc-one.png')).toBeNull();
expect(await store.get('user-abc-two.png')).toBeNull();
expect(await store.get('user-abcd-keep.png')).not.toBeNull();
});

it('delete is a no-op for a missing key', async () => {
await expect(store.delete('never-existed')).resolves.toBeUndefined();
});
Expand Down
Loading
Loading