Skip to content

Commit 28fcf4c

Browse files
committed
test(plugin-email,plugin-auth): pin identity mail in sys_identity_email and the 18.0 sys_email dual read
Claude-Session: https://claude.ai/code/session_016CvQEdxxYYVa8C8AjKZBtL Co-authored-by: Claude <noreply@anthropic.com>
1 parent 04235f9 commit 28fcf4c

6 files changed

Lines changed: 378 additions & 117 deletions

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0131 D7, ruling of record 6257510973, letter Q2 A] The auth mails
5+
* AuthManager sends about a PERSON — verification, password reset, magic link,
6+
* the email-change notice — are identity mail: each send says
7+
* `identityMail: true` and names no organization, in every posture, so the
8+
* mail service records it in `sys_identity_email`, the deployment-level
9+
* sibling of `sys_email` with no organization column.
10+
*
11+
* The organization invitation is not identity mail: it holds its
12+
* organization, which it threads, and its row stays tenant mail in
13+
* `sys_email` (#11303 decision 2).
14+
*
15+
* Before this stage the identity sends carried the Default Organization under
16+
* `single` (ADR-0131 D9's derivation) and nothing under a wall — an
17+
* organization-less `sys_email` row. Neither shape is written any more.
18+
*/
19+
20+
import { describe, it, expect, vi } from 'vitest';
21+
import { AuthManager } from './auth-manager';
22+
23+
vi.mock('better-auth', () => ({
24+
betterAuth: vi.fn(() => ({ handler: vi.fn(), api: {} })),
25+
}));
26+
vi.mock('better-auth/plugins/organization', () => ({
27+
organization: vi.fn((opts: any) => ({ id: 'organization', _opts: opts })),
28+
}));
29+
vi.mock('better-auth/plugins/magic-link', () => ({
30+
magicLink: vi.fn((opts: any) => ({ id: 'magic-link', _opts: opts })),
31+
}));
32+
vi.mock('better-auth/plugins/two-factor', () => ({
33+
twoFactor: vi.fn((opts: any) => ({ id: 'two-factor', _opts: opts })),
34+
}));
35+
vi.mock('better-auth/plugins/custom-session', () => ({
36+
customSession: vi.fn((fn: any) => ({ id: 'custom-session', _fn: fn })),
37+
}));
38+
vi.mock('better-auth/plugins/haveibeenpwned', () => ({
39+
haveIBeenPwned: vi.fn((opts: any) => ({ id: 'have-i-been-pwned', _opts: opts })),
40+
}));
41+
42+
const USER = { id: 'u1', email: 'ada@example.com', name: 'Ada' };
43+
const DEFAULT_ORG = 'org_default';
44+
45+
type Tenancy = { defaultOrgId(): Promise<string | null> } | undefined;
46+
47+
/** `single`: the Default Organization. A wall: `null`. */
48+
const SINGLE: Tenancy = { defaultOrgId: async () => DEFAULT_ORG };
49+
const WALLED: Tenancy = { defaultOrgId: async () => null };
50+
51+
const IDENTITY_TEMPLATES = ['auth.password_reset', 'auth.verify_email', 'auth.magic_link', 'auth.email_change_notice'];
52+
53+
/** Boot an AuthManager with a recording email service, drive the five auth sends, return what was sent. */
54+
async function driveAuthMail(tenancy: Tenancy) {
55+
const { betterAuth } = await import('better-auth');
56+
let config: any;
57+
(betterAuth as any).mockImplementation((captured: any) => {
58+
config = captured;
59+
return { handler: vi.fn(), api: {} };
60+
});
61+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
62+
const manager = new AuthManager({
63+
secret: 'test-secret-at-least-32-chars-long',
64+
baseUrl: 'http://localhost:3000',
65+
emailAndPassword: { enabled: true },
66+
emailVerification: { sendOnSignUp: true },
67+
plugins: { magicLink: true },
68+
...(tenancy ? { getTenancy: () => tenancy } : {}),
69+
} as never);
70+
const sent: any[] = [];
71+
manager.setEmailService({
72+
async send() { return { id: 'e', status: 'sent' }; },
73+
async sendTemplate(input: any) { sent.push(input); return { id: `e${sent.length}`, status: 'sent' }; },
74+
} as never);
75+
await manager.getAuthInstance();
76+
warnSpy.mockRestore();
77+
78+
await config.emailAndPassword.sendResetPassword(
79+
{ user: USER, url: 'http://x/reset', token: 't' },
80+
new Request('http://x/api/v1/auth/request-password-reset'),
81+
);
82+
await config.emailVerification.sendVerificationEmail({ user: USER, url: 'http://x/verify', token: 't' });
83+
await config.plugins.find((p: any) => p.id === 'magic-link')._opts.sendMagicLink({
84+
email: USER.email, url: 'http://x/magic', token: 't',
85+
});
86+
await config.hooks.after({
87+
path: '/change-email',
88+
body: { newEmail: 'new@example.com' },
89+
context: { __osChangeEmailFrom: { email: USER.email, name: USER.name, id: USER.id }, returned: { status: true } },
90+
});
91+
await config.plugins.find((p: any) => p.id === 'organization')._opts.sendInvitationEmail({
92+
email: 'invitee@example.com',
93+
invitation: { id: 'inv1', organizationId: 'org_northwind', role: 'member' },
94+
organization: { name: 'Northwind' },
95+
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
96+
});
97+
return sent;
98+
}
99+
100+
describe('[ADR-0131 D7, Q2 A] mail about a person is identity mail, in every posture', () => {
101+
for (const [posture, tenancy] of [['single', SINGLE], ['walled', WALLED], ['no tenancy service', undefined]] as const) {
102+
it(`${posture}: the four identity sends say identityMail and name no organization`, async () => {
103+
const sent = await driveAuthMail(tenancy);
104+
const identity = sent.filter((s) => IDENTITY_TEMPLATES.includes(s.template));
105+
expect(identity.map((s) => s.template).sort()).toEqual([...IDENTITY_TEMPLATES].sort());
106+
for (const s of identity) {
107+
expect(s.identityMail, s.template).toBe(true);
108+
expect('organizationId' in s, s.template).toBe(false);
109+
}
110+
});
111+
112+
it(`${posture}: the organization invitation stays tenant mail, stamped with its organization`, async () => {
113+
const sent = await driveAuthMail(tenancy);
114+
const invitation = sent.find((s) => s.template === 'auth.invitation');
115+
expect(invitation).toMatchObject({ relatedObject: 'sys_invitation', organizationId: 'org_northwind' });
116+
expect('identityMail' in invitation).toBe(false);
117+
});
118+
}
119+
});

‎packages/plugins/plugin-auth/src/producers-name-organization.test.ts‎

Lines changed: 0 additions & 114 deletions
This file was deleted.

‎packages/plugins/plugin-email/src/attachment-reclaim.test.ts‎

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ const storageColumn = JSON.stringify([{
4747
storageKey: KEYS[0],
4848
}]);
4949

50-
function fakeEngine(rows: Array<Record<string, any>>) {
51-
const tables = new Map<string, any[]>([[RECLAIM_OBJECT, rows.map((r) => ({ ...r }))]]);
50+
function fakeEngine(rows: Array<Record<string, any>>, table: string = RECLAIM_OBJECT) {
51+
const tables = new Map<string, any[]>([[table, rows.map((r) => ({ ...r }))]]);
5252
const rowsOf = (t: string) => tables.get(t) ?? [];
5353
const engine = {
5454
rows: (t = RECLAIM_OBJECT) => [...rowsOf(t)],
@@ -320,3 +320,52 @@ describe('failure handling', () => {
320320
expect(out.kind).toBe('reclaimed');
321321
});
322322
});
323+
324+
describe('[ADR-0131 D7, Q2 A] the job reads the mail log it names', () => {
325+
const IDENTITY = 'sys_identity_email';
326+
327+
it('a sys_identity_email job reclaims and rewrites the sibling row', async () => {
328+
const engine = fakeEngine([terminalRow()], IDENTITY);
329+
const store = fakeStore();
330+
331+
const out = await reclaimAttachmentContent({ rowId: 'row-1', keys: KEYS, object: IDENTITY }, {
332+
engine, store, now: () => NOW, logger: logger(),
333+
});
334+
335+
expect(out).toMatchObject({ kind: 'reclaimed', rowId: 'row-1' });
336+
expect(store.objects.size).toBe(0);
337+
expect(engine.updates).toEqual([expect.objectContaining({ table: IDENTITY, id: 'row-1' })]);
338+
expect(JSON.parse(engine.rows(IDENTITY)[0].attachments_json)[0].storageKey).toBeUndefined();
339+
});
340+
341+
it('a sys_identity_email job whose row is still queued keeps the content and re-arms', async () => {
342+
const engine = fakeEngine([terminalRow({ status: 'queued', message_id: undefined })], IDENTITY);
343+
const store = fakeStore();
344+
const rearm = vi.fn(async () => true);
345+
346+
const out = await reclaimAttachmentContent({ rowId: 'row-1', keys: KEYS, object: IDENTITY }, {
347+
engine, store, now: () => NOW, rearm,
348+
});
349+
350+
expect(out.kind).toBe('rearmed');
351+
expect(store.objects.size).toBe(1);
352+
});
353+
354+
it('a job naming no mail log stalls without deleting or re-arming', async () => {
355+
const engine = fakeEngine([terminalRow()]);
356+
const store = fakeStore();
357+
const rearm = vi.fn(async () => true);
358+
const log = logger();
359+
360+
const out = await reclaimAttachmentContent(
361+
{ rowId: 'row-1', keys: KEYS, object: 'sys_user' as never },
362+
{ engine, store, now: () => NOW, rearm, logger: log },
363+
);
364+
365+
expect(out.kind).toBe('stalled');
366+
expect(rearm).not.toHaveBeenCalled();
367+
expect(store.objects.size).toBe(1);
368+
expect(engine.updates).toEqual([]);
369+
expect(String(log.error.mock.calls[0]?.[0])).toMatch(/names 'sys_user', which is not a mail log/);
370+
});
371+
});

0 commit comments

Comments
 (0)