Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ norbix scheduler list
| `norbix files list/info/get-by-id/upload/download/sign/delete/publish/unpublish` | Upload, download, manage and publish files |
| `norbix files integrations test <id>` | Check that a files integration really works (live upload/read/list/delete probe) |
| `norbix users list/get/invite/block/unblock/delete` | Manage project users (membership) |
| `norbix membership password reset request <email>` | Admin: send a test password-reset e-mail in `--env`; says whether a login was found, which integration sends it, and (from the hub's project-log entry) whether it went out, through which integration and template |
| `norbix membership password reset request <email>` | Admin: send a test password-reset e-mail in `--env`; says whether a login was found, which integration sends it, which template and language the hub will use (or `fallback: <reason>`, e.g. `mjml-render-failed (401)`), the MJML renderer's state, and (from the hub's project-log entry) whether it went out, through which integration and template |
| `norbix env list/use/delete/promote/rollback`, `norbix env built-ins repair` | Manage project environments and move content TEST → PROD (see *Environments* below) |
| `norbix logs list/trail/get` | Read project logs, follow one correlation ID, show one entry |
| `norbix logs write` | Write your app's own lines (`--title …` or `--file entries.json`); prints `accepted: n`, on a 429 says how many seconds to wait |
Expand Down
36 changes: 32 additions & 4 deletions src/commands/membership/password/reset/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ export interface TestPasswordResetAnswer {
integrationName?: string
provider?: string
integrationProblem?: string
/** The template and language the hub will use (gateway N46). */
templateId?: string
templateName?: string
templateSource?: string
language?: string
languageRule?: string
templateLanguages?: string[]
/** Set when the fixed English text would be sent: a code, then the reason (e.g. `mjml-render-failed (401): …`). */
fallback?: string
/** The MJML renderer: ok, unauthorized (401), unreachable, error (…), not-configured. */
mjmlRenderer?: string
responseStatus?: {isSuccess?: boolean; errors?: Array<{message?: string; errorCode?: string}>}
}

Expand All @@ -32,7 +43,9 @@ export interface DeliveryResult {
integrationName?: string
provider?: string
template?: string
templateName?: string
language?: string
languageRule?: string
fallbackReason?: string
reason?: string
}
Expand All @@ -52,7 +65,9 @@ export function deliveryFrom(entries: LogEntry[]): DeliveryResult {
integrationName: m.integrationName,
provider: m.provider,
template: m.template,
templateName: m.templateName,
language: m.language,
languageRule: m.languageRule,
fallbackReason: m.fallbackReason,
}
: {status: 'not-sent', reason: m.reason, integrationId: m.integrationId, fallbackReason: m.fallbackReason}
Expand All @@ -65,7 +80,10 @@ Admin only (needs the right to change project users' logins). It runs the same
reset flow an app runs — the login is looked up in the --env environment, a
one-time link is issued and the hub sends the e-mail — and answers what the
public request never tells: whether a login was found, the environment, the
integration that will send it, and the correlation id. It then waits for the
integration that will send it, the template and language it will use (or
"fallback: <reason>" when the fixed English text would be sent, e.g.
mjml-render-failed (401) when the MJML renderer refuses the hub's token), the
MJML renderer's state, and the correlation id. It then waits for the
hub's project-log entry with that correlation id and prints what really
happened (sent through which integration and template, or why not).

Expand Down Expand Up @@ -166,12 +184,22 @@ export function describe(answer: TestPasswordResetAnswer, delivery: DeliveryResu
if (answer.integrationProblem) lines.push(` The hub will NOT send it: ${answer.integrationProblem}.`)
else if (answer.integrationId) lines.push(` Integration for ${env}: ${answer.integrationName} (${answer.provider}, ${answer.integrationId}).`)

if (answer.queued) {
if (answer.templateId)
lines.push(` Template: ${answer.templateName} (${answer.templateId}, ${answer.templateSource}), language ${answer.language} (${answer.languageRule}; the template has: ${(answer.templateLanguages ?? []).join(', ')}).`)
if (answer.fallback) lines.push(` fallback: ${answer.fallback}`)
if (answer.mjmlRenderer) lines.push(` MJML renderer: ${answer.mjmlRenderer}.`)
}

if (answer.queued) {
if (delivery.status === 'sent') {
lines.push(`Sent by the hub through ${delivery.integrationName} (${delivery.provider}, ${delivery.integrationId}).`)
lines.push(delivery.template
? ` Template ${delivery.template}, language ${delivery.language}.`
: ` Fixed English text (template not used: ${delivery.fallbackReason ?? 'unknown'}).`)
// The hub names the chosen template even when it could not render it, so the fallback goes first.
lines.push(delivery.fallbackReason
? ` Fixed English text — fallback: ${delivery.fallbackReason}`
: delivery.template
? ` Template ${delivery.templateName ? `${delivery.templateName} (${delivery.template})` : delivery.template}, language ${delivery.language}${delivery.languageRule ? ` (${delivery.languageRule})` : ''}.`
: ' Fixed English text (no template named by the hub).')
} else if (delivery.status === 'not-sent') {
lines.push(`The hub did NOT send it: ${delivery.reason ?? 'unknown reason'}.`)
} else if (waited > 0) {
Expand Down
79 changes: 79 additions & 0 deletions test/membership-password-reset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ describe('norbix membership password reset request', () => {
integrationName: 'Test Me',
provider: 'Fake',
template: 'tmpl_3ckGBebabo8Z5BiaOgfdG4',
templateName: undefined,
language: 'lt',
languageRule: undefined,
fallbackReason: undefined,
})
})
Expand Down Expand Up @@ -158,3 +160,80 @@ describe('the text report', () => {
expect(deliveryFrom([{eventCode: 'membership:email:not-sent', meta: {stage: 'send', reason: 'expired before it could be sent'}}]).status).toBe('not-sent')
})
})

/**
* Gateway N46 (reopened): the admin answer names the template and language
* the hub will use, or `fallback: <code (status)>: <reason>` — Happy Tummy's
* MJML renderer refused the hub's token, so every reset went out as the fixed
* English text while the template and language were fine.
*/
describe('template, language and fallback (N46)', () => {
const chosen = {
...queued,
templateId: 'tmpl_4OFYLs4G9VYV223hjuAGhR',
templateName: 'Password reset',
templateSource: 'project',
language: 'en',
languageRule: 'english',
templateLanguages: ['en'],
}
const refused = {
...chosen,
fallback: "mjml-render-failed (401): the MJML renderer refused the hub's token",
mjmlRenderer: 'unauthorized (401)',
}

it('prints the template and language the hub will use', () => {
expect(report({...chosen, mjmlRenderer: 'ok'}, {status: 'unknown'}, 0)).toBe([
'Queued in TEST (correlation 0e563daa3ce7d2389f7ecc185e01656d; the link expires 2026-10-22T08:45:32Z).',
' Integration for TEST: Test Me (Fake, int_57wC6x4g7mHr2uuUO7hMJB).',
' Template: Password reset (tmpl_4OFYLs4G9VYV223hjuAGhR, project), language en (english; the template has: en).',
' MJML renderer: ok.',
].join('\n'))
})

it('prints fallback: mjml-render-failed (401) when the renderer refuses the token', () => {
const text = report(refused, {status: 'unknown'}, 0)
expect(text).toContain(" fallback: mjml-render-failed (401): the MJML renderer refused the hub's token")
expect(text).toContain(' MJML renderer: unauthorized (401).')
})

it('puts the fallback in --json', async () => {
fakeFetch([['/membership/userauth/password/reset/test', refused]])

const {result, error} = await runCommand<{fallback: string; mjmlRenderer: string; templateId: string; language: string}>([
'membership', 'password', 'reset', 'request', 'tester@example.com', '--env', 'TEST', '--wait', '0', '--json', ...globalArgs,
])

expect(error).toBeUndefined()
expect(result?.fallback).toBe("mjml-render-failed (401): the MJML renderer refused the hub's token")
expect(result?.mjmlRenderer).toBe('unauthorized (401)')
expect(result?.templateId).toBe('tmpl_4OFYLs4G9VYV223hjuAGhR')
expect(result?.language).toBe('en')
})

it('reads the hub fallback first: the hub names the chosen template even when it could not render it', () => {
const delivery = deliveryFrom([
{
eventCode: 'membership:email:sent',
meta: {
stage: 'send',
integrationId: 'int_2XKN',
integrationName: 'Happy Tummy TEST',
provider: 'AwsSes',
template: 'tmpl_4OFYLs4G9VYV223hjuAGhR',
templateName: 'Password reset',
language: 'en',
languageRule: 'english',
fallbackReason: "mjml-render-failed (401): the MJML renderer refused the hub's token",
},
},
])
expect(report(chosen, delivery, 5)).toContain(
" Fixed English text — fallback: mjml-render-failed (401): the MJML renderer refused the hub's token",
)
expect(report(chosen, {...delivery, fallbackReason: undefined}, 5)).toContain(
' Template Password reset (tmpl_4OFYLs4G9VYV223hjuAGhR), language en (english).',
)
})
})
Loading