@memberjunction/connector-wordpress 1.0.0 → 1.1.0
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.
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* probe.mjs — live behavioural probe for the MJ WP Activity Log Bridge.
|
|
4
|
+
*
|
|
5
|
+
* Drives the bridge's real HTTP surface on a real WordPress install and asserts the properties the MJ
|
|
6
|
+
* WordPress connector actually depends on: discoverability, bounded pagination, a correct incremental
|
|
7
|
+
* watermark, and a schema for OPTIONS-based field discovery.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* WP_URL=http://localhost:8088 WP_USER=admin WP_APP_PASSWORD='xxxx xxxx …' \
|
|
11
|
+
* node Platform/WordPress/wp-plugin/mj-wsal-bridge/test/probe.mjs
|
|
12
|
+
*
|
|
13
|
+
* Read-only: it issues GET and OPTIONS only, and never writes to the site.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const BASE = (process.env.WP_URL || 'http://localhost:8088').replace(/\/$/, '');
|
|
17
|
+
const USER = process.env.WP_USER;
|
|
18
|
+
const PASS = process.env.WP_APP_PASSWORD;
|
|
19
|
+
|
|
20
|
+
if (!USER || !PASS) {
|
|
21
|
+
console.error('Set WP_USER and WP_APP_PASSWORD (a WordPress Application Password).');
|
|
22
|
+
process.exit(2);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const NS = `${BASE}/wp-json/mj-wsal/v1`;
|
|
26
|
+
const AUTH = 'Basic ' + Buffer.from(`${USER}:${PASS}`).toString('base64');
|
|
27
|
+
|
|
28
|
+
const failures = [];
|
|
29
|
+
function check(name, ok, detail = '') {
|
|
30
|
+
console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` ${detail}` : ''}`);
|
|
31
|
+
if (!ok) failures.push(name);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function req(url, method = 'GET', withAuth = true) {
|
|
35
|
+
const res = await fetch(url, { method, headers: withAuth ? { Authorization: AUTH } : {} });
|
|
36
|
+
const text = await res.text();
|
|
37
|
+
let body = null;
|
|
38
|
+
try { body = JSON.parse(text); } catch { /* non-JSON body */ }
|
|
39
|
+
return { status: res.status, headers: res.headers, body };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── 0. Discovery: what the connector's route-index scan will see ─────────────
|
|
43
|
+
console.log('\n0. discovery (route index)');
|
|
44
|
+
{
|
|
45
|
+
const { body } = await req(`${BASE}/wp-json/`, 'GET', false);
|
|
46
|
+
check('mj-wsal/v1 is advertised as a namespace', (body?.namespaces || []).includes('mj-wsal/v1'));
|
|
47
|
+
const routes = body?.routes || {};
|
|
48
|
+
for (const path of ['/mj-wsal/v1/events', '/mj-wsal/v1/event-types']) {
|
|
49
|
+
const eps = routes[path]?.endpoints || [];
|
|
50
|
+
const listable = eps.some((e) => (e.methods || []).includes('GET') && 'per_page' in (e.args || {}));
|
|
51
|
+
// This is exactly the connector's discriminator: a GET collection route registering per_page.
|
|
52
|
+
check(`${path} is a listable GET collection (registers per_page)`, listable);
|
|
53
|
+
}
|
|
54
|
+
const rootEps = routes['/mj-wsal/v1']?.endpoints || [];
|
|
55
|
+
check('the namespace root is NOT mistaken for a collection',
|
|
56
|
+
!rootEps.some((e) => 'per_page' in (e.args || {})));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── 1. Auth ──────────────────────────────────────────────────────────────────
|
|
60
|
+
console.log('\n1. auth');
|
|
61
|
+
{
|
|
62
|
+
const anon = await req(`${NS}/events`, 'GET', false);
|
|
63
|
+
check('unauthenticated read is denied', anon.status === 401 || anon.status === 403, `HTTP ${anon.status}`);
|
|
64
|
+
const authed = await req(`${NS}/events?per_page=1`);
|
|
65
|
+
check('authenticated read succeeds', authed.status === 200, `HTTP ${authed.status}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── 2. Pagination is bounded and totally ordered ─────────────────────────────
|
|
69
|
+
console.log('\n2. pagination');
|
|
70
|
+
const all = (await req(`${NS}/events?per_page=100`)).body || [];
|
|
71
|
+
{
|
|
72
|
+
const first = await req(`${NS}/events?per_page=2&page=1`);
|
|
73
|
+
const totalPages = parseInt(first.headers.get('x-wp-totalpages') || '0', 10);
|
|
74
|
+
const total = parseInt(first.headers.get('x-wp-total') || '0', 10);
|
|
75
|
+
check('X-WP-Total / X-WP-TotalPages are emitted', total > 0 && totalPages > 0, `total=${total} pages=${totalPages}`);
|
|
76
|
+
|
|
77
|
+
const paged = [];
|
|
78
|
+
for (let p = 1; p <= totalPages; p++) {
|
|
79
|
+
const r = await req(`${NS}/events?per_page=2&page=${p}`);
|
|
80
|
+
paged.push(...(r.body || []).map((e) => e.id));
|
|
81
|
+
}
|
|
82
|
+
const single = all.map((e) => e.id);
|
|
83
|
+
check('paging yields the same rows as one big page', JSON.stringify(paged) === JSON.stringify(single),
|
|
84
|
+
`paged=${paged.length} single=${single.length}`);
|
|
85
|
+
check('no row is duplicated across page boundaries', new Set(paged).size === paged.length);
|
|
86
|
+
check('X-WP-Total agrees with the row count', total === single.length, `header=${total} rows=${single.length}`);
|
|
87
|
+
|
|
88
|
+
// The route must stay bounded: an oversized per_page is REJECTED, never honoured.
|
|
89
|
+
const over = await req(`${NS}/events?per_page=5000`);
|
|
90
|
+
check('per_page above the cap is rejected', over.status === 400, `HTTP ${over.status}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── 3. Incremental watermark — the property the sync path rests on ───────────
|
|
94
|
+
console.log('\n3. incremental watermark');
|
|
95
|
+
{
|
|
96
|
+
const mid = all[Math.floor(all.length / 2)];
|
|
97
|
+
const since = (await req(`${NS}/events?per_page=100&after=${encodeURIComponent(mid.created_at)}`)).body || [];
|
|
98
|
+
check('after=<created_at> INCLUDES the boundary row', since.some((e) => e.id === mid.id), `boundary id=${mid.id}`);
|
|
99
|
+
check('after returns nothing older than the bound',
|
|
100
|
+
since.every((e) => e.created_on >= Math.floor(mid.created_on * 1000) / 1000));
|
|
101
|
+
|
|
102
|
+
// THE core safety property. created_on is a double with microsecond precision, but created_at carries
|
|
103
|
+
// only milliseconds, so several events can share one watermark value. Re-syncing from the newest
|
|
104
|
+
// watermark must therefore return that row (and any co-timestamped sibling) — never zero rows, which
|
|
105
|
+
// would mean an event was skipped, and never the whole history, which would mean no progress at all.
|
|
106
|
+
const newest = all[all.length - 1];
|
|
107
|
+
const resync = (await req(`${NS}/events?per_page=100&after=${encodeURIComponent(newest.created_at)}`)).body || [];
|
|
108
|
+
const bucket = all.filter((e) => e.created_at === newest.created_at).length;
|
|
109
|
+
check('re-sync from the newest watermark never skips an event', resync.some((e) => e.id === newest.id));
|
|
110
|
+
check('re-sync re-delivers only the co-timestamped tail, not the history',
|
|
111
|
+
resync.length === bucket && resync.length < all.length, `redelivered=${resync.length} bucket=${bucket} total=${all.length}`);
|
|
112
|
+
|
|
113
|
+
const byEpoch = (await req(`${NS}/events?per_page=100&after=${mid.created_on}`)).body || [];
|
|
114
|
+
check('after also accepts a raw epoch', JSON.stringify(byEpoch.map((e) => e.id)) === JSON.stringify(since.map((e) => e.id)));
|
|
115
|
+
|
|
116
|
+
const bad = await req(`${NS}/events?after=not-a-date`);
|
|
117
|
+
check('an unparseable after is rejected', bad.status === 400, `HTTP ${bad.status}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── 4. Payload shape ─────────────────────────────────────────────────────────
|
|
121
|
+
console.log('\n4. payload');
|
|
122
|
+
{
|
|
123
|
+
const e = all[0];
|
|
124
|
+
check('created_on / created_at describe the same instant',
|
|
125
|
+
Math.abs(Date.parse(e.created_at) / 1000 - e.created_on) < 0.002,
|
|
126
|
+
`${e.created_on} vs ${e.created_at}`);
|
|
127
|
+
check('created_at is not the 1970 epoch-unit bug', new Date(e.created_at).getUTCFullYear() > 2000, e.created_at);
|
|
128
|
+
check('severity resolves to a name', all.every((x) => x.severity_label && x.severity_label !== ''),
|
|
129
|
+
JSON.stringify([...new Set(all.map((x) => `${x.severity}=${x.severity_label}`))]));
|
|
130
|
+
check('alert_label is resolved', all.every((x) => typeof x.alert_label === 'string'));
|
|
131
|
+
check('events carry pivoted metadata', all.some((x) => x.meta && Object.keys(x.meta).length > 0));
|
|
132
|
+
// A serialised stdClass (WSAL's PluginData) must arrive as real JSON, not an "O:8:…" string.
|
|
133
|
+
const structured = all.filter((x) => Object.values(x.meta || {}).some((v) => v && typeof v === 'object'));
|
|
134
|
+
const rawSerialized = all.filter((x) => Object.values(x.meta || {}).some((v) => typeof v === 'string' && /^[OaC]:\d+:/.test(v)));
|
|
135
|
+
check('serialised metadata is decoded to real structures', structured.length > 0, `${structured.length} row(s)`);
|
|
136
|
+
check('no metadata value leaks as a raw PHP-serialised string', rawSerialized.length === 0, `${rawSerialized.length} row(s)`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── 5. Catalog + join integrity ──────────────────────────────────────────────
|
|
140
|
+
console.log('\n5. event-type catalog');
|
|
141
|
+
{
|
|
142
|
+
const first = await req(`${NS}/event-types?per_page=100`);
|
|
143
|
+
const totalPages = parseInt(first.headers.get('x-wp-totalpages') || '0', 10);
|
|
144
|
+
const types = [];
|
|
145
|
+
for (let p = 1; p <= totalPages; p++) {
|
|
146
|
+
types.push(...((await req(`${NS}/event-types?per_page=100&page=${p}`)).body || []));
|
|
147
|
+
}
|
|
148
|
+
check('catalog is populated', types.length > 300, `${types.length} event types`);
|
|
149
|
+
const ids = new Set(types.map((t) => t.alert_id));
|
|
150
|
+
check('catalog ids are unique', ids.size === types.length);
|
|
151
|
+
const unresolved = all.map((e) => e.alert_id).filter((id) => !ids.has(id));
|
|
152
|
+
check('every logged alert_id resolves in the catalog', unresolved.length === 0, `unresolved=${JSON.stringify(unresolved)}`);
|
|
153
|
+
check('catalog rows carry label + category + severity',
|
|
154
|
+
types.slice(0, 25).every((t) => t.label && t.category && t.severity));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── 6. OPTIONS schema — what DiscoverFields reads ────────────────────────────
|
|
158
|
+
console.log('\n6. OPTIONS schema');
|
|
159
|
+
{
|
|
160
|
+
const { body } = await req(`${NS}/events`, 'OPTIONS');
|
|
161
|
+
const props = body?.schema?.properties || {};
|
|
162
|
+
check('OPTIONS advertises a field schema', Object.keys(props).length >= 20, `${Object.keys(props).length} properties`);
|
|
163
|
+
check('created_at is typed as a date-time', props.created_at?.format === 'date-time');
|
|
164
|
+
check('meta is typed as an object', props.meta?.type === 'object');
|
|
165
|
+
const payloadKeys = Object.keys(all[0] || {});
|
|
166
|
+
const undeclared = payloadKeys.filter((k) => !(k in props));
|
|
167
|
+
check('every payload field is declared in the schema', undeclared.length === 0, `undeclared=${JSON.stringify(undeclared)}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
console.log(failures.length ? `\n${failures.length} FAILURE(S): ${JSON.stringify(failures)}\n` : '\nALL PASS\n');
|
|
171
|
+
process.exit(failures.length ? 1 : 0);
|