@teleporthq/teleport-plugin-next-workflows 0.45.8 → 0.45.9

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.
@@ -723,6 +723,171 @@ describe('the newsletter routes', () => {
723
723
  expect(ok.body).toEqual({ ok: true, email: 'wait@site.test' })
724
724
  const bad = await route(app, 'confirm', { method: 'POST', body: { token: 'x.y' } })
725
725
  expect(bad.body).toEqual({ ok: false })
726
+ const queriesBefore = app.db.queries.length
727
+ const none = await route(app, 'confirm', { method: 'POST', body: {} })
728
+ expect(none.statusCode).toBe(200)
729
+ expect(none.body).toEqual({ ok: false })
730
+ expect(app.db.queries).toHaveLength(queriesBefore)
731
+ app.db.failWith = { message: 'connection refused', code: 'ECONNREFUSED' }
732
+ const down = await route(app, 'confirm', { method: 'POST', body: { token } })
733
+ expect(down.statusCode).toBe(503)
734
+ expect(down.body).toEqual({
735
+ error: 'We could not confirm the subscription. Please try again in a moment.',
736
+ })
737
+ const put = await route(app, 'confirm', { method: 'PUT', body: { token } })
738
+ expect(put.statusCode).toBe(405)
739
+ })
740
+
741
+ it("confirm: the emailed link to the site's confirm route shows a button; only its POST confirms", async () => {
742
+ const app = loadApp({
743
+ settings: {
744
+ pages: { confirm: '/api/newsletter/confirm', unsubscribe: '/api/newsletter/unsubscribe' },
745
+ },
746
+ })
747
+ await app.helper.subscribeNewsletterPerson({
748
+ db: app.db,
749
+ email: 'click@reader.test',
750
+ signUpPoint: 'block',
751
+ req: REQ,
752
+ })
753
+ expect(app.sent[0].html).toContain('https://studio.example/api/newsletter/confirm?t=')
754
+ const token = linkToken(app.sent[0].html)
755
+ // The person's click, and a mail client's link scanner opening the same
756
+ // link first, subscribe nobody.
757
+ for (let opened = 0; opened < 2; opened++) {
758
+ const page = await route(app, 'confirm', { method: 'GET', query: { t: token } })
759
+ expect(page.statusCode).toBe(200)
760
+ expect(page.headers['Content-Type']).toBe('text/html; charset=utf-8')
761
+ expect(page.headers['Cache-Control']).toBe('no-store')
762
+ expect(page.body).toContain('<h1>Confirm your subscription</h1>')
763
+ expect(page.body).toContain(
764
+ '<form method="post" action="/api/newsletter/confirm"><input type="hidden" name="t" value="' +
765
+ token +
766
+ '"><button type="submit">Confirm subscription</button></form>'
767
+ )
768
+ }
769
+ expect(app.db.rows[0].newsletter_status).toBe('pending')
770
+ expect(app.db.writes().map((query) => query.sql)).toEqual([NEWSLETTER_SQL.insertPending])
771
+ expect(app.sent).toHaveLength(1)
772
+
773
+ const pressButton = () =>
774
+ route(app, 'confirm', {
775
+ method: 'POST',
776
+ headers: { host: 'studio.example', 'content-type': 'application/x-www-form-urlencoded' },
777
+ body: { t: token },
778
+ })
779
+ const done = await pressButton()
780
+ expect(done.statusCode).toBe(200)
781
+ expect(done.headers['Content-Type']).toBe('text/html; charset=utf-8')
782
+ expect(done.body).toContain('<h1>You&#39;re subscribed</h1>')
783
+ expect(app.db.rows[0]).toMatchObject({
784
+ newsletter_status: 'subscribed',
785
+ marketing_consent: true,
786
+ })
787
+ expect(app.events.map((event) => event.type)).toEqual(['newsletter-subscribed'])
788
+ expect(app.sent).toHaveLength(2)
789
+ expect(app.sent[1].html).toContain('https://studio.example/api/newsletter/unsubscribe?t=')
790
+ // The same link used again: the same answer, nothing happens twice.
791
+ const again = await pressButton()
792
+ expect(again.body).toContain('<h1>You&#39;re subscribed</h1>')
793
+ expect(app.events).toHaveLength(1)
794
+ expect(app.sent).toHaveLength(2)
795
+ })
796
+
797
+ it('confirm: a bad, expired or wrong link answers a plain page and confirms nothing', async () => {
798
+ const app = loadApp()
799
+ app.db.rows.push({ id: 'u-p', email: 'wait@site.test', newsletter_status: 'pending' })
800
+ const eightDaysAgo = Math.floor(Date.now() / 1000) - 8 * 24 * 3600
801
+ const notALink =
802
+ 'This confirmation link does not work. A link works for 7 days: sign up again to get a new one.'
803
+ const badLinks = [
804
+ '',
805
+ 'x.y',
806
+ '"><script>alert(1)</script>',
807
+ app.helper.signNewsletterToken('wait@site.test', 'confirm', eightDaysAgo),
808
+ app.helper.signNewsletterToken('wait@site.test', 'unsubscribe'),
809
+ loadApp({ env: { NEXTAUTH_SECRET: 'another-site' } }).helper.signNewsletterToken(
810
+ 'wait@site.test',
811
+ 'confirm'
812
+ ),
813
+ ]
814
+ const expectNotALinkPage = (res: Row) => {
815
+ expect(res.statusCode).toBe(200)
816
+ expect(res.headers['Content-Type']).toBe('text/html; charset=utf-8')
817
+ expect(res.body).toContain('<h1>Link not valid</h1>')
818
+ expect(res.body).toContain(notALink)
819
+ expect(res.body).not.toContain('<form')
820
+ expect(res.body).not.toContain('<script>')
821
+ }
822
+ expectNotALinkPage(await route(app, 'confirm', { method: 'GET' }))
823
+ for (const t of badLinks) {
824
+ expectNotALinkPage(await route(app, 'confirm', { method: 'GET', query: { t } }))
825
+ expectNotALinkPage(
826
+ await route(app, 'confirm', {
827
+ method: 'POST',
828
+ headers: { host: 'studio.example', 'content-type': 'application/x-www-form-urlencoded' },
829
+ body: { t },
830
+ })
831
+ )
832
+ }
833
+ expect(app.db.writes()).toEqual([])
834
+ expect(app.db.rows[0].newsletter_status).toBe('pending')
835
+ expect(app.sent).toEqual([])
836
+
837
+ // The database down while the button is pressed: a plain page, not the error.
838
+ app.db.failWith = { message: 'connection refused', code: 'ECONNREFUSED' }
839
+ const down = await route(app, 'confirm', {
840
+ method: 'POST',
841
+ headers: { host: 'studio.example', 'content-type': 'application/x-www-form-urlencoded' },
842
+ body: { t: app.helper.signNewsletterToken('wait@site.test', 'confirm') },
843
+ })
844
+ expect(down.statusCode).toBe(503)
845
+ expect(down.body).toContain('<h1>Please try again</h1>')
846
+ expect(down.body).not.toContain('connection refused')
847
+ expect(down.body).not.toContain('ECONNREFUSED')
848
+ })
849
+
850
+ it('confirm: a project that still has its own confirm page keeps linking to it, and its step confirms', async () => {
851
+ const app = loadApp()
852
+ await app.helper.subscribeNewsletterPerson({
853
+ db: app.db,
854
+ email: 'page@reader.test',
855
+ signUpPoint: 'footer',
856
+ req: REQ,
857
+ })
858
+ expect(app.sent[0].html).toContain('https://studio.example/newsletter/confirm#t=')
859
+ expect(app.sent[0].html).not.toContain('/api/newsletter/confirm')
860
+ const realFetch = (global as any).fetch
861
+ ;(global as any).fetch = async (
862
+ url: string,
863
+ init: { method: string; headers: Row; body: string }
864
+ ) => {
865
+ expect(url).toBe('https://studio.example/api/newsletter/confirm')
866
+ const res = await route(app, 'confirm', {
867
+ method: init.method,
868
+ headers: { host: 'studio.example', 'content-type': init.headers['Content-Type'] },
869
+ body: JSON.parse(init.body),
870
+ })
871
+ return {
872
+ ok: res.statusCode < 400,
873
+ json: async () => {
874
+ expect(typeof res.body).toBe('object')
875
+ return res.body
876
+ },
877
+ }
878
+ }
879
+ try {
880
+ const confirmStep = loadHandler('newsletter-confirm')
881
+ expect(
882
+ await confirmStep(
883
+ { token: linkToken(app.sent[0].html) },
884
+ { __baseUrl: 'https://studio.example' }
885
+ )
886
+ ).toEqual({ ok: true, email: 'page@reader.test' })
887
+ } finally {
888
+ ;(global as any).fetch = realFetch
889
+ }
890
+ expect(app.db.rows[0].newsletter_status).toBe('subscribed')
726
891
  })
727
892
  })
728
893
 
@@ -444,8 +444,8 @@ function canSendNewsletterEmail() {
444
444
 
445
445
  // A link to one of the site's pages carries the token in its fragment: a
446
446
  // fragment never reaches a server, a CDN log or a Referer (the page's first
447
- // head script keeps it). The site's own unsubscribe route reads it on the
448
- // server, so a link to it carries it in the query.
447
+ // head script keeps it). The site's own confirm and unsubscribe routes read it
448
+ // on the server, so a link to one of them carries it in the query.
449
449
  function pageLink(path, token) {
450
450
  return path + (path.indexOf('/api/') === 0 ? '?t=' : '#t=') + encodeURIComponent(token);
451
451
  }
@@ -1 +1 @@
1
- {"version":3,"file":"routes-code.d.ts","sourceRoot":"","sources":["../../../src/newsletter/routes-code.ts"],"names":[],"mappings":"AA0BA,6DAA6D;AAC7D,eAAO,MAAM,6BAA6B,KAAK,CAAA;AAoB/C,eAAO,MAAM,gCAAgC,QAAO,MAuCnD,CAAA;AAED,eAAO,MAAM,8BAA8B,QAAO,MAqBjD,CAAA;AAED,eAAO,MAAM,kCAAkC,QAAO,MA0ErD,CAAA"}
1
+ {"version":3,"file":"routes-code.d.ts","sourceRoot":"","sources":["../../../src/newsletter/routes-code.ts"],"names":[],"mappings":"AA4BA,6DAA6D;AAC7D,eAAO,MAAM,6BAA6B,KAAK,CAAA;AAoB/C,eAAO,MAAM,gCAAgC,QAAO,MAuCnD,CAAA;AA8BD,eAAO,MAAM,8BAA8B,QAAO,MAmDjD,CAAA;AAED,eAAO,MAAM,kCAAkC,QAAO,MAkDrD,CAAA"}
@@ -11,12 +11,13 @@ exports.generateNewsletterUnsubscribeRoute = exports.generateNewsletterConfirmRo
11
11
  * `footer`, `popup`, `form`), else `block`; errors are logged by code and
12
12
  * message only (never a database row); every answer is `{ ok: true, pending }`, after at least
13
13
  * `NEWSLETTER_MIN_RESPONSE_MS`, whatever happened to the address.
14
- * - `confirm` — POST `{ token }` → `{ ok, email? }`.
15
- * - `unsubscribe` — POST `{ token }` → `{ ok, email? }`; a GET with `?t=`
16
- * (a click in a mail client) answers a small page whose button POSTs it, so a
17
- * link scanner that opens every link unsubscribes nobody.
14
+ * - `confirm` and `unsubscribe` — POST `{ token }` → `{ ok, email? }`; a GET
15
+ * with `?t=` (a click in a mail client) answers a small page whose button
16
+ * POSTs it, so a link scanner that opens every link confirms and
17
+ * unsubscribes nobody.
18
18
  */
19
19
  const security_runtime_code_1 = require("../security-runtime/security-runtime-code");
20
+ const helper_code_1 = require("./helper-code");
20
21
  const newsletter_scope_1 = require("./newsletter-scope");
21
22
  /** From `pages/api/newsletter/` to the project root. */
22
23
  const RELATIVE_PREFIX = '../../..';
@@ -79,32 +80,7 @@ ${methodGuard(['POST'])}
79
80
  };
80
81
  `;
81
82
  exports.generateNewsletterSubscribeRoute = generateNewsletterSubscribeRoute;
82
- const generateNewsletterConfirmRoute = () => `${ROUTE_PREAMBLE}
83
-
84
- module.exports = async function handler(req, res) {
85
- ${methodGuard(['POST'])}
86
- if (!(await __security.rateLimitRequest(req, res, { scope: 'newsletter-confirm', limit: ${exports.NEWSLETTER_LINK_VISITOR_LIMIT}, windowSeconds: 3600 }))) {
87
- return;
88
- }
89
- var body = readBody(req);
90
- var token = typeof body.token === 'string' ? body.token : '';
91
- if (!token) {
92
- return res.status(200).json({ ok: false });
93
- }
94
- try {
95
- var result = await __newsletter.withNewsletterDb(function (db) {
96
- return __newsletter.confirmNewsletter({ db: db, token: token, req: req });
97
- });
98
- return res.status(200).json(result.ok ? { ok: true, email: result.email } : { ok: false });
99
- } catch (err) {
100
- return __security.sendClientError(res, 503, 'We could not confirm the subscription. Please try again in a moment.', __newsletter.safeError(err));
101
- }
102
- };
103
- `;
104
- exports.generateNewsletterConfirmRoute = generateNewsletterConfirmRoute;
105
- const generateNewsletterUnsubscribeRoute = () => `${ROUTE_PREAMBLE}
106
-
107
- function escapeHtml(value) {
83
+ const LINK_PAGE_CODE = `function escapeHtml(value) {
108
84
  return String(value == null ? '' : value)
109
85
  .replace(/&/g, '&amp;')
110
86
  .replace(/</g, '&lt;')
@@ -128,7 +104,64 @@ function sendPage(res, status, title, message, form) {
128
104
  'button{font:inherit;padding:10px 18px;border:0;border-radius:6px;background:#111;color:#fff;cursor:pointer}</style>' +
129
105
  '</head><body><h1>' + escapeHtml(title) + '</h1><p>' + escapeHtml(message) + '</p>' + (form || '') + '</body></html>'
130
106
  );
131
- }
107
+ }`;
108
+ const CONFIRM_LINK_DAYS = helper_code_1.newsletterTokenSpec.confirmMaxAgeSeconds / 86400;
109
+ const generateNewsletterConfirmRoute = () => `${ROUTE_PREAMBLE}
110
+
111
+ ${LINK_PAGE_CODE}
112
+
113
+ var NOT_A_LINK = 'This confirmation link does not work. A link works for ${CONFIRM_LINK_DAYS} days: sign up again to get a new one.';
114
+
115
+ module.exports = async function handler(req, res) {
116
+ ${methodGuard(['GET', 'POST'])}
117
+ if (!(await __security.rateLimitRequest(req, res, { scope: 'newsletter-confirm', limit: ${exports.NEWSLETTER_LINK_VISITOR_LIMIT}, windowSeconds: 3600 }))) {
118
+ return;
119
+ }
120
+ if (req.method === 'GET') {
121
+ // Opening the link changes nothing (a mail client's link scanner opens it
122
+ // too, and double opt-in means the person's own action): the button does.
123
+ var linkToken = req.query && typeof req.query.t === 'string' ? req.query.t : '';
124
+ if (!__newsletter.verifyNewsletterToken(linkToken, 'confirm')) {
125
+ return sendPage(res, 200, 'Link not valid', NOT_A_LINK, '');
126
+ }
127
+ return sendPage(
128
+ res,
129
+ 200,
130
+ 'Confirm your subscription',
131
+ 'Receive the newsletter at this address?',
132
+ '<form method="post" action="${newsletter_scope_1.NEWSLETTER_CONFIRM_URL}"><input type="hidden" name="t" value="' +
133
+ escapeHtml(linkToken) + '"><button type="submit">Confirm subscription</button></form>'
134
+ );
135
+ }
136
+ var body = readBody(req);
137
+ var fromForm = String((req.headers && req.headers['content-type']) || '').indexOf('application/x-www-form-urlencoded') !== -1;
138
+ var token = typeof body.token === 'string' ? body.token : typeof body.t === 'string' ? body.t : '';
139
+ var result = { ok: false };
140
+ try {
141
+ if (token) {
142
+ result = await __newsletter.withNewsletterDb(function (db) {
143
+ return __newsletter.confirmNewsletter({ db: db, token: token, req: req });
144
+ });
145
+ }
146
+ } catch (err) {
147
+ if (fromForm) {
148
+ console.error('[newsletter] confirm failed:', __newsletter.safeError(err));
149
+ return sendPage(res, 503, 'Please try again', 'We could not confirm your subscription just now. Please try again in a moment.', '');
150
+ }
151
+ return __security.sendClientError(res, 503, 'We could not confirm the subscription. Please try again in a moment.', __newsletter.safeError(err));
152
+ }
153
+ if (fromForm) {
154
+ return result.ok
155
+ ? sendPage(res, 200, "You're subscribed", 'Thank you for confirming. You will receive the newsletter at this address.', '')
156
+ : sendPage(res, 200, 'Link not valid', NOT_A_LINK, '');
157
+ }
158
+ return res.status(200).json(result.ok ? { ok: true, email: result.email } : { ok: false });
159
+ };
160
+ `;
161
+ exports.generateNewsletterConfirmRoute = generateNewsletterConfirmRoute;
162
+ const generateNewsletterUnsubscribeRoute = () => `${ROUTE_PREAMBLE}
163
+
164
+ ${LINK_PAGE_CODE}
132
165
 
133
166
  var NOT_A_LINK = 'This unsubscribe link does not work. Use the link in a newer email from us.';
134
167
 
@@ -1 +1 @@
1
- {"version":3,"file":"routes-code.js","sourceRoot":"","sources":["../../../src/newsletter/routes-code.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;;;;;;GAcG;AACH,qFAAoF;AACpF,yDAK2B;AAE3B,wDAAwD;AACxD,MAAM,eAAe,GAAG,UAAU,CAAA;AAElC,6DAA6D;AAChD,QAAA,6BAA6B,GAAG,EAAE,CAAA;AAE/C,MAAM,cAAc,GAAG,6BAA6B,IAAA,gDAAwB,EAAC,eAAe,CAAC;8BAC/D,IAAA,4CAAyB,EAAC,eAAe,CAAC;;;;;;;;EAQtE,CAAA;AAEF,MAAM,WAAW,GAAG,CAAC,OAAiB,EAAU,EAAE,CAAC,SAAS,OAAO;KAChE,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,mBAAmB,MAAM,GAAG,CAAC;KAC7C,IAAI,CAAC,MAAM,CAAC;8BACe,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC;;IAE5C,CAAA;AAEG,MAAM,gCAAgC,GAAG,GAAW,EAAE,CAAC,GAAG,cAAc;;;EAG7E,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC;OAChB,2CAAwB;mDACoB,2CAAwB;8EACG,2CAAwB,aAAa,2CAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiC1I,CAAA;AAvCY,QAAA,gCAAgC,oCAuC5C;AAEM,MAAM,8BAA8B,GAAG,GAAW,EAAE,CAAC,GAAG,cAAc;;;EAG3E,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC;4FACqE,qCAA6B;;;;;;;;;;;;;;;;;CAiBxH,CAAA;AArBY,QAAA,8BAA8B,kCAqB1C;AAEM,MAAM,kCAAkC,GAAG,GAAW,EAAE,CAAC,GAAG,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA+B/E,WAAW,CAAC,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;gGACkE,qCAA6B;;;;;;;;;;;;;;qCAcxF,6CAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4B9D,CAAA;AA1EY,QAAA,kCAAkC,sCA0E9C"}
1
+ {"version":3,"file":"routes-code.js","sourceRoot":"","sources":["../../../src/newsletter/routes-code.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;;;;;;GAcG;AACH,qFAAoF;AACpF,+CAAmD;AACnD,yDAM2B;AAE3B,wDAAwD;AACxD,MAAM,eAAe,GAAG,UAAU,CAAA;AAElC,6DAA6D;AAChD,QAAA,6BAA6B,GAAG,EAAE,CAAA;AAE/C,MAAM,cAAc,GAAG,6BAA6B,IAAA,gDAAwB,EAAC,eAAe,CAAC;8BAC/D,IAAA,4CAAyB,EAAC,eAAe,CAAC;;;;;;;;EAQtE,CAAA;AAEF,MAAM,WAAW,GAAG,CAAC,OAAiB,EAAU,EAAE,CAAC,SAAS,OAAO;KAChE,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,mBAAmB,MAAM,GAAG,CAAC;KAC7C,IAAI,CAAC,MAAM,CAAC;8BACe,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC;;IAE5C,CAAA;AAEG,MAAM,gCAAgC,GAAG,GAAW,EAAE,CAAC,GAAG,cAAc;;;EAG7E,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC;OAChB,2CAAwB;mDACoB,2CAAwB;8EACG,2CAAwB,aAAa,2CAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiC1I,CAAA;AAvCY,QAAA,gCAAgC,oCAuC5C;AAED,MAAM,cAAc,GAAG;;;;;;;;;;;;;;;;;;;;;;;;EAwBrB,CAAA;AAEF,MAAM,iBAAiB,GAAG,iCAAmB,CAAC,oBAAoB,GAAG,KAAK,CAAA;AAEnE,MAAM,8BAA8B,GAAG,GAAW,EAAE,CAAC,GAAG,cAAc;;EAE3E,cAAc;;2EAE2D,iBAAiB;;;EAG1F,WAAW,CAAC,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;4FAC8D,qCAA6B;;;;;;;;;;;;;;;qCAepF,yCAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4B1D,CAAA;AAnDY,QAAA,8BAA8B,kCAmD1C;AAEM,MAAM,kCAAkC,GAAG,GAAW,EAAE,CAAC,GAAG,cAAc;;EAE/E,cAAc;;;;;EAKd,WAAW,CAAC,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;gGACkE,qCAA6B;;;;;;;;;;;;;;qCAcxF,6CAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4B9D,CAAA;AAlDY,QAAA,kCAAkC,sCAkD9C"}