@proteinjs/user-server 1.7.0 → 1.9.0
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 +37 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/generated/index.js +11 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/src/authentication/DbSessionStore.d.ts +3 -2
- package/dist/src/authentication/DbSessionStore.d.ts.map +1 -1
- package/dist/src/authentication/DbSessionStore.js +3 -2
- package/dist/src/authentication/DbSessionStore.js.map +1 -1
- package/dist/src/authentication/PasswordHasher.d.ts +45 -0
- package/dist/src/authentication/PasswordHasher.d.ts.map +1 -0
- package/dist/src/authentication/PasswordHasher.js +152 -0
- package/dist/src/authentication/PasswordHasher.js.map +1 -0
- package/dist/src/authentication/UserStatusTableWatcher.d.ts +19 -0
- package/dist/src/authentication/UserStatusTableWatcher.d.ts.map +1 -0
- package/dist/src/authentication/UserStatusTableWatcher.js +115 -0
- package/dist/src/authentication/UserStatusTableWatcher.js.map +1 -0
- package/dist/src/authentication/authenticate.d.ts.map +1 -1
- package/dist/src/authentication/authenticate.js +41 -13
- package/dist/src/authentication/authenticate.js.map +1 -1
- package/dist/src/authentication/establishSession.d.ts +30 -0
- package/dist/src/authentication/establishSession.d.ts.map +1 -0
- package/dist/src/authentication/establishSession.js +83 -0
- package/dist/src/authentication/establishSession.js.map +1 -0
- package/dist/src/authorization/userCache.d.ts.map +1 -1
- package/dist/src/authorization/userCache.js +10 -1
- package/dist/src/authorization/userCache.js.map +1 -1
- package/dist/src/emails/AccountDeletionEmailConfigs.d.ts +35 -0
- package/dist/src/emails/AccountDeletionEmailConfigs.d.ts.map +1 -0
- package/dist/src/emails/AccountDeletionEmailConfigs.js +36 -0
- package/dist/src/emails/AccountDeletionEmailConfigs.js.map +1 -0
- package/dist/src/emails/AccountDeletionEmails.d.ts +13 -0
- package/dist/src/emails/AccountDeletionEmails.d.ts.map +1 -0
- package/dist/src/emails/AccountDeletionEmails.js +98 -0
- package/dist/src/emails/AccountDeletionEmails.js.map +1 -0
- package/dist/src/routes/devLogin.d.ts.map +1 -1
- package/dist/src/routes/devLogin.js +6 -12
- package/dist/src/routes/devLogin.js.map +1 -1
- package/dist/src/routes/executePasswordReset.js +5 -3
- package/dist/src/routes/executePasswordReset.js.map +1 -1
- package/dist/src/routes/login.d.ts.map +1 -1
- package/dist/src/routes/login.js +27 -4
- package/dist/src/routes/login.js.map +1 -1
- package/dist/src/routes/logout.d.ts +10 -0
- package/dist/src/routes/logout.d.ts.map +1 -1
- package/dist/src/routes/logout.js +13 -32
- package/dist/src/routes/logout.js.map +1 -1
- package/dist/src/routes/signup.d.ts +19 -0
- package/dist/src/routes/signup.d.ts.map +1 -0
- package/dist/src/routes/signup.js +102 -0
- package/dist/src/routes/signup.js.map +1 -0
- package/dist/src/routes/validateResetPasswordToken.d.ts.map +1 -1
- package/dist/src/routes/validateResetPasswordToken.js +5 -1
- package/dist/src/routes/validateResetPasswordToken.js.map +1 -1
- package/dist/src/services/AccountDeletion.d.ts +52 -0
- package/dist/src/services/AccountDeletion.d.ts.map +1 -0
- package/dist/src/services/AccountDeletion.js +426 -0
- package/dist/src/services/AccountDeletion.js.map +1 -0
- package/dist/src/services/MachineCredentials.d.ts +21 -0
- package/dist/src/services/MachineCredentials.d.ts.map +1 -0
- package/dist/src/services/MachineCredentials.js +164 -0
- package/dist/src/services/MachineCredentials.js.map +1 -0
- package/dist/src/services/Roles.d.ts.map +1 -1
- package/dist/src/services/Roles.js +7 -0
- package/dist/src/services/Roles.js.map +1 -1
- package/dist/src/services/SetUserStatus.d.ts +18 -0
- package/dist/src/services/SetUserStatus.d.ts.map +1 -0
- package/dist/src/services/SetUserStatus.js +118 -0
- package/dist/src/services/SetUserStatus.js.map +1 -0
- package/dist/src/services/Signup.d.ts +20 -3
- package/dist/src/services/Signup.d.ts.map +1 -1
- package/dist/src/services/Signup.js +30 -19
- package/dist/src/services/Signup.js.map +1 -1
- package/dist/src/services/UpdateUserInfo.d.ts.map +1 -1
- package/dist/src/services/UpdateUserInfo.js +20 -17
- package/dist/src/services/UpdateUserInfo.js.map +1 -1
- package/dist/test/AccountDeletion.integration.test.d.ts +2 -0
- package/dist/test/AccountDeletion.integration.test.d.ts.map +1 -0
- package/dist/test/AccountDeletion.integration.test.js +718 -0
- package/dist/test/AccountDeletion.integration.test.js.map +1 -0
- package/dist/test/DevLogin.test.js +27 -31
- package/dist/test/DevLogin.test.js.map +1 -1
- package/dist/test/LogoutRoute.test.d.ts +2 -0
- package/dist/test/LogoutRoute.test.d.ts.map +1 -0
- package/dist/test/LogoutRoute.test.js +81 -0
- package/dist/test/LogoutRoute.test.js.map +1 -0
- package/dist/test/MachineAccounts.integration.test.d.ts +2 -0
- package/dist/test/MachineAccounts.integration.test.d.ts.map +1 -0
- package/dist/test/MachineAccounts.integration.test.js +597 -0
- package/dist/test/MachineAccounts.integration.test.js.map +1 -0
- package/dist/test/PasswordMigration.integration.test.d.ts +2 -0
- package/dist/test/PasswordMigration.integration.test.d.ts.map +1 -0
- package/dist/test/PasswordMigration.integration.test.js +332 -0
- package/dist/test/PasswordMigration.integration.test.js.map +1 -0
- package/dist/test/SetUserStatus.integration.test.d.ts +2 -0
- package/dist/test/SetUserStatus.integration.test.d.ts.map +1 -0
- package/dist/test/SetUserStatus.integration.test.js +266 -0
- package/dist/test/SetUserStatus.integration.test.js.map +1 -0
- package/dist/test/Signup.test.js +4 -2
- package/dist/test/Signup.test.js.map +1 -1
- package/dist/test/SignupRoute.test.d.ts +2 -0
- package/dist/test/SignupRoute.test.d.ts.map +1 -0
- package/dist/test/SignupRoute.test.js +338 -0
- package/dist/test/SignupRoute.test.js.map +1 -0
- package/dist/test/UserStatusGate.integration.test.d.ts +2 -0
- package/dist/test/UserStatusGate.integration.test.d.ts.map +1 -0
- package/dist/test/UserStatusGate.integration.test.js +162 -0
- package/dist/test/UserStatusGate.integration.test.js.map +1 -0
- package/dist/test/ValidateResetToken.test.d.ts +2 -0
- package/dist/test/ValidateResetToken.test.d.ts.map +1 -0
- package/dist/test/ValidateResetToken.test.js +170 -0
- package/dist/test/ValidateResetToken.test.js.map +1 -0
- package/dist/test/passportSessionHarness.d.ts +31 -0
- package/dist/test/passportSessionHarness.d.ts.map +1 -0
- package/dist/test/passportSessionHarness.js +107 -0
- package/dist/test/passportSessionHarness.js.map +1 -0
- package/generated/index.ts +11 -1
- package/index.ts +3 -0
- package/package.json +11 -7
- package/src/authentication/DbSessionStore.ts +3 -2
- package/src/authentication/PasswordHasher.ts +87 -0
- package/src/authentication/UserStatusTableWatcher.ts +56 -0
- package/src/authentication/authenticate.ts +31 -6
- package/src/authentication/establishSession.ts +33 -0
- package/src/authorization/userCache.ts +9 -1
- package/src/emails/AccountDeletionEmailConfigs.ts +79 -0
- package/src/emails/AccountDeletionEmails.ts +39 -0
- package/src/routes/devLogin.ts +4 -6
- package/src/routes/executePasswordReset.ts +2 -2
- package/src/routes/login.ts +25 -3
- package/src/routes/logout.ts +13 -18
- package/src/routes/signup.ts +55 -0
- package/src/routes/validateResetPasswordToken.ts +5 -1
- package/src/services/AccountDeletion.ts +291 -0
- package/src/services/MachineCredentials.ts +94 -0
- package/src/services/Roles.ts +10 -0
- package/src/services/SetUserStatus.ts +56 -0
- package/src/services/Signup.ts +25 -6
- package/src/services/UpdateUserInfo.ts +4 -5
- package/test/AccountDeletion.integration.test.ts +395 -0
- package/test/DevLogin.test.ts +16 -18
- package/test/LogoutRoute.test.ts +33 -0
- package/test/MachineAccounts.integration.test.ts +312 -0
- package/test/PasswordMigration.integration.test.ts +132 -0
- package/test/SetUserStatus.integration.test.ts +120 -0
- package/test/Signup.test.ts +4 -2
- package/test/SignupRoute.test.ts +219 -0
- package/test/UserStatusGate.integration.test.ts +68 -0
- package/test/ValidateResetToken.test.ts +86 -0
- package/test/passportSessionHarness.ts +58 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { randomBytes } from 'crypto';
|
|
2
|
+
import { getDbAsSystem } from '@proteinjs/db';
|
|
3
|
+
import {
|
|
4
|
+
MachineAccountView,
|
|
5
|
+
MachineCredentialsService,
|
|
6
|
+
MintedMachineCredential,
|
|
7
|
+
USER_PERMISSIONS,
|
|
8
|
+
getMachineAccounts,
|
|
9
|
+
tables,
|
|
10
|
+
} from '@proteinjs/user';
|
|
11
|
+
import { Logger } from '@proteinjs/logger';
|
|
12
|
+
import { Service } from '@proteinjs/service';
|
|
13
|
+
import { PasswordHasher } from '../authentication/PasswordHasher';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Credential minting for code-declared machine accounts — the ONLY manual step in a machine
|
|
17
|
+
* account's life (identity and grants come from the `MachineAccount` declaration at boot).
|
|
18
|
+
*
|
|
19
|
+
* Generates a strong random password (never human-chosen), stores its hash on the account row
|
|
20
|
+
* via PasswordHasher's machine mode — sha256, deliberately not the argon2id human KDF: a
|
|
21
|
+
* generated 256-bit secret gains nothing from key stretching, and the bridge logs in fresh per
|
|
22
|
+
* poll, so verifies stay cheap. (The plaintext is never persisted or logged.) Kills the
|
|
23
|
+
* account's existing sessions (the old
|
|
24
|
+
* credential dies with them), and returns the plaintext ONCE for pasting into the declaration's
|
|
25
|
+
* Secret Manager secret. The same call rotates. Machine rows only (`is_loaded_from_source`);
|
|
26
|
+
* human credentials go through the password-reset flow.
|
|
27
|
+
*/
|
|
28
|
+
export class MachineCredentials implements MachineCredentialsService {
|
|
29
|
+
public serviceMetadata: Service['serviceMetadata'] = {
|
|
30
|
+
auth: {
|
|
31
|
+
permission: USER_PERMISSIONS.users,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
async listMachineAccounts(): Promise<MachineAccountView[]> {
|
|
36
|
+
const db = getDbAsSystem();
|
|
37
|
+
const views: MachineAccountView[] = [];
|
|
38
|
+
for (const declaration of getMachineAccounts()) {
|
|
39
|
+
const row = await db.get(tables.User, { email: declaration.email });
|
|
40
|
+
const booted = !!row && row.isLoadedFromSource === true;
|
|
41
|
+
views.push({
|
|
42
|
+
email: declaration.email,
|
|
43
|
+
accountName: declaration.accountName,
|
|
44
|
+
roles: [...declaration.roles],
|
|
45
|
+
secretName: declaration.secretName,
|
|
46
|
+
status: booted ? (row.status === 'deactivated' ? 'deactivated' : 'active') : 'pending first boot',
|
|
47
|
+
hasCredential: booted && !!row.password,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return views;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async mintCredential(email: string): Promise<MintedMachineCredential> {
|
|
55
|
+
const logger = new Logger({ name: 'MachineCredentials.mintCredential' });
|
|
56
|
+
const normalizedEmail = email?.toLowerCase();
|
|
57
|
+
const declaration = getMachineAccounts().find((machineAccount) => machineAccount.email === normalizedEmail);
|
|
58
|
+
if (!declaration) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`No machine account is declared for '${email}'. Credentials can only be minted for ` +
|
|
61
|
+
`code-declared machine accounts (MachineAccount declarations).`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const db = getDbAsSystem();
|
|
66
|
+
const user = await db.get(tables.User, { email: normalizedEmail });
|
|
67
|
+
if (!user || user.isLoadedFromSource !== true) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`The machine account row for '${normalizedEmail}' has not been loaded from source yet — ` +
|
|
70
|
+
`the boot sync creates (or adopts) it. Boot the server with the declaration, then mint.`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 256 bits of entropy; standard API-key UX — generated, shown once, hash-only at rest.
|
|
75
|
+
const password = randomBytes(32).toString('hex');
|
|
76
|
+
await db.update(tables.User, { id: user.id, password: await new PasswordHasher().hash(password, 'machine') });
|
|
77
|
+
// Rotation kills the old credential's sessions immediately (the bridge logs in fresh per
|
|
78
|
+
// poll, so this is cheap for it — the mechanism is the categorical one).
|
|
79
|
+
await db.delete(tables.Session, { userEmail: normalizedEmail });
|
|
80
|
+
logger.info({
|
|
81
|
+
message: `Minted machine credential`,
|
|
82
|
+
obj: { email: normalizedEmail, secretName: declaration.secretName },
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
email: normalizedEmail,
|
|
87
|
+
password,
|
|
88
|
+
secretName: declaration.secretName,
|
|
89
|
+
note:
|
|
90
|
+
`Shown once. Paste this password into the '${declaration.secretName}' Secret Manager ` +
|
|
91
|
+
`secret, then restart the service that reads it — the previous credential is already invalid.`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
package/src/services/Roles.ts
CHANGED
|
@@ -36,6 +36,16 @@ export class Roles implements RolesService {
|
|
|
36
36
|
throw new Error(`No user found for id: ${userId}`);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// Machine grants live in the MachineAccount declaration and reconcile at boot — git history
|
|
40
|
+
// is their audit ledger, role_grant_event is the HUMAN ledger, and the two never interleave
|
|
41
|
+
// on one row. A runtime grant here would also just be reverted by the next boot.
|
|
42
|
+
if (user.isLoadedFromSource === true) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`'${user.email}' is a machine account: its roles are declared in code (its MachineAccount ` +
|
|
45
|
+
`declaration) and reverted to the declaration on every boot. Change the declaration instead.`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
39
49
|
const roles = user.roles ?? [];
|
|
40
50
|
if (action === 'grant' ? roles.includes(role) : !roles.includes(role)) {
|
|
41
51
|
// No change, no audit row: the trail records what happened, not what was re-asked.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { getDbAsSystem } from '@proteinjs/db';
|
|
2
|
+
import { SetUserStatusService, USER_STATUSES, UserRepo, UserStatus, tables } from '@proteinjs/user';
|
|
3
|
+
import { Logger } from '@proteinjs/logger';
|
|
4
|
+
import { Service } from '@proteinjs/service';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The ONE write path for a user's account standing (the `user.status` column is
|
|
8
|
+
* service-protected — generic record writes cannot touch it). The status update and its
|
|
9
|
+
* `user_status_event` audit row (actor, target, status; `created` is the timestamp) commit in
|
|
10
|
+
* one transaction, so the trail cannot diverge from the standing.
|
|
11
|
+
*
|
|
12
|
+
* Admin-only by explicit scope: deactivation locks the target out of the product (login refused,
|
|
13
|
+
* live sessions stop resolving via userCache), so the door is the break-glass role, not a mapped
|
|
14
|
+
* permission. This method only flips standing — `deleteRequestedAt`/`purgeAfter` belong to the
|
|
15
|
+
* account-deletion flow.
|
|
16
|
+
*/
|
|
17
|
+
export class SetUserStatus implements SetUserStatusService {
|
|
18
|
+
public serviceMetadata: Service['serviceMetadata'] = {
|
|
19
|
+
auth: {
|
|
20
|
+
roles: ['admin'],
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
async setUserStatus(userId: string, status: UserStatus): Promise<void> {
|
|
25
|
+
const logger = new Logger({ name: 'SetUserStatus.setUserStatus' });
|
|
26
|
+
if (!USER_STATUSES.includes(status)) {
|
|
27
|
+
throw new Error(`'${status}' is not a known user status. Pick one of: ${USER_STATUSES.join(', ')}.`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const db = getDbAsSystem();
|
|
31
|
+
const user = await db.get(tables.User, { id: userId });
|
|
32
|
+
if (!user) {
|
|
33
|
+
throw new Error(`No user found for id: ${userId}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Rows predating the status column read null, which every gate treats as active.
|
|
37
|
+
if ((user.status ?? 'active') === status) {
|
|
38
|
+
// No change, no audit row: the trail records what happened, not what was re-asked.
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const actor = new UserRepo().getUser();
|
|
43
|
+
await db.runTransaction(async () => {
|
|
44
|
+
await db.update(tables.User, { id: userId, status });
|
|
45
|
+
await db.insert(tables.UserStatusEvent, {
|
|
46
|
+
actor: actor.id,
|
|
47
|
+
target: userId,
|
|
48
|
+
status,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
logger.info({
|
|
52
|
+
message: `User status set to ${status}`,
|
|
53
|
+
obj: { actor: actor.id, target: userId, status },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/services/Signup.ts
CHANGED
|
@@ -20,8 +20,8 @@ import {
|
|
|
20
20
|
getDefaultInviteEmailConfigFactory,
|
|
21
21
|
getDefaultSignupConfirmationEmailConfigFactory,
|
|
22
22
|
} from '@proteinjs/email-server';
|
|
23
|
-
import sha256 from 'crypto-js/sha256';
|
|
24
23
|
import { Loadable, SourceRepository } from '@proteinjs/reflection';
|
|
24
|
+
import { PasswordHasher } from '../authentication/PasswordHasher';
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* How long an invite link stays usable. Deliberately generous: a legitimate invite that gets
|
|
@@ -55,6 +55,18 @@ export const getDefaultInviteConfigFactory = (): DefaultInviteConfigFactory => {
|
|
|
55
55
|
return factory || defaultFactory;
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* `createUser`'s outcome, for the signup route's eyes only (it never crosses the wire): the
|
|
60
|
+
* route establishes a session on 'created' and must NOT on 'exists' — auto-login must never
|
|
61
|
+
* hand out a session for an account the caller didn't just create. The response body stays
|
|
62
|
+
* identical either way (existence is reported to the mailbox owner by email, not the caller).
|
|
63
|
+
*/
|
|
64
|
+
export type CreateUserResult = {
|
|
65
|
+
outcome: 'created' | 'exists';
|
|
66
|
+
/** The account email, lowercased — resolved from the invite when a token was provided. */
|
|
67
|
+
email: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
58
70
|
export class Signup implements SignupService {
|
|
59
71
|
public serviceMetadata = {
|
|
60
72
|
auth: {
|
|
@@ -72,7 +84,13 @@ export class Signup implements SignupService {
|
|
|
72
84
|
},
|
|
73
85
|
};
|
|
74
86
|
|
|
75
|
-
|
|
87
|
+
/**
|
|
88
|
+
* The signup flow's account creation + notification emails. Not RPC-reachable (deliberately
|
|
89
|
+
* absent from the SignupService interface): its caller is the `routes.signup` ROUTE, which
|
|
90
|
+
* establishes the session (auto-login) from this result — a request-level concern services
|
|
91
|
+
* never see.
|
|
92
|
+
*/
|
|
93
|
+
async createUser(user: UserSignup, token?: string): Promise<CreateUserResult> {
|
|
76
94
|
const logger = new Logger({ name: 'Signup.createUser' });
|
|
77
95
|
const db = getDbAsSystem();
|
|
78
96
|
|
|
@@ -116,7 +134,7 @@ export class Signup implements SignupService {
|
|
|
116
134
|
...config.options,
|
|
117
135
|
});
|
|
118
136
|
}
|
|
119
|
-
return;
|
|
137
|
+
return { outcome: 'exists', email };
|
|
120
138
|
}
|
|
121
139
|
|
|
122
140
|
const { text, html } = config.getNewUserEmailContent();
|
|
@@ -128,6 +146,7 @@ export class Signup implements SignupService {
|
|
|
128
146
|
...config.options,
|
|
129
147
|
});
|
|
130
148
|
logger.info({ message: `Created user`, obj: { email } });
|
|
149
|
+
return { outcome: 'created', email };
|
|
131
150
|
}
|
|
132
151
|
|
|
133
152
|
async sendInvite(email: string): Promise<SendInviteResponse> {
|
|
@@ -256,8 +275,8 @@ export class Signup implements SignupService {
|
|
|
256
275
|
}
|
|
257
276
|
|
|
258
277
|
/**
|
|
259
|
-
* Single owner of account-record creation: case-normalized existence check +
|
|
260
|
-
* insert. Both doors into a user row go through here — the signup flow (`createUser`, which
|
|
278
|
+
* Single owner of account-record creation: case-normalized existence check + argon2id-hashed
|
|
279
|
+
* insert (PasswordHasher). Both doors into a user row go through here — the signup flow (`createUser`, which
|
|
261
280
|
* layers invite validation and confirmation emails on top) and the dev-login bootstrap
|
|
262
281
|
* (`devLogin`, which auto-creates missing same-domain test accounts). Not exposed as an RPC:
|
|
263
282
|
* the service surface is the `SignupService` INTERFACE (ServiceRouter walks its declared
|
|
@@ -280,7 +299,7 @@ export class Signup implements SignupService {
|
|
|
280
299
|
await db.insert(tables.User, {
|
|
281
300
|
name: account.name,
|
|
282
301
|
email,
|
|
283
|
-
password:
|
|
302
|
+
password: await new PasswordHasher().hash(account.password),
|
|
284
303
|
emailVerified: account.emailVerified,
|
|
285
304
|
roles: [],
|
|
286
305
|
invitedBy: account.invitedBy,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import sharp from 'sharp';
|
|
2
2
|
import heicDecode from 'heic-decode';
|
|
3
|
-
import sha256 from 'crypto-js/sha256';
|
|
4
3
|
import { getDbAsSystem } from '@proteinjs/db';
|
|
5
4
|
import { FileStorage } from '@proteinjs/db-file';
|
|
6
5
|
import { tables, User, UserRepo, UpdatePasswordResponse, UpdatedUser, UpdateUserInfoService } from '@proteinjs/user';
|
|
7
6
|
import { EmailSender, getDefaultPasswordUpdatedEmailConfigFactory } from '@proteinjs/email-server';
|
|
7
|
+
import { PasswordHasher } from '../authentication/PasswordHasher';
|
|
8
8
|
|
|
9
9
|
export class UpdateUserInfo implements UpdateUserInfoService {
|
|
10
10
|
/** Stored avatar photos are exactly this: cover-cropped square JPEG. */
|
|
@@ -37,10 +37,9 @@ export class UpdateUserInfo implements UpdateUserInfoService {
|
|
|
37
37
|
const db = getDbAsSystem();
|
|
38
38
|
const userId = new UserRepo().getUser().id;
|
|
39
39
|
|
|
40
|
-
// verify current password
|
|
41
|
-
const hashedCurrentPassword = sha256(currentPassword).toString();
|
|
40
|
+
// verify current password (format-discriminating: legacy sha256 rows verify too)
|
|
42
41
|
const user = await db.get(tables.User, { id: userId });
|
|
43
|
-
if (
|
|
42
|
+
if (!(await new PasswordHasher().verify(user.password, currentPassword))) {
|
|
44
43
|
return {
|
|
45
44
|
updated: false,
|
|
46
45
|
error: `Invalid password`,
|
|
@@ -70,7 +69,7 @@ export class UpdateUserInfo implements UpdateUserInfoService {
|
|
|
70
69
|
// If email is sent successfully,
|
|
71
70
|
// hash and store new password
|
|
72
71
|
try {
|
|
73
|
-
const hashedNewPassword =
|
|
72
|
+
const hashedNewPassword = await new PasswordHasher().hash(newPassword);
|
|
74
73
|
await this.saveUserInfo({ password: hashedNewPassword });
|
|
75
74
|
} catch (error: any) {
|
|
76
75
|
return {
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import sha256 from 'crypto-js/sha256';
|
|
2
|
+
import moment from 'moment';
|
|
3
|
+
import { Reference, getDbAsSystem } from '@proteinjs/db';
|
|
4
|
+
import { SourceRepository } from '@proteinjs/reflection';
|
|
5
|
+
// Deep import (test-only): ServiceAuth is the real RPC door but is not index-exported by
|
|
6
|
+
// @proteinjs/service — the door test below runs the actual gate rather than pinning metadata.
|
|
7
|
+
import { ServiceAuth } from '@proteinjs/service/dist/src/ServiceAuth';
|
|
8
|
+
import { AccessGrant, ManifestGrant, User, UserRepo, guestUser, tables } from '@proteinjs/user';
|
|
9
|
+
import { AccountDeletion } from '../src/services/AccountDeletion';
|
|
10
|
+
import { authenticate } from '../src/authentication/authenticate';
|
|
11
|
+
import { login } from '../src/routes/login';
|
|
12
|
+
import { createPassportRequest } from './passportSessionHarness';
|
|
13
|
+
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
14
|
+
|
|
15
|
+
const testEnv = new UserServerTestEnvironment();
|
|
16
|
+
|
|
17
|
+
const PASSWORD_A = 'alpha-password';
|
|
18
|
+
const PASSWORD_B = 'beta-password';
|
|
19
|
+
const RESOURCE_TABLE = 'file';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* BUILD §9.1 — the deactivation + cancel suite, two users, outcomes only (rows written against
|
|
23
|
+
* the emulator, never calls made). Fixture: A owns r1/r2 (shared outbound to B), B owns r3
|
|
24
|
+
* (shared inbound to A). A's deletion must revoke both directions while B's own grant — and
|
|
25
|
+
* A's owner grants, the purge walker's terminal targets — survive.
|
|
26
|
+
*/
|
|
27
|
+
describe('AccountDeletion — deactivation, manifest, resume, cancel-by-login', () => {
|
|
28
|
+
let userA: User;
|
|
29
|
+
let userB: User;
|
|
30
|
+
const r1 = 'resource-a-one';
|
|
31
|
+
const r2 = 'resource-a-two';
|
|
32
|
+
const r3 = 'resource-b-three';
|
|
33
|
+
let ownerA1: AccessGrant;
|
|
34
|
+
let ownerA2: AccessGrant;
|
|
35
|
+
let outbound1: AccessGrant;
|
|
36
|
+
let outbound2: AccessGrant;
|
|
37
|
+
let inbound: AccessGrant;
|
|
38
|
+
let ownerB: AccessGrant;
|
|
39
|
+
|
|
40
|
+
beforeAll(async () => {
|
|
41
|
+
await testEnv.beforeAll();
|
|
42
|
+
}, 120000);
|
|
43
|
+
|
|
44
|
+
afterAll(async () => {
|
|
45
|
+
await testEnv.afterAll();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
beforeEach(async () => {
|
|
49
|
+
const db = getDbAsSystem();
|
|
50
|
+
await db.delete(tables.AccountDeletion, {});
|
|
51
|
+
await db.delete(tables.AccessGrant, {});
|
|
52
|
+
await db.delete(tables.Session, {});
|
|
53
|
+
await db.delete(tables.UserStatusEvent, {});
|
|
54
|
+
await db.delete(tables.User, {});
|
|
55
|
+
|
|
56
|
+
userA = await createAccount('deleter@test.local', PASSWORD_A);
|
|
57
|
+
userB = await createAccount('keeper@test.local', PASSWORD_B);
|
|
58
|
+
|
|
59
|
+
ownerA1 = await insertGrant(userA.id, r1, 'owner');
|
|
60
|
+
ownerA2 = await insertGrant(userA.id, r2, 'owner');
|
|
61
|
+
outbound1 = await insertGrant(userB.id, r1, 'read');
|
|
62
|
+
outbound2 = await insertGrant(userB.id, r2, 'write');
|
|
63
|
+
ownerB = await insertGrant(userB.id, r3, 'owner');
|
|
64
|
+
inbound = await insertGrant(userA.id, r3, 'read');
|
|
65
|
+
|
|
66
|
+
await insertSession('session-a-1', userA.email);
|
|
67
|
+
await insertSession('session-a-2', userA.email);
|
|
68
|
+
await insertSession('session-b-1', userB.email);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/** Insert an account the way the product stores it: lowercased email, sha256 password. */
|
|
72
|
+
const createAccount = async (email: string, password: string) => {
|
|
73
|
+
return await getDbAsSystem().insert(tables.User, {
|
|
74
|
+
name: 'Deletion test user',
|
|
75
|
+
email,
|
|
76
|
+
password: sha256(password).toString(),
|
|
77
|
+
emailVerified: true,
|
|
78
|
+
roles: [],
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const insertGrant = async (principalId: string, resourceId: string, accessLevel: AccessGrant['accessLevel']) => {
|
|
83
|
+
return await getDbAsSystem().insert(tables.AccessGrant, {
|
|
84
|
+
principal: new Reference(tables.User.name, principalId),
|
|
85
|
+
resource: new Reference(RESOURCE_TABLE, resourceId),
|
|
86
|
+
resourceTable: RESOURCE_TABLE,
|
|
87
|
+
accessLevel,
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const insertSession = async (sessionId: string, userEmail: string) => {
|
|
92
|
+
return await getDbAsSystem().insert(tables.Session, {
|
|
93
|
+
sessionId,
|
|
94
|
+
session: '{}',
|
|
95
|
+
expires: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
|
96
|
+
userEmail,
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const grantRows = async () => await getDbAsSystem().query(tables.AccessGrant, {});
|
|
101
|
+
const sessionRows = async () => await getDbAsSystem().query(tables.Session, {});
|
|
102
|
+
const auditRows = async () => await getDbAsSystem().query(tables.UserStatusEvent, {});
|
|
103
|
+
const deletionRow = async (userId: string) => await getDbAsSystem().get(tables.AccountDeletion, { userId });
|
|
104
|
+
const userRow = async (id: string) => await getDbAsSystem().get(tables.User, { id });
|
|
105
|
+
|
|
106
|
+
const sortById = <T extends { id: string }>(rows: T[]) => [...rows].sort((a, b) => a.id.localeCompare(b.id));
|
|
107
|
+
|
|
108
|
+
const expectedManifest = (): ManifestGrant[] => [
|
|
109
|
+
{ id: inbound.id, principal: userA.id, resource: r3, resourceTable: RESOURCE_TABLE, accessLevel: 'read' },
|
|
110
|
+
{ id: outbound1.id, principal: userB.id, resource: r1, resourceTable: RESOURCE_TABLE, accessLevel: 'read' },
|
|
111
|
+
{ id: outbound2.id, principal: userB.id, resource: r2, resourceTable: RESOURCE_TABLE, accessLevel: 'write' },
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Drive the real login route over REAL passport login machinery (passportSessionHarness):
|
|
116
|
+
* establishSession regenerates the id (fixation) and commits the session row before the route
|
|
117
|
+
* responds — both live inside passport's `request.login` now; ordering is pinned by
|
|
118
|
+
* SignupRoute.test.ts.
|
|
119
|
+
*/
|
|
120
|
+
const invokeLogin = async (email: string, password: string) => {
|
|
121
|
+
let sent: any;
|
|
122
|
+
const response: any = {
|
|
123
|
+
send: (body: any) => {
|
|
124
|
+
sent = body;
|
|
125
|
+
},
|
|
126
|
+
status: () => response,
|
|
127
|
+
};
|
|
128
|
+
const { request, events } = await createPassportRequest({ body: { email, password } });
|
|
129
|
+
await login.onRequest(request, response);
|
|
130
|
+
return {
|
|
131
|
+
loggedInAs: request.session.passport?.user as string | undefined,
|
|
132
|
+
regenerated: events.includes('regenerate'),
|
|
133
|
+
sent,
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
it('the service door admits any signed-in account and refuses guests', () => {
|
|
138
|
+
// ServiceAuth funnels through UserAuth, which resolves the current user from the
|
|
139
|
+
// source-graph-registered AuthenticatedUserRepo — seed it the way the env seeds session
|
|
140
|
+
// storage (tests do not load the generated source graph).
|
|
141
|
+
(SourceRepository.get() as unknown as { objectCache: Record<string, unknown[]> }).objectCache[
|
|
142
|
+
'@proteinjs/user-auth/AuthenticatedUserRepo'
|
|
143
|
+
] = [new UserRepo()];
|
|
144
|
+
const canRun = () =>
|
|
145
|
+
ServiceAuth.canRunService(new AccountDeletion(), { name: 'requestDeletion' } as any, [PASSWORD_A]);
|
|
146
|
+
|
|
147
|
+
testEnv.actAs(userA); // a plain account with no roles at all
|
|
148
|
+
expect(canRun()).toBe(true);
|
|
149
|
+
|
|
150
|
+
testEnv.actAs(guestUser as User);
|
|
151
|
+
expect(canRun()).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('deactivates: revokes grants both directions, persists the full manifest, flips + audits standing, kills sessions', async () => {
|
|
155
|
+
testEnv.actAs(userA);
|
|
156
|
+
const result = await new AccountDeletion().requestDeletion(PASSWORD_A);
|
|
157
|
+
|
|
158
|
+
// Grants: outbound (B on r1/r2) and inbound (A on r3) revoked; A's owner grants (the purge
|
|
159
|
+
// walker's terminal targets) and B's own owner grant survive.
|
|
160
|
+
const remaining = await grantRows();
|
|
161
|
+
expect(sortById(remaining).map((grant) => grant.id)).toEqual(
|
|
162
|
+
sortById([ownerA1, ownerA2, ownerB]).map((grant) => grant.id)
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// Manifest row: full grant set both directions, owned-resource snapshot, grace phase.
|
|
166
|
+
const deletion = await deletionRow(userA.id);
|
|
167
|
+
expect(deletion).toBeTruthy();
|
|
168
|
+
expect(deletion.phase).toBe('grace');
|
|
169
|
+
expect(deletion.leaseSeq).toBe(0);
|
|
170
|
+
expect(deletion.userEmail).toBe(userA.email);
|
|
171
|
+
expect([...deletion.ownedResourceIds].sort()).toEqual([r1, r2].sort());
|
|
172
|
+
expect(sortById(deletion.manifestGrants)).toEqual(sortById(expectedManifest()));
|
|
173
|
+
|
|
174
|
+
// purgeAfter ≈ now + 30 days, and the service returned the same stamp.
|
|
175
|
+
const hoursOut = moment(deletion.purgeAfter).diff(moment(), 'hours', true);
|
|
176
|
+
expect(hoursOut).toBeGreaterThan(719);
|
|
177
|
+
expect(hoursOut).toBeLessThan(721);
|
|
178
|
+
expect(Math.abs(result.purgeAfter.valueOf() - moment(deletion.purgeAfter).valueOf())).toBeLessThan(1000);
|
|
179
|
+
|
|
180
|
+
// User row: audited flip + deletion stamps.
|
|
181
|
+
const flipped = await userRow(userA.id);
|
|
182
|
+
expect(flipped.status).toBe('deactivated');
|
|
183
|
+
expect(flipped.deleteRequestedAt).toBeTruthy();
|
|
184
|
+
expect(Math.abs(moment(flipped.purgeAfter).valueOf() - moment(deletion.purgeAfter).valueOf())).toBeLessThan(1000);
|
|
185
|
+
const events = await auditRows();
|
|
186
|
+
expect(events).toHaveLength(1);
|
|
187
|
+
expect(events[0]).toMatchObject({ actor: userA.id, target: userA.id, status: 'deactivated' });
|
|
188
|
+
|
|
189
|
+
// Sessions: A's two killed, B's untouched.
|
|
190
|
+
const sessions = await sessionRows();
|
|
191
|
+
expect(sessions).toHaveLength(1);
|
|
192
|
+
expect(sessions[0].userEmail).toBe(userB.email);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('refuses a wrong password with zero writes', async () => {
|
|
196
|
+
testEnv.actAs(userA);
|
|
197
|
+
await expect(new AccountDeletion().requestDeletion('wrong-password')).rejects.toThrow('Password incorrect');
|
|
198
|
+
|
|
199
|
+
expect(await deletionRow(userA.id)).toBeFalsy();
|
|
200
|
+
expect(await grantRows()).toHaveLength(6);
|
|
201
|
+
expect(await sessionRows()).toHaveLength(3);
|
|
202
|
+
expect(await auditRows()).toHaveLength(0);
|
|
203
|
+
const untouched = await userRow(userA.id);
|
|
204
|
+
expect(untouched.status).toBe('active');
|
|
205
|
+
expect(untouched.deleteRequestedAt).toBeFalsy();
|
|
206
|
+
expect(untouched.purgeAfter).toBeFalsy();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('resume keeps the FULL manifest after a crash mid-revocation — never re-enumerates', async () => {
|
|
210
|
+
testEnv.actAs(userA);
|
|
211
|
+
const service = new AccountDeletion();
|
|
212
|
+
await service.requestDeletion(PASSWORD_A);
|
|
213
|
+
const persisted = await deletionRow(userA.id);
|
|
214
|
+
expect(persisted.manifestGrants).toHaveLength(3);
|
|
215
|
+
|
|
216
|
+
// Simulate the §3.3 crash window (mid step 4): two of the revoked grants were never actually
|
|
217
|
+
// deleted — restore them with their ORIGINAL ids — and the user row/sessions never flipped.
|
|
218
|
+
for (const grant of [outbound1, outbound2]) {
|
|
219
|
+
await getDbAsSystem().insert(tables.AccessGrant, {
|
|
220
|
+
id: grant.id,
|
|
221
|
+
principal: new Reference(tables.User.name, grant.principal._id as string),
|
|
222
|
+
resource: new Reference(RESOURCE_TABLE, grant.resource._id as string),
|
|
223
|
+
resourceTable: RESOURCE_TABLE,
|
|
224
|
+
accessLevel: grant.accessLevel,
|
|
225
|
+
} as any);
|
|
226
|
+
}
|
|
227
|
+
await getDbAsSystem().update(tables.User, {
|
|
228
|
+
id: userA.id,
|
|
229
|
+
status: 'active',
|
|
230
|
+
deleteRequestedAt: null,
|
|
231
|
+
purgeAfter: null,
|
|
232
|
+
});
|
|
233
|
+
await insertSession('session-a-3', userA.email);
|
|
234
|
+
|
|
235
|
+
// The user's retry resumes from the stored manifest.
|
|
236
|
+
await service.requestDeletion(PASSWORD_A);
|
|
237
|
+
|
|
238
|
+
// THE red assertion: the manifest still holds the FULL pre-crash set. A re-enumerating
|
|
239
|
+
// implementation rebuilds it from the shrunken live grants (the inbound grant is already
|
|
240
|
+
// gone) and this fails.
|
|
241
|
+
const resumed = await deletionRow(userA.id);
|
|
242
|
+
expect(sortById(resumed.manifestGrants)).toEqual(sortById(expectedManifest()));
|
|
243
|
+
expect([...resumed.ownedResourceIds].sort()).toEqual([r1, r2].sort());
|
|
244
|
+
|
|
245
|
+
// And the resume completed the crashed pass: revocation drained, standing flipped, session dead.
|
|
246
|
+
const remaining = await grantRows();
|
|
247
|
+
expect(sortById(remaining).map((grant) => grant.id)).toEqual(
|
|
248
|
+
sortById([ownerA1, ownerA2, ownerB]).map((grant) => grant.id)
|
|
249
|
+
);
|
|
250
|
+
expect((await userRow(userA.id)).status).toBe('deactivated');
|
|
251
|
+
expect((await sessionRows()).map((session) => session.userEmail)).toEqual([userB.email]);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('cancel restores grants (fresh ids), reactivates + audits, nulls stamps, deletes the manifest row', async () => {
|
|
255
|
+
testEnv.actAs(userA);
|
|
256
|
+
await new AccountDeletion().requestDeletion(PASSWORD_A);
|
|
257
|
+
const manifest = (await deletionRow(userA.id)).manifestGrants;
|
|
258
|
+
|
|
259
|
+
const outcome = await new AccountDeletion().cancelPendingDeletion(userA.email);
|
|
260
|
+
expect(outcome).toBe('restored');
|
|
261
|
+
|
|
262
|
+
// Every revoked grant is back — matched by (principal, resource, accessLevel), with a FRESH id.
|
|
263
|
+
const all = await grantRows();
|
|
264
|
+
expect(all).toHaveLength(6);
|
|
265
|
+
for (const entry of manifest) {
|
|
266
|
+
const matches = all.filter(
|
|
267
|
+
(grant) =>
|
|
268
|
+
grant.principal._id === entry.principal &&
|
|
269
|
+
grant.resource._id === entry.resource &&
|
|
270
|
+
grant.accessLevel === entry.accessLevel
|
|
271
|
+
);
|
|
272
|
+
expect(matches).toHaveLength(1);
|
|
273
|
+
expect(matches[0].id).not.toBe(entry.id);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// User row: active again through the audited path, stamps nulled.
|
|
277
|
+
const restored = await userRow(userA.id);
|
|
278
|
+
expect(restored.status).toBe('active');
|
|
279
|
+
expect(restored.deleteRequestedAt).toBeFalsy();
|
|
280
|
+
expect(restored.purgeAfter).toBeFalsy();
|
|
281
|
+
const events = await auditRows();
|
|
282
|
+
expect(events.map((event) => event.status).sort()).toEqual(['active', 'deactivated']);
|
|
283
|
+
|
|
284
|
+
expect(await deletionRow(userA.id)).toBeFalsy();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('cancel resumes a crashed prior cancel: restoring-phase re-entry, no duplicate grants', async () => {
|
|
288
|
+
testEnv.actAs(userA);
|
|
289
|
+
await new AccountDeletion().requestDeletion(PASSWORD_A);
|
|
290
|
+
const deletion = await deletionRow(userA.id);
|
|
291
|
+
|
|
292
|
+
// A prior cancel claimed the row, restored ONE grant, then crashed.
|
|
293
|
+
await getDbAsSystem().update(tables.AccountDeletion, { id: deletion.id, phase: 'restoring' });
|
|
294
|
+
const first = deletion.manifestGrants[0];
|
|
295
|
+
await getDbAsSystem().insert(tables.AccessGrant, {
|
|
296
|
+
principal: new Reference(tables.User.name, first.principal),
|
|
297
|
+
resource: new Reference(first.resourceTable as string, first.resource),
|
|
298
|
+
resourceTable: first.resourceTable,
|
|
299
|
+
accessLevel: first.accessLevel,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const outcome = await new AccountDeletion().cancelPendingDeletion(userA.email);
|
|
303
|
+
expect(outcome).toBe('restored');
|
|
304
|
+
|
|
305
|
+
// Idempotent re-insert: exactly one grant per manifest entry — no duplicates.
|
|
306
|
+
const all = await grantRows();
|
|
307
|
+
expect(all).toHaveLength(6);
|
|
308
|
+
for (const entry of deletion.manifestGrants) {
|
|
309
|
+
const matches = all.filter(
|
|
310
|
+
(grant) =>
|
|
311
|
+
grant.principal._id === entry.principal &&
|
|
312
|
+
grant.resource._id === entry.resource &&
|
|
313
|
+
grant.accessLevel === entry.accessLevel
|
|
314
|
+
);
|
|
315
|
+
expect(matches).toHaveLength(1);
|
|
316
|
+
}
|
|
317
|
+
expect((await userRow(userA.id)).status).toBe('active');
|
|
318
|
+
expect(await deletionRow(userA.id)).toBeFalsy();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('cancel refuses once the purge walker has claimed the row', async () => {
|
|
322
|
+
testEnv.actAs(userA);
|
|
323
|
+
await new AccountDeletion().requestDeletion(PASSWORD_A);
|
|
324
|
+
const deletion = await deletionRow(userA.id);
|
|
325
|
+
await getDbAsSystem().update(tables.AccountDeletion, { id: deletion.id, phase: 'purging' });
|
|
326
|
+
|
|
327
|
+
const outcome = await new AccountDeletion().cancelPendingDeletion(userA.email);
|
|
328
|
+
expect(outcome).toBe('purging');
|
|
329
|
+
|
|
330
|
+
// Nothing restored: standing stays deactivated, grants stay revoked, manifest row stays.
|
|
331
|
+
expect((await userRow(userA.id)).status).toBe('deactivated');
|
|
332
|
+
expect(await grantRows()).toHaveLength(3);
|
|
333
|
+
expect((await deletionRow(userA.id)).phase).toBe('purging');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('cancel is a no-op for an account with no pending deletion', async () => {
|
|
337
|
+
expect(await new AccountDeletion().cancelPendingDeletion(userB.email)).toBe('not-pending');
|
|
338
|
+
expect(await grantRows()).toHaveLength(6);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('authenticate matrix: active, staff-deactivated, pending-deletion, mid-purge', async () => {
|
|
342
|
+
// Active + correct credentials.
|
|
343
|
+
expect(await authenticate(userB.email, PASSWORD_B)).toBe(true);
|
|
344
|
+
|
|
345
|
+
// Staff deactivation (no deleteRequestedAt) stays refused.
|
|
346
|
+
await getDbAsSystem().update(tables.User, { id: userB.id, status: 'deactivated' });
|
|
347
|
+
expect(await authenticate(userB.email, PASSWORD_B)).toBe('This account has been deactivated');
|
|
348
|
+
|
|
349
|
+
// Pending deletion: credentials valid — the login route decides (cancel hook).
|
|
350
|
+
testEnv.actAs(userA);
|
|
351
|
+
await new AccountDeletion().requestDeletion(PASSWORD_A);
|
|
352
|
+
expect(await authenticate(userA.email, PASSWORD_A)).toBe(true);
|
|
353
|
+
|
|
354
|
+
// Mid-purge: still credentials-valid here; the route's cancel hook returns the refusal.
|
|
355
|
+
const deletion = await deletionRow(userA.id);
|
|
356
|
+
await getDbAsSystem().update(tables.AccountDeletion, { id: deletion.id, phase: 'purging' });
|
|
357
|
+
expect(await authenticate(userA.email, PASSWORD_A)).toBe(true);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('cancel-by-login: logging back in during grace restores the account before the session starts', async () => {
|
|
361
|
+
testEnv.actAs(userA);
|
|
362
|
+
await new AccountDeletion().requestDeletion(PASSWORD_A);
|
|
363
|
+
|
|
364
|
+
const { loggedInAs, regenerated, sent } = await invokeLogin(userA.email, PASSWORD_A);
|
|
365
|
+
|
|
366
|
+
expect(sent).toEqual({});
|
|
367
|
+
expect(loggedInAs).toBe(userA.email);
|
|
368
|
+
expect(regenerated).toBe(true); // the login door inherits fresh-id-on-privilege-change
|
|
369
|
+
expect((await userRow(userA.id)).status).toBe('active');
|
|
370
|
+
expect(await grantRows()).toHaveLength(6);
|
|
371
|
+
expect(await deletionRow(userA.id)).toBeFalsy();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('login refuses a mid-purge account with the no-longer-restorable message', async () => {
|
|
375
|
+
testEnv.actAs(userA);
|
|
376
|
+
await new AccountDeletion().requestDeletion(PASSWORD_A);
|
|
377
|
+
const deletion = await deletionRow(userA.id);
|
|
378
|
+
await getDbAsSystem().update(tables.AccountDeletion, { id: deletion.id, phase: 'purging' });
|
|
379
|
+
|
|
380
|
+
const { loggedInAs, sent } = await invokeLogin(userA.email, PASSWORD_A);
|
|
381
|
+
|
|
382
|
+
expect(sent).toEqual({ error: 'This account is being deleted and can no longer be restored.' });
|
|
383
|
+
expect(loggedInAs).toBeUndefined();
|
|
384
|
+
expect((await userRow(userA.id)).status).toBe('deactivated');
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('login refuses a staff-deactivated account outright', async () => {
|
|
388
|
+
await getDbAsSystem().update(tables.User, { id: userB.id, status: 'deactivated' });
|
|
389
|
+
|
|
390
|
+
const { loggedInAs, sent } = await invokeLogin(userB.email, PASSWORD_B);
|
|
391
|
+
|
|
392
|
+
expect(sent).toEqual({ error: 'This account has been deactivated' });
|
|
393
|
+
expect(loggedInAs).toBeUndefined();
|
|
394
|
+
});
|
|
395
|
+
});
|