|
2 | 2 |
|
3 | 3 | import { describe, it, expect, vi } from 'vitest'; |
4 | 4 | import { assertEngineUpdateDispatch } from '@objectstack/objectql'; |
| 5 | +import { SysUser } from '@objectstack/platform-objects/identity'; |
5 | 6 | import { |
6 | 7 | runAdminCreateUser, |
7 | 8 | runAdminSetUserPassword, |
@@ -146,9 +147,13 @@ describe('runAdminCreateUser', () => { |
146 | 147 | }); |
147 | 148 |
|
148 | 149 | it('rejects when neither password nor generatePassword is provided', async () => { |
| 150 | + // CONTROL for the Setup dialog's one password rule: a caller that is not |
| 151 | + // the dialog and sends neither still gets the 400 — the endpoint's |
| 152 | + // contract did not move when the dialog started sending the constant. |
149 | 153 | const m = makeDeps(); |
150 | 154 | const res = await runAdminCreateUser(m.deps, makeRequest({ email: 'a@b.co' }), ACTOR); |
151 | 155 | expect(res.status).toBe(400); |
| 156 | + expect(res.body.error?.code).toBe('INVALID_REQUEST'); |
152 | 157 | expect(m.createUser).not.toHaveBeenCalled(); |
153 | 158 | }); |
154 | 159 |
|
@@ -703,6 +708,130 @@ describe('runAdminSetUserPassword', () => { |
703 | 708 | expect(meta.event).toBe('user.admin_password_set'); |
704 | 709 | expect(meta.passwordGenerated).toBe(true); |
705 | 710 | }); |
| 711 | + |
| 712 | + it('prefers an explicit newPassword over generatePassword (the dialog sends both)', async () => { |
| 713 | + const m = makeDeps(); |
| 714 | + const res = await runAdminSetUserPassword( |
| 715 | + m.deps, |
| 716 | + makeRequest({ userId: 'user-9', newPassword: 'Str0ng!Pass', generatePassword: true }), |
| 717 | + ACTOR, |
| 718 | + ); |
| 719 | + expect(res.status).toBe(200); |
| 720 | + expect(m.authCtx.internalAdapter.updatePassword).toHaveBeenCalledWith('user-9', 'hashed(Str0ng!Pass)'); |
| 721 | + expect((res.body.data as any).temporaryPassword).toBeUndefined(); |
| 722 | + }); |
| 723 | + |
| 724 | + it('generates when generatePassword is true and newPassword is empty', async () => { |
| 725 | + const m = makeDeps(); |
| 726 | + const res = await runAdminSetUserPassword( |
| 727 | + m.deps, |
| 728 | + makeRequest({ userId: 'user-9', newPassword: '', generatePassword: true }), |
| 729 | + ACTOR, |
| 730 | + ); |
| 731 | + expect(res.status).toBe(200); |
| 732 | + const temp = (res.body.data as any).temporaryPassword as string; |
| 733 | + expect(typeof temp).toBe('string'); |
| 734 | + expect(m.authCtx.internalAdapter.updatePassword).toHaveBeenCalledWith('user-9', `hashed(${temp})`); |
| 735 | + }); |
| 736 | + |
| 737 | + it('rejects when neither newPassword nor generatePassword is provided', async () => { |
| 738 | + // CONTROL, as for create-user: a caller that is not the dialog and sends |
| 739 | + // neither still gets the 400, and nothing is written. |
| 740 | + const m = makeDeps(); |
| 741 | + const res = await runAdminSetUserPassword(m.deps, makeRequest({ userId: 'user-9' }), ACTOR); |
| 742 | + expect(res.status).toBe(400); |
| 743 | + expect(res.body.error?.code).toBe('INVALID_REQUEST'); |
| 744 | + expect(m.authCtx.internalAdapter.updatePassword).not.toHaveBeenCalled(); |
| 745 | + expect(m.authCtx.internalAdapter.createAccount).not.toHaveBeenCalled(); |
| 746 | + }); |
| 747 | +}); |
| 748 | + |
| 749 | +// ── The Setup dialogs: the password field is the one rule ────────────────── |
| 750 | +// |
| 751 | +// `sys_user`'s `create_user` and `set_user_password` actions are this |
| 752 | +// endpoint's first-party callers. Their dialog offers one rule — the password |
| 753 | +// field's own label, "leave empty to generate" — so `generatePassword` is not a |
| 754 | +// control the admin sees: it rides in `bodyExtra` as a constant, and the |
| 755 | +// explicit-wins rule above turns that into the label's behaviour. A ticked box |
| 756 | +// beside a typed password was a control the server ignored. |
| 757 | +// |
| 758 | +// These pins read the DECLARED actions and compose the request body the way |
| 759 | +// the console does for a `type: 'api'` action: the values the dialog collected, |
| 760 | +// the `recordIdParam`, then `bodyExtra` merged last (the spec's documented |
| 761 | +// semantics for that key). The dialog submits only the params it shows, so a |
| 762 | +// param left out of `params` is never on the wire — `bodyExtra` is the one |
| 763 | +// place a constant the admin does not see can come from. |
| 764 | +describe('Setup dialogs: one password rule (sys_user create_user / set_user_password)', () => { |
| 765 | + function declaredAction(name: string) { |
| 766 | + const action = (SysUser.actions ?? []).find((a) => a.name === name); |
| 767 | + if (!action) throw new Error(`sys_user declares no action named "${name}"`); |
| 768 | + return action; |
| 769 | + } |
| 770 | + |
| 771 | + function dialogBody(name: string, collected: Record<string, unknown>, recordId?: string) { |
| 772 | + const action = declaredAction(name); |
| 773 | + const body: Record<string, unknown> = { ...collected }; |
| 774 | + if (action.recordIdParam && recordId !== undefined) body[action.recordIdParam] = recordId; |
| 775 | + return { ...body, ...(action.bodyExtra ?? {}) }; |
| 776 | + } |
| 777 | + |
| 778 | + const DIALOGS = [ |
| 779 | + { |
| 780 | + action: 'create_user', |
| 781 | + explicitKey: 'password', |
| 782 | + base: { email: 'a@b.co', mustChangePassword: true }, |
| 783 | + recordId: undefined, |
| 784 | + run: (m: ReturnType<typeof makeDeps>, body: Record<string, unknown>) => |
| 785 | + runAdminCreateUser(m.deps, makeRequest(body), ACTOR), |
| 786 | + appliedPassword: (m: ReturnType<typeof makeDeps>) => |
| 787 | + callsOf(m.createUser).map((c) => (c[0] as unknown as { body: { password: string } }).body.password), |
| 788 | + }, |
| 789 | + { |
| 790 | + action: 'set_user_password', |
| 791 | + explicitKey: 'newPassword', |
| 792 | + base: { mustChangePassword: true }, |
| 793 | + recordId: 'user-9', |
| 794 | + run: (m: ReturnType<typeof makeDeps>, body: Record<string, unknown>) => |
| 795 | + runAdminSetUserPassword(m.deps, makeRequest(body), ACTOR), |
| 796 | + appliedPassword: (m: ReturnType<typeof makeDeps>) => |
| 797 | + callsOf(m.authCtx.password.hash).map((c) => c[0]), |
| 798 | + }, |
| 799 | + ] as const; |
| 800 | + |
| 801 | + for (const d of DIALOGS) { |
| 802 | + it(`${d.action}: offers no generatePassword control and sends it as a constant`, () => { |
| 803 | + const action = declaredAction(d.action); |
| 804 | + const offered = (action.params ?? []).map((p) => p.name ?? p.field); |
| 805 | + expect(offered).toContain(d.explicitKey); |
| 806 | + expect(offered).not.toContain('generatePassword'); |
| 807 | + expect(action.bodyExtra?.generatePassword).toBe(true); |
| 808 | + }); |
| 809 | + |
| 810 | + it(`${d.action}: a typed password is the one applied, and nothing is generated`, async () => { |
| 811 | + const m = makeDeps(); |
| 812 | + const body = dialogBody(d.action, { ...d.base, [d.explicitKey]: 'Explicit1!' }, d.recordId); |
| 813 | + const res = await d.run(m, body); |
| 814 | + expect(res.status).toBe(200); |
| 815 | + expect(d.appliedPassword(m)).toEqual(['Explicit1!']); |
| 816 | + expect((res.body.data as any).temporaryPassword).toBeUndefined(); |
| 817 | + }); |
| 818 | + |
| 819 | + for (const [shape, collected] of [ |
| 820 | + ['left untouched (absent)', {}], |
| 821 | + ['typed then cleared (empty string)', { [d.explicitKey]: '' }], |
| 822 | + ] as const) { |
| 823 | + it(`${d.action}: a password ${shape} generates a temporary one, returned once`, async () => { |
| 824 | + const m = makeDeps(); |
| 825 | + const body = dialogBody(d.action, { ...d.base, ...collected }, d.recordId); |
| 826 | + const res = await d.run(m, body); |
| 827 | + expect(res.status).toBe(200); |
| 828 | + const temp = (res.body.data as any).temporaryPassword as string; |
| 829 | + expect(typeof temp).toBe('string'); |
| 830 | + expect(d.appliedPassword(m)).toEqual([temp]); |
| 831 | + expectNoPasswordLeak(m, temp); |
| 832 | + }); |
| 833 | + } |
| 834 | + } |
706 | 835 | }); |
707 | 836 |
|
708 | 837 | // ── The ledger row records the admin's decisions, never a field value ────── |
|
0 commit comments