diff --git a/src/controllers/suggestions.js b/src/controllers/suggestions.js index 8f3e775e43..1caee79405 100644 --- a/src/controllers/suggestions.js +++ b/src/controllers/suggestions.js @@ -182,6 +182,48 @@ async function postPlgSuggestionSkipAlert(site, opportunity, suggestion, context } } +// TTL for presigned impact-measurement raw-data URLs (7 days). +const INSIGHTS_PRESIGN_TTL_SECONDS = 60 * 60 * 24 * 7; + +/** + * Replace each analysis's `rawDataUrl` (an `s3://bucket/key` URI) in place with a presigned HTTPS URL + * so the UI can download the per-analysis detail blobs. Same field name — the `s3://` value is simply + * swapped for the presigned URL. Best-effort per analysis — on a presign failure the original + * `rawDataUrl` is left as-is. Analyses without a `rawDataUrl` are untouched. + * + * @param {object} insights - ExperimentInsights object. + * @param {object} s3Ctx - context.s3 ({ s3Client, getSignedUrl, GetObjectCommand }). + * @param {object} log - logger. + * @returns {Promise} a new insights object with presigned rawDataUrls. + */ +async function presignInsightsRawData(insights, s3Ctx, log) { + const analyses = insights?.analyses; + if (!Array.isArray(analyses)) { + return insights; + } + const { s3Client, getSignedUrl, GetObjectCommand } = s3Ctx; + const presignedAnalyses = await Promise.all(analyses.map(async (analysis) => { + const match = /^s3:\/\/([^/]+)\/(.+)$/.exec(analysis?.rawDataUrl || ''); + if (!match) { + return analysis; + } + const [, bucket, key] = match; + try { + // Replace the s3:// rawDataUrl in place with a presigned HTTPS URL the browser can fetch. + const rawDataUrl = await getSignedUrl( + s3Client, + new GetObjectCommand({ Bucket: bucket, Key: key }), + { expiresIn: INSIGHTS_PRESIGN_TTL_SECONDS }, + ); + return { ...analysis, rawDataUrl }; + } catch (e) { + log.info(`[geo-experiment] Could not presign rawDataUrl ${analysis.rawDataUrl}: ${e.message}`); + return analysis; + } + })); + return { ...insights, analyses: presignedAnalyses }; +} + /** * Suggestions controller. * @param {object} ctx - Context of the request. @@ -2430,6 +2472,8 @@ function SuggestionsController(ctx, sqs, env) { ); const body = await response.Body.transformToString(); insights = JSON.parse(body); + // Presign each analysis's rawDataUrl so the UI can download the S3 detail blobs directly. + insights = await presignInsightsRawData(insights, context.s3, context.log); } catch (s3Error) { // Insights may not exist yet (e.g. impact measurement not yet complete) context.log.info(`[geo-experiment] Could not fetch insights for ${geoExperimentId}: ${s3Error.message}`); diff --git a/test/controllers/suggestions.test.js b/test/controllers/suggestions.test.js index 34ac0a3f7a..6ad0a9b216 100644 --- a/test/controllers/suggestions.test.js +++ b/test/controllers/suggestions.test.js @@ -9990,6 +9990,7 @@ describe('Suggestions Controller', () => { s3Client: { send: sandbox.stub() }, s3Bucket: 'test-bucket', GetObjectCommand: StubGetObjectCommand, + getSignedUrl: sandbox.stub().resolves('https://presigned.example/blob'), }; sandbox.stub(AccessControlUtil.prototype, 'hasAccess').resolves(true); @@ -10165,6 +10166,86 @@ describe('Suggestions Controller', () => { expect(body.insights).to.deep.equal(insightsPayload); }); + it('replaces each analysis rawDataUrl with a presigned URL when includeInsights=true', async () => { + const insightsKey = `geo-experiments/${SITE_ID}/${GEO_EXP_ID}-insights.json`; + const insightsPayload = { + analyses: [ + { kind: 'cited_text', rawDataUrl: 's3://mystique-bucket/geo-experiments/x/cited_text.json' }, + { kind: 'url_presence' }, // no rawDataUrl → left untouched + ], + }; + mockGeoExperiment.getInsightsLocation = () => insightsKey; + context.s3.getSignedUrl = sandbox.stub().resolves('https://signed.example/cited_text.json'); + context.s3.s3Client.send.callsFake((command) => { + const payload = command.Key === insightsKey ? insightsPayload : []; + return Promise.resolve({ + Body: { transformToString: sandbox.stub().resolves(JSON.stringify(payload)) }, + }); + }); + const response = await suggestionsController.getGeoExperiment({ + ...context, + data: { includeInsights: 'true' }, + params: { siteId: SITE_ID, geoExperimentId: GEO_EXP_ID }, + }); + expect(response.status).to.equal(200); + const body = await response.json(); + const [cited, urlPresence] = body.insights.analyses; + // rawDataUrl is replaced in place with the presigned HTTPS URL (no new fields, no s3://). + expect(cited.rawDataUrl).to.equal('https://signed.example/cited_text.json'); + expect(cited).to.not.have.property('rawDataPresignedUrl'); + // analysis without rawDataUrl is untouched. + expect(urlPresence).to.not.have.property('rawDataUrl'); + // presigned against the URL's OWN bucket/key (Mystique bucket), not the api-service bucket. + const signedCommand = context.s3.getSignedUrl.firstCall.args[1]; + expect(signedCommand.Bucket).to.equal('mystique-bucket'); + expect(signedCommand.Key).to.equal('geo-experiments/x/cited_text.json'); + }); + + it('returns insights unchanged when it has no analyses array', async () => { + const insightsKey = `geo-experiments/${SITE_ID}/${GEO_EXP_ID}-insights.json`; + const insightsPayload = { version: '1' }; // no analyses -> presign is a no-op + mockGeoExperiment.getInsightsLocation = () => insightsKey; + context.s3.s3Client.send.callsFake((command) => { + const payload = command.Key === insightsKey ? insightsPayload : []; + return Promise.resolve({ + Body: { transformToString: sandbox.stub().resolves(JSON.stringify(payload)) }, + }); + }); + const response = await suggestionsController.getGeoExperiment({ + ...context, + data: { includeInsights: 'true' }, + params: { siteId: SITE_ID, geoExperimentId: GEO_EXP_ID }, + }); + expect(response.status).to.equal(200); + const body = await response.json(); + expect(body.insights).to.deep.equal(insightsPayload); + expect(context.s3.getSignedUrl.called).to.equal(false); + }); + + it('leaves rawDataUrl unchanged and logs when presigning fails', async () => { + const insightsKey = `geo-experiments/${SITE_ID}/${GEO_EXP_ID}-insights.json`; + const rawUri = 's3://mystique-bucket/geo-experiments/x/cited_text.json'; + const insightsPayload = { analyses: [{ kind: 'cited_text', rawDataUrl: rawUri }] }; + mockGeoExperiment.getInsightsLocation = () => insightsKey; + context.s3.getSignedUrl = sandbox.stub().rejects(new Error('presign boom')); + context.s3.s3Client.send.callsFake((command) => { + const payload = command.Key === insightsKey ? insightsPayload : []; + return Promise.resolve({ + Body: { transformToString: sandbox.stub().resolves(JSON.stringify(payload)) }, + }); + }); + const response = await suggestionsController.getGeoExperiment({ + ...context, + data: { includeInsights: 'true' }, + params: { siteId: SITE_ID, geoExperimentId: GEO_EXP_ID }, + }); + expect(response.status).to.equal(200); + const body = await response.json(); + // presign failed → original s3:// rawDataUrl left untouched, and the failure is logged. + expect(body.insights.analyses[0].rawDataUrl).to.equal(rawUri); + expect(context.log.info.calledWithMatch(/Could not presign rawDataUrl/)).to.equal(true); + }); + it('returns null insights and logs when insights S3 fetch fails', async () => { const insightsKey = `geo-experiments/${SITE_ID}/${GEO_EXP_ID}-insights.json`; mockGeoExperiment.getInsightsLocation = () => insightsKey;