@raketa-cloud/admin 1.9.0 → 1.9.2
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.
- package/package.json +1 -1
- package/src/cloud/Resources.js +9 -0
- package/src/helpers/AuthHelper.js +6 -1
- package/src/helpers/reports/googleAuth.js +67 -18
- package/src/helpers/reports/reportsUtils.js +1 -1
- package/src/magicPreview.js +103 -0
- package/src/setupAuthentication.js +12 -0
- package/src/tests/handlers/resources/ReportsAPIHandler.test/show.test.js +5 -4
- package/src/tests/handlers/resources/ResourcesHandler.test/show_reports.test.js +2 -2
- package/src/tests/handlers/resources/TranslationsHandler.test/create.test.js +79 -1
- package/src/tests/helpers/AuthHelper.test.js +10 -0
- package/src/tests/helpers/reports/quick-insights.test.js +53 -18
- package/src/tests/magicPreview.test.js +127 -0
- package/src/tests/setupAuthentication.test.js +55 -0
package/package.json
CHANGED
package/src/cloud/Resources.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildMagicPreviewAddress } from "../magicPreview";
|
|
1
2
|
import { toDateInput, toDateTimeInput, toUTCISO8601String } from "../utils";
|
|
2
3
|
import RaketaClient from "./RaketaClient";
|
|
3
4
|
import {
|
|
@@ -162,6 +163,14 @@ export default class Resources extends RaketaClient {
|
|
|
162
163
|
|
|
163
164
|
input.workflow = {
|
|
164
165
|
type: workflow.type,
|
|
166
|
+
previewUrl: buildMagicPreviewAddress(
|
|
167
|
+
{
|
|
168
|
+
slug: input.resource.slug,
|
|
169
|
+
locale: input.locale,
|
|
170
|
+
resourceType: input.resource.resourceType,
|
|
171
|
+
},
|
|
172
|
+
this.getConfig(),
|
|
173
|
+
),
|
|
165
174
|
};
|
|
166
175
|
|
|
167
176
|
if (workflow.due_date) {
|
|
@@ -25,7 +25,12 @@ const AuthHelper = {
|
|
|
25
25
|
isAuthenticated: ({ path, token }) => {
|
|
26
26
|
const ALLOWED_PATHS = ["/login", "/auth/google/callback", "/remote/verify"];
|
|
27
27
|
|
|
28
|
-
if (
|
|
28
|
+
if (
|
|
29
|
+
ALLOWED_PATHS.includes(path) ||
|
|
30
|
+
path?.startsWith("/remote/magic-preview/")
|
|
31
|
+
) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
29
34
|
|
|
30
35
|
if (token && AuthHelper.verifyJWT(token)) return true;
|
|
31
36
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Service-account auth for the GA4 Data API (the Insights tab).
|
|
2
2
|
//
|
|
3
3
|
// GA is per-property authorized: it rejects API keys and needs a principal. The
|
|
4
|
-
// key
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// key at config.ga.serviceAccount (GOOGLE_APPLICATION_CREDENTIALS) provides one
|
|
5
|
+
// with no human in the loop — sign a JWT with it, swap that for an access token.
|
|
6
|
+
// Nothing is persisted, so a restart costs one token exchange.
|
|
7
7
|
//
|
|
8
8
|
// The key's client_email needs Viewer access on the GA property.
|
|
9
9
|
import { createSign } from "node:crypto";
|
|
@@ -18,44 +18,93 @@ const SCOPE = "https://www.googleapis.com/auth/analytics.readonly";
|
|
|
18
18
|
const TOKEN_LIFETIME_S = 3600;
|
|
19
19
|
const EXPIRY_SKEW_MS = 60 * 1000;
|
|
20
20
|
|
|
21
|
-
// Parsed
|
|
22
|
-
// token exchange per client_email (see accessToken).
|
|
21
|
+
// Parsed keys by credential source, access tokens by client_email, plus the
|
|
22
|
+
// in-flight token exchange per client_email (see accessToken).
|
|
23
23
|
const keyCache = new Map();
|
|
24
24
|
const tokenCache = new Map();
|
|
25
25
|
const pendingTokens = new Map();
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
const credentialSource = (ga = {}) => ga.serviceAccount;
|
|
28
|
+
|
|
29
|
+
// True when a credential is configured — gates the Insights tab, so an
|
|
28
30
|
// unconfigured admin sees no GA UI at all.
|
|
29
31
|
export const serviceAccountConfigured = (ga = {}) =>
|
|
30
|
-
Boolean(ga
|
|
32
|
+
Boolean(credentialSource(ga));
|
|
33
|
+
|
|
34
|
+
// GOOGLE_APPLICATION_CREDENTIALS now holds the whole key JSON rather than a path
|
|
35
|
+
// to it, so a source can be: an already-parsed object, the JSON itself, that
|
|
36
|
+
// JSON base64-encoded (what most hosts accept without quoting headaches), or a
|
|
37
|
+
// file path.
|
|
38
|
+
function parseCredentialSource(source) {
|
|
39
|
+
if (typeof source === "object") return { key: source, label: "config" };
|
|
40
|
+
|
|
41
|
+
const value = source.trim();
|
|
42
|
+
|
|
43
|
+
if (value.startsWith("{")) {
|
|
44
|
+
try {
|
|
45
|
+
return {
|
|
46
|
+
key: JSON.parse(value),
|
|
47
|
+
label: "GOOGLE_APPLICATION_CREDENTIALS",
|
|
48
|
+
};
|
|
49
|
+
} catch (err) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`Could not parse the service-account key JSON in GOOGLE_APPLICATION_CREDENTIALS: ${err.message}`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
31
55
|
|
|
32
|
-
|
|
33
|
-
|
|
56
|
+
const decoded = decodeBase64Json(value);
|
|
57
|
+
if (decoded) {
|
|
58
|
+
return {
|
|
59
|
+
key: decoded,
|
|
60
|
+
label: "GOOGLE_APPLICATION_CREDENTIALS (base64)",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
34
63
|
|
|
35
|
-
|
|
64
|
+
try {
|
|
65
|
+
return { key: JSON.parse(readFileSync(value, "utf8")), label: value };
|
|
66
|
+
} catch (err) {
|
|
36
67
|
throw new Error(
|
|
37
|
-
|
|
68
|
+
`Could not read the service-account key at ${value}: ${err.message}`,
|
|
38
69
|
);
|
|
39
70
|
}
|
|
71
|
+
}
|
|
40
72
|
|
|
41
|
-
|
|
73
|
+
// Only claims a value as base64 when it decodes to a JSON object — anything
|
|
74
|
+
// else is a path, and a path must keep reporting a read error, not a parse one.
|
|
75
|
+
function decodeBase64Json(value) {
|
|
76
|
+
if (!/^[A-Za-z0-9+/_-]+={0,2}$/.test(value)) return null;
|
|
42
77
|
|
|
43
|
-
let key;
|
|
44
78
|
try {
|
|
45
|
-
|
|
46
|
-
|
|
79
|
+
const decoded = Buffer.from(value, "base64").toString("utf8").trim();
|
|
80
|
+
return decoded.startsWith("{") ? JSON.parse(decoded) : null;
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function serviceAccountKey(ga = {}) {
|
|
87
|
+
const source = credentialSource(ga);
|
|
88
|
+
|
|
89
|
+
if (!source) {
|
|
47
90
|
throw new Error(
|
|
48
|
-
|
|
91
|
+
"Google service-account key is not set. Put the key JSON in GOOGLE_APPLICATION_CREDENTIALS (surfaced via config.ga.serviceAccount).",
|
|
49
92
|
);
|
|
50
93
|
}
|
|
51
94
|
|
|
95
|
+
if (typeof source === "string" && keyCache.has(source)) {
|
|
96
|
+
return keyCache.get(source);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const { key, label } = parseCredentialSource(source);
|
|
100
|
+
|
|
52
101
|
if (!key.client_email || !key.private_key) {
|
|
53
102
|
throw new Error(
|
|
54
|
-
`The service-account key
|
|
103
|
+
`The service-account key from ${label} has no client_email / private_key.`,
|
|
55
104
|
);
|
|
56
105
|
}
|
|
57
106
|
|
|
58
|
-
keyCache.set(
|
|
107
|
+
if (typeof source === "string") keyCache.set(source, key);
|
|
59
108
|
return key;
|
|
60
109
|
}
|
|
61
110
|
|
|
@@ -135,7 +135,7 @@ async function fetchQuickInsights(ctx, resource, resourceType) {
|
|
|
135
135
|
try {
|
|
136
136
|
if (!gaEnabled(ctx)) {
|
|
137
137
|
throw new Error(
|
|
138
|
-
"Google Analytics is not configured. Set config.ga.propertyId and config.ga.
|
|
138
|
+
"Google Analytics is not configured. Set config.ga.propertyId and config.ga.serviceAccount (GOOGLE_APPLICATION_CREDENTIALS) to enable reports.",
|
|
139
139
|
);
|
|
140
140
|
}
|
|
141
141
|
const pagePath = gaPagePath(ctx, resource, resourceType);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCipheriv,
|
|
3
|
+
createDecipheriv,
|
|
4
|
+
createHash,
|
|
5
|
+
randomBytes,
|
|
6
|
+
} from "node:crypto";
|
|
7
|
+
|
|
8
|
+
const ALGORITHM = "aes-256-gcm";
|
|
9
|
+
const IV_LENGTH = 12;
|
|
10
|
+
const TAG_LENGTH = 16;
|
|
11
|
+
|
|
12
|
+
function getSecretKey(config = {}) {
|
|
13
|
+
const secret = config.secrets.crypto_secret;
|
|
14
|
+
|
|
15
|
+
if (!secret) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
"crypto_secret needs to be setup in the config for the magic preview feature to work",
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return createHash("sha256").update(secret).digest();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function generateMagicPreviewToken(
|
|
25
|
+
{ slug, resourceType, locale } = {},
|
|
26
|
+
config = {},
|
|
27
|
+
) {
|
|
28
|
+
const payload = {
|
|
29
|
+
slug,
|
|
30
|
+
resourceType,
|
|
31
|
+
locale,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const key = getSecretKey(config);
|
|
35
|
+
const iv = randomBytes(IV_LENGTH);
|
|
36
|
+
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
37
|
+
const encrypted = Buffer.concat([
|
|
38
|
+
cipher.update(JSON.stringify(payload), "utf8"),
|
|
39
|
+
cipher.final(),
|
|
40
|
+
]);
|
|
41
|
+
const tag = cipher.getAuthTag();
|
|
42
|
+
|
|
43
|
+
return Buffer.concat([iv, tag, encrypted]).toString("base64url");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function decipherMagicPreviewToken(token, config) {
|
|
47
|
+
if (!token || typeof token !== "string") {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const key = getSecretKey(config);
|
|
53
|
+
const combined = Buffer.from(token, "base64url");
|
|
54
|
+
|
|
55
|
+
if (combined.length < IV_LENGTH + TAG_LENGTH) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const iv = combined.subarray(0, IV_LENGTH);
|
|
60
|
+
const tag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
|
61
|
+
const encrypted = combined.subarray(IV_LENGTH + TAG_LENGTH);
|
|
62
|
+
|
|
63
|
+
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
|
64
|
+
decipher.setAuthTag(tag);
|
|
65
|
+
|
|
66
|
+
const decrypted = Buffer.concat([
|
|
67
|
+
decipher.update(encrypted),
|
|
68
|
+
decipher.final(),
|
|
69
|
+
]).toString("utf8");
|
|
70
|
+
|
|
71
|
+
const payload = JSON.parse(decrypted);
|
|
72
|
+
|
|
73
|
+
if (!payload || typeof payload !== "object" || !payload.slug) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return payload;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function buildMagicPreviewAddress(
|
|
84
|
+
{ slug, resourceType, locale },
|
|
85
|
+
globalConfig,
|
|
86
|
+
) {
|
|
87
|
+
if (!slug || !resourceType || !locale) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
"Can't generate a token without a slug, resourceType or locale!",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const token = generateMagicPreviewToken(
|
|
94
|
+
{
|
|
95
|
+
slug,
|
|
96
|
+
resourceType,
|
|
97
|
+
locale,
|
|
98
|
+
},
|
|
99
|
+
globalConfig,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
return globalConfig.remoteConfig.magicPreviewUrl(token, resourceType, locale);
|
|
103
|
+
}
|
|
@@ -3,6 +3,7 @@ import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
|
3
3
|
import currentPath from "./currentPath.cjs";
|
|
4
4
|
import AuthHelper from "./helpers/AuthHelper";
|
|
5
5
|
import cipher from "./cipher";
|
|
6
|
+
import { decipherMagicPreviewToken } from "./magicPreview";
|
|
6
7
|
|
|
7
8
|
const DELIMITER = ",";
|
|
8
9
|
|
|
@@ -149,6 +150,17 @@ const setupAuthentication = (app, config) => {
|
|
|
149
150
|
|
|
150
151
|
return c.json({ token });
|
|
151
152
|
});
|
|
153
|
+
|
|
154
|
+
app.get("/remote/magic-preview/:token", (c) => {
|
|
155
|
+
const token = c.req.param("token") || "";
|
|
156
|
+
const payload = decipherMagicPreviewToken(token, config);
|
|
157
|
+
|
|
158
|
+
if (!payload?.slug) {
|
|
159
|
+
return c.json({ error: "Invalid or expired preview token" }, 404);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return c.json(payload);
|
|
163
|
+
});
|
|
152
164
|
};
|
|
153
165
|
|
|
154
166
|
export default setupAuthentication;
|
|
@@ -15,7 +15,8 @@ import ReportsAPIHandler from "../../../../handlers/resources/ReportsAPIHandler"
|
|
|
15
15
|
|
|
16
16
|
import Factory from "../../../support/factory";
|
|
17
17
|
|
|
18
|
-
const
|
|
18
|
+
const SERVICE_ACCOUNT =
|
|
19
|
+
'{"client_email":"reports@project.iam.gserviceaccount.com","private_key":"pem"}';
|
|
19
20
|
|
|
20
21
|
describe("Reports API Handler", () => {
|
|
21
22
|
function buildCtx(resource, { ga, reports = true } = {}) {
|
|
@@ -63,7 +64,7 @@ describe("Reports API Handler", () => {
|
|
|
63
64
|
const ctx = buildCtx(resource, {
|
|
64
65
|
ga: {
|
|
65
66
|
propertyId: "1234",
|
|
66
|
-
|
|
67
|
+
serviceAccount: SERVICE_ACCOUNT,
|
|
67
68
|
apiKey: "crux-key",
|
|
68
69
|
},
|
|
69
70
|
});
|
|
@@ -98,7 +99,7 @@ describe("Reports API Handler", () => {
|
|
|
98
99
|
test("does not cache the assembled payload", async () => {
|
|
99
100
|
const resource = Factory.build("resource", { translations: {} });
|
|
100
101
|
const ctx = buildCtx(resource, {
|
|
101
|
-
ga: { propertyId: "1234",
|
|
102
|
+
ga: { propertyId: "1234", serviceAccount: SERVICE_ACCOUNT },
|
|
102
103
|
});
|
|
103
104
|
|
|
104
105
|
jest.spyOn(ctx.client.resources, "find").mockImplementation(() => resource);
|
|
@@ -117,7 +118,7 @@ describe("Reports API Handler", () => {
|
|
|
117
118
|
test("passes a failed side through as an error", async () => {
|
|
118
119
|
const resource = Factory.build("resource", { translations: {} });
|
|
119
120
|
const ctx = buildCtx(resource, {
|
|
120
|
-
ga: { propertyId: "1234",
|
|
121
|
+
ga: { propertyId: "1234", serviceAccount: SERVICE_ACCOUNT },
|
|
121
122
|
});
|
|
122
123
|
|
|
123
124
|
jest.spyOn(ctx.client.resources, "find").mockImplementation(() => resource);
|
|
@@ -46,7 +46,7 @@ describe("Resources Handler show reports section", () => {
|
|
|
46
46
|
const resource = Factory.build("resource", { translations: {} });
|
|
47
47
|
const ctx = buildCtx(resource, {
|
|
48
48
|
propertyId: "1234",
|
|
49
|
-
|
|
49
|
+
serviceAccount: "{}",
|
|
50
50
|
apiKey: "crux-key",
|
|
51
51
|
});
|
|
52
52
|
mockClient(ctx, resource);
|
|
@@ -65,7 +65,7 @@ describe("Resources Handler show reports section", () => {
|
|
|
65
65
|
const resource = Factory.build("resource", { translations: {} });
|
|
66
66
|
const ctx = buildCtx(resource, {
|
|
67
67
|
propertyId: "1234",
|
|
68
|
-
|
|
68
|
+
serviceAccount: "{}",
|
|
69
69
|
});
|
|
70
70
|
mockClient(ctx, resource);
|
|
71
71
|
|
|
@@ -5,8 +5,26 @@ import Factory from "../../../support/factory";
|
|
|
5
5
|
|
|
6
6
|
function buildCtx(params, resource) {
|
|
7
7
|
return {
|
|
8
|
+
globalConfig: {
|
|
9
|
+
i18n: { defaultLocale: "en" },
|
|
10
|
+
remoteConfig: {
|
|
11
|
+
magicPreviewUrl: (token, type, loc) =>
|
|
12
|
+
`https://example.com/${loc}/magic-preview/${type}s/${token}`,
|
|
13
|
+
},
|
|
14
|
+
},
|
|
8
15
|
client: {
|
|
9
|
-
resources: new Resources(
|
|
16
|
+
resources: new Resources(
|
|
17
|
+
() => ({
|
|
18
|
+
secrets: {
|
|
19
|
+
crypto_secret: "super-secret-for-testing-1234",
|
|
20
|
+
},
|
|
21
|
+
remoteConfig: {
|
|
22
|
+
magicPreviewUrl: (token, type, loc) =>
|
|
23
|
+
`https://example.com/${loc}/magic-preview/${type}s/${token}`,
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
"token",
|
|
27
|
+
),
|
|
10
28
|
resourceRevisions: new ResourceRevisions(() => ({}), "token"),
|
|
11
29
|
},
|
|
12
30
|
params: { parent_id: resource.id },
|
|
@@ -327,4 +345,64 @@ describe("Resource Translations Handler", () => {
|
|
|
327
345
|
},
|
|
328
346
|
});
|
|
329
347
|
});
|
|
348
|
+
|
|
349
|
+
test("create generates previewUrl when workflow is provided", async () => {
|
|
350
|
+
const resource = Factory.build("resource", {
|
|
351
|
+
translations: {},
|
|
352
|
+
categories: [{ id: "cat1" }],
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const params = {
|
|
356
|
+
languages: ["fr"],
|
|
357
|
+
workflow: {
|
|
358
|
+
type: "LOKALISE_TT_AI",
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const ctx = buildCtx(params, resource);
|
|
363
|
+
|
|
364
|
+
jest.spyOn(ctx.client.resources, "find").mockImplementation(() => resource);
|
|
365
|
+
const mockQuery = jest
|
|
366
|
+
.spyOn(ctx.client.resources, "query")
|
|
367
|
+
.mockImplementation(() => ({
|
|
368
|
+
translate0: {
|
|
369
|
+
resource: {
|
|
370
|
+
id: "translated-resource-fr",
|
|
371
|
+
slug: "resource-slug",
|
|
372
|
+
visibility: "public",
|
|
373
|
+
},
|
|
374
|
+
errors: null,
|
|
375
|
+
},
|
|
376
|
+
}));
|
|
377
|
+
|
|
378
|
+
const result = await TranslationsHandler.create(ctx);
|
|
379
|
+
|
|
380
|
+
expect(mockQuery).toHaveBeenCalledWith(expect.any(String), {
|
|
381
|
+
input0: {
|
|
382
|
+
locale: "fr",
|
|
383
|
+
resource: {
|
|
384
|
+
categories: ["cat1"],
|
|
385
|
+
resourceType: "article",
|
|
386
|
+
settings:
|
|
387
|
+
'{"layout":"default","meta":{"title":"Open graph","description":"Open graph???","image":{}}}',
|
|
388
|
+
slug: "resource-slug",
|
|
389
|
+
tags: [],
|
|
390
|
+
title: "Resource Title",
|
|
391
|
+
visibility: "private",
|
|
392
|
+
widgets: "[]",
|
|
393
|
+
},
|
|
394
|
+
sourceResourceId: "2",
|
|
395
|
+
workflow: {
|
|
396
|
+
previewUrl: expect.stringMatching(
|
|
397
|
+
/^https:\/\/example\.com\/fr\/magic-preview\/articles\/[A-Za-z0-9_-]+$/,
|
|
398
|
+
),
|
|
399
|
+
type: "LOKALISE_TT_AI",
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
expect(result).toEqual({
|
|
405
|
+
redirect: `/en/articles/${resource.id}/translations?translations=fr:translated-resource-fr`,
|
|
406
|
+
});
|
|
407
|
+
});
|
|
330
408
|
});
|
|
@@ -69,6 +69,16 @@ describe("AuthHelper", () => {
|
|
|
69
69
|
AuthHelper.isAuthenticated({ path: "/auth/google/callback" }),
|
|
70
70
|
).toBe(true);
|
|
71
71
|
expect(AuthHelper.isAuthenticated({ path: "/remote/verify" })).toBe(true);
|
|
72
|
+
expect(
|
|
73
|
+
AuthHelper.isAuthenticated({
|
|
74
|
+
path: "/remote/magic-preview/any-token-here",
|
|
75
|
+
}),
|
|
76
|
+
).toBe(true);
|
|
77
|
+
expect(
|
|
78
|
+
AuthHelper.isAuthenticated({
|
|
79
|
+
path: "/remote/magic-preview/:token",
|
|
80
|
+
}),
|
|
81
|
+
).toBe(true);
|
|
72
82
|
});
|
|
73
83
|
|
|
74
84
|
test("returns true for valid token", () => {
|
|
@@ -14,31 +14,31 @@ import {
|
|
|
14
14
|
} from "../../../helpers/reports/quick-insights";
|
|
15
15
|
import localCache from "../../../localCache";
|
|
16
16
|
|
|
17
|
-
// One throwaway RSA key
|
|
18
|
-
// ga.
|
|
17
|
+
// One throwaway RSA key. The app passes the whole key JSON through
|
|
18
|
+
// ga.serviceAccount, so that is the form used here; the same key is also written
|
|
19
|
+
// to a temp file, since that config value also accepts a path.
|
|
19
20
|
const { privateKey } = generateKeyPairSync("rsa", {
|
|
20
21
|
modulusLength: 2048,
|
|
21
22
|
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
22
23
|
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
23
24
|
});
|
|
24
25
|
|
|
26
|
+
const keyJson = JSON.stringify({
|
|
27
|
+
type: "service_account",
|
|
28
|
+
client_email: "reports@project.iam.gserviceaccount.com",
|
|
29
|
+
private_key_id: "key-1",
|
|
30
|
+
private_key: privateKey,
|
|
31
|
+
});
|
|
32
|
+
|
|
25
33
|
const dir = mkdtempSync(join(tmpdir(), "quick-insights-"));
|
|
26
34
|
const keyPath = join(dir, "service-account.json");
|
|
27
35
|
const missingPath = join(dir, "nope.json");
|
|
28
36
|
const brokenPath = join(dir, "broken.json");
|
|
29
37
|
|
|
30
|
-
writeFileSync(
|
|
31
|
-
keyPath,
|
|
32
|
-
JSON.stringify({
|
|
33
|
-
type: "service_account",
|
|
34
|
-
client_email: "reports@project.iam.gserviceaccount.com",
|
|
35
|
-
private_key_id: "key-1",
|
|
36
|
-
private_key: privateKey,
|
|
37
|
-
}),
|
|
38
|
-
);
|
|
38
|
+
writeFileSync(keyPath, keyJson);
|
|
39
39
|
writeFileSync(brokenPath, JSON.stringify({ client_email: "a@b.c" }));
|
|
40
40
|
|
|
41
|
-
const GA = { propertyId: "1234",
|
|
41
|
+
const GA = { propertyId: "1234", serviceAccount: keyJson };
|
|
42
42
|
|
|
43
43
|
const reportBody = {
|
|
44
44
|
rows: [
|
|
@@ -80,13 +80,13 @@ describe("quick-insights auth", () => {
|
|
|
80
80
|
delete global.fetch;
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
test("serviceAccountConfigured follows the
|
|
83
|
+
test("serviceAccountConfigured follows the credential", () => {
|
|
84
84
|
expect(serviceAccountConfigured(GA)).toBe(true);
|
|
85
85
|
expect(serviceAccountConfigured({})).toBe(false);
|
|
86
86
|
expect(serviceAccountConfigured()).toBe(false);
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
-
test("signs a JWT from the key
|
|
89
|
+
test("signs a JWT from the key JSON and calls the Data API with it", async () => {
|
|
90
90
|
mockGoogle();
|
|
91
91
|
|
|
92
92
|
const result = await runPageReport({
|
|
@@ -129,6 +129,29 @@ describe("quick-insights auth", () => {
|
|
|
129
129
|
expect(result.rows).toEqual([{ date: "2026-07-01", views: 4 }]);
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
+
// Hosts that dislike raw JSON in an env var get the base64 form; a path is
|
|
133
|
+
// still accepted for anything pointing at a key file.
|
|
134
|
+
test.each([
|
|
135
|
+
["base64 key JSON", Buffer.from(keyJson).toString("base64")],
|
|
136
|
+
["a key file path", keyPath],
|
|
137
|
+
])("accepts %s", async (_label, serviceAccount) => {
|
|
138
|
+
mockGoogle();
|
|
139
|
+
|
|
140
|
+
const result = await runPageReport({
|
|
141
|
+
pagePath: "/pricing",
|
|
142
|
+
startDate: "2026-07-01",
|
|
143
|
+
endDate: "2026-07-01",
|
|
144
|
+
ga: { propertyId: "1234", serviceAccount },
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const [, tokenOptions] = global.fetch.mock.calls[0];
|
|
148
|
+
const [, claims] = Object.fromEntries(tokenOptions.body).assertion.split(
|
|
149
|
+
".",
|
|
150
|
+
);
|
|
151
|
+
expect(decode(claims).iss).toBe("reports@project.iam.gserviceaccount.com");
|
|
152
|
+
expect(result.rows).toEqual([{ date: "2026-07-01", views: 4 }]);
|
|
153
|
+
});
|
|
154
|
+
|
|
132
155
|
test("reuses the access token across reports", async () => {
|
|
133
156
|
mockGoogle();
|
|
134
157
|
|
|
@@ -203,7 +226,7 @@ describe("quick-insights auth", () => {
|
|
|
203
226
|
await expect(runPageReport(args)).rejects.toThrow(/GA Data API 403/);
|
|
204
227
|
});
|
|
205
228
|
|
|
206
|
-
test("reports unusable
|
|
229
|
+
test("reports unusable credentials", async () => {
|
|
207
230
|
// The property id is checked while building the request URL, so every case
|
|
208
231
|
// here keeps it set — otherwise that error would mask the key one.
|
|
209
232
|
mockGoogle();
|
|
@@ -220,13 +243,25 @@ describe("quick-insights auth", () => {
|
|
|
220
243
|
await expect(
|
|
221
244
|
runPageReport({
|
|
222
245
|
...args,
|
|
223
|
-
ga: { propertyId: "1234",
|
|
246
|
+
ga: { propertyId: "1234", serviceAccount: '{"client_email":' },
|
|
247
|
+
}),
|
|
248
|
+
).rejects.toThrow(/Could not parse the service-account key JSON/);
|
|
249
|
+
await expect(
|
|
250
|
+
runPageReport({
|
|
251
|
+
...args,
|
|
252
|
+
ga: { propertyId: "1234", serviceAccount: '{"client_email":"a@b.c"}' },
|
|
253
|
+
}),
|
|
254
|
+
).rejects.toThrow(/no client_email \/ private_key/);
|
|
255
|
+
await expect(
|
|
256
|
+
runPageReport({
|
|
257
|
+
...args,
|
|
258
|
+
ga: { propertyId: "1234", serviceAccount: missingPath },
|
|
224
259
|
}),
|
|
225
260
|
).rejects.toThrow(/Could not read the service-account key/);
|
|
226
261
|
await expect(
|
|
227
262
|
runPageReport({
|
|
228
263
|
...args,
|
|
229
|
-
ga: { propertyId: "1234",
|
|
264
|
+
ga: { propertyId: "1234", serviceAccount: brokenPath },
|
|
230
265
|
}),
|
|
231
266
|
).rejects.toThrow(/no client_email \/ private_key/);
|
|
232
267
|
});
|
|
@@ -239,7 +274,7 @@ describe("quick-insights auth", () => {
|
|
|
239
274
|
pagePath: "/pricing",
|
|
240
275
|
startDate: "2026-07-01",
|
|
241
276
|
endDate: "2026-07-01",
|
|
242
|
-
ga: {
|
|
277
|
+
ga: { serviceAccount: keyJson },
|
|
243
278
|
}),
|
|
244
279
|
).rejects.toThrow(/GA property id is not set/);
|
|
245
280
|
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildMagicPreviewAddress,
|
|
3
|
+
decipherMagicPreviewToken,
|
|
4
|
+
generateMagicPreviewToken,
|
|
5
|
+
} from "../magicPreview";
|
|
6
|
+
|
|
7
|
+
describe("magicPreview helper", () => {
|
|
8
|
+
const config = {
|
|
9
|
+
secrets: {
|
|
10
|
+
crypto_secret: "super-secret-key-for-testing-123456",
|
|
11
|
+
},
|
|
12
|
+
i18n: {
|
|
13
|
+
defaultLocale: "en",
|
|
14
|
+
locales: { en: {}, de: {}, fr: {} },
|
|
15
|
+
},
|
|
16
|
+
remoteConfig: {
|
|
17
|
+
magicPreviewUrl: (token, resourceType, locale) => {
|
|
18
|
+
return [
|
|
19
|
+
"https://example.com",
|
|
20
|
+
locale === "en" ? "" : locale,
|
|
21
|
+
"magic-preview",
|
|
22
|
+
resourceType === "page" ? "" : resourceType,
|
|
23
|
+
token,
|
|
24
|
+
]
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
.join("/");
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe("buildMagicPreviewAddress", () => {
|
|
32
|
+
it("builds full preview URL using remoteConfig.magicPreviewUrl", () => {
|
|
33
|
+
const address = buildMagicPreviewAddress(
|
|
34
|
+
{
|
|
35
|
+
slug: "gocardless",
|
|
36
|
+
resourceType: "customer",
|
|
37
|
+
locale: "en",
|
|
38
|
+
},
|
|
39
|
+
config,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
expect(address).toMatch(
|
|
43
|
+
/^https:\/\/example\.com\/magic-preview\/customer\/[A-Za-z0-9_-]+$/,
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("builds localized preview URL for non-default locale", () => {
|
|
48
|
+
const address = buildMagicPreviewAddress(
|
|
49
|
+
{
|
|
50
|
+
slug: "gocardless",
|
|
51
|
+
resourceType: "customer",
|
|
52
|
+
locale: "de",
|
|
53
|
+
},
|
|
54
|
+
config,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
expect(address).toMatch(
|
|
58
|
+
/^https:\/\/example\.com\/de\/magic-preview\/customer\/[A-Za-z0-9_-]+$/,
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("throws an error for missing slug", () => {
|
|
63
|
+
expect(() => {
|
|
64
|
+
buildMagicPreviewAddress(
|
|
65
|
+
{ resourceType: "page", locale: "de" },
|
|
66
|
+
config,
|
|
67
|
+
);
|
|
68
|
+
}).toThrow(
|
|
69
|
+
"Can't generate a token without a slug, resourceType or locale!",
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("throws an error for missing resourceType", () => {
|
|
74
|
+
expect(() => {
|
|
75
|
+
buildMagicPreviewAddress({ slug: "page", locale: "de" }, config);
|
|
76
|
+
}).toThrow(
|
|
77
|
+
"Can't generate a token without a slug, resourceType or locale!",
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("throws an error for missing locale", () => {
|
|
82
|
+
expect(() => {
|
|
83
|
+
buildMagicPreviewAddress(
|
|
84
|
+
{ resourceType: "page", slug: "dada" },
|
|
85
|
+
config,
|
|
86
|
+
);
|
|
87
|
+
}).toThrow(
|
|
88
|
+
"Can't generate a token without a slug, resourceType or locale!",
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("generateMagicPreviewToken & decipherMagicPreviewToken", () => {
|
|
94
|
+
it("generates an encrypted token and deciphers it back", () => {
|
|
95
|
+
const token = generateMagicPreviewToken(
|
|
96
|
+
{
|
|
97
|
+
slug: "my-first-ebook",
|
|
98
|
+
resourceType: "ebook",
|
|
99
|
+
locale: "en",
|
|
100
|
+
},
|
|
101
|
+
config,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
expect(typeof token).toBe("string");
|
|
105
|
+
expect(token).not.toContain("my-first-ebook");
|
|
106
|
+
|
|
107
|
+
const payload = decipherMagicPreviewToken(token, config);
|
|
108
|
+
expect(payload).not.toBeNull();
|
|
109
|
+
expect(payload.slug).toBe("my-first-ebook");
|
|
110
|
+
expect(payload.resourceType).toBe("ebook");
|
|
111
|
+
expect(payload.locale).toBe("en");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns null on invalid / tampered token", () => {
|
|
115
|
+
expect(decipherMagicPreviewToken("", config)).toBeNull();
|
|
116
|
+
expect(decipherMagicPreviewToken(null, config)).toBeNull();
|
|
117
|
+
expect(decipherMagicPreviewToken("invalid-token", config)).toBeNull();
|
|
118
|
+
|
|
119
|
+
const token = generateMagicPreviewToken(
|
|
120
|
+
{ slug: "secure", resourceType: "page", locale: "en" },
|
|
121
|
+
config,
|
|
122
|
+
);
|
|
123
|
+
const tampered = `${token.slice(0, -4)}XXXX`;
|
|
124
|
+
expect(decipherMagicPreviewToken(tampered, config)).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -50,6 +50,7 @@ function makeCtx(overrides = {}) {
|
|
|
50
50
|
path: "/en/pages",
|
|
51
51
|
url: "https://example.com/en/pages",
|
|
52
52
|
query: jest.fn().mockReturnValue({}),
|
|
53
|
+
param: jest.fn().mockReturnValue(""),
|
|
53
54
|
json: jest.fn().mockResolvedValue({}),
|
|
54
55
|
...overrides.req,
|
|
55
56
|
},
|
|
@@ -72,6 +73,10 @@ const config = {
|
|
|
72
73
|
remoteLogin: "https://remote/login",
|
|
73
74
|
remoteLogout: "https://remote/logout",
|
|
74
75
|
},
|
|
76
|
+
secrets: {
|
|
77
|
+
crypto_secret:
|
|
78
|
+
"cafeb74e0fc1079d384f68620dc0b0919c82bd36351a74fb48f3c96562c1bf672ff5fd4db42180bc9550d06f3621edba5305a443a232337e2945d7bb1271cafe",
|
|
79
|
+
},
|
|
75
80
|
};
|
|
76
81
|
|
|
77
82
|
describe("setupAuthentication", () => {
|
|
@@ -96,6 +101,10 @@ describe("setupAuthentication", () => {
|
|
|
96
101
|
"/remote/verify",
|
|
97
102
|
expect.any(Function),
|
|
98
103
|
);
|
|
104
|
+
expect(app.get).toHaveBeenCalledWith(
|
|
105
|
+
"/remote/magic-preview/:token",
|
|
106
|
+
expect.any(Function),
|
|
107
|
+
);
|
|
99
108
|
});
|
|
100
109
|
|
|
101
110
|
describe("use middleware", () => {
|
|
@@ -407,4 +416,50 @@ describe("setupAuthentication", () => {
|
|
|
407
416
|
expect(result.status).toBe(401);
|
|
408
417
|
});
|
|
409
418
|
});
|
|
419
|
+
|
|
420
|
+
describe("/remote/magic-preview/:token", () => {
|
|
421
|
+
test("returns the token payload for a valid token", async () => {
|
|
422
|
+
const app = makeApp();
|
|
423
|
+
setupAuthentication(app, config);
|
|
424
|
+
|
|
425
|
+
const { generateMagicPreviewToken } = require("../magicPreview");
|
|
426
|
+
const token = generateMagicPreviewToken(
|
|
427
|
+
{ slug: "article-slug", resourceType: "article", locale: "es" },
|
|
428
|
+
config,
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
const ctx = makeCtx({
|
|
432
|
+
req: {
|
|
433
|
+
param: jest.fn().mockReturnValue(token),
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
await app.handlers["GET /remote/magic-preview/:token"](ctx);
|
|
438
|
+
|
|
439
|
+
expect(ctx.json).toHaveBeenCalledWith({
|
|
440
|
+
slug: "article-slug",
|
|
441
|
+
resourceType: "article",
|
|
442
|
+
locale: "es",
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("returns 404 for invalid token", async () => {
|
|
447
|
+
const app = makeApp();
|
|
448
|
+
|
|
449
|
+
setupAuthentication(app, config);
|
|
450
|
+
|
|
451
|
+
const ctx = makeCtx({
|
|
452
|
+
req: {
|
|
453
|
+
param: jest.fn().mockReturnValue("bad-token"),
|
|
454
|
+
},
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
await app.handlers["GET /remote/magic-preview/:token"](ctx);
|
|
458
|
+
|
|
459
|
+
expect(ctx.json).toHaveBeenCalledWith(
|
|
460
|
+
{ error: "Invalid or expired preview token" },
|
|
461
|
+
404,
|
|
462
|
+
);
|
|
463
|
+
});
|
|
464
|
+
});
|
|
410
465
|
});
|