|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * framework#5713 — what `SmsServicePlugin` is constructed with on the |
| 5 | + * `os serve` path, and what happens when the provider tag cannot deliver. |
| 6 | + * |
| 7 | + * The `sms` settings namespace declares `provider` as a `select` with an |
| 8 | + * options table (`log` / `aliyun` / `twilio`), #5131 enforces that table on the |
| 9 | + * write path, and #5204 closed the `SettingsService` env-override branch that |
| 10 | + * bypassed it. None of those three gates can see this path: `os serve` reads |
| 11 | + * `OS_SMS_PROVIDER` while assembling the kernel, *before* a settings service |
| 12 | + * exists, and handed the string straight to the plugin. |
| 13 | + * |
| 14 | + * Measured on `origin/main` before this change, `provider: 'twilo'` (a plausible |
| 15 | + * misspelling of `twilio`) reached `SmsServicePlugin.init`, threw inside |
| 16 | + * `makeSmsTransport`, was caught, and became `LogSmsTransport`: |
| 17 | + * |
| 18 | + * booted_without_throw: true transport_class: 'LogSmsTransport' |
| 19 | + * isConfigured(): false send() → { status: 'sent', messageId: 'dev-sms-…' } |
| 20 | + * |
| 21 | + * — a server that answers every OTP send "sent" and delivers nothing. That is |
| 22 | + * the declared-but-not-delivered shape of Prime Directive #10, and the same one |
| 23 | + * #5132 closed for mail in the neighbouring arm of this very loop. |
| 24 | + * |
| 25 | + * These pin the invariant in one piece: a configuration this server can deliver |
| 26 | + * through reaches the plugin unchanged (credentials included — they legitimately |
| 27 | + * arrive later, from the settings namespace at `kernel:ready`), and a provider |
| 28 | + * tag it cannot deliver through throws. The counterpart the throw depends on is |
| 29 | + * pinned too: an operator who does not want SMS sent says so with |
| 30 | + * `OS_SMS_PROVIDER=log`, and that still boots. |
| 31 | + */ |
| 32 | + |
| 33 | +import { describe, it, expect } from 'vitest'; |
| 34 | +import { SMS_TRANSPORT_PROVIDERS } from '@objectstack/service-sms'; |
| 35 | +import { resolveSmsCapabilityArg } from './serve.js'; |
| 36 | + |
| 37 | +describe('resolveSmsCapabilityArg', () => { |
| 38 | + it('defaults to the log provider when nothing is configured', () => { |
| 39 | + const { options } = resolveSmsCapabilityArg({}, {}); |
| 40 | + expect(options).toMatchObject({ provider: 'log' }); |
| 41 | + expect(options).not.toHaveProperty('providerOptions'); |
| 42 | + expect(options).not.toHaveProperty('retries'); |
| 43 | + }); |
| 44 | + |
| 45 | + it('boots on an EXPLICIT provider=log — the way to say "this environment does not send SMS"', () => { |
| 46 | + // The premise of every throw below: refusing an undeliverable provider is |
| 47 | + // only fair because "no SMS from here" has its own spelling. If this ever |
| 48 | + // stops booting, the errors elsewhere in this file stop being actionable. |
| 49 | + expect(() => resolveSmsCapabilityArg({}, { OS_SMS_PROVIDER: 'log' })).not.toThrow(); |
| 50 | + expect(resolveSmsCapabilityArg({}, { OS_SMS_PROVIDER: 'log' }).options) |
| 51 | + .toMatchObject({ provider: 'log' }); |
| 52 | + // …including from objectstack.config.ts. |
| 53 | + expect(resolveSmsCapabilityArg({ provider: 'log' }, {}).options).toMatchObject({ provider: 'log' }); |
| 54 | + }); |
| 55 | + |
| 56 | + it('lets env beat config, and normalizes the case', () => { |
| 57 | + expect(resolveSmsCapabilityArg({ provider: 'aliyun' }, { OS_SMS_PROVIDER: 'twilio' }).options) |
| 58 | + .toMatchObject({ provider: 'twilio' }); |
| 59 | + // `OS_SMS_PROVIDER=Twilio` is the same declaration — the guard runs on the |
| 60 | + // lower-cased value, never on the raw env string. |
| 61 | + expect(resolveSmsCapabilityArg({}, { OS_SMS_PROVIDER: 'Twilio' }).options) |
| 62 | + .toMatchObject({ provider: 'twilio' }); |
| 63 | + expect(resolveSmsCapabilityArg({ provider: 'ALIYUN' }, {}).options) |
| 64 | + .toMatchObject({ provider: 'aliyun' }); |
| 65 | + }); |
| 66 | + |
| 67 | + it('passes a deliverable provider through WITHOUT demanding credentials', () => { |
| 68 | + // Unlike mail, SMS credentials are not a boot-time input: the `sms` |
| 69 | + // settings namespace binds them at kernel:ready. A bare provider tag is a |
| 70 | + // complete configuration here, so this arm refuses the tag and nothing |
| 71 | + // else — demanding keys would break every host that stores them in |
| 72 | + // Settings, which is the documented home for them. |
| 73 | + for (const provider of SMS_TRANSPORT_PROVIDERS) { |
| 74 | + expect(() => resolveSmsCapabilityArg({}, { OS_SMS_PROVIDER: provider }), provider).not.toThrow(); |
| 75 | + expect(resolveSmsCapabilityArg({}, { OS_SMS_PROVIDER: provider }).options, provider) |
| 76 | + .toMatchObject({ provider }); |
| 77 | + } |
| 78 | + }); |
| 79 | + |
| 80 | + it('carries config.sms.providerOptions and retries through untouched', () => { |
| 81 | + const { options } = resolveSmsCapabilityArg( |
| 82 | + { provider: 'aliyun', providerOptions: { accessKeyId: 'ak', signName: '签名' }, retries: 2 }, |
| 83 | + {}, |
| 84 | + ); |
| 85 | + expect(options).toMatchObject({ |
| 86 | + provider: 'aliyun', |
| 87 | + providerOptions: { accessKeyId: 'ak', signName: '签名' }, |
| 88 | + retries: 2, |
| 89 | + }); |
| 90 | + // `retries: 0` is a real declaration (no retry), not an absence. |
| 91 | + expect(resolveSmsCapabilityArg({ retries: 0 }, {}).options).toMatchObject({ retries: 0 }); |
| 92 | + }); |
| 93 | + |
| 94 | + it('THROWS on a provider tag no transport can deliver — no silent LogSmsTransport (#5713)', () => { |
| 95 | + const boot = () => resolveSmsCapabilityArg({}, { OS_SMS_PROVIDER: 'twilo' }); |
| 96 | + expect(boot).toThrow(/provider='twilo'/); |
| 97 | + // Consequence AND fix in the one message (AGENTS.md degradation-log-level). |
| 98 | + expect(boot).toThrow(/nothing would leave the box/); |
| 99 | + expect(boot).toThrow(/log \/ aliyun \/ twilio/); |
| 100 | + expect(boot).toThrow(/OS_SMS_PROVIDER=log/); |
| 101 | + // …and never the old silent rewrite. |
| 102 | + expect(boot).not.toThrow(/falling back to LogSmsTransport/); |
| 103 | + }); |
| 104 | + |
| 105 | + it('refuses the same tag declared through config.sms.provider', () => { |
| 106 | + // A typo in objectstack.config.ts is the same declaration by another |
| 107 | + // channel — and the one an operator cannot fix with an env var. |
| 108 | + expect(() => resolveSmsCapabilityArg({ provider: 'aliyn' }, {})) |
| 109 | + .toThrow(/provider='aliyn'/); |
| 110 | + // Retired-looking and never-supported tags land in the same arm, and the |
| 111 | + // message names the vocabulary rather than guessing at an intent. |
| 112 | + for (const tag of ['sendgrid', 'aws-sns', 'tencent', 'smtp']) { |
| 113 | + expect(() => resolveSmsCapabilityArg({}, { OS_SMS_PROVIDER: tag }), tag) |
| 114 | + .toThrow(/log \/ aliyun \/ twilio/); |
| 115 | + } |
| 116 | + }); |
| 117 | + |
| 118 | + it('reads its vocabulary from @objectstack/service-sms, not a second literal', () => { |
| 119 | + // #5094's lesson, pinned: if a transport is added to service-sms and this |
| 120 | + // file kept its own list, the new provider would be refused at boot while |
| 121 | + // the plugin could build it. The error message enumerates the exported |
| 122 | + // vocabulary, so this assertion goes red the day the two diverge. |
| 123 | + let message = ''; |
| 124 | + try { |
| 125 | + resolveSmsCapabilityArg({}, { OS_SMS_PROVIDER: 'definitely-not-a-provider' }); |
| 126 | + } catch (err) { |
| 127 | + message = (err as Error).message; |
| 128 | + } |
| 129 | + expect(message).toContain(SMS_TRANSPORT_PROVIDERS.join(' / ')); |
| 130 | + }); |
| 131 | +}); |
0 commit comments