backend-manager 5.0.127 → 5.0.129
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/CLAUDE.md +53 -0
- package/README.md +5 -0
- package/package.json +1 -1
- package/src/manager/events/auth/before-signin.js +4 -2
- package/src/manager/helpers/usage.js +60 -21
- package/src/manager/helpers/user.js +4 -2
- package/src/manager/routes/admin/post/post.js +14 -1
- package/src/manager/routes/admin/users/sync/post.js +2 -2
- package/templates/firestore.rules +1 -0
- package/test/routes/admin/create-post.js +311 -0
package/CLAUDE.md
CHANGED
|
@@ -84,6 +84,12 @@ src/
|
|
|
84
84
|
daily.js # Daily cron runner
|
|
85
85
|
daily/{job}.js # Individual cron jobs
|
|
86
86
|
routes/ # Built-in routes
|
|
87
|
+
admin/
|
|
88
|
+
post/ # POST /admin/post - Create blog posts via GitHub
|
|
89
|
+
post.js # Extracts images, uploads to GitHub, rewrites body to @post/ format
|
|
90
|
+
put.js # PUT /admin/post - Edit existing posts
|
|
91
|
+
templates/
|
|
92
|
+
post.html # Post frontmatter template
|
|
87
93
|
payments/
|
|
88
94
|
intent/ # POST /payments/intent
|
|
89
95
|
post.js # Intent creation orchestrator
|
|
@@ -426,6 +432,28 @@ Manager.handlers.bm_api = function (mod, position) {
|
|
|
426
432
|
| Auth Events | `events/auth/` | `{event}.js` |
|
|
427
433
|
| Cron Jobs | `cron/daily/` or `hooks/cron/daily/` | `{job}.js` |
|
|
428
434
|
|
|
435
|
+
## Admin Post Route
|
|
436
|
+
|
|
437
|
+
The `POST /admin/post` route creates blog posts via GitHub's API. It handles image extraction, upload, and body rewriting.
|
|
438
|
+
|
|
439
|
+
### Image Processing Flow
|
|
440
|
+
1. Receives markdown body with external image URLs (e.g., ``)
|
|
441
|
+
2. Extracts all `` patterns from the body using regex
|
|
442
|
+
3. Downloads each image and uploads it to `src/assets/images/blog/post-{id}/` on GitHub
|
|
443
|
+
4. **Rewrites the body** to replace external URLs with `@post/{filename}` format
|
|
444
|
+
5. The `@post/` prefix is resolved at Jekyll build time by `jekyll-uj-powertools` to the full path
|
|
445
|
+
|
|
446
|
+
### Key Details
|
|
447
|
+
- Image filenames are derived from `hyphenate(alt_text)` + downloaded extension
|
|
448
|
+
- Header image (`headerImageURL`) is uploaded but NOT rewritten in the body (it's in frontmatter)
|
|
449
|
+
- Failed image downloads are skipped — the original external URL stays in the body
|
|
450
|
+
- The `extractImages()` function returns a URL mapping used for body rewriting
|
|
451
|
+
|
|
452
|
+
### Files
|
|
453
|
+
- `src/manager/routes/admin/post/post.js` — POST handler (create)
|
|
454
|
+
- `src/manager/routes/admin/post/put.js` — PUT handler (edit)
|
|
455
|
+
- `src/manager/routes/admin/post/templates/post.html` — Post template
|
|
456
|
+
|
|
429
457
|
## Testing
|
|
430
458
|
|
|
431
459
|
### Running Tests
|
|
@@ -673,6 +701,31 @@ Products can opt out of daily caps by setting `rateLimit: 'monthly'` (default is
|
|
|
673
701
|
}
|
|
674
702
|
```
|
|
675
703
|
|
|
704
|
+
### Proxy Usage (setUser + Mirrors)
|
|
705
|
+
|
|
706
|
+
Sometimes usage must be billed to a different user than the one making the request (e.g., anonymous visitors consuming an agent owner's credits). Use `setUser()` to swap the target and `addMirror()` / `setMirrors()` to write usage to additional Firestore docs:
|
|
707
|
+
|
|
708
|
+
```js
|
|
709
|
+
// Switch usage target to the agent owner (fetches their user doc)
|
|
710
|
+
await usage.setUser(ownerUid);
|
|
711
|
+
|
|
712
|
+
// Also write usage data to the agent doc
|
|
713
|
+
usage.addMirror(`agents/${agentId}`);
|
|
714
|
+
|
|
715
|
+
// Now validate, increment, and update all operate on the owner's data
|
|
716
|
+
// update() writes to users/{ownerUid} AND agents/{agentId} in parallel
|
|
717
|
+
await usage.validate('credits');
|
|
718
|
+
usage.increment('credits');
|
|
719
|
+
await usage.update();
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
**Methods:**
|
|
723
|
+
- `setUser(uid)` — async, fetches `users/{uid}` from Firestore, replaces `self.user`, sets `useUnauthenticatedStorage = false`
|
|
724
|
+
- `setMirrors(paths)` — sync, overwrites the mirror array with the given paths
|
|
725
|
+
- `addMirror(path)` — sync, appends a single path to the mirror array
|
|
726
|
+
|
|
727
|
+
Mirrors are write-only — `update()` writes `{ usage: self.user.usage }` (merge) to each mirror path. No reads are performed on mirrors.
|
|
728
|
+
|
|
676
729
|
### Reset Schedule
|
|
677
730
|
|
|
678
731
|
| Target | Frequency | What happens |
|
package/README.md
CHANGED
|
@@ -532,6 +532,11 @@ await usage.update();
|
|
|
532
532
|
|
|
533
533
|
// Whitelist keys
|
|
534
534
|
usage.addWhitelistKeys(['another-key']);
|
|
535
|
+
|
|
536
|
+
// Proxy usage: bill a different user and mirror writes to additional docs
|
|
537
|
+
await usage.setUser('owner-uid'); // Switch target user (fetches from Firestore)
|
|
538
|
+
usage.addMirror('agents/agent-id'); // Also write usage to this doc on update()
|
|
539
|
+
usage.setMirrors(['agents/a', 'orgs/b']); // Overwrite mirror list
|
|
535
540
|
```
|
|
536
541
|
|
|
537
542
|
### Middleware
|
package/package.json
CHANGED
|
@@ -15,11 +15,13 @@ module.exports = async ({ Manager, assistant, user, context, libraries }) => {
|
|
|
15
15
|
// Update last activity and geolocation
|
|
16
16
|
const update = await admin.firestore().doc(`users/${user.uid}`)
|
|
17
17
|
.set({
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
metadata: {
|
|
19
|
+
updated: {
|
|
20
20
|
timestamp: now.toISOString(),
|
|
21
21
|
timestampUNIX: Math.round(now.getTime() / 1000),
|
|
22
22
|
},
|
|
23
|
+
},
|
|
24
|
+
activity: {
|
|
23
25
|
geolocation: {
|
|
24
26
|
ip: context.ipAddress,
|
|
25
27
|
language: context.locale,
|
|
@@ -23,6 +23,8 @@ function Usage(m) {
|
|
|
23
23
|
user: '',
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
self._mirrors = [];
|
|
27
|
+
|
|
26
28
|
self.initialized = false;
|
|
27
29
|
}
|
|
28
30
|
|
|
@@ -100,6 +102,39 @@ Usage.prototype.init = function (assistant, options) {
|
|
|
100
102
|
});
|
|
101
103
|
};
|
|
102
104
|
|
|
105
|
+
Usage.prototype.setUser = async function (uid) {
|
|
106
|
+
const self = this;
|
|
107
|
+
const admin = self.Manager.libraries.admin;
|
|
108
|
+
|
|
109
|
+
const doc = await admin.firestore().doc(`users/${uid}`).get().catch(() => null);
|
|
110
|
+
const data = (doc && doc.exists) ? doc.data() : {};
|
|
111
|
+
|
|
112
|
+
self.user = {
|
|
113
|
+
...data,
|
|
114
|
+
auth: { uid: uid, ...(data.auth || {}) },
|
|
115
|
+
usage: data.usage || {},
|
|
116
|
+
subscription: data.subscription || {},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
self.useUnauthenticatedStorage = false;
|
|
120
|
+
|
|
121
|
+
self.log(`Usage.setUser(): Switched to user ${uid}`);
|
|
122
|
+
|
|
123
|
+
return self;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
Usage.prototype.setMirrors = function (paths) {
|
|
127
|
+
const self = this;
|
|
128
|
+
self._mirrors = Array.isArray(paths) ? paths : [];
|
|
129
|
+
return self;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
Usage.prototype.addMirror = function (path) {
|
|
133
|
+
const self = this;
|
|
134
|
+
self._mirrors.push(path);
|
|
135
|
+
return self;
|
|
136
|
+
};
|
|
137
|
+
|
|
103
138
|
Usage.prototype.validate = function (name, options) {
|
|
104
139
|
const self = this;
|
|
105
140
|
|
|
@@ -357,40 +392,44 @@ Usage.prototype.update = function () {
|
|
|
357
392
|
return new Promise(async function(resolve, reject) {
|
|
358
393
|
const { admin } = Manager.libraries;
|
|
359
394
|
|
|
395
|
+
// Build the primary write promise
|
|
396
|
+
let mainWrite;
|
|
397
|
+
|
|
360
398
|
// Write self.user to firestore or local if no user or if key is set
|
|
361
399
|
if (self.useUnauthenticatedStorage) {
|
|
362
400
|
if (self.options.unauthenticatedMode === 'firestore') {
|
|
363
|
-
admin.firestore().doc(`usage/${self.key}`)
|
|
364
|
-
.set(self.user.usage, { merge: true })
|
|
365
|
-
.then(() => {
|
|
366
|
-
self.log(`Usage.update(): Updated user.usage in firestore`, self.user.usage);
|
|
367
|
-
|
|
368
|
-
return resolve(self.user.usage);
|
|
369
|
-
})
|
|
370
|
-
.catch(e => {
|
|
371
|
-
return reject(assistant.errorify(e, {code: 500, sentry: true}));
|
|
372
|
-
});
|
|
401
|
+
mainWrite = admin.firestore().doc(`usage/${self.key}`)
|
|
402
|
+
.set(self.user.usage, { merge: true });
|
|
373
403
|
} else {
|
|
374
404
|
self.storage.set(`${self.paths.user}.usage`, self.user.usage).write();
|
|
375
405
|
|
|
376
406
|
self.log(`Usage.update(): Updated user.usage in local storage`, self.user.usage);
|
|
377
407
|
|
|
378
|
-
|
|
408
|
+
mainWrite = Promise.resolve();
|
|
379
409
|
}
|
|
380
410
|
} else {
|
|
381
|
-
admin.firestore().doc(`users/${self.user.auth.uid}`)
|
|
411
|
+
mainWrite = admin.firestore().doc(`users/${self.user.auth.uid}`)
|
|
382
412
|
.set({
|
|
383
413
|
usage: self.user.usage,
|
|
384
|
-
}, { merge: true })
|
|
385
|
-
.then(() => {
|
|
386
|
-
self.log(`Usage.update(): Updated user.usage in firestore`, self.user.usage);
|
|
387
|
-
|
|
388
|
-
return resolve(self.user.usage);
|
|
389
|
-
})
|
|
390
|
-
.catch(e => {
|
|
391
|
-
return reject(assistant.errorify(e, {code: 500, sentry: true}));
|
|
392
|
-
});
|
|
414
|
+
}, { merge: true });
|
|
393
415
|
}
|
|
416
|
+
|
|
417
|
+
// Build mirror write promises
|
|
418
|
+
const mirrorWrites = (self._mirrors || []).map((path) => {
|
|
419
|
+
return admin.firestore().doc(path)
|
|
420
|
+
.set({ usage: self.user.usage }, { merge: true });
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// Execute all writes in parallel
|
|
424
|
+
Promise.all([mainWrite, ...mirrorWrites])
|
|
425
|
+
.then(() => {
|
|
426
|
+
self.log(`Usage.update(): Updated user.usage in firestore (+ ${mirrorWrites.length} mirrors)`, self.user.usage);
|
|
427
|
+
|
|
428
|
+
return resolve(self.user.usage);
|
|
429
|
+
})
|
|
430
|
+
.catch(e => {
|
|
431
|
+
return reject(assistant.errorify(e, {code: 500, sentry: true}));
|
|
432
|
+
});
|
|
394
433
|
});
|
|
395
434
|
};
|
|
396
435
|
|
|
@@ -66,9 +66,11 @@ const SCHEMA = {
|
|
|
66
66
|
code: { type: 'string', default: '$randomId' },
|
|
67
67
|
referrals: { type: 'array', default: [] },
|
|
68
68
|
},
|
|
69
|
-
|
|
70
|
-
lastActivity: '$timestamp:now',
|
|
69
|
+
metadata: {
|
|
71
70
|
created: '$timestamp:now',
|
|
71
|
+
updated: '$timestamp:now',
|
|
72
|
+
},
|
|
73
|
+
activity: {
|
|
72
74
|
geolocation: {
|
|
73
75
|
ip: { type: 'string', default: null, nullable: true },
|
|
74
76
|
continent: { type: 'string', default: null, nullable: true },
|
|
@@ -98,6 +98,11 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
|
|
|
98
98
|
return assistant.respond(imageResult.message, { code: 400 });
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
// Rewrite body to use @post/ prefix for extracted images
|
|
102
|
+
for (const { originalUrl, localFilename } of imageResult) {
|
|
103
|
+
settings.body = settings.body.split(originalUrl).join(`@post/${localFilename}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
101
106
|
// Set defaults
|
|
102
107
|
const formattedContent = powertools.template(
|
|
103
108
|
POST_TEMPLATE,
|
|
@@ -120,6 +125,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
|
|
|
120
125
|
|
|
121
126
|
// Helper: Extract and upload images
|
|
122
127
|
async function extractImages(assistant, octokit, settings) {
|
|
128
|
+
const urlMap = [];
|
|
123
129
|
|
|
124
130
|
const matches = settings.body.matchAll(IMAGE_REGEX);
|
|
125
131
|
const images = Array.from(matches).map(match => ({
|
|
@@ -138,7 +144,7 @@ async function extractImages(assistant, octokit, settings) {
|
|
|
138
144
|
assistant.log('extractImages(): images', images);
|
|
139
145
|
|
|
140
146
|
if (!images.length) {
|
|
141
|
-
return;
|
|
147
|
+
return urlMap;
|
|
142
148
|
}
|
|
143
149
|
|
|
144
150
|
for (let index = 0; index < images.length; index++) {
|
|
@@ -171,7 +177,14 @@ async function extractImages(assistant, octokit, settings) {
|
|
|
171
177
|
continue;
|
|
172
178
|
}
|
|
173
179
|
}
|
|
180
|
+
|
|
181
|
+
// Track successfully uploaded non-header images for body rewriting
|
|
182
|
+
if (!image.header) {
|
|
183
|
+
urlMap.push({ originalUrl: image.src, localFilename: download.filename });
|
|
184
|
+
}
|
|
174
185
|
}
|
|
186
|
+
|
|
187
|
+
return urlMap;
|
|
175
188
|
}
|
|
176
189
|
|
|
177
190
|
// Helper: Download image
|
|
@@ -57,12 +57,12 @@ module.exports = async ({ assistant, Manager, user, settings, analytics, librari
|
|
|
57
57
|
uid: uid,
|
|
58
58
|
email: email,
|
|
59
59
|
},
|
|
60
|
-
|
|
60
|
+
metadata: {
|
|
61
61
|
created: {
|
|
62
62
|
timestamp: created.toISOString(),
|
|
63
63
|
timestampUNIX: Math.floor(created.getTime() / 1000),
|
|
64
64
|
},
|
|
65
|
-
|
|
65
|
+
updated: {
|
|
66
66
|
timestamp: activity.toISOString(),
|
|
67
67
|
timestampUNIX: Math.floor(activity.getTime() / 1000),
|
|
68
68
|
},
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: POST /admin/post
|
|
3
|
+
* Tests the admin create post endpoint
|
|
4
|
+
* Creates blog posts via GitHub with image extraction and @post/ body rewriting
|
|
5
|
+
* Requires admin/blogger role, GitHub API key, and repo_website config
|
|
6
|
+
*/
|
|
7
|
+
const { Octokit } = require('@octokit/rest');
|
|
8
|
+
|
|
9
|
+
module.exports = {
|
|
10
|
+
description: 'Admin create post on GitHub',
|
|
11
|
+
type: 'suite',
|
|
12
|
+
timeout: 300000, // 5 minutes total for the suite
|
|
13
|
+
|
|
14
|
+
tests: [
|
|
15
|
+
// --- Validation tests (no GitHub needed) ---
|
|
16
|
+
|
|
17
|
+
{
|
|
18
|
+
name: 'missing-title-rejected',
|
|
19
|
+
auth: 'admin',
|
|
20
|
+
timeout: 15000,
|
|
21
|
+
|
|
22
|
+
async run({ http, assert }) {
|
|
23
|
+
const response = await http.post('admin/post', {
|
|
24
|
+
url: 'test-post',
|
|
25
|
+
description: 'Test description',
|
|
26
|
+
headerImageURL: 'https://example.com/image.jpg',
|
|
27
|
+
body: 'Test content',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
assert.isError(response, 400, 'Missing title should return 400');
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
{
|
|
35
|
+
name: 'missing-url-rejected',
|
|
36
|
+
auth: 'admin',
|
|
37
|
+
timeout: 15000,
|
|
38
|
+
|
|
39
|
+
async run({ http, assert }) {
|
|
40
|
+
const response = await http.post('admin/post', {
|
|
41
|
+
title: 'Test Post',
|
|
42
|
+
description: 'Test description',
|
|
43
|
+
headerImageURL: 'https://example.com/image.jpg',
|
|
44
|
+
body: 'Test content',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
assert.isError(response, 400, 'Missing URL should return 400');
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
{
|
|
52
|
+
name: 'missing-description-rejected',
|
|
53
|
+
auth: 'admin',
|
|
54
|
+
timeout: 15000,
|
|
55
|
+
|
|
56
|
+
async run({ http, assert }) {
|
|
57
|
+
const response = await http.post('admin/post', {
|
|
58
|
+
title: 'Test Post',
|
|
59
|
+
url: 'test-post',
|
|
60
|
+
headerImageURL: 'https://example.com/image.jpg',
|
|
61
|
+
body: 'Test content',
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
assert.isError(response, 400, 'Missing description should return 400');
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
{
|
|
69
|
+
name: 'missing-header-image-rejected',
|
|
70
|
+
auth: 'admin',
|
|
71
|
+
timeout: 15000,
|
|
72
|
+
|
|
73
|
+
async run({ http, assert }) {
|
|
74
|
+
const response = await http.post('admin/post', {
|
|
75
|
+
title: 'Test Post',
|
|
76
|
+
url: 'test-post',
|
|
77
|
+
description: 'Test description',
|
|
78
|
+
body: 'Test content',
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
assert.isError(response, 400, 'Missing headerImageURL should return 400');
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
{
|
|
86
|
+
name: 'missing-body-rejected',
|
|
87
|
+
auth: 'admin',
|
|
88
|
+
timeout: 15000,
|
|
89
|
+
|
|
90
|
+
async run({ http, assert }) {
|
|
91
|
+
const response = await http.post('admin/post', {
|
|
92
|
+
title: 'Test Post',
|
|
93
|
+
url: 'test-post',
|
|
94
|
+
description: 'Test description',
|
|
95
|
+
headerImageURL: 'https://example.com/image.jpg',
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
assert.isError(response, 400, 'Missing body should return 400');
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
// --- Integration: create post with inline image, verify @post/ rewriting ---
|
|
103
|
+
|
|
104
|
+
{
|
|
105
|
+
name: 'create-post-rewrites-body-images',
|
|
106
|
+
auth: 'admin',
|
|
107
|
+
timeout: 120000,
|
|
108
|
+
skip: !process.env.TEST_EXTENDED_MODE ? 'TEST_EXTENDED_MODE env var not set' : false,
|
|
109
|
+
|
|
110
|
+
async run({ http, assert, state, config }) {
|
|
111
|
+
if (!process.env.GITHUB_TOKEN) {
|
|
112
|
+
assert.fail('GITHUB_TOKEN env var not set');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!config.githubRepoWebsite) {
|
|
117
|
+
assert.fail('githubRepoWebsite not configured');
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Parse owner/repo for cleanup later
|
|
122
|
+
const repoMatch = config.githubRepoWebsite.match(/github\.com\/([^/]+)\/([^/]+)/);
|
|
123
|
+
if (!repoMatch) {
|
|
124
|
+
assert.fail('Could not parse githubRepoWebsite');
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
state.owner = repoMatch[1];
|
|
129
|
+
state.repo = repoMatch[2];
|
|
130
|
+
|
|
131
|
+
// Use a real public .jpg for the inline image
|
|
132
|
+
const inlineImageURL = 'https://picsum.photos/id/10/200/200.jpg';
|
|
133
|
+
|
|
134
|
+
const response = await http.post('admin/post', {
|
|
135
|
+
title: 'BEM Test Create Post',
|
|
136
|
+
url: 'bem-test-create-post',
|
|
137
|
+
description: 'Test post created by BEM test suite to verify @post/ body rewriting.',
|
|
138
|
+
headerImageURL: 'https://picsum.photos/id/1/400/300.jpg',
|
|
139
|
+
body: `# BEM Test Create Post\n\nSome intro text.\n\n\n\nMore text after the image.`,
|
|
140
|
+
postPath: 'guest',
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
assert.isSuccess(response, 'Create post should succeed');
|
|
144
|
+
assert.hasProperty(response, 'data.id', 'Response should have post id');
|
|
145
|
+
assert.hasProperty(response, 'data.path', 'Response should have post path');
|
|
146
|
+
|
|
147
|
+
// Store for cleanup
|
|
148
|
+
state.postId = response.data.id;
|
|
149
|
+
state.postPath = `${response.data.path}/${response.data.date}-bem-test-create-post.md`;
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
{
|
|
154
|
+
name: 'verify-body-has-at-post-prefix',
|
|
155
|
+
auth: 'admin',
|
|
156
|
+
timeout: 30000,
|
|
157
|
+
skip: !process.env.TEST_EXTENDED_MODE ? 'TEST_EXTENDED_MODE env var not set' : false,
|
|
158
|
+
|
|
159
|
+
async run({ assert, state }) {
|
|
160
|
+
if (!state.postPath) {
|
|
161
|
+
return; // Previous test didn't run
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
|
|
165
|
+
|
|
166
|
+
// Fetch the committed post from GitHub
|
|
167
|
+
const { data: fileData } = await octokit.rest.repos.getContent({
|
|
168
|
+
owner: state.owner,
|
|
169
|
+
repo: state.repo,
|
|
170
|
+
path: state.postPath,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const content = Buffer.from(fileData.content, 'base64').toString();
|
|
174
|
+
state.currentSha = fileData.sha;
|
|
175
|
+
|
|
176
|
+
// The body should contain @post/ prefix instead of the original external URL
|
|
177
|
+
assert.ok(
|
|
178
|
+
content.includes('@post/'),
|
|
179
|
+
'Post body should contain @post/ prefix for downloaded images',
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
assert.ok(
|
|
183
|
+
!content.includes('picsum.photos/id/10'),
|
|
184
|
+
'Post body should NOT contain the original external inline image URL',
|
|
185
|
+
);
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
// --- Auth rejection tests ---
|
|
190
|
+
|
|
191
|
+
{
|
|
192
|
+
name: 'unauthenticated-rejected',
|
|
193
|
+
auth: 'none',
|
|
194
|
+
timeout: 15000,
|
|
195
|
+
|
|
196
|
+
async run({ http, assert }) {
|
|
197
|
+
const response = await http.post('admin/post', {
|
|
198
|
+
title: 'Test Post',
|
|
199
|
+
url: 'test-post',
|
|
200
|
+
description: 'Test description',
|
|
201
|
+
headerImageURL: 'https://example.com/image.jpg',
|
|
202
|
+
body: 'Test content',
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
assert.isError(response, 401, 'Create post should fail without authentication');
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
{
|
|
210
|
+
name: 'non-admin-rejected',
|
|
211
|
+
auth: 'basic',
|
|
212
|
+
timeout: 15000,
|
|
213
|
+
|
|
214
|
+
async run({ http, assert }) {
|
|
215
|
+
const response = await http.post('admin/post', {
|
|
216
|
+
title: 'Test Post',
|
|
217
|
+
url: 'test-post',
|
|
218
|
+
description: 'Test description',
|
|
219
|
+
headerImageURL: 'https://example.com/image.jpg',
|
|
220
|
+
body: 'Test content',
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
assert.isError(response, 403, 'Create post should fail for non-admin user');
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
// --- Cleanup ---
|
|
228
|
+
|
|
229
|
+
{
|
|
230
|
+
name: 'cleanup',
|
|
231
|
+
auth: 'admin',
|
|
232
|
+
timeout: 60000,
|
|
233
|
+
|
|
234
|
+
async run({ state }) {
|
|
235
|
+
if (!process.env.GITHUB_TOKEN || !state.postPath) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
|
|
240
|
+
|
|
241
|
+
// Delete the test post
|
|
242
|
+
try {
|
|
243
|
+
const { data: fileData } = await octokit.rest.repos.getContent({
|
|
244
|
+
owner: state.owner,
|
|
245
|
+
repo: state.repo,
|
|
246
|
+
path: state.postPath,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
await octokit.rest.repos.deleteFile({
|
|
250
|
+
owner: state.owner,
|
|
251
|
+
repo: state.repo,
|
|
252
|
+
path: state.postPath,
|
|
253
|
+
message: '🧹 BEM test cleanup: delete create-post test',
|
|
254
|
+
sha: fileData.sha,
|
|
255
|
+
});
|
|
256
|
+
} catch (e) {
|
|
257
|
+
// File might not exist, ignore
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Delete uploaded test images
|
|
261
|
+
const imagePath = `src/assets/images/blog/post-${state.postId}/`;
|
|
262
|
+
try {
|
|
263
|
+
const { data: dirData } = await octokit.rest.repos.getContent({
|
|
264
|
+
owner: state.owner,
|
|
265
|
+
repo: state.repo,
|
|
266
|
+
path: imagePath,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Delete each image file
|
|
270
|
+
for (const file of dirData) {
|
|
271
|
+
await octokit.rest.repos.deleteFile({
|
|
272
|
+
owner: state.owner,
|
|
273
|
+
repo: state.repo,
|
|
274
|
+
path: file.path,
|
|
275
|
+
message: `🧹 BEM test cleanup: delete test image ${file.name}`,
|
|
276
|
+
sha: file.sha,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
} catch (e) {
|
|
280
|
+
// Directory might not exist, ignore
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Cancel any running workflows triggered by our test commits
|
|
284
|
+
try {
|
|
285
|
+
const { data: runs } = await octokit.rest.actions.listWorkflowRunsForRepo({
|
|
286
|
+
owner: state.owner,
|
|
287
|
+
repo: state.repo,
|
|
288
|
+
status: 'in_progress',
|
|
289
|
+
per_page: 10,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
for (const run of runs.workflow_runs) {
|
|
293
|
+
if (run.head_commit?.message?.includes('BEM test')) {
|
|
294
|
+
try {
|
|
295
|
+
await octokit.rest.actions.cancelWorkflowRun({
|
|
296
|
+
owner: state.owner,
|
|
297
|
+
repo: state.repo,
|
|
298
|
+
run_id: run.id,
|
|
299
|
+
});
|
|
300
|
+
} catch (e) {
|
|
301
|
+
// May already be completed, ignore
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
} catch (e) {
|
|
306
|
+
// Workflow cancellation failed, not critical
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
],
|
|
311
|
+
};
|