From 3f9d0e08b4daa713018276ea3dc27afa8c80e9cf Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 23 Sep 2026 08:53:59 -0700 Subject: [PATCH] Reject incomplete account deletion instead of reporting success --- system/backend/at.mjs | 12 ++--- .../functions/delete-erase-and-forget-me.mjs | 22 +++++--- system/tests/account-deletion.test.mjs | 54 +++++++++++++++++++ 3 files changed, 75 insertions(+), 13 deletions(-) create mode 100644 system/tests/account-deletion.test.mjs diff --git a/system/backend/at.mjs b/system/backend/at.mjs index 27c684872e..316c387cf8 100644 --- a/system/backend/at.mjs +++ b/system/backend/at.mjs @@ -134,12 +134,6 @@ export async function updateAtprotoHandle(database, sub, handle) { } export async function deleteAtprotoAccount(database, sub) { - const adminPassword = getAdminPassword(); - if (!adminPassword) { - shell.log("🪦 Skipping PDS deletion, missing admin password env."); - return { deleted: false, reason: "missing-admin-password" }; - } - const users = database.db.collection("users"); const userRecord = await users.findOne({ _id: sub }); @@ -148,6 +142,12 @@ export async function deleteAtprotoAccount(database, sub) { return { deleted: false, reason: "missing-did" }; } + const adminPassword = getAdminPassword(); + if (!adminPassword) { + shell.log("🪦 Skipping PDS deletion, missing admin password env."); + return { deleted: false, reason: "missing-admin-password" }; + } + const did = userRecord.atproto.did; const auth = Buffer.from(`admin:${adminPassword}`).toString("base64"); diff --git a/system/netlify/functions/delete-erase-and-forget-me.mjs b/system/netlify/functions/delete-erase-and-forget-me.mjs index fb196095bb..f6145914a5 100644 --- a/system/netlify/functions/delete-erase-and-forget-me.mjs +++ b/system/netlify/functions/delete-erase-and-forget-me.mjs @@ -41,6 +41,7 @@ export async function handler(event, context) { } // 1. POST: Delete the user's account. + let database; try { const user = await authorize(event.headers); @@ -65,6 +66,7 @@ export async function handler(event, context) { ); } catch (err) { console.error("List error:", err); + throw new Error("Could not delete account storage. Please retry."); } if ( @@ -86,11 +88,14 @@ export async function handler(event, context) { }, }; - await s3User.send(new DeleteObjectsCommand(deleteParams)); + const deletedObjects = await s3User.send(new DeleteObjectsCommand(deleteParams)); + if (deletedObjects.Errors?.length) { + throw new Error("Could not delete all account storage. Please retry."); + } continuationToken = listedObjects.NextContinuationToken; } while (continuationToken); - const database = await connect(); + database = await connect(); const sub = user.sub; // Delete `paintings` and `moods` associated with the user's sub. @@ -190,16 +195,17 @@ export async function handler(event, context) { if (atprotoResult.deleted) { console.log("🪦 Deleted PDS account."); - } else { - console.log("🪦 PDS account not removed:", atprotoResult.reason); + } else if (atprotoResult.reason !== "missing-did") { + throw new Error("Could not delete the linked account. Please retry."); } console.log("❌ Deleted database data."); - await database.disconnect(); - // 3. Delete the user's auth0 account. const deleted = await deleteUser(sub); + if (!deleted?.success) { + throw new Error("Could not delete the account registration. Please retry."); + } console.log("❌ Deleted user registration:", deleted); return respond(200, { result: "Deleted!" }); // Successful account deletion. @@ -207,6 +213,8 @@ export async function handler(event, context) { return respond(401, { message: "Authorization failure..." }); } } catch (error) { - return respond(500, { message: error }); + return respond(500, { message: error.message || "Account deletion failed. Please retry." }); + } finally { + if (database) await database.disconnect(); } } diff --git a/system/tests/account-deletion.test.mjs b/system/tests/account-deletion.test.mjs new file mode 100644 index 0000000000..bf8d186152 --- /dev/null +++ b/system/tests/account-deletion.test.mjs @@ -0,0 +1,54 @@ +// node --experimental-vm-modules --test system/tests/account-deletion.test.mjs +import test from 'node:test'; +import assert from 'node:assert/strict'; +import vm from 'node:vm'; +import {readFile} from 'node:fs/promises'; + +async function fixture({listError=false,objectErrors=false,identity=true,pds={deleted:true},authorized=true}={}) { + const calls=[]; + class ListObjectsV2Command {} + class DeleteObjectsCommand {} + const context=vm.createContext({process:{env:{}},console:{log(){},error(){}}}); + const collection={deleteMany:async()=>{},deleteOne:async()=>{},updateMany:async()=>{}}; + const mocks={ + '../../backend/authorization.mjs':{ + authorize:async()=>authorized?{sub:'test-user'}:null, + getHandleOrEmail:async()=>null,userIDFromEmail:async()=>null, + deleteUser:async()=>{calls.push('identity');return {success:identity};} + }, + '../../backend/database.mjs':{connect:async()=>({db:{collection:()=>collection},disconnect:async()=>calls.push('disconnect')})}, + '../../backend/http.mjs':{respond:(statusCode,body)=>({statusCode,body})}, + '@aws-sdk/client-s3':{ListObjectsV2Command,DeleteObjectsCommand,S3Client:class{async send(command){ + if(command instanceof ListObjectsV2Command){if(listError)throw Error('offline');return {Contents:[{Key:'test-user/art'}]};} + return objectErrors?{Errors:[{Code:'AccessDenied'}]}:{}; + }}}, + '../../backend/kv.mjs':{},'../../backend/shell.mjs':{shell:{log(){}}}, + '../../backend/at.mjs':{deleteAtprotoAccount:async()=>pds}, + }; + const module=new vm.SourceTextModule(await readFile(new URL('../netlify/functions/delete-erase-and-forget-me.mjs',import.meta.url),'utf8'),{context}); + await module.link(name=>new vm.SyntheticModule(Object.keys(mocks[name]),function(){for(const [key,value]of Object.entries(mocks[name]))this.setExport(key,value);},{context})); + await module.evaluate(); + return {calls,run:()=>module.namespace.handler({httpMethod:'POST',headers:{}})}; +} + +test('storage listing and partial object failures never report successful deletion',async()=>{ + for(const options of [{listError:true},{objectErrors:true}]){ + const f=await fixture(options);assert.equal((await f.run()).statusCode,500);assert.ok(!f.calls.includes('identity')); + } +}); +test('identity deletion failure is retryable and always closes the database',async()=>{ + const f=await fixture({identity:false});const result=await f.run();assert.equal(result.statusCode,500);assert.match(result.body.message,/registration/);assert.deepEqual(f.calls,['identity','disconnect']); +}); +test('linked-account failures stop before deleting the sign-in identity',async()=>{ + for(const reason of ['request-failed','missing-admin-password']){ + const f=await fixture({pds:{deleted:false,reason}});assert.equal((await f.run()).statusCode,500);assert.deepEqual(f.calls,['disconnect']); + } +}); +test('accounts with or without a linked identity complete and close the database',async()=>{ + for(const pds of [{deleted:true},{deleted:false,reason:'missing-did'}]){ + const f=await fixture({pds});assert.equal((await f.run()).statusCode,200);assert.deepEqual(f.calls,['identity','disconnect']); + } +}); +test('unauthenticated deletion cannot reach identity or database operations',async()=>{ + const f=await fixture({authorized:false});assert.equal((await f.run()).statusCode,401);assert.deepEqual(f.calls,[]); +}); -- 2.51.2