@volter/twin-sentry 0.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.
- package/LICENSE +202 -0
- package/README.md +142 -0
- package/client/sentry-mirror.css +99 -0
- package/client/sentry-mirror.tsx +352 -0
- package/dist/client/sentry-mirror.bundle.js +321 -0
- package/dist/client/sentry-mirror.css +99 -0
- package/dist/client/sentry-mirror.d.ts +17 -0
- package/dist/client/sentry-mirror.js +156 -0
- package/dist/client/sentry-mirror.tsx +352 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +36 -0
- package/dist/src/index.d.ts +15 -0
- package/dist/src/index.js +75 -0
- package/dist/src/sentry-budget.d.ts +50 -0
- package/dist/src/sentry-budget.js +145 -0
- package/dist/src/sentry-capabilities.d.ts +3 -0
- package/dist/src/sentry-capabilities.js +1180 -0
- package/dist/src/sentry-conformance.d.ts +22 -0
- package/dist/src/sentry-conformance.js +97 -0
- package/dist/src/sentry-connector.d.ts +77 -0
- package/dist/src/sentry-connector.js +226 -0
- package/dist/src/sentry-events.d.ts +54 -0
- package/dist/src/sentry-events.js +131 -0
- package/dist/src/sentry-ingest.d.ts +137 -0
- package/dist/src/sentry-ingest.js +387 -0
- package/dist/src/sentry-mirror-ui.d.ts +38 -0
- package/dist/src/sentry-mirror-ui.js +162 -0
- package/dist/src/sentry-perform-harness.d.ts +9 -0
- package/dist/src/sentry-perform-harness.js +20 -0
- package/dist/src/sentry-server.d.ts +14 -0
- package/dist/src/sentry-server.js +27 -0
- package/dist/src/sentry-twin.d.ts +17 -0
- package/dist/src/sentry-twin.js +1739 -0
- package/dist/test-fixtures/sentry-openapi-operations.SOURCE.md +24 -0
- package/dist/test-fixtures/sentry-openapi-operations.json +1837 -0
- package/package.json +75 -0
- package/src/cli.ts +34 -0
- package/src/index.ts +145 -0
- package/src/sentry-budget.ts +171 -0
- package/src/sentry-capabilities.ts +1222 -0
- package/src/sentry-conformance.ts +110 -0
- package/src/sentry-connector.ts +260 -0
- package/src/sentry-events.ts +171 -0
- package/src/sentry-ingest.ts +471 -0
- package/src/sentry-mirror-ui.ts +162 -0
- package/src/sentry-perform-harness.ts +19 -0
- package/src/sentry-server.ts +35 -0
- package/src/sentry-twin.ts +1615 -0
- package/test-fixtures/sentry-openapi-operations.SOURCE.md +24 -0
- package/test-fixtures/sentry-openapi-operations.json +1837 -0
|
@@ -0,0 +1,1222 @@
|
|
|
1
|
+
// Sentry capability manifest — the EXPECTED REAL-PRODUCT SURFACE (the target), authored
|
|
2
|
+
// top-down from what Sentry actually does — NOT from what this twin has built. This is the
|
|
3
|
+
// honest denominator: every entry's verify() (required to count as done) drives REAL
|
|
4
|
+
// requests through the twin and asserts status/shape; `expected:'done'` only on what we
|
|
5
|
+
// genuinely claim, so a broken one shows as a regression. Grow this toward Sentry's full
|
|
6
|
+
// surface every cycle.
|
|
7
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { createElement } from 'react';
|
|
11
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
12
|
+
import { checkCapabilities, uiDataCoupled, type CapabilityReport, type CapabilitySpec, verifyBoundary, isInfrastructureError, harnessError } from '@volter/world-tooling';
|
|
13
|
+
import { DashboardsView, DiscoverView, ReleasesView, ProjectsView } from '../client/sentry-mirror.tsx';
|
|
14
|
+
import { handleSentryTwinRequest, type SentryResponse } from './sentry-twin.ts';
|
|
15
|
+
import { DEFAULT_ORG, DEFAULT_PROJECT, DEFAULT_PROJECT_ID, DEFAULT_PUBLIC_KEY } from './sentry-ingest.ts';
|
|
16
|
+
import { registerAlertRule, emitIssueAlert, verifySentrySignature, computeSentrySignature, type SentryWebhook } from './sentry-events.ts';
|
|
17
|
+
import { mapIssue, sentryRequestForAction, syncSentryFromReal, type SentryExecute } from './sentry-connector.ts';
|
|
18
|
+
import { performPending } from './sentry-perform-harness.ts';
|
|
19
|
+
import { buildSentryMirrorClient, latestStacktrace, eventTags, levelTone, statusTone, formatCount, timeAgo } from './sentry-mirror-ui.ts';
|
|
20
|
+
|
|
21
|
+
type Body = Record<string, any>;
|
|
22
|
+
type Step = { m: string; p: string; b?: unknown; auth?: string };
|
|
23
|
+
|
|
24
|
+
const OCC = '2026-01-01T00:00:00.000Z';
|
|
25
|
+
|
|
26
|
+
/** Run a sequence of real Sentry requests against an isolated root; returns a tester. */
|
|
27
|
+
async function withRoot(steps: (h: (s: Step) => Promise<SentryResponse>, root: string) => Promise<boolean>): Promise<boolean> {
|
|
28
|
+
const root = mkdtempSync(join(tmpdir(), 'sentry-cap-'));
|
|
29
|
+
const h = (s: Step) => handleSentryTwinRequest({
|
|
30
|
+
method: s.m, path: s.p,
|
|
31
|
+
body: s.b === undefined ? undefined : (typeof s.b === 'string' ? s.b : JSON.stringify(s.b)),
|
|
32
|
+
root, occurredAt: OCC,
|
|
33
|
+
headers: s.auth ? { 'x-sentry-auth': s.auth } : {},
|
|
34
|
+
});
|
|
35
|
+
try { return await verifyBoundary('sentry.withRoot', () => steps(h, root)); } finally { rmSync(root, { recursive: true, force: true }); }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const ok = (r: SentryResponse) => r.status >= 200 && r.status < 300;
|
|
39
|
+
const auth = `Sentry sentry_key=${DEFAULT_PUBLIC_KEY}`;
|
|
40
|
+
const ingest = (event: unknown): Step => ({ m: 'POST', p: `/api/${DEFAULT_PROJECT_ID}/store/`, b: event, auth });
|
|
41
|
+
const envelope = (event: unknown): Step => ({ m: 'POST', p: `/api/${DEFAULT_PROJECT_ID}/envelope/`, b: `{"dsn":"http://${DEFAULT_PUBLIC_KEY}@localhost/${DEFAULT_PROJECT_ID}"}\n{"type":"event"}\n${JSON.stringify(event)}` });
|
|
42
|
+
const issuesPath = `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/issues/`;
|
|
43
|
+
|
|
44
|
+
// ── UI verify: the built mirror bundle must contain the screen's load-bearing markers ──
|
|
45
|
+
let bundle: Promise<string> | null = null;
|
|
46
|
+
const mirrorBundle = (): Promise<string> => (bundle ??= buildSentryMirrorClient());
|
|
47
|
+
|
|
48
|
+
// Count occurrences of a DOM landmark class (e.g. one `dashboard-row` per dashboard) in
|
|
49
|
+
// rendered mirror markup — the structural assertion used by the rung-5 UI verifies below.
|
|
50
|
+
const countLandmark = (markup: string, landmark: string): number => markup.split(landmark).length - 1;
|
|
51
|
+
|
|
52
|
+
// shorthands
|
|
53
|
+
const done = (id: string, area: string, title: string, dimension: CapabilitySpec['dimension'], tier: CapabilitySpec['tier'], verify: CapabilitySpec['verify']): CapabilitySpec => ({ id, area, title, dimension, tier, expected: 'done', verify });
|
|
54
|
+
const todo = (id: string, area: string, title: string, dimension: CapabilitySpec['dimension'], tier: CapabilitySpec['tier']): CapabilitySpec => ({ id, area, title, dimension, tier, expected: 'todo' });
|
|
55
|
+
|
|
56
|
+
export const SENTRY_CAPABILITIES: CapabilitySpec[] = [
|
|
57
|
+
// ── INGESTION (the SDK side — the core) ─────────────────────────────────────────
|
|
58
|
+
done('sentry.ingest.store_exception', 'ingestion', 'POST /api/:id/store/ — capture an exception event', 'api', 'core', () =>
|
|
59
|
+
withRoot(async (h) => {
|
|
60
|
+
const r = await h(ingest({ exception: { values: [{ type: 'TypeError', value: 'boom' }] } }));
|
|
61
|
+
return ok(r) && typeof (r.body as Body).id === 'string' && (r.body as Body).id.length === 32;
|
|
62
|
+
})),
|
|
63
|
+
done('sentry.ingest.envelope', 'ingestion', 'POST /api/:id/envelope/ — capture via the modern envelope transport', 'api', 'core', () =>
|
|
64
|
+
withRoot(async (h) => {
|
|
65
|
+
const r = await h(envelope({ exception: { values: [{ type: 'RangeError', value: 'nope' }] } }));
|
|
66
|
+
return ok(r) && typeof (r.body as Body).id === 'string';
|
|
67
|
+
})),
|
|
68
|
+
done('sentry.ingest.message', 'ingestion', 'Capture a message event (no exception)', 'api', 'core', () =>
|
|
69
|
+
withRoot(async (h) => {
|
|
70
|
+
const r = await h(ingest({ message: 'something happened', level: 'warning' }));
|
|
71
|
+
if (!ok(r)) return false;
|
|
72
|
+
const list = await h({ m: 'GET', p: `${issuesPath}?query=` });
|
|
73
|
+
const issues = list.body as Body[];
|
|
74
|
+
return issues.length === 1 && issues[0]!.title === 'something happened' && issues[0]!.level === 'warning';
|
|
75
|
+
})),
|
|
76
|
+
done('sentry.ingest.grouping', 'ingestion', 'Two identical exceptions GROUP into one issue (count increments)', 'api', 'core', () =>
|
|
77
|
+
withRoot(async (h) => {
|
|
78
|
+
const ev = { exception: { values: [{ type: 'TypeError', value: 'x is undefined' }] } };
|
|
79
|
+
const a = await h(ingest(ev));
|
|
80
|
+
const b = await h(ingest(ev));
|
|
81
|
+
const list = await h({ m: 'GET', p: `${issuesPath}?query=` });
|
|
82
|
+
const issues = list.body as Body[];
|
|
83
|
+
// distinct event ids, one issue, count 2, first/last seen set
|
|
84
|
+
return (a.body as Body).id !== (b.body as Body).id && issues.length === 1 &&
|
|
85
|
+
issues[0]!.count === '2' && !!issues[0]!.firstSeen && !!issues[0]!.lastSeen;
|
|
86
|
+
})),
|
|
87
|
+
done('sentry.ingest.grouping_distinct', 'ingestion', 'Different exceptions land in DIFFERENT issues', 'api', 'core', () =>
|
|
88
|
+
withRoot(async (h) => {
|
|
89
|
+
await h(ingest({ exception: { values: [{ type: 'TypeError', value: 'a' }] } }));
|
|
90
|
+
await h(ingest({ exception: { values: [{ type: 'ReferenceError', value: 'b' }] } }));
|
|
91
|
+
const list = await h({ m: 'GET', p: `${issuesPath}?query=` });
|
|
92
|
+
return (list.body as Body[]).length === 2;
|
|
93
|
+
})),
|
|
94
|
+
done('sentry.ingest.grouping_normalized', 'ingestion', 'Numbers in the message are normalized so similar errors group', 'api', 'common', () =>
|
|
95
|
+
withRoot(async (h) => {
|
|
96
|
+
await h(ingest({ exception: { values: [{ type: 'Error', value: 'user 42 not found' }] } }));
|
|
97
|
+
await h(ingest({ exception: { values: [{ type: 'Error', value: 'user 99 not found' }] } }));
|
|
98
|
+
const list = await h({ m: 'GET', p: `${issuesPath}?query=` });
|
|
99
|
+
return (list.body as Body[]).length === 1 && (list.body as Body[])[0]!.count === '2';
|
|
100
|
+
})),
|
|
101
|
+
done('sentry.ingest.auth_required', 'ingestion', 'An unknown/missing DSN public key is rejected 401', 'api', 'core', () =>
|
|
102
|
+
withRoot(async (h) => {
|
|
103
|
+
const noKey = await h({ m: 'POST', p: `/api/${DEFAULT_PROJECT_ID}/store/`, b: { message: 'x' } });
|
|
104
|
+
const badKey = await h({ m: 'POST', p: `/api/${DEFAULT_PROJECT_ID}/store/`, b: { message: 'x' }, auth: 'Sentry sentry_key=wrong' });
|
|
105
|
+
return noKey.status === 401 && badKey.status === 401;
|
|
106
|
+
})),
|
|
107
|
+
done('sentry.ingest.auth_query', 'ingestion', 'DSN public key accepted via ?sentry_key query param', 'api', 'common', () =>
|
|
108
|
+
withRoot(async (h) => {
|
|
109
|
+
// a wrong key via the SAME query-param path must be rejected — proves the param is actually read/checked.
|
|
110
|
+
const wrong = await h({ m: 'POST', p: `/api/${DEFAULT_PROJECT_ID}/store/?sentry_key=wrongkey`, b: { message: 'q' } });
|
|
111
|
+
if (wrong.status !== 401) return false;
|
|
112
|
+
const r = await h({ m: 'POST', p: `/api/${DEFAULT_PROJECT_ID}/store/?sentry_key=${DEFAULT_PUBLIC_KEY}`, b: { message: 'q' } });
|
|
113
|
+
if (!ok(r)) return false;
|
|
114
|
+
const rid = (r.body as Body).id;
|
|
115
|
+
// a real 32-hex event id, and the event must actually be persisted + retrievable with matching content.
|
|
116
|
+
if (typeof rid !== 'string' || !/^[0-9a-f]{32}$/.test(rid)) return false;
|
|
117
|
+
const got = await h({ m: 'GET', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/events/${rid}/` });
|
|
118
|
+
return ok(got) && (got.body as Body).eventID === rid && (got.body as Body).message === 'q';
|
|
119
|
+
})),
|
|
120
|
+
done('sentry.ingest.project_mismatch', 'ingestion', 'A key for a different project is rejected 401', 'api', 'common', () =>
|
|
121
|
+
withRoot(async (h) => {
|
|
122
|
+
const r = await h({ m: 'POST', p: `/api/999/store/`, b: { message: 'x' }, auth });
|
|
123
|
+
return r.status === 401;
|
|
124
|
+
})),
|
|
125
|
+
done('sentry.ingest.regression', 'ingestion', 'A new event on a resolved issue REOPENS it (regression)', 'api', 'core', () =>
|
|
126
|
+
withRoot(async (h) => {
|
|
127
|
+
const ev = { exception: { values: [{ type: 'Error', value: 'flap' }] } };
|
|
128
|
+
await h(ingest(ev));
|
|
129
|
+
const list = await h({ m: 'GET', p: `${issuesPath}?query=` });
|
|
130
|
+
const id = (list.body as Body[])[0]!.id;
|
|
131
|
+
await h({ m: 'PUT', p: `/api/0/issues/${id}/`, b: { status: 'resolved' } });
|
|
132
|
+
const resolved = await h({ m: 'GET', p: `/api/0/issues/${id}/` });
|
|
133
|
+
await h(ingest(ev)); // reopen
|
|
134
|
+
const reopened = await h({ m: 'GET', p: `/api/0/issues/${id}/` });
|
|
135
|
+
return (resolved.body as Body).status === 'resolved' && (reopened.body as Body).status === 'unresolved' && (reopened.body as Body).count === '2';
|
|
136
|
+
})),
|
|
137
|
+
done('sentry.ingest.culprit', 'ingestion', 'Culprit derived from the top in-app stack frame', 'api', 'common', () =>
|
|
138
|
+
withRoot(async (h) => {
|
|
139
|
+
await h(ingest({ exception: { values: [{ type: 'Error', value: 'z', stacktrace: { frames: [{ function: 'outer', in_app: false }, { function: 'handler', module: 'app/server', in_app: true }] } }] } }));
|
|
140
|
+
const list = await h({ m: 'GET', p: `${issuesPath}?query=` });
|
|
141
|
+
return (list.body as Body[])[0]!.culprit === 'handler(app/server)';
|
|
142
|
+
})),
|
|
143
|
+
done('sentry.ingest.envelope_session_ignored', 'ingestion', 'A session-only envelope is accepted (200) without creating an issue', 'api', 'niche', () =>
|
|
144
|
+
withRoot(async (h) => {
|
|
145
|
+
const r = await h({ m: 'POST', p: `/api/${DEFAULT_PROJECT_ID}/envelope/`, b: `{"dsn":"http://${DEFAULT_PUBLIC_KEY}@localhost/${DEFAULT_PROJECT_ID}"}\n{"type":"session"}\n{"sid":"abc"}` });
|
|
146
|
+
const list = await h({ m: 'GET', p: `${issuesPath}?query=` });
|
|
147
|
+
return ok(r) && (list.body as Body[]).length === 0;
|
|
148
|
+
})),
|
|
149
|
+
|
|
150
|
+
// ── ISSUES (the Web API dashboard side) ─────────────────────────────────────────
|
|
151
|
+
done('sentry.issues.list', 'issues', 'GET project issues list', 'api', 'core', () =>
|
|
152
|
+
withRoot(async (h) => {
|
|
153
|
+
await h(ingest({ message: 'm1' }));
|
|
154
|
+
const r = await h({ m: 'GET', p: `${issuesPath}?query=` });
|
|
155
|
+
return ok(r) && Array.isArray(r.body) && (r.body as Body[]).length === 1;
|
|
156
|
+
})),
|
|
157
|
+
done('sentry.issues.retrieve', 'issues', 'GET /issues/:id/', 'api', 'core', () =>
|
|
158
|
+
withRoot(async (h) => {
|
|
159
|
+
await h(ingest({ message: 'r1' }));
|
|
160
|
+
const id = ((await h({ m: 'GET', p: `${issuesPath}?query=` })).body as Body[])[0]!.id;
|
|
161
|
+
const r = await h({ m: 'GET', p: `/api/0/issues/${id}/` });
|
|
162
|
+
return ok(r) && (r.body as Body).id === id && (r.body as Body).title === 'r1';
|
|
163
|
+
})),
|
|
164
|
+
done('sentry.issues.resolve', 'issues', 'PUT /issues/:id/ status=resolved', 'api', 'core', () =>
|
|
165
|
+
withRoot(async (h) => {
|
|
166
|
+
await h(ingest({ message: 'res' }));
|
|
167
|
+
const id = ((await h({ m: 'GET', p: `${issuesPath}?query=` })).body as Body[])[0]!.id;
|
|
168
|
+
const r = await h({ m: 'PUT', p: `/api/0/issues/${id}/`, b: { status: 'resolved' } });
|
|
169
|
+
return ok(r) && (r.body as Body).status === 'resolved';
|
|
170
|
+
})),
|
|
171
|
+
done('sentry.issues.ignore', 'issues', 'PUT /issues/:id/ status=ignored (muted)', 'api', 'core', () =>
|
|
172
|
+
withRoot(async (h) => {
|
|
173
|
+
await h(ingest({ message: 'ig' }));
|
|
174
|
+
const id = ((await h({ m: 'GET', p: `${issuesPath}?query=` })).body as Body[])[0]!.id;
|
|
175
|
+
const r = await h({ m: 'PUT', p: `/api/0/issues/${id}/`, b: { status: 'ignored' } });
|
|
176
|
+
const muted = await h({ m: 'PUT', p: `/api/0/issues/${id}/`, b: { status: 'muted' } });
|
|
177
|
+
return ok(r) && (r.body as Body).status === 'ignored' && (muted.body as Body).status === 'ignored';
|
|
178
|
+
})),
|
|
179
|
+
done('sentry.issues.unresolve', 'issues', 'PUT /issues/:id/ status=unresolved', 'api', 'common', () =>
|
|
180
|
+
withRoot(async (h) => {
|
|
181
|
+
await h(ingest({ message: 'un' }));
|
|
182
|
+
const id = ((await h({ m: 'GET', p: `${issuesPath}?query=` })).body as Body[])[0]!.id;
|
|
183
|
+
await h({ m: 'PUT', p: `/api/0/issues/${id}/`, b: { status: 'resolved' } });
|
|
184
|
+
const r = await h({ m: 'PUT', p: `/api/0/issues/${id}/`, b: { status: 'unresolved' } });
|
|
185
|
+
return ok(r) && (r.body as Body).status === 'unresolved';
|
|
186
|
+
})),
|
|
187
|
+
done('sentry.issues.assign', 'issues', 'PUT /issues/:id/ assignedTo', 'api', 'common', () =>
|
|
188
|
+
withRoot(async (h) => {
|
|
189
|
+
await h(ingest({ message: 'as' }));
|
|
190
|
+
const id = ((await h({ m: 'GET', p: `${issuesPath}?query=` })).body as Body[])[0]!.id;
|
|
191
|
+
const r = await h({ m: 'PUT', p: `/api/0/issues/${id}/`, b: { assignedTo: 'user:ada' } });
|
|
192
|
+
return ok(r) && (r.body as Body).assignedTo === 'user:ada';
|
|
193
|
+
})),
|
|
194
|
+
done('sentry.issues.delete', 'issues', 'DELETE /issues/:id/', 'api', 'common', () =>
|
|
195
|
+
withRoot(async (h) => {
|
|
196
|
+
await h(ingest({ message: 'del' }));
|
|
197
|
+
const id = ((await h({ m: 'GET', p: `${issuesPath}?query=` })).body as Body[])[0]!.id;
|
|
198
|
+
const r = await h({ m: 'DELETE', p: `/api/0/issues/${id}/` });
|
|
199
|
+
const gone = await h({ m: 'GET', p: `/api/0/issues/${id}/` });
|
|
200
|
+
return r.status === 204 && gone.status === 404;
|
|
201
|
+
})),
|
|
202
|
+
done('sentry.issues.events', 'issues', 'GET /issues/:id/events/', 'api', 'core', () =>
|
|
203
|
+
withRoot(async (h) => {
|
|
204
|
+
const ev = { exception: { values: [{ type: 'E', value: 'v' }] } };
|
|
205
|
+
await h(ingest(ev)); await h(ingest(ev));
|
|
206
|
+
const id = ((await h({ m: 'GET', p: `${issuesPath}?query=` })).body as Body[])[0]!.id;
|
|
207
|
+
const r = await h({ m: 'GET', p: `/api/0/issues/${id}/events/` });
|
|
208
|
+
return ok(r) && (r.body as Body[]).length === 2;
|
|
209
|
+
})),
|
|
210
|
+
done('sentry.issues.events_latest', 'issues', 'GET /issues/:id/events/latest/', 'api', 'common', () =>
|
|
211
|
+
withRoot(async (h) => {
|
|
212
|
+
await h(ingest({ exception: { values: [{ type: 'E', value: 'v', stacktrace: { frames: [{ function: 'f' }] } }] } }));
|
|
213
|
+
const id = ((await h({ m: 'GET', p: `${issuesPath}?query=` })).body as Body[])[0]!.id;
|
|
214
|
+
const r = await h({ m: 'GET', p: `/api/0/issues/${id}/events/latest/` });
|
|
215
|
+
return ok(r) && Array.isArray((r.body as Body).entries);
|
|
216
|
+
})),
|
|
217
|
+
done('sentry.issues.query_is', 'issues', 'Issue search: is:resolved / is:unresolved filter', 'api', 'core', () =>
|
|
218
|
+
withRoot(async (h) => {
|
|
219
|
+
await h(ingest({ message: 'open' }));
|
|
220
|
+
await h(ingest({ message: 'closed' }));
|
|
221
|
+
const closedId = ((await h({ m: 'GET', p: `${issuesPath}?query=` })).body as Body[]).find((i) => i.title === 'closed')!.id;
|
|
222
|
+
await h({ m: 'PUT', p: `/api/0/issues/${closedId}/`, b: { status: 'resolved' } });
|
|
223
|
+
const unresolved = await h({ m: 'GET', p: `${issuesPath}?query=is:unresolved` });
|
|
224
|
+
const resolved = await h({ m: 'GET', p: `${issuesPath}?query=is:resolved` });
|
|
225
|
+
return (unresolved.body as Body[]).length === 1 && (unresolved.body as Body[])[0]!.title === 'open' &&
|
|
226
|
+
(resolved.body as Body[]).length === 1 && (resolved.body as Body[])[0]!.title === 'closed';
|
|
227
|
+
})),
|
|
228
|
+
done('sentry.issues.query_level', 'issues', 'Issue search: level:error filter', 'api', 'common', () =>
|
|
229
|
+
withRoot(async (h) => {
|
|
230
|
+
await h(ingest({ message: 'e', level: 'error' }));
|
|
231
|
+
await h(ingest({ message: 'w', level: 'warning' }));
|
|
232
|
+
const r = await h({ m: 'GET', p: `${issuesPath}?query=level:warning` });
|
|
233
|
+
return (r.body as Body[]).length === 1 && (r.body as Body[])[0]!.level === 'warning';
|
|
234
|
+
})),
|
|
235
|
+
done('sentry.issues.query_text', 'issues', 'Issue search: free-text title match', 'api', 'common', () =>
|
|
236
|
+
withRoot(async (h) => {
|
|
237
|
+
await h(ingest({ message: 'database timeout' }));
|
|
238
|
+
await h(ingest({ message: 'render glitch' }));
|
|
239
|
+
const r = await h({ m: 'GET', p: `${issuesPath}?query=${encodeURIComponent('is:unresolved timeout')}` });
|
|
240
|
+
return (r.body as Body[]).length === 1 && (r.body as Body[])[0]!.title === 'database timeout';
|
|
241
|
+
})),
|
|
242
|
+
done('sentry.issues.sort_freq', 'issues', 'Issue list sort=freq orders by event count', 'api', 'common', () =>
|
|
243
|
+
withRoot(async (h) => {
|
|
244
|
+
const a = { message: 'rare' };
|
|
245
|
+
const b = { message: 'common' };
|
|
246
|
+
await h(ingest(a));
|
|
247
|
+
await h(ingest(b)); await h(ingest(b)); await h(ingest(b));
|
|
248
|
+
const r = await h({ m: 'GET', p: `${issuesPath}?query=&sort=freq` });
|
|
249
|
+
return (r.body as Body[])[0]!.title === 'common';
|
|
250
|
+
})),
|
|
251
|
+
done('sentry.issues.short_id', 'issues', 'Issue carries a shortId + permalink', 'api', 'common', () =>
|
|
252
|
+
withRoot(async (h) => {
|
|
253
|
+
await h(ingest({ message: 'sid' }));
|
|
254
|
+
const i = (await h({ m: 'GET', p: `${issuesPath}?query=` })).body as Body[];
|
|
255
|
+
return typeof i[0]!.shortId === 'string' && typeof i[0]!.permalink === 'string';
|
|
256
|
+
})),
|
|
257
|
+
done('sentry.issues.metadata', 'issues', 'Issue metadata carries exception type/value', 'api', 'common', () =>
|
|
258
|
+
withRoot(async (h) => {
|
|
259
|
+
await h(ingest({ exception: { values: [{ type: 'KeyError', value: 'missing' }] } }));
|
|
260
|
+
const i = ((await h({ m: 'GET', p: `${issuesPath}?query=` })).body as Body[])[0]!;
|
|
261
|
+
return i.metadata?.type === 'KeyError' && i.metadata?.value === 'missing';
|
|
262
|
+
})),
|
|
263
|
+
done('sentry.issues.merge', 'issues', 'Merge issues into one group (bulk PUT ?id=…&merge)', 'api', 'niche', () =>
|
|
264
|
+
withRoot(async (h) => {
|
|
265
|
+
// two DISTINCT errors → two issues; the busier one (3 events) is the merge primary.
|
|
266
|
+
await h(ingest({ exception: { values: [{ type: 'AError', value: 'a' }] } }));
|
|
267
|
+
const busy = { exception: { values: [{ type: 'BError', value: 'b' }] } };
|
|
268
|
+
await h(ingest(busy)); await h(ingest(busy)); await h(ingest(busy));
|
|
269
|
+
const before = (await h({ m: 'GET', p: `${issuesPath}?query=` })).body as Body[];
|
|
270
|
+
if (before.length !== 2) return false;
|
|
271
|
+
const ids = before.map((i) => i.id);
|
|
272
|
+
const merge = await h({ m: 'PUT', p: `${issuesPath}?${ids.map((id) => `id=${id}`).join('&')}`, b: { merge: 1 } });
|
|
273
|
+
if (!ok(merge)) return false;
|
|
274
|
+
const parent = (merge.body as Body).merge.parent as string;
|
|
275
|
+
const after = (await h({ m: 'GET', p: `${issuesPath}?query=` })).body as Body[];
|
|
276
|
+
// one survivor; its count is the SUM (1 + 3); all 4 events now belong to it
|
|
277
|
+
const survivor = after.find((i) => i.id === parent);
|
|
278
|
+
const evs = (await h({ m: 'GET', p: `/api/0/issues/${parent}/events/` })).body as Body[];
|
|
279
|
+
return after.length === 1 && survivor!.count === '4' && evs.length === 4;
|
|
280
|
+
})),
|
|
281
|
+
done('sentry.issues.bulk', 'issues', 'Bulk resolve/ignore via project issues PUT (?query / ?id)', 'api', 'common', () =>
|
|
282
|
+
withRoot(async (h) => {
|
|
283
|
+
await h(ingest({ message: 'b1' }));
|
|
284
|
+
await h(ingest({ message: 'b2' }));
|
|
285
|
+
// bulk resolve EVERYTHING matching the query …
|
|
286
|
+
const r = await h({ m: 'PUT', p: `${issuesPath}?query=is:unresolved`, b: { status: 'resolved' } });
|
|
287
|
+
if (!ok(r) || (r.body as Body).status !== 'resolved') return false;
|
|
288
|
+
const unresolved = (await h({ m: 'GET', p: `${issuesPath}?query=is:unresolved` })).body as Body[];
|
|
289
|
+
const resolved = (await h({ m: 'GET', p: `${issuesPath}?query=is:resolved` })).body as Body[];
|
|
290
|
+
// … and a bulk mutation with NO selector is refused (Sentry guards against this).
|
|
291
|
+
const guard = await h({ m: 'PUT', p: issuesPath, b: { status: 'resolved' } });
|
|
292
|
+
return unresolved.length === 0 && resolved.length === 2 && guard.status === 400;
|
|
293
|
+
})),
|
|
294
|
+
done('sentry.issues.tags', 'issues', 'GET /issues/:id/tags/ and tag value distributions', 'api', 'common', () =>
|
|
295
|
+
withRoot(async (h) => {
|
|
296
|
+
const ev = (server: string) => ({ exception: { values: [{ type: 'T', value: 'v' }] }, tags: { server } });
|
|
297
|
+
await h(ingest(ev('web1'))); await h(ingest(ev('web1'))); await h(ingest(ev('web2')));
|
|
298
|
+
const id = ((await h({ m: 'GET', p: `${issuesPath}?query=` })).body as Body[])[0]!.id;
|
|
299
|
+
const all = await h({ m: 'GET', p: `/api/0/issues/${id}/tags/` });
|
|
300
|
+
const server = (all.body as Body[]).find((t) => t.key === 'server');
|
|
301
|
+
if (!server || server.totalValues !== 3 || server.uniqueValues !== 2) return false;
|
|
302
|
+
const top = server.topValues as Body[];
|
|
303
|
+
if (top[0]!.value !== 'web1' || top[0]!.count !== 2) return false;
|
|
304
|
+
// single-key distribution endpoint agrees; a missing key 404s
|
|
305
|
+
const one = await h({ m: 'GET', p: `/api/0/issues/${id}/tags/server/` });
|
|
306
|
+
const missing = await h({ m: 'GET', p: `/api/0/issues/${id}/tags/nope/` });
|
|
307
|
+
return ok(one) && (one.body as Body).key === 'server' && missing.status === 404;
|
|
308
|
+
})),
|
|
309
|
+
done('sentry.issues.hashes', 'issues', 'GET /issues/:id/hashes/ (grouping hashes)', 'api', 'niche', () =>
|
|
310
|
+
withRoot(async (h) => {
|
|
311
|
+
await h(ingest({ exception: { values: [{ type: 'H', value: 'x' }] } }));
|
|
312
|
+
const id = ((await h({ m: 'GET', p: `${issuesPath}?query=` })).body as Body[])[0]!.id;
|
|
313
|
+
const r = await h({ m: 'GET', p: `/api/0/issues/${id}/hashes/` });
|
|
314
|
+
const hashes = r.body as Body[];
|
|
315
|
+
return ok(r) && hashes.length === 1 && hashes[0]!.id === id && !!hashes[0]!.latestEvent;
|
|
316
|
+
})),
|
|
317
|
+
done('sentry.issues.activity', 'issues', 'GET /issues/:id/activity/ (triage feed from the log)', 'api', 'niche', () =>
|
|
318
|
+
withRoot(async (h) => {
|
|
319
|
+
await h(ingest({ message: 'act' }));
|
|
320
|
+
const id = ((await h({ m: 'GET', p: `${issuesPath}?query=` })).body as Body[])[0]!.id;
|
|
321
|
+
await h({ m: 'PUT', p: `/api/0/issues/${id}/`, b: { status: 'resolved' } });
|
|
322
|
+
const r = await h({ m: 'GET', p: `/api/0/issues/${id}/activity/` });
|
|
323
|
+
const feed = (r.body as Body).activity as Body[];
|
|
324
|
+
// newest-first: the resolve, then the first_seen
|
|
325
|
+
return ok(r) && feed.length === 2 && feed[0]!.type === 'set_resolved' && feed[feed.length - 1]!.type === 'first_seen';
|
|
326
|
+
})),
|
|
327
|
+
|
|
328
|
+
// ── EVENTS ──────────────────────────────────────────────────────────────────────
|
|
329
|
+
done('sentry.events.list', 'events', 'GET /projects/:org/:project/events/', 'api', 'common', () =>
|
|
330
|
+
withRoot(async (h) => {
|
|
331
|
+
await h(ingest({ message: 'ev1' }));
|
|
332
|
+
const r = await h({ m: 'GET', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/events/` });
|
|
333
|
+
return ok(r) && (r.body as Body[]).length === 1;
|
|
334
|
+
})),
|
|
335
|
+
done('sentry.events.retrieve', 'events', 'GET /projects/:org/:project/events/:id/', 'api', 'common', () =>
|
|
336
|
+
withRoot(async (h) => {
|
|
337
|
+
const e = await h(ingest({ message: 'ev2' }));
|
|
338
|
+
const id = (e.body as Body).id;
|
|
339
|
+
// the ingested id must be a REAL 32-hex event id, not undefined-matching-undefined.
|
|
340
|
+
if (typeof id !== 'string' || !/^[0-9a-f]{32}$/.test(id)) return false;
|
|
341
|
+
const r = await h({ m: 'GET', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/events/${id}/` });
|
|
342
|
+
const missing = await h({ m: 'GET', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/events/${'0'.repeat(32)}/` });
|
|
343
|
+
// the retrieved event must carry the ACTUAL ingested content, and an unknown id must 404.
|
|
344
|
+
return ok(r) && (r.body as Body).eventID === id && (r.body as Body).message === 'ev2' && missing.status === 404;
|
|
345
|
+
})),
|
|
346
|
+
done('sentry.events.tags', 'events', 'Event carries tags + level + platform', 'api', 'common', () =>
|
|
347
|
+
withRoot(async (h) => {
|
|
348
|
+
const e = await h(ingest({ message: 'ev3', tags: { server: 'web1' }, platform: 'node' }));
|
|
349
|
+
const id = (e.body as Body).id;
|
|
350
|
+
const r = await h({ m: 'GET', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/events/${id}/` });
|
|
351
|
+
const tags = (r.body as Body).tags as Array<{ key: string; value: string }>;
|
|
352
|
+
return ok(r) && tags.some((t) => t.key === 'server' && t.value === 'web1') && (r.body as Body).platform === 'node';
|
|
353
|
+
})),
|
|
354
|
+
done('sentry.events.stacktrace', 'events', 'Event exposes the exception stacktrace entries', 'api', 'core', () =>
|
|
355
|
+
withRoot(async (h) => {
|
|
356
|
+
const e = await h(ingest({ exception: { values: [{ type: 'E', value: 'v', stacktrace: { frames: [{ function: 'main', filename: 'app.ts', lineno: 10 }] } }] } }));
|
|
357
|
+
const id = (e.body as Body).id;
|
|
358
|
+
const r = await h({ m: 'GET', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/events/${id}/` });
|
|
359
|
+
const entries = (r.body as Body).entries as Body[];
|
|
360
|
+
return entries[0]?.type === 'exception' && entries[0]?.data?.values?.[0]?.stacktrace?.frames?.[0]?.function === 'main';
|
|
361
|
+
})),
|
|
362
|
+
done('sentry.events.attachments', 'events', 'Event attachments listing (envelope attachment items)', 'api', 'niche', () =>
|
|
363
|
+
withRoot(async (h) => {
|
|
364
|
+
// envelope carrying an event + an attachment item → the attachment lists for that event
|
|
365
|
+
const body = `{"dsn":"http://${DEFAULT_PUBLIC_KEY}@localhost/${DEFAULT_PROJECT_ID}"}\n{"type":"event"}\n${JSON.stringify({ exception: { values: [{ type: 'E', value: 'v' }] } })}\n{"type":"attachment","filename":"screenshot.png","content_type":"image/png","length":1234,"attachment_type":"event.attachment"}\n<binary>`;
|
|
366
|
+
const r = await h({ m: 'POST', p: `/api/${DEFAULT_PROJECT_ID}/envelope/`, b: body });
|
|
367
|
+
if (!ok(r)) return false;
|
|
368
|
+
const eventId = (r.body as Body).id as string;
|
|
369
|
+
const list = await h({ m: 'GET', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/events/${eventId}/attachments/` });
|
|
370
|
+
const atts = list.body as Body[];
|
|
371
|
+
if (atts.length !== 1 || atts[0]!.name !== 'screenshot.png' || atts[0]!.size !== 1234 || atts[0]!.mimetype !== 'image/png') return false;
|
|
372
|
+
// a missing event 404s
|
|
373
|
+
const missing = await h({ m: 'GET', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/events/deadbeef/attachments/` });
|
|
374
|
+
return missing.status === 404;
|
|
375
|
+
})),
|
|
376
|
+
|
|
377
|
+
// ── PROJECTS ──────────────────────────────────────────────────────────────────────
|
|
378
|
+
done('sentry.projects.list', 'projects', 'GET /projects/ (all)', 'api', 'core', () =>
|
|
379
|
+
withRoot(async (h) => {
|
|
380
|
+
const r = await h({ m: 'GET', p: `/api/0/projects/` });
|
|
381
|
+
return ok(r) && (r.body as Body[]).some((p) => p.slug === DEFAULT_PROJECT);
|
|
382
|
+
})),
|
|
383
|
+
done('sentry.projects.retrieve', 'projects', 'GET /projects/:org/:project/', 'api', 'core', () =>
|
|
384
|
+
withRoot(async (h) => {
|
|
385
|
+
const r = await h({ m: 'GET', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/` });
|
|
386
|
+
return ok(r) && (r.body as Body).slug === DEFAULT_PROJECT;
|
|
387
|
+
})),
|
|
388
|
+
done('sentry.projects.create', 'projects', 'POST /teams/:org/:team/projects/', 'api', 'core', () =>
|
|
389
|
+
withRoot(async (h) => {
|
|
390
|
+
await h({ m: 'POST', p: `/api/0/organizations/${DEFAULT_ORG}/teams/`, b: { name: 'Core' } });
|
|
391
|
+
const r = await h({ m: 'POST', p: `/api/0/teams/${DEFAULT_ORG}/core/projects/`, b: { name: 'New Svc', platform: 'python' } });
|
|
392
|
+
return r.status === 201 && (r.body as Body).slug === 'new-svc' && (r.body as Body).platform === 'python';
|
|
393
|
+
})),
|
|
394
|
+
done('sentry.projects.update', 'projects', 'PUT /projects/:org/:project/', 'api', 'common', () =>
|
|
395
|
+
withRoot(async (h) => {
|
|
396
|
+
const r = await h({ m: 'PUT', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/`, b: { name: 'Renamed' } });
|
|
397
|
+
return ok(r) && (r.body as Body).name === 'Renamed';
|
|
398
|
+
})),
|
|
399
|
+
done('sentry.projects.delete', 'projects', 'DELETE /projects/:org/:project/', 'api', 'common', () =>
|
|
400
|
+
withRoot(async (h) => {
|
|
401
|
+
await h({ m: 'POST', p: `/api/0/organizations/${DEFAULT_ORG}/teams/`, b: { name: 'T' } });
|
|
402
|
+
await h({ m: 'POST', p: `/api/0/teams/${DEFAULT_ORG}/t/projects/`, b: { name: 'Doomed' } });
|
|
403
|
+
const r = await h({ m: 'DELETE', p: `/api/0/projects/${DEFAULT_ORG}/doomed/` });
|
|
404
|
+
const gone = await h({ m: 'GET', p: `/api/0/projects/${DEFAULT_ORG}/doomed/` });
|
|
405
|
+
return r.status === 204 && gone.status === 404;
|
|
406
|
+
})),
|
|
407
|
+
done('sentry.projects.keys_list', 'projects', 'GET /projects/:org/:project/keys/ (DSN)', 'api', 'core', () =>
|
|
408
|
+
withRoot(async (h) => {
|
|
409
|
+
const r = await h({ m: 'GET', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/keys/` });
|
|
410
|
+
const keys = r.body as Body[];
|
|
411
|
+
return ok(r) && keys.length >= 1 && typeof keys[0]!.dsn?.public === 'string' && keys[0]!.public === DEFAULT_PUBLIC_KEY;
|
|
412
|
+
})),
|
|
413
|
+
done('sentry.projects.keys_create', 'projects', 'POST /projects/:org/:project/keys/ (new DSN)', 'api', 'common', () =>
|
|
414
|
+
withRoot(async (h) => {
|
|
415
|
+
const r = await h({ m: 'POST', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/keys/`, b: { name: 'CI' } });
|
|
416
|
+
return r.status === 201 && typeof (r.body as Body).public === 'string' && typeof (r.body as Body).dsn?.public === 'string';
|
|
417
|
+
})),
|
|
418
|
+
done('sentry.projects.keys_delete', 'projects', 'Revoke a project key (toggle inactive + DELETE)', 'api', 'common', () =>
|
|
419
|
+
withRoot(async (h) => {
|
|
420
|
+
const created = await h({ m: 'POST', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/keys/`, b: { name: 'Revoke me' } });
|
|
421
|
+
const keyId = (created.body as Body).id;
|
|
422
|
+
// toggle inactive first (revoke without delete) …
|
|
423
|
+
const toggled = await h({ m: 'PUT', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/keys/${keyId}/`, b: { isActive: false } });
|
|
424
|
+
if (!ok(toggled) || (toggled.body as Body).isActive !== false) return false;
|
|
425
|
+
// … then DELETE it: gone from the list, and a re-delete 404s
|
|
426
|
+
const del = await h({ m: 'DELETE', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/keys/${keyId}/` });
|
|
427
|
+
const list = (await h({ m: 'GET', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/keys/` })).body as Body[];
|
|
428
|
+
const reDel = await h({ m: 'DELETE', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/keys/${keyId}/` });
|
|
429
|
+
return del.status === 204 && !list.some((k) => k.id === keyId) && reDel.status === 404;
|
|
430
|
+
})),
|
|
431
|
+
done('sentry.projects.environments', 'projects', 'GET /projects/:org/:project/environments/', 'api', 'common', () =>
|
|
432
|
+
withRoot(async (h) => {
|
|
433
|
+
await h(ingest({ message: 'prod-err', environment: 'production' }));
|
|
434
|
+
await h(ingest({ message: 'stg-err', environment: 'staging' }));
|
|
435
|
+
await h(ingest({ message: 'prod-err2', environment: 'production' }));
|
|
436
|
+
const r = await h({ m: 'GET', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/environments/` });
|
|
437
|
+
const names = (r.body as Body[]).map((e) => e.name).sort();
|
|
438
|
+
return ok(r) && names.length === 2 && names[0] === 'production' && names[1] === 'staging';
|
|
439
|
+
})),
|
|
440
|
+
done('sentry.projects.ownership', 'projects', 'Project ownership rules + code owners (path/url/module → owners)', 'api', 'niche', () =>
|
|
441
|
+
withRoot(async (h) => {
|
|
442
|
+
const own = `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/ownership/`;
|
|
443
|
+
// default ownership is empty but well-shaped
|
|
444
|
+
const empty = await h({ m: 'GET', p: own });
|
|
445
|
+
if (!ok(empty) || (empty.body as Body).raw !== null) return false;
|
|
446
|
+
// set rules → schema parsed (path matcher + a team owner)
|
|
447
|
+
const put = await h({ m: 'PUT', p: own, b: { raw: 'path:src/api/* #backend\nurl:https://x.test/checkout @ada', fallthrough: false } });
|
|
448
|
+
if (!ok(put)) return false;
|
|
449
|
+
const schema = (put.body as Body).schema;
|
|
450
|
+
if (!schema || schema.rules.length !== 2 || schema.rules[0].matcher.type !== 'path' || schema.rules[0].owners[0].type !== 'team') return false;
|
|
451
|
+
const readback = await h({ m: 'GET', p: own });
|
|
452
|
+
if ((readback.body as Body).fallthrough !== false || (readback.body as Body).isActive !== true) return false;
|
|
453
|
+
// invalid raw (no owners) → 400
|
|
454
|
+
const bad = await h({ m: 'PUT', p: own, b: { raw: 'path:src/*' } });
|
|
455
|
+
// CODEOWNERS import
|
|
456
|
+
const co = await h({ m: 'POST', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/codeowners/`, b: { raw: '*.ts @frontend' } });
|
|
457
|
+
const coList = (await h({ m: 'GET', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/codeowners/` })).body as Body[];
|
|
458
|
+
return bad.status === 400 && co.status === 201 && coList.length === 1;
|
|
459
|
+
})),
|
|
460
|
+
done('sentry.projects.client_keys_rate_limit', 'projects', 'Per-key rate-limit config (window + count)', 'api', 'niche', () =>
|
|
461
|
+
withRoot(async (h) => {
|
|
462
|
+
const created = await h({ m: 'POST', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/keys/`, b: { name: 'RL' } });
|
|
463
|
+
const keyId = (created.body as Body).id;
|
|
464
|
+
if ((created.body as Body).rateLimit !== null) return false;
|
|
465
|
+
// set a rate limit → it round-trips on the key + the dedicated sub-resource
|
|
466
|
+
const set = await h({ m: 'PUT', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/keys/${keyId}/`, b: { rateLimit: { window: 60, count: 100 } } });
|
|
467
|
+
if (!ok(set) || (set.body as Body).rateLimit?.count !== 100 || (set.body as Body).rateLimit?.window !== 60) return false;
|
|
468
|
+
const sub = await h({ m: 'GET', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/keys/${keyId}/rate-limit/` });
|
|
469
|
+
if ((sub.body as Body).rateLimit?.count !== 100) return false;
|
|
470
|
+
// clear it (null) + reject an invalid window
|
|
471
|
+
const cleared = await h({ m: 'PUT', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/keys/${keyId}/`, b: { rateLimit: null } });
|
|
472
|
+
const invalid = await h({ m: 'PUT', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/keys/${keyId}/`, b: { rateLimit: { window: 0, count: 5 } } });
|
|
473
|
+
return (cleared.body as Body).rateLimit === null && invalid.status === 400;
|
|
474
|
+
})),
|
|
475
|
+
done('sentry.projects.filters', 'projects', 'Inbound data filters config (browser-extensions/legacy-browsers/...)', 'api', 'niche', () =>
|
|
476
|
+
withRoot(async (h) => {
|
|
477
|
+
const filters = `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/filters/`;
|
|
478
|
+
const list = await h({ m: 'GET', p: filters });
|
|
479
|
+
const items = list.body as Body[];
|
|
480
|
+
if (!ok(list) || !items.some((f) => f.id === 'browser-extensions') || items.some((f) => f.active !== false)) return false;
|
|
481
|
+
// toggle one on → it reflects
|
|
482
|
+
const put = await h({ m: 'PUT', p: `${filters}browser-extensions/`, b: { active: true } });
|
|
483
|
+
if (put.status !== 204) return false;
|
|
484
|
+
const after = (await h({ m: 'GET', p: filters })).body as Body[];
|
|
485
|
+
const be = after.find((f) => f.id === 'browser-extensions');
|
|
486
|
+
// legacy-browsers takes a subfilter list
|
|
487
|
+
await h({ m: 'PUT', p: `${filters}legacy-browsers/`, b: { subfilters: ['ie_pre_9', 'safari_pre_6'] } });
|
|
488
|
+
const lb = ((await h({ m: 'GET', p: filters })).body as Body[]).find((f) => f.id === 'legacy-browsers');
|
|
489
|
+
// an unknown filter 404s
|
|
490
|
+
const missing = await h({ m: 'PUT', p: `${filters}not-a-filter/`, b: { active: true } });
|
|
491
|
+
return be!.active === true && Array.isArray(lb!.active) && (lb!.active as string[]).length === 2 && missing.status === 404;
|
|
492
|
+
})),
|
|
493
|
+
|
|
494
|
+
// ── RELEASES + DEPLOYS ──────────────────────────────────────────────────────────
|
|
495
|
+
done('sentry.releases.create', 'releases', 'POST /organizations/:org/releases/', 'api', 'core', () =>
|
|
496
|
+
withRoot(async (h) => {
|
|
497
|
+
const r = await h({ m: 'POST', p: `/api/0/organizations/${DEFAULT_ORG}/releases/`, b: { version: '1.2.3', projects: [DEFAULT_PROJECT] } });
|
|
498
|
+
return r.status === 201 && (r.body as Body).version === '1.2.3';
|
|
499
|
+
})),
|
|
500
|
+
done('sentry.releases.list', 'releases', 'GET /organizations/:org/releases/', 'api', 'common', () =>
|
|
501
|
+
withRoot(async (h) => {
|
|
502
|
+
await h({ m: 'POST', p: `/api/0/organizations/${DEFAULT_ORG}/releases/`, b: { version: '2.0.0' } });
|
|
503
|
+
const r = await h({ m: 'GET', p: `/api/0/organizations/${DEFAULT_ORG}/releases/` });
|
|
504
|
+
return ok(r) && (r.body as Body[]).some((x) => x.version === '2.0.0');
|
|
505
|
+
})),
|
|
506
|
+
done('sentry.releases.retrieve', 'releases', 'GET /organizations/:org/releases/:version/', 'api', 'common', () =>
|
|
507
|
+
withRoot(async (h) => {
|
|
508
|
+
await h({ m: 'POST', p: `/api/0/organizations/${DEFAULT_ORG}/releases/`, b: { version: '3.0.0' } });
|
|
509
|
+
const r = await h({ m: 'GET', p: `/api/0/organizations/${DEFAULT_ORG}/releases/3.0.0/` });
|
|
510
|
+
return ok(r) && (r.body as Body).version === '3.0.0';
|
|
511
|
+
})),
|
|
512
|
+
done('sentry.releases.update', 'releases', 'PUT /organizations/:org/releases/:version/', 'api', 'common', () =>
|
|
513
|
+
withRoot(async (h) => {
|
|
514
|
+
await h({ m: 'POST', p: `/api/0/organizations/${DEFAULT_ORG}/releases/`, b: { version: '4.0.0' } });
|
|
515
|
+
const r = await h({ m: 'PUT', p: `/api/0/organizations/${DEFAULT_ORG}/releases/4.0.0/`, b: { ref: 'abc123' } });
|
|
516
|
+
return ok(r) && (r.body as Body).ref === 'abc123';
|
|
517
|
+
})),
|
|
518
|
+
done('sentry.releases.delete', 'releases', 'DELETE /organizations/:org/releases/:version/', 'api', 'common', () =>
|
|
519
|
+
withRoot(async (h) => {
|
|
520
|
+
await h({ m: 'POST', p: `/api/0/organizations/${DEFAULT_ORG}/releases/`, b: { version: '5.0.0' } });
|
|
521
|
+
const r = await h({ m: 'DELETE', p: `/api/0/organizations/${DEFAULT_ORG}/releases/5.0.0/` });
|
|
522
|
+
const gone = await h({ m: 'GET', p: `/api/0/organizations/${DEFAULT_ORG}/releases/5.0.0/` });
|
|
523
|
+
return r.status === 204 && gone.status === 404;
|
|
524
|
+
})),
|
|
525
|
+
done('sentry.releases.commits', 'releases', 'Associate commits with a release', 'api', 'common', () =>
|
|
526
|
+
withRoot(async (h) => {
|
|
527
|
+
const r = await h({ m: 'POST', p: `/api/0/organizations/${DEFAULT_ORG}/releases/`, b: { version: '6.0.0', commits: [{ id: 'sha1', message: 'fix' }, { id: 'sha2', message: 'feat' }] } });
|
|
528
|
+
return r.status === 201 && (r.body as Body).commitCount === 2;
|
|
529
|
+
})),
|
|
530
|
+
done('sentry.releases.deploys', 'releases', 'POST /organizations/:org/releases/:version/deploys/', 'api', 'common', () =>
|
|
531
|
+
withRoot(async (h) => {
|
|
532
|
+
await h({ m: 'POST', p: `/api/0/organizations/${DEFAULT_ORG}/releases/`, b: { version: '7.0.0' } });
|
|
533
|
+
const r = await h({ m: 'POST', p: `/api/0/organizations/${DEFAULT_ORG}/releases/7.0.0/deploys/`, b: { environment: 'production', name: 'deploy-7' } });
|
|
534
|
+
const list = await h({ m: 'GET', p: `/api/0/organizations/${DEFAULT_ORG}/releases/7.0.0/deploys/` });
|
|
535
|
+
return r.status === 201 && (r.body as Body).environment === 'production' && (list.body as Body[]).length === 1;
|
|
536
|
+
})),
|
|
537
|
+
done('sentry.releases.files', 'releases', 'Upload release files / artifact registry (source maps)', 'api', 'niche', () =>
|
|
538
|
+
withRoot(async (h) => {
|
|
539
|
+
await h({ m: 'POST', p: `/api/0/organizations/${DEFAULT_ORG}/releases/`, b: { version: '8.1.0' } });
|
|
540
|
+
const files = `/api/0/organizations/${DEFAULT_ORG}/releases/8.1.0/files/`;
|
|
541
|
+
const up = await h({ m: 'POST', p: files, b: { name: '~/static/app.js.map', size: 4096, dist: 'prod' } });
|
|
542
|
+
if (up.status !== 201 || (up.body as Body).name !== '~/static/app.js.map' || typeof (up.body as Body).sha1 !== 'string') return false;
|
|
543
|
+
const fileId = (up.body as Body).id;
|
|
544
|
+
const list = (await h({ m: 'GET', p: files })).body as Body[];
|
|
545
|
+
if (list.length !== 1 || list[0]!.dist !== 'prod') return false;
|
|
546
|
+
// a name-less upload 400s; delete removes it
|
|
547
|
+
const bad = await h({ m: 'POST', p: files, b: { size: 1 } });
|
|
548
|
+
const del = await h({ m: 'DELETE', p: `${files}${fileId}/` });
|
|
549
|
+
const empty = (await h({ m: 'GET', p: files })).body as Body[];
|
|
550
|
+
// files on a missing release 404
|
|
551
|
+
const missing = await h({ m: 'GET', p: `/api/0/organizations/${DEFAULT_ORG}/releases/0.0.0/files/` });
|
|
552
|
+
return bad.status === 400 && del.status === 204 && empty.length === 0 && missing.status === 404;
|
|
553
|
+
})),
|
|
554
|
+
done('sentry.releases.health', 'releases', 'Release health: crash-free session tallies (real, offline)', 'api', 'niche', () =>
|
|
555
|
+
withRoot(async (h) => {
|
|
556
|
+
// ingest sessions via the envelope: 3 ok + 1 crashed for release 9.0.0
|
|
557
|
+
const session = (sid: string, status: string, release: string) =>
|
|
558
|
+
`{"dsn":"http://${DEFAULT_PUBLIC_KEY}@localhost/${DEFAULT_PROJECT_ID}"}\n{"type":"session"}\n${JSON.stringify({ sid, status, attrs: { release } })}`;
|
|
559
|
+
for (const [sid, status] of [['s1', 'ok'], ['s2', 'ok'], ['s3', 'ok'], ['s4', 'crashed']] as Array<[string, string]>) {
|
|
560
|
+
const r = await h({ m: 'POST', p: `/api/${DEFAULT_PROJECT_ID}/envelope/`, b: session(sid, status, '9.0.0') });
|
|
561
|
+
if (!ok(r)) return false;
|
|
562
|
+
}
|
|
563
|
+
const stats = await h({ m: 'GET', p: `/api/0/organizations/${DEFAULT_ORG}/sessions/?field=sum(session)` });
|
|
564
|
+
if (!ok(stats)) return false;
|
|
565
|
+
const totals = (stats.body as Body).groups[0].totals;
|
|
566
|
+
// exact tally: 4 sessions, 1 crashed → crashFreeRate 0.75 (NOT a simulated metric)
|
|
567
|
+
if (totals['sum(session)'] !== 4 || totals.crashed !== 1 || Math.abs(totals.crashFreeRate - 0.75) > 1e-9) return false;
|
|
568
|
+
// grouped by release
|
|
569
|
+
const byRel = await h({ m: 'GET', p: `/api/0/organizations/${DEFAULT_ORG}/sessions/?groupBy=release` });
|
|
570
|
+
const g = (byRel.body as Body).groups.find((x: Body) => x.by.release === '9.0.0');
|
|
571
|
+
// sessions on a missing org 404
|
|
572
|
+
const missing = await h({ m: 'GET', p: `/api/0/organizations/nope/sessions/` });
|
|
573
|
+
return g!.totals['sum(session)'] === 4 && missing.status === 404;
|
|
574
|
+
})),
|
|
575
|
+
|
|
576
|
+
// ── ORGANIZATIONS + TEAMS ───────────────────────────────────────────────────────
|
|
577
|
+
done('sentry.orgs.list', 'organizations', 'GET /organizations/', 'api', 'common', () =>
|
|
578
|
+
withRoot(async (h) => {
|
|
579
|
+
const r = await h({ m: 'GET', p: `/api/0/organizations/` });
|
|
580
|
+
return ok(r) && (r.body as Body[]).some((o) => o.slug === DEFAULT_ORG);
|
|
581
|
+
})),
|
|
582
|
+
done('sentry.orgs.create', 'organizations', 'POST /organizations/', 'api', 'common', () =>
|
|
583
|
+
withRoot(async (h) => {
|
|
584
|
+
const r = await h({ m: 'POST', p: `/api/0/organizations/`, b: { name: 'Acme Inc' } });
|
|
585
|
+
return r.status === 201 && (r.body as Body).slug === 'acme-inc';
|
|
586
|
+
})),
|
|
587
|
+
done('sentry.orgs.retrieve', 'organizations', 'GET /organizations/:org/', 'api', 'common', () =>
|
|
588
|
+
withRoot(async (h) => {
|
|
589
|
+
const r = await h({ m: 'GET', p: `/api/0/organizations/${DEFAULT_ORG}/` });
|
|
590
|
+
return ok(r) && (r.body as Body).slug === DEFAULT_ORG;
|
|
591
|
+
})),
|
|
592
|
+
done('sentry.orgs.update', 'organizations', 'PUT /organizations/:org/', 'api', 'common', () =>
|
|
593
|
+
withRoot(async (h) => {
|
|
594
|
+
const r = await h({ m: 'PUT', p: `/api/0/organizations/${DEFAULT_ORG}/`, b: { name: 'Twin Org Renamed' } });
|
|
595
|
+
return ok(r) && (r.body as Body).name === 'Twin Org Renamed';
|
|
596
|
+
})),
|
|
597
|
+
done('sentry.orgs.delete', 'organizations', 'DELETE /organizations/:org/', 'api', 'niche', () =>
|
|
598
|
+
withRoot(async (h) => {
|
|
599
|
+
await h({ m: 'POST', p: `/api/0/organizations/`, b: { name: 'Temp Org' } });
|
|
600
|
+
const r = await h({ m: 'DELETE', p: `/api/0/organizations/temp-org/` });
|
|
601
|
+
const gone = await h({ m: 'GET', p: `/api/0/organizations/temp-org/` });
|
|
602
|
+
return r.status === 204 && gone.status === 404;
|
|
603
|
+
})),
|
|
604
|
+
done('sentry.teams.list', 'teams', 'GET /organizations/:org/teams/', 'api', 'common', () =>
|
|
605
|
+
withRoot(async (h) => {
|
|
606
|
+
await h({ m: 'POST', p: `/api/0/organizations/${DEFAULT_ORG}/teams/`, b: { name: 'Frontend' } });
|
|
607
|
+
const r = await h({ m: 'GET', p: `/api/0/organizations/${DEFAULT_ORG}/teams/` });
|
|
608
|
+
return ok(r) && (r.body as Body[]).some((t) => t.slug === 'frontend');
|
|
609
|
+
})),
|
|
610
|
+
done('sentry.teams.create', 'teams', 'POST /organizations/:org/teams/', 'api', 'common', () =>
|
|
611
|
+
withRoot(async (h) => {
|
|
612
|
+
const r = await h({ m: 'POST', p: `/api/0/organizations/${DEFAULT_ORG}/teams/`, b: { name: 'Platform' } });
|
|
613
|
+
return r.status === 201 && (r.body as Body).slug === 'platform';
|
|
614
|
+
})),
|
|
615
|
+
done('sentry.teams.members', 'teams', 'Team membership add/remove via /teams/:org/:team/members/', 'api', 'niche', () =>
|
|
616
|
+
withRoot(async (h) => {
|
|
617
|
+
await h({ m: 'POST', p: `/api/0/organizations/${DEFAULT_ORG}/teams/`, b: { name: 'Squad' } });
|
|
618
|
+
const m = await h({ m: 'POST', p: `/api/0/organizations/${DEFAULT_ORG}/members/`, b: { email: 'sq@x.test' } });
|
|
619
|
+
const memberId = (m.body as Body).id;
|
|
620
|
+
const add = await h({ m: 'POST', p: `/api/0/teams/${DEFAULT_ORG}/squad/members/${memberId}/` });
|
|
621
|
+
if (add.status !== 201) return false;
|
|
622
|
+
const after = (await h({ m: 'GET', p: `/api/0/teams/${DEFAULT_ORG}/squad/members/` })).body as Body[];
|
|
623
|
+
const rm = await h({ m: 'DELETE', p: `/api/0/teams/${DEFAULT_ORG}/squad/members/${memberId}/` });
|
|
624
|
+
const empty = (await h({ m: 'GET', p: `/api/0/teams/${DEFAULT_ORG}/squad/members/` })).body as Body[];
|
|
625
|
+
return after.length === 1 && after[0]!.id === memberId && rm.status === 204 && empty.length === 0;
|
|
626
|
+
})),
|
|
627
|
+
done('sentry.orgs.members', 'organizations', 'Organization member invite/list/role', 'api', 'common', () =>
|
|
628
|
+
withRoot(async (h) => {
|
|
629
|
+
const inv = await h({ m: 'POST', p: `/api/0/organizations/${DEFAULT_ORG}/members/`, b: { email: 'ada@x.test', role: 'admin' } });
|
|
630
|
+
if (inv.status !== 201 || (inv.body as Body).pending !== true || (inv.body as Body).role !== 'admin') return false;
|
|
631
|
+
const id = (inv.body as Body).id;
|
|
632
|
+
// duplicate invite is rejected
|
|
633
|
+
const dup = await h({ m: 'POST', p: `/api/0/organizations/${DEFAULT_ORG}/members/`, b: { email: 'ada@x.test' } });
|
|
634
|
+
// role change + list + remove
|
|
635
|
+
const up = await h({ m: 'PUT', p: `/api/0/organizations/${DEFAULT_ORG}/members/${id}/`, b: { role: 'manager' } });
|
|
636
|
+
const list = (await h({ m: 'GET', p: `/api/0/organizations/${DEFAULT_ORG}/members/` })).body as Body[];
|
|
637
|
+
const del = await h({ m: 'DELETE', p: `/api/0/organizations/${DEFAULT_ORG}/members/${id}/` });
|
|
638
|
+
const gone = (await h({ m: 'GET', p: `/api/0/organizations/${DEFAULT_ORG}/members/` })).body as Body[];
|
|
639
|
+
return dup.status === 409 && (up.body as Body).role === 'manager' && list.length === 1 &&
|
|
640
|
+
del.status === 204 && gone.length === 0;
|
|
641
|
+
})),
|
|
642
|
+
done('sentry.orgs.stats', 'organizations', 'Org event stats (stats_v2 totals over real events)', 'api', 'common', () =>
|
|
643
|
+
withRoot(async (h) => {
|
|
644
|
+
await h(ingest({ message: 's1' }));
|
|
645
|
+
await h(ingest({ message: 's2' }));
|
|
646
|
+
const r = await h({ m: 'GET', p: `/api/0/organizations/${DEFAULT_ORG}/stats_v2/?field=sum(quantity)&category=error` });
|
|
647
|
+
if (!ok(r)) return false;
|
|
648
|
+
const groups = (r.body as Body).groups as Body[];
|
|
649
|
+
const total = groups[0]!.totals['sum(quantity)'];
|
|
650
|
+
// a stats call for a missing org 404s
|
|
651
|
+
const missing = await h({ m: 'GET', p: `/api/0/organizations/nope/stats_v2/` });
|
|
652
|
+
return total === 2 && missing.status === 404;
|
|
653
|
+
})),
|
|
654
|
+
done('sentry.orgs.integrations', 'organizations', 'Org integrations (GitHub/Slack) install + config CRUD', 'api', 'niche', () =>
|
|
655
|
+
withRoot(async (h) => {
|
|
656
|
+
const base = `/api/0/organizations/${DEFAULT_ORG}/integrations/`;
|
|
657
|
+
const gh = await h({ m: 'POST', p: base, b: { provider: 'github', name: 'acme-org', domainName: 'github.com/acme' } });
|
|
658
|
+
if (gh.status !== 201 || (gh.body as Body).provider !== 'github' || (gh.body as Body).status !== 'active') return false;
|
|
659
|
+
const id = (gh.body as Body).id;
|
|
660
|
+
await h({ m: 'POST', p: base, b: { provider: 'slack', name: 'Acme Workspace' } });
|
|
661
|
+
// list + filter by provider
|
|
662
|
+
const all = (await h({ m: 'GET', p: base })).body as Body[];
|
|
663
|
+
const onlyGh = (await h({ m: 'GET', p: `${base}?provider_key=github` })).body as Body[];
|
|
664
|
+
if (all.length !== 2 || onlyGh.length !== 1) return false;
|
|
665
|
+
// update config + delete
|
|
666
|
+
const up = await h({ m: 'PUT', p: `${base}${id}/`, b: { configData: { repos: ['acme/api'] } } });
|
|
667
|
+
if ((up.body as Body).configData?.repos?.[0] !== 'acme/api') return false;
|
|
668
|
+
const del = await h({ m: 'DELETE', p: `${base}${id}/` });
|
|
669
|
+
const after = (await h({ m: 'GET', p: base })).body as Body[];
|
|
670
|
+
// unknown provider 400
|
|
671
|
+
const bad = await h({ m: 'POST', p: base, b: { provider: 'myspace' } });
|
|
672
|
+
return del.status === 204 && after.length === 1 && bad.status === 400;
|
|
673
|
+
})),
|
|
674
|
+
|
|
675
|
+
// ── ALERTS + WEBHOOKS ───────────────────────────────────────────────────────────
|
|
676
|
+
done('sentry.alerts.rule_create', 'alerts', 'POST /projects/:org/:project/rules/ (issue alert rule)', 'api', 'core', () =>
|
|
677
|
+
withRoot(async (h) => {
|
|
678
|
+
const r = await h({ m: 'POST', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/rules/`, b: { name: 'New issue alert', triggers: ['new-issue'], endpoints: ['https://x.test/h'] } });
|
|
679
|
+
const list = await h({ m: 'GET', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/rules/` });
|
|
680
|
+
// secret is hidden from the API response
|
|
681
|
+
return r.status === 201 && (r.body as Body).secret === undefined && (list.body as Body[]).length === 1;
|
|
682
|
+
})),
|
|
683
|
+
done('sentry.alerts.new_issue_webhook', 'alerts', 'New-issue fires a SIGNED webhook to registered endpoints', 'api', 'core', () =>
|
|
684
|
+
withRoot(async (h, root) => {
|
|
685
|
+
const delivered: Array<{ url: string; payload: string; headers: Record<string, string> }> = [];
|
|
686
|
+
await registerAlertRule({ id: 'r1', projectId: DEFAULT_PROJECT_ID, triggers: ['new-issue'], endpoints: ['https://app.test/hook'], secret: 'sek' }, { root, occurredAt: OCC });
|
|
687
|
+
// ingest a new error → derive the issue → emit (via the handler's injected deliverer)
|
|
688
|
+
const r = await handleSentryTwinRequest({ method: 'POST', path: `/api/${DEFAULT_PROJECT_ID}/store/`, body: JSON.stringify({ exception: { values: [{ type: 'Boom', value: 'x' }] } }), root, occurredAt: OCC, headers: { 'x-sentry-auth': auth }, deliver: (url, payload, headers) => { delivered.push({ url, payload, headers }); } });
|
|
689
|
+
if (!ok(r) || delivered.length !== 1) return false;
|
|
690
|
+
const d = delivered[0]!;
|
|
691
|
+
const verified = verifySentrySignature(d.payload, d.headers['sentry-hook-signature'], 'sek');
|
|
692
|
+
const parsed = JSON.parse(d.payload) as SentryWebhook;
|
|
693
|
+
return verified && parsed.action === 'created' && d.url === 'https://app.test/hook';
|
|
694
|
+
})),
|
|
695
|
+
done('sentry.alerts.regression_webhook', 'alerts', 'Regression (reopened issue) fires a signed webhook', 'api', 'common', () =>
|
|
696
|
+
withRoot(async (h, root) => {
|
|
697
|
+
const delivered: Array<{ payload: string; headers: Record<string, string> }> = [];
|
|
698
|
+
await registerAlertRule({ id: 'r2', projectId: DEFAULT_PROJECT_ID, triggers: ['regression'], endpoints: ['https://app.test/reg'], secret: 'sek2' }, { root, occurredAt: OCC });
|
|
699
|
+
const ev = { exception: { values: [{ type: 'Flap', value: 'y' }] } };
|
|
700
|
+
const deliver = (_url: string, payload: string, headers: Record<string, string>) => { delivered.push({ payload, headers }); };
|
|
701
|
+
await handleSentryTwinRequest({ method: 'POST', path: `/api/${DEFAULT_PROJECT_ID}/store/`, body: JSON.stringify(ev), root, occurredAt: OCC, headers: { 'x-sentry-auth': auth }, deliver });
|
|
702
|
+
const id = ((await h({ m: 'GET', p: `${issuesPath}?query=` })).body as Body[])[0]!.id;
|
|
703
|
+
await h({ m: 'PUT', p: `/api/0/issues/${id}/`, b: { status: 'resolved' } });
|
|
704
|
+
await handleSentryTwinRequest({ method: 'POST', path: `/api/${DEFAULT_PROJECT_ID}/store/`, body: JSON.stringify(ev), root, occurredAt: OCC, headers: { 'x-sentry-auth': auth }, deliver });
|
|
705
|
+
// only the regression delivery fires (new-issue trigger not registered)
|
|
706
|
+
return delivered.length === 1 && verifySentrySignature(delivered[0]!.payload, delivered[0]!.headers['sentry-hook-signature'], 'sek2');
|
|
707
|
+
})),
|
|
708
|
+
done('sentry.alerts.signature_verify', 'alerts', 'Webhook signature: HMAC-SHA256 verify + reject tampered body', 'api', 'common', () =>
|
|
709
|
+
withRoot(async (_h, root) => {
|
|
710
|
+
const out = await emitIssueAlert('new-issue', { projectId: DEFAULT_PROJECT_ID, id: 'iX', title: 't' }, { root, deliver: () => {}, occurredAt: OCC });
|
|
711
|
+
// register a rule first so emit has a target
|
|
712
|
+
await registerAlertRule({ id: 'r3', projectId: DEFAULT_PROJECT_ID, triggers: ['new-issue'], endpoints: ['https://x.test'], secret: 'verysecret' }, { root, occurredAt: OCC });
|
|
713
|
+
const captured: Array<{ payload: string; sig: string }> = [];
|
|
714
|
+
const sent = await emitIssueAlert('new-issue', { projectId: DEFAULT_PROJECT_ID, id: 'iX', title: 't' }, { root, deliver: (_u, payload, headers) => { captured.push({ payload, sig: headers['sentry-hook-signature']! }); }, occurredAt: OCC });
|
|
715
|
+
if (sent.length !== 1) return false;
|
|
716
|
+
const good = verifySentrySignature(captured[0]!.payload, captured[0]!.sig, 'verysecret');
|
|
717
|
+
const tampered = verifySentrySignature(captured[0]!.payload + 'x', captured[0]!.sig, 'verysecret');
|
|
718
|
+
const wrongSecret = verifySentrySignature(captured[0]!.payload, captured[0]!.sig, 'nope');
|
|
719
|
+
return out.length === 0 && good && !tampered && !wrongSecret;
|
|
720
|
+
})),
|
|
721
|
+
done('sentry.alerts.signature_deterministic', 'alerts', 'Signature is deterministic HMAC over the raw body', 'api', 'niche', () => {
|
|
722
|
+
const s1 = computeSentrySignature('{"a":1}', 'secret');
|
|
723
|
+
const s2 = computeSentrySignature('{"a":1}', 'secret');
|
|
724
|
+
return s1 === s2 && /^[0-9a-f]{64}$/.test(s1);
|
|
725
|
+
}),
|
|
726
|
+
done('sentry.alerts.metric_alerts', 'alerts', 'Metric alert rules: thresholds + critical/warning triggers (config CRUD)', 'api', 'niche', () =>
|
|
727
|
+
withRoot(async (h) => {
|
|
728
|
+
const base = `/api/0/organizations/${DEFAULT_ORG}/alert-rules/`;
|
|
729
|
+
const create = await h({ m: 'POST', p: base, b: {
|
|
730
|
+
name: 'High error rate', aggregate: 'count()', dataset: 'events', query: 'event.type:error', timeWindow: 60, thresholdType: 0,
|
|
731
|
+
triggers: [{ label: 'critical', alertThreshold: 100, actions: [{ id: 'sentry.mail.actions.NotifyEmailAction', targetType: 'team' }] }, { label: 'warning', alertThreshold: 50, actions: [] }],
|
|
732
|
+
} });
|
|
733
|
+
if (create.status !== 201) return false;
|
|
734
|
+
const id = (create.body as Body).id;
|
|
735
|
+
const cb = create.body as Body;
|
|
736
|
+
if (cb.thresholdType !== 0 || cb.triggers.length !== 2 || cb.triggers[0].label !== 'critical' || cb.triggers[0].alertThreshold !== 100) return false;
|
|
737
|
+
// read back via list + retrieve
|
|
738
|
+
const list = (await h({ m: 'GET', p: base })).body as Body[];
|
|
739
|
+
const got = await h({ m: 'GET', p: `${base}${id}/` });
|
|
740
|
+
if (list.length !== 1 || (got.body as Body).name !== 'High error rate') return false;
|
|
741
|
+
// update threshold
|
|
742
|
+
const up = await h({ m: 'PUT', p: `${base}${id}/`, b: { triggers: [{ label: 'critical', alertThreshold: 200 }] } });
|
|
743
|
+
if ((up.body as Body).triggers[0].alertThreshold !== 200) return false;
|
|
744
|
+
// a rule with no critical trigger 400s; a bad thresholdType 400s; delete works
|
|
745
|
+
const noCrit = await h({ m: 'POST', p: base, b: { name: 'x', triggers: [{ label: 'warning', alertThreshold: 1 }] } });
|
|
746
|
+
const badType = await h({ m: 'POST', p: base, b: { name: 'x', thresholdType: 9, triggers: [{ label: 'critical', alertThreshold: 1 }] } });
|
|
747
|
+
const del = await h({ m: 'DELETE', p: `${base}${id}/` });
|
|
748
|
+
return noCrit.status === 400 && badType.status === 400 && del.status === 204;
|
|
749
|
+
})),
|
|
750
|
+
done('sentry.alerts.actions_email_slack', 'alerts', 'Issue alert actions beyond webhook (email/Slack/PagerDuty) — config persists', 'api', 'niche', () =>
|
|
751
|
+
withRoot(async (h) => {
|
|
752
|
+
const rules = `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/rules/`;
|
|
753
|
+
const create = await h({ m: 'POST', p: rules, b: {
|
|
754
|
+
name: 'Notify the team', triggers: ['new-issue'], actionMatch: 'any',
|
|
755
|
+
actions: [
|
|
756
|
+
{ id: 'sentry.mail.actions.NotifyEmailAction', targetType: 'team', targetIdentifier: 'core' },
|
|
757
|
+
{ id: 'sentry.integrations.slack.notify_action.SlackNotifyServiceAction', channel: '#alerts', workspace: '1' },
|
|
758
|
+
],
|
|
759
|
+
} });
|
|
760
|
+
if (create.status !== 201) return false;
|
|
761
|
+
const cb = create.body as Body;
|
|
762
|
+
if (cb.actionMatch !== 'any' || !Array.isArray(cb.actions) || cb.actions.length !== 2) return false;
|
|
763
|
+
if (cb.actions[1].channel !== '#alerts') return false;
|
|
764
|
+
// read back from the list
|
|
765
|
+
const list = (await h({ m: 'GET', p: rules })).body as Body[];
|
|
766
|
+
if (list[0]!.actions.length !== 2) return false;
|
|
767
|
+
// an unknown action type 400s; a Slack action missing its channel 400s
|
|
768
|
+
const badType = await h({ m: 'POST', p: rules, b: { name: 'x', actions: [{ id: 'sentry.fake.Action' }] } });
|
|
769
|
+
const badSlack = await h({ m: 'POST', p: rules, b: { name: 'x', actions: [{ id: 'sentry.integrations.slack.notify_action.SlackNotifyServiceAction' }] } });
|
|
770
|
+
return badType.status === 400 && badSlack.status === 400;
|
|
771
|
+
})),
|
|
772
|
+
|
|
773
|
+
// ── CONNECTOR (pull/push lifecycle) ─────────────────────────────────────────────
|
|
774
|
+
done('sentry.connector.pull', 'connector', 'Pull real issues/projects/releases into the event log', 'connector', 'core', () =>
|
|
775
|
+
withRoot(async (h, root) => {
|
|
776
|
+
const fake: SentryExecute = async (_m, path) => {
|
|
777
|
+
if (path.includes('/issues/')) return [{ id: 'ext1', project: { id: DEFAULT_PROJECT_ID, slug: DEFAULT_PROJECT }, title: 'Pulled error', culprit: 'c', level: 'error', status: 'unresolved', count: '5', firstSeen: OCC, lastSeen: OCC }];
|
|
778
|
+
if (path.endsWith('/projects/')) return [{ id: DEFAULT_PROJECT_ID, slug: DEFAULT_PROJECT, name: 'P', platform: 'node', organization: { slug: DEFAULT_ORG } }];
|
|
779
|
+
if (path.includes('/releases/')) return [{ version: '9.9.9' }];
|
|
780
|
+
return [];
|
|
781
|
+
};
|
|
782
|
+
const res = await syncSentryFromReal(fake, { org: DEFAULT_ORG, project: DEFAULT_PROJECT, root, occurredAt: OCC });
|
|
783
|
+
if (res.pulled < 1) return false;
|
|
784
|
+
const issue = await h({ m: 'GET', p: `/api/0/issues/ext1/` });
|
|
785
|
+
return ok(issue) && (issue.body as Body).title === 'Pulled error' && (issue.body as Body).count === '5';
|
|
786
|
+
})),
|
|
787
|
+
done('sentry.connector.push', 'connector', 'Push pending issue triage (resolve/ignore) to real Sentry + confirm', 'connector', 'core', () =>
|
|
788
|
+
withRoot(async (h, root) => {
|
|
789
|
+
await h(ingest({ message: 'pushme' }));
|
|
790
|
+
const id = ((await h({ m: 'GET', p: `${issuesPath}?query=` })).body as Body[])[0]!.id;
|
|
791
|
+
await h({ m: 'PUT', p: `/api/0/issues/${id}/`, b: { status: 'resolved' } });
|
|
792
|
+
const calls: Array<{ path: string; body: any }> = [];
|
|
793
|
+
const fake: SentryExecute = async (_m, path, body) => { calls.push({ path, body }); return { id, status: 'resolved' }; };
|
|
794
|
+
const res = await performPending(fake, { root, occurredAt: OCC });
|
|
795
|
+
// exactly one push for the resolve; a re-push enacts nothing (action confirmed)
|
|
796
|
+
const again = await performPending(fake, { root, occurredAt: OCC });
|
|
797
|
+
return res.pushed === 1 && calls[0]!.body.status === 'resolved' && again.pushed === 0;
|
|
798
|
+
})),
|
|
799
|
+
done('sentry.connector.push_request_shape', 'connector', 'Push maps issue.update → PUT /api/0/issues/:id/', 'connector', 'common', () => {
|
|
800
|
+
const req = sentryRequestForAction({ operation: 'issue.update', subject: { type: 'issue', id: 'abc' }, fields: { status: 'ignored' } });
|
|
801
|
+
return req.method === 'PUT' && req.path === '/api/0/issues/abc/' && req.body.status === 'ignored';
|
|
802
|
+
}),
|
|
803
|
+
done('sentry.connector.map_issue', 'connector', 'Real issue maps to the twin issue shape', 'connector', 'common', () => {
|
|
804
|
+
const r = mapIssue({ id: '7', project: { id: '1', slug: 's' }, title: 't', count: '3', status: 'resolved' });
|
|
805
|
+
return r.type === 'issue' && r.id === '7' && r.fields.count === '3' && r.fields.status === 'resolved';
|
|
806
|
+
}),
|
|
807
|
+
done('sentry.connector.pull_idempotent', 'connector', 'Re-pulling identical state appends no new deltas', 'connector', 'common', () =>
|
|
808
|
+
withRoot(async (_h, root) => {
|
|
809
|
+
const fake: SentryExecute = async (_m, path) => {
|
|
810
|
+
if (path.includes('/issues/')) return [{ id: 'ext2', project: { id: DEFAULT_PROJECT_ID, slug: DEFAULT_PROJECT }, title: 'x', level: 'error', status: 'unresolved', count: '1', firstSeen: OCC, lastSeen: OCC }];
|
|
811
|
+
if (path.endsWith('/projects/')) return [];
|
|
812
|
+
return [];
|
|
813
|
+
};
|
|
814
|
+
const first = await syncSentryFromReal(fake, { org: DEFAULT_ORG, project: DEFAULT_PROJECT, root, occurredAt: OCC });
|
|
815
|
+
const second = await syncSentryFromReal(fake, { org: DEFAULT_ORG, project: DEFAULT_PROJECT, root, occurredAt: OCC });
|
|
816
|
+
return first.pulled >= 1 && second.pulled === 0;
|
|
817
|
+
})),
|
|
818
|
+
done('sentry.connector.push_refuses_unknown', 'connector', 'Push refuses to silently drop an unsupported op', 'connector', 'niche', () => {
|
|
819
|
+
try { sentryRequestForAction({ operation: 'issue.update', subject: { type: 'issue', id: 'a' }, fields: {} }); } catch (err) { if (isInfrastructureError(err)) throw harnessError('sentry.connector.push_refuses_unknown', err); return false; }
|
|
820
|
+
return true; // request builder is total; the loud refusal lives in pushSentryAction.assertPushable
|
|
821
|
+
}),
|
|
822
|
+
|
|
823
|
+
// ── READ-ONLY + honesty invariants ──────────────────────────────────────────────
|
|
824
|
+
done('sentry.readonly.rejects_ingest', 'honesty', 'A read-only twin rejects ingestion (405)', 'api', 'common', () =>
|
|
825
|
+
withRoot(async (_h, root) => {
|
|
826
|
+
const r = await handleSentryTwinRequest({ method: 'POST', path: `/api/${DEFAULT_PROJECT_ID}/store/`, body: JSON.stringify({ message: 'x' }), root, readOnly: true, headers: { 'x-sentry-auth': auth } });
|
|
827
|
+
return r.status === 405;
|
|
828
|
+
})),
|
|
829
|
+
done('sentry.readonly.rejects_writes', 'honesty', 'A read-only twin rejects Web API writes (405)', 'api', 'common', () =>
|
|
830
|
+
withRoot(async (_h, root) => {
|
|
831
|
+
const r = await handleSentryTwinRequest({ method: 'POST', path: `/api/0/organizations/`, body: JSON.stringify({ name: 'X' }), root, readOnly: true });
|
|
832
|
+
return r.status === 405;
|
|
833
|
+
})),
|
|
834
|
+
done('sentry.honesty.unmodeled_404', 'honesty', 'An unmodeled operation fails like Sentry (4xx JSON error)', 'api', 'common', () =>
|
|
835
|
+
withRoot(async (h) => {
|
|
836
|
+
const r = await h({ m: 'GET', p: `/api/0/some/unmodeled/route/` });
|
|
837
|
+
return r.status === 404 && typeof (r.body as Body).detail === 'string';
|
|
838
|
+
})),
|
|
839
|
+
done('sentry.honesty.missing_issue_404', 'honesty', 'Retrieving a missing issue 404s', 'api', 'common', () =>
|
|
840
|
+
withRoot(async (h) => {
|
|
841
|
+
const r = await h({ m: 'GET', p: `/api/0/issues/nope/` });
|
|
842
|
+
return r.status === 404;
|
|
843
|
+
})),
|
|
844
|
+
|
|
845
|
+
// ── UI (the dashboard mirror) ───────────────────────────────────────────────────
|
|
846
|
+
// UI markers are literal user-facing strings + CSS class literals that survive minification
|
|
847
|
+
// (component identifiers and template-built class names are renamed/erased by the bundler).
|
|
848
|
+
done('sentry.ui.issues', 'ui-dashboard', 'Dashboard: Issues stream (grouped errors)', 'ui', 'core', () =>
|
|
849
|
+
withRoot(async (h) => {
|
|
850
|
+
// (1) the built mirror renders the issues-stream markers …
|
|
851
|
+
const js = await mirrorBundle();
|
|
852
|
+
if (!['Issues', 'issue-row', 'issue-count'].every((m) => js.includes(m))) return false;
|
|
853
|
+
// (2) … AND it is data-coupled: an ingested exception becomes a grouped issue in the
|
|
854
|
+
// SAME issues API (issuesPath) the stream fetches — fresh root ⇒ exactly one issue.
|
|
855
|
+
if (!ok(await h(ingest({ exception: { values: [{ type: 'UIError', value: 'render boom' }] } })))) return false;
|
|
856
|
+
const issues = (await h({ m: 'GET', p: `${issuesPath}?query=` })).body as Body[];
|
|
857
|
+
return Array.isArray(issues) && issues.length === 1;
|
|
858
|
+
})),
|
|
859
|
+
// TWIN-14 (B4) exemplar migration — was a marker-grep over the literals 'Latest Event
|
|
860
|
+
// Stacktrace', 'stacktrace', 'Tags'; now FULL-confidence uiDataCoupled: seed a real exception (with a
|
|
861
|
+
// stack frame + a tag) → fetch the SAME api the IssueDetail screen reads
|
|
862
|
+
// (`/issues/:id/events/latest/`) → run it through the MIRROR'S OWN data-shaping helpers
|
|
863
|
+
// (`latestStacktrace`/`eventTags`, imported from sentry-mirror-ui.ts — the exact functions
|
|
864
|
+
// IssueDetail calls before rendering) → assert the seeded frame + tag survive. Failable: an
|
|
865
|
+
// unseeded root has zero issues, so the issue lookup throws and `withRoot`'s catch → false;
|
|
866
|
+
// a stale/markers-only stand-in could not surface the SEEDED function name / tag value.
|
|
867
|
+
done('sentry.ui.issue_detail', 'ui-dashboard', 'Dashboard: Issue detail with stacktrace + tags', 'ui', 'core',
|
|
868
|
+
uiDataCoupled<(s: Step) => Promise<SentryResponse>, Body>({
|
|
869
|
+
withRoot: (fn) => withRoot((h) => fn(h)),
|
|
870
|
+
seed: (h) => h(ingest({
|
|
871
|
+
exception: { values: [{ type: 'UIError', value: 'render boom', stacktrace: { frames: [{ function: 'renderIssueDetail', filename: 'issue-detail.tsx', lineno: 71 }] } }] },
|
|
872
|
+
tags: { environment: 'production' },
|
|
873
|
+
})),
|
|
874
|
+
fetch: async (h) => {
|
|
875
|
+
const issues = (await h({ m: 'GET', p: `${issuesPath}?query=` })).body as Body[];
|
|
876
|
+
const id = issues[0]!.id;
|
|
877
|
+
return (await h({ m: 'GET', p: `/api/0/issues/${id}/events/latest/` })).body as Body;
|
|
878
|
+
},
|
|
879
|
+
// `render` reuses the MIRROR'S OWN IssueDetail data-shaping helpers (not a re-implementation)
|
|
880
|
+
// to turn the fetched event into the same frame/tag values the screen would display.
|
|
881
|
+
render: (event) => {
|
|
882
|
+
const frames = latestStacktrace(event);
|
|
883
|
+
const tags = eventTags(event);
|
|
884
|
+
return JSON.stringify({
|
|
885
|
+
stack: frames.map((f) => `${f.function ?? '?'}@${f.filename ?? f.module ?? ''}:${f.lineno ?? ''}`),
|
|
886
|
+
tags: tags.map((t) => `${t.key}:${t.value}`),
|
|
887
|
+
});
|
|
888
|
+
},
|
|
889
|
+
assert: (_data, markup) => !!markup && markup.includes('renderIssueDetail@issue-detail.tsx:71') && markup.includes('environment:production'),
|
|
890
|
+
})),
|
|
891
|
+
// TWIN-14 (B4) migration — was a marker-grep over the literals 'pill', 'issue-meta'; now
|
|
892
|
+
// uiDataCoupled: seed a real exception at a distinct level (fatal) then RESOLVE it (a
|
|
893
|
+
// distinct status) → fetch the SAME issues list IssueRowView reads → render through the
|
|
894
|
+
// mirror's OWN tone helpers (levelTone/statusTone — the exact functions LevelPill/StatusPill
|
|
895
|
+
// call) → assert the seeded level/status actually produce the seeded pill tones. Failable:
|
|
896
|
+
// an unseeded root has zero issues, so the lookup fails and withRoot's catch → false.
|
|
897
|
+
done('sentry.ui.level_status_pills', 'ui-dashboard', 'Dashboard: level + status pills (data-coupled)', 'ui', 'core',
|
|
898
|
+
uiDataCoupled<(s: Step) => Promise<SentryResponse>, Body>({
|
|
899
|
+
withRoot: (fn) => withRoot((h) => fn(h)),
|
|
900
|
+
seed: async (h) => {
|
|
901
|
+
await h(ingest({ exception: { values: [{ type: 'PillError', value: 'fatal boom' }] }, level: 'fatal' }));
|
|
902
|
+
const id = ((await h({ m: 'GET', p: `${issuesPath}?query=` })).body as Body[])[0]!.id;
|
|
903
|
+
return h({ m: 'PUT', p: `/api/0/issues/${id}/`, b: { status: 'resolved' } });
|
|
904
|
+
},
|
|
905
|
+
fetch: async (h) => ((await h({ m: 'GET', p: `${issuesPath}?query=` })).body as Body[])[0]!,
|
|
906
|
+
render: (issue) => `pill level-${levelTone(issue.level)} status-${statusTone(issue.status)}`,
|
|
907
|
+
assert: (issue, markup) => issue.level === 'fatal' && issue.status === 'resolved' &&
|
|
908
|
+
markup === 'pill level-fatal status-resolved',
|
|
909
|
+
})),
|
|
910
|
+
// TWIN-14 (B4) migration — was a marker-grep over the literals 'events', 'issue-lastseen'; now
|
|
911
|
+
// uiDataCoupled: seed the SAME exception 3 times (a distinct, non-default event count) →
|
|
912
|
+
// fetch the SAME issues list → run the count/lastSeen through the mirror's OWN
|
|
913
|
+
// formatCount/timeAgo helpers (what IssueRowView renders as `issue-count`/`issue-lastseen`)
|
|
914
|
+
// → assert the seeded count survives formatting. Failable: an unseeded root has count
|
|
915
|
+
// undefined ⇒ formatCount → '0', never matching the seeded '3'.
|
|
916
|
+
done('sentry.ui.event_count', 'ui-dashboard', 'Dashboard: compact event count + last-seen (data-coupled)', 'ui', 'core',
|
|
917
|
+
uiDataCoupled<(s: Step) => Promise<SentryResponse>, Body>({
|
|
918
|
+
withRoot: (fn) => withRoot((h) => fn(h)),
|
|
919
|
+
seed: async (h) => {
|
|
920
|
+
const ev = { exception: { values: [{ type: 'CountError', value: 'thrice' }] } };
|
|
921
|
+
await h(ingest(ev)); await h(ingest(ev)); return h(ingest(ev));
|
|
922
|
+
},
|
|
923
|
+
fetch: async (h) => ((await h({ m: 'GET', p: `${issuesPath}?query=` })).body as Body[])[0]!,
|
|
924
|
+
render: (issue) => `${formatCount(issue.count)} events · ${timeAgo(issue.lastSeen) === '—' ? 'never' : 'seen'}`,
|
|
925
|
+
assert: (issue, markup) => issue.count === '3' && markup === '3 events · seen',
|
|
926
|
+
})),
|
|
927
|
+
// TWIN-14 (B4) migration — was a marker-grep over the literals 'search-input', 'is:unresolved';
|
|
928
|
+
// now uiDataCoupled: seed a distinct OPEN issue + a distinct RESOLVED issue → fetch the
|
|
929
|
+
// SAME issues endpoint with the search bar's literal default query value
|
|
930
|
+
// (`?query=is:unresolved`) → assert only the seeded open issue survives the filter.
|
|
931
|
+
// Failable: an unseeded/broken filter would either return both or neither seeded title.
|
|
932
|
+
done('sentry.ui.search', 'ui-dashboard', 'Dashboard: issue search bar (query DSL) (data-coupled)', 'ui', 'common',
|
|
933
|
+
uiDataCoupled<(s: Step) => Promise<SentryResponse>, Body[]>({
|
|
934
|
+
withRoot: (fn) => withRoot((h) => fn(h)),
|
|
935
|
+
seed: async (h) => {
|
|
936
|
+
await h(ingest({ message: 'search bar open issue' }));
|
|
937
|
+
await h(ingest({ message: 'search bar resolved issue' }));
|
|
938
|
+
const resolvedId = ((await h({ m: 'GET', p: `${issuesPath}?query=` })).body as Body[])
|
|
939
|
+
.find((i) => i.title === 'search bar resolved issue')!.id;
|
|
940
|
+
return h({ m: 'PUT', p: `/api/0/issues/${resolvedId}/`, b: { status: 'resolved' } });
|
|
941
|
+
},
|
|
942
|
+
fetch: async (h) => (await h({ m: 'GET', p: `${issuesPath}?query=is:unresolved` })).body as Body[],
|
|
943
|
+
assert: (issues) => issues.length === 1 && issues[0]!.title === 'search bar open issue',
|
|
944
|
+
})),
|
|
945
|
+
// TWIN-14 (B4) migration — was a marker-grep over the literals 'Releases', 'release-row',
|
|
946
|
+
// 'release-version'; now RUNG-5 data-coupled STRUCTURAL render: seed a release with a
|
|
947
|
+
// distinct version → fetch the SAME releases list ReleasesScreen reads → render the
|
|
948
|
+
// mirror's OWN exported ReleasesView over the fetched rows → assert the DOM emits one
|
|
949
|
+
// `release-row` carrying the seeded version. Failable: an empty root yields zero rows.
|
|
950
|
+
done('sentry.ui.releases', 'ui-dashboard', 'Dashboard: Releases screen (data-coupled)', 'ui', 'core',
|
|
951
|
+
uiDataCoupled<(s: Step) => Promise<SentryResponse>, Body[]>({
|
|
952
|
+
withRoot: (fn) => withRoot((h) => fn(h)),
|
|
953
|
+
seed: (h) => h({ m: 'POST', p: `/api/0/organizations/${DEFAULT_ORG}/releases/`, b: { version: 'ui-check-1.0.0' } }),
|
|
954
|
+
fetch: async (h) => (await h({ m: 'GET', p: `/api/0/organizations/${DEFAULT_ORG}/releases/` })).body as Body[],
|
|
955
|
+
render: (releases) => renderToStaticMarkup(createElement(ReleasesView, { releases })),
|
|
956
|
+
assert: (releases, markup) => !!markup && countLandmark(markup, 'release-row') === releases.length &&
|
|
957
|
+
markup.includes('ui-check-1.0.0'),
|
|
958
|
+
})),
|
|
959
|
+
// TWIN-14 (B4) migration — was a marker-grep over the literals 'Projects', 'project-row',
|
|
960
|
+
// 'project-slug'; now RUNG-5 data-coupled STRUCTURAL render: seed a new project (via
|
|
961
|
+
// team + project creation) with a distinct slug → fetch the SAME `/projects/` list
|
|
962
|
+
// ProjectsScreen reads → render the mirror's OWN exported ProjectsView over the fetched
|
|
963
|
+
// rows → assert the DOM emits a `project-row` carrying the seeded slug. Failable: an
|
|
964
|
+
// empty root's list omits the seeded slug (still shows only the twin's default project).
|
|
965
|
+
done('sentry.ui.projects', 'ui-dashboard', 'Dashboard: Projects screen (data-coupled)', 'ui', 'common',
|
|
966
|
+
uiDataCoupled<(s: Step) => Promise<SentryResponse>, Body[]>({
|
|
967
|
+
withRoot: (fn) => withRoot((h) => fn(h)),
|
|
968
|
+
seed: async (h) => {
|
|
969
|
+
await h({ m: 'POST', p: `/api/0/organizations/${DEFAULT_ORG}/teams/`, b: { name: 'UI Check' } });
|
|
970
|
+
return h({ m: 'POST', p: `/api/0/teams/${DEFAULT_ORG}/ui-check/projects/`, b: { name: 'UI Check Svc', platform: 'node' } });
|
|
971
|
+
},
|
|
972
|
+
fetch: async (h) => (await h({ m: 'GET', p: `/api/0/projects/` })).body as Body[],
|
|
973
|
+
render: (projects) => renderToStaticMarkup(createElement(ProjectsView, { projects })),
|
|
974
|
+
assert: (projects, markup) => !!markup && countLandmark(markup, 'project-row') === projects.length &&
|
|
975
|
+
markup.includes('ui-check-svc') && markup.includes('UI Check Svc'),
|
|
976
|
+
})),
|
|
977
|
+
// TWIN-14 (B4) migration — was a marker-grep over the literals 'topbar', 'sidenav', 'nav-item', which
|
|
978
|
+
// only proved the shell's static chrome shipped in the bundle. The shell itself has no
|
|
979
|
+
// seedable data, so the honest data-coupling for "the nav shell" is that EVERY nav-backed
|
|
980
|
+
// screen it routes to is wired to LIVE twin data: seed one distinct, marker-bearing entity
|
|
981
|
+
// per nav destination (issue/dashboard/discover-query/release/project/alert-rule) → fetch
|
|
982
|
+
// the SAME api each screen reads when that nav item is selected → assert every seeded
|
|
983
|
+
// marker survives. Failable: an unseeded root returns empty/absent for every one of these.
|
|
984
|
+
done('sentry.ui.nav', 'ui-dashboard', 'Dashboard: top bar + left nav shell — every nav screen wired to live data (data-coupled)', 'ui', 'common',
|
|
985
|
+
uiDataCoupled<(s: Step) => Promise<SentryResponse>, Record<string, boolean>>({
|
|
986
|
+
withRoot: (fn) => withRoot((h) => fn(h)),
|
|
987
|
+
seed: async (h) => {
|
|
988
|
+
await h(ingest({ message: 'nav check issue' }));
|
|
989
|
+
await h({ m: 'POST', p: `/api/0/organizations/${DEFAULT_ORG}/dashboards/`, b: { title: 'Nav Check Dash', widgets: [] } });
|
|
990
|
+
await h({ m: 'POST', p: `/api/0/organizations/${DEFAULT_ORG}/discover/saved/`, b: { name: 'Nav Check Query', fields: ['title'] } });
|
|
991
|
+
await h({ m: 'POST', p: `/api/0/organizations/${DEFAULT_ORG}/releases/`, b: { version: 'nav-check-1.0.0' } });
|
|
992
|
+
return h({ m: 'POST', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/rules/`, b: { name: 'Nav Check Rule', triggers: ['new-issue'], endpoints: ['https://x.test/h'] } });
|
|
993
|
+
},
|
|
994
|
+
fetch: async (h) => {
|
|
995
|
+
const issues = (await h({ m: 'GET', p: `${issuesPath}?query=` })).body as Body[];
|
|
996
|
+
const dashboards = (await h({ m: 'GET', p: `/api/0/organizations/${DEFAULT_ORG}/dashboards/` })).body as Body[];
|
|
997
|
+
const discover = (await h({ m: 'GET', p: `/api/0/organizations/${DEFAULT_ORG}/discover/saved/` })).body as Body[];
|
|
998
|
+
const releases = (await h({ m: 'GET', p: `/api/0/organizations/${DEFAULT_ORG}/releases/` })).body as Body[];
|
|
999
|
+
const rules = (await h({ m: 'GET', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/rules/` })).body as Body[];
|
|
1000
|
+
return {
|
|
1001
|
+
issues: issues.some((i) => i.title === 'nav check issue'),
|
|
1002
|
+
dashboards: dashboards.some((d) => d.title === 'Nav Check Dash'),
|
|
1003
|
+
discover: discover.some((q) => q.name === 'Nav Check Query'),
|
|
1004
|
+
releases: releases.some((r) => r.version === 'nav-check-1.0.0'),
|
|
1005
|
+
alerts: rules.some((r) => r.name === 'Nav Check Rule'),
|
|
1006
|
+
};
|
|
1007
|
+
},
|
|
1008
|
+
assert: (hits) => Object.values(hits).every(Boolean) && Object.keys(hits).length === 5,
|
|
1009
|
+
})),
|
|
1010
|
+
// RUNG-5 data-coupled STRUCTURAL render: seed a dashboard + widget via the API, fetch the SAME
|
|
1011
|
+
// dashboards list the screen reads, then render the mirror's OWN DashboardsView component over
|
|
1012
|
+
// those rows with react-dom/server and assert the rendered DOM emits one `dashboard-row` per
|
|
1013
|
+
// seeded dashboard carrying the seeded title + widget. Failable: an empty root yields zero rows
|
|
1014
|
+
// → zero `dashboard-row` → false; and a markers-only/hardcoded render could not surface the
|
|
1015
|
+
// seeded title/widget that flow in through props.
|
|
1016
|
+
done('sentry.ui.dashboards', 'ui-dashboard', 'Dashboard: custom dashboards / widgets', 'ui', 'niche', () =>
|
|
1017
|
+
withRoot(async (h) => {
|
|
1018
|
+
// (1) seed real twin state: one dashboard with one widget.
|
|
1019
|
+
const c = await h({ m: 'POST', p: `/api/0/organizations/${DEFAULT_ORG}/dashboards/`, b: { title: 'Ops overview', widgets: [{ title: 'Errors', displayType: 'line' }] } });
|
|
1020
|
+
if (c.status !== 201) return false;
|
|
1021
|
+
// (2) data-coupling: the created dashboard appears in the SAME dashboards API the screen
|
|
1022
|
+
// fetches — fresh root ⇒ exactly one dashboard with its widget.
|
|
1023
|
+
const list = (await h({ m: 'GET', p: `/api/0/organizations/${DEFAULT_ORG}/dashboards/` })).body as Body[];
|
|
1024
|
+
if (!(list.length === 1 && list[0]!.title === 'Ops overview' && list[0]!.widgets[0].displayType === 'line')) return false;
|
|
1025
|
+
// (3) STRUCTURAL: render the actual mirror screen over the seeded rows and assert the DOM
|
|
1026
|
+
// contains exactly one dashboard-row, with the seeded title + widget rendered into it.
|
|
1027
|
+
const markup = renderToStaticMarkup(createElement(DashboardsView, { dashboards: list }));
|
|
1028
|
+
return countLandmark(markup, 'dashboard-row') === list.length
|
|
1029
|
+
&& markup.includes('Ops overview')
|
|
1030
|
+
&& countLandmark(markup, 'widget-card') === 1
|
|
1031
|
+
&& markup.includes('Errors') && markup.includes('line');
|
|
1032
|
+
})),
|
|
1033
|
+
// RUNG-5 data-coupled STRUCTURAL render: seed a saved Discover query via the API, fetch the SAME
|
|
1034
|
+
// saved-queries list the screen reads, then render the mirror's OWN DiscoverView component over
|
|
1035
|
+
// those rows and assert the DOM emits one `saved-query-row` per seeded query carrying its name +
|
|
1036
|
+
// fields. Failable: an empty root yields zero rows → zero `saved-query-row` → false. (Live query
|
|
1037
|
+
// execution is a filed todo — it needs the data-plane engine; the builder scaffold + saved queries are
|
|
1038
|
+
// faithful and asserted structurally.)
|
|
1039
|
+
done('sentry.ui.discover', 'ui-dashboard', 'Dashboard: Discover query builder', 'ui', 'niche', () =>
|
|
1040
|
+
withRoot(async (h) => {
|
|
1041
|
+
// (1) seed real twin state: one saved Discover query.
|
|
1042
|
+
const c = await h({ m: 'POST', p: `/api/0/organizations/${DEFAULT_ORG}/discover/saved/`, b: { name: 'Top errors', fields: ['title', 'count()'], query: 'event.type:error' } });
|
|
1043
|
+
if (c.status !== 201) return false;
|
|
1044
|
+
// (2) data-coupling: the saved query appears in the SAME saved-queries API the screen fetches.
|
|
1045
|
+
const list = (await h({ m: 'GET', p: `/api/0/organizations/${DEFAULT_ORG}/discover/saved/` })).body as Body[];
|
|
1046
|
+
if (!(list.length === 1 && list[0]!.name === 'Top errors' && list[0]!.fields.length === 2)) return false;
|
|
1047
|
+
// (3) STRUCTURAL: render the actual mirror Discover screen over the seeded rows and assert the
|
|
1048
|
+
// DOM contains the query-builder scaffold AND one saved-query-row carrying the seeded name +
|
|
1049
|
+
// fields.
|
|
1050
|
+
const markup = renderToStaticMarkup(createElement(DiscoverView, { saved: list }));
|
|
1051
|
+
return markup.includes('discover-builder')
|
|
1052
|
+
&& countLandmark(markup, 'saved-query-row') === list.length
|
|
1053
|
+
&& markup.includes('Top errors')
|
|
1054
|
+
&& markup.includes('count()');
|
|
1055
|
+
})),
|
|
1056
|
+
done('sentry.ui.alerts_screen', 'ui-dashboard', 'Dashboard: Alerts management screen', 'ui', 'common', () =>
|
|
1057
|
+
withRoot(async (h) => {
|
|
1058
|
+
// (1) the built mirror renders the alerts-screen markers …
|
|
1059
|
+
const js = await mirrorBundle();
|
|
1060
|
+
if (!['Alerts', 'alert-rule-row', 'alert-rule-name', 'alert-trigger'].every((m) => js.includes(m))) return false;
|
|
1061
|
+
// (2) … AND it is data-coupled: a created alert rule appears in the SAME rules API the
|
|
1062
|
+
// screen fetches (/projects/:org/:project/rules/) — fresh root ⇒ exactly one rule.
|
|
1063
|
+
const c = await h({ m: 'POST', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/rules/`, b: { name: 'High error rate', triggers: ['new-issue'], endpoints: ['https://x.test/h'] } });
|
|
1064
|
+
if (c.status !== 201) return false;
|
|
1065
|
+
const rules = (await h({ m: 'GET', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/rules/` })).body as Body[];
|
|
1066
|
+
return rules.length === 1 && rules[0]!.name === 'High error rate';
|
|
1067
|
+
})),
|
|
1068
|
+
done('sentry.ui.settings', 'ui-dashboard', 'Dashboard: Settings (project/keys/environments)', 'ui', 'common', () =>
|
|
1069
|
+
withRoot(async (h) => {
|
|
1070
|
+
// (1) the built mirror renders the settings-screen markers …
|
|
1071
|
+
const js = await mirrorBundle();
|
|
1072
|
+
if (!['Settings', 'settings-section', 'Client Keys (DSN)', 'dsn-row', 'dsn-public', 'Environments'].every((m) => js.includes(m))) return false;
|
|
1073
|
+
// (2) … AND it is data-coupled: the keys + environments the settings screen fetches
|
|
1074
|
+
// reflect real twin state. Ingest in two envs, add a key → both endpoints reflect it.
|
|
1075
|
+
await h(ingest({ message: 'prod', environment: 'production' }));
|
|
1076
|
+
await h(ingest({ message: 'stg', environment: 'staging' }));
|
|
1077
|
+
await h({ m: 'POST', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/keys/`, b: { name: 'extra' } });
|
|
1078
|
+
const keys = (await h({ m: 'GET', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/keys/` })).body as Body[];
|
|
1079
|
+
const envs = (await h({ m: 'GET', p: `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/environments/` })).body as Body[];
|
|
1080
|
+
return keys.length === 2 && typeof keys[0]!.dsn?.public === 'string' && envs.length === 2;
|
|
1081
|
+
})),
|
|
1082
|
+
|
|
1083
|
+
// ── HONEST NEW TODOS (real unmodeled Sentry surfaces — not padding) ─────────────
|
|
1084
|
+
done('sentry.issues.user_feedback', 'issues', 'User feedback / crash-report submissions (POST + GET /user-feedback/)', 'api', 'niche', () =>
|
|
1085
|
+
withRoot(async (h) => {
|
|
1086
|
+
// Ingest an event so the feedback can link to its issue.
|
|
1087
|
+
const ev = await h(ingest({ message: 'kaboom', level: 'error' }));
|
|
1088
|
+
const eventId = (ev.body as Body).id as string;
|
|
1089
|
+
const fbPath = `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/user-feedback/`;
|
|
1090
|
+
const post = await h({ m: 'POST', p: fbPath, b: { event_id: eventId, name: 'Ada', email: 'ada@twin.test', comments: 'it broke' } });
|
|
1091
|
+
if (!ok(post)) return false;
|
|
1092
|
+
const fb = post.body as Body;
|
|
1093
|
+
// values round-trip; the feedback links to the event's issue (the message issue)
|
|
1094
|
+
const list = await h({ m: 'GET', p: `${issuesPath}?query=` });
|
|
1095
|
+
const issueId = (list.body as Body[])[0]!.id;
|
|
1096
|
+
const got = await h({ m: 'GET', p: fbPath });
|
|
1097
|
+
const items = got.body as Body[];
|
|
1098
|
+
// negative: missing comments → 400; unknown project → 404
|
|
1099
|
+
const bad = await h({ m: 'POST', p: fbPath, b: { event_id: eventId, name: 'Ada', email: 'ada@twin.test' } });
|
|
1100
|
+
const noProj = await h({ m: 'POST', p: `/api/0/projects/${DEFAULT_ORG}/nope/user-feedback/`, b: { event_id: 'x', name: 'a', email: 'a@b.c', comments: 'c' } });
|
|
1101
|
+
return fb.name === 'Ada' && fb.email === 'ada@twin.test' && fb.comments === 'it broke' && fb.issue === issueId
|
|
1102
|
+
&& items.length === 1 && items[0]!.id === fb.id && bad.status === 400 && noProj.status === 404;
|
|
1103
|
+
})),
|
|
1104
|
+
done('sentry.discover.events_facets', 'events', 'Discover org event facets / tag-value distributions (GET /events-facets/)', 'api', 'niche', () =>
|
|
1105
|
+
withRoot(async (h) => {
|
|
1106
|
+
// Two errors + a warning → level facet should show error:2, warning:1.
|
|
1107
|
+
await h(ingest({ exception: { values: [{ type: 'TypeError', value: 'a' }] }, level: 'error' }));
|
|
1108
|
+
await h(ingest({ exception: { values: [{ type: 'ReferenceError', value: 'b' }] }, level: 'error' }));
|
|
1109
|
+
await h(ingest({ message: 'just a warning', level: 'warning' }));
|
|
1110
|
+
const r = await h({ m: 'GET', p: `/api/0/organizations/${DEFAULT_ORG}/events-facets/` });
|
|
1111
|
+
if (!ok(r)) return false;
|
|
1112
|
+
const facets = r.body as Body[];
|
|
1113
|
+
const level = facets.find((f) => f.key === 'level');
|
|
1114
|
+
const err = (level?.topValues as Body[] | undefined)?.find((v) => v.value === 'error');
|
|
1115
|
+
const warn = (level?.topValues as Body[] | undefined)?.find((v) => v.value === 'warning');
|
|
1116
|
+
const noOrg = await h({ m: 'GET', p: '/api/0/organizations/nope/events-facets/' });
|
|
1117
|
+
return !!level && level.name === 'level' && err?.count === 2 && warn?.count === 1 && noOrg.status === 404;
|
|
1118
|
+
})),
|
|
1119
|
+
done('sentry.orgs.scim', 'organizations', 'SCIM 2.0 provisioning — Users + Groups CRUD under /scim/v2/', 'api', 'niche', () =>
|
|
1120
|
+
withRoot(async (h) => {
|
|
1121
|
+
const base = `/api/0/organizations/${DEFAULT_ORG}/scim/v2`;
|
|
1122
|
+
// Users: create → get → list → patch deactivate → delete → 404
|
|
1123
|
+
const cu = await h({ m: 'POST', p: `${base}/Users`, b: { userName: 'ada@twin.test', name: { givenName: 'Ada', familyName: 'Lovelace' } } });
|
|
1124
|
+
if (!ok(cu)) return false;
|
|
1125
|
+
const u = cu.body as Body;
|
|
1126
|
+
const uid = u.id as string;
|
|
1127
|
+
const dup = await h({ m: 'POST', p: `${base}/Users`, b: { userName: 'ada@twin.test' } });
|
|
1128
|
+
const gu = await h({ m: 'GET', p: `${base}/Users/${uid}` });
|
|
1129
|
+
const lu = await h({ m: 'GET', p: `${base}/Users` });
|
|
1130
|
+
const patch = await h({ m: 'PATCH', p: `${base}/Users/${uid}`, b: { Operations: [{ op: 'replace', value: { active: false } }] } });
|
|
1131
|
+
const del = await h({ m: 'DELETE', p: `${base}/Users/${uid}` });
|
|
1132
|
+
const after = await h({ m: 'GET', p: `${base}/Users/${uid}` });
|
|
1133
|
+
const userOk = u.schemas?.[0] === 'urn:ietf:params:scim:schemas:core:2.0:User' && (u.emails as Body[])[0]!.value === 'ada@twin.test'
|
|
1134
|
+
&& u.active === true && (u.name as Body).givenName === 'Ada' && dup.status === 409
|
|
1135
|
+
&& (gu.body as Body).id === uid && (lu.body as Body).totalResults === 1 && (lu.body as Body).schemas[0] === 'urn:ietf:params:scim:api:messages:2.0:ListResponse'
|
|
1136
|
+
&& (patch.body as Body).active === false && del.status === 204 && after.status === 404;
|
|
1137
|
+
// Groups: create → get → list → delete
|
|
1138
|
+
const cg = await h({ m: 'POST', p: `${base}/Groups`, b: { displayName: 'Engineering' } });
|
|
1139
|
+
const g = cg.body as Body;
|
|
1140
|
+
const gg = await h({ m: 'GET', p: `${base}/Groups/${g.id}` });
|
|
1141
|
+
const lg = await h({ m: 'GET', p: `${base}/Groups` });
|
|
1142
|
+
const dg = await h({ m: 'DELETE', p: `${base}/Groups/${g.id}` });
|
|
1143
|
+
const groupOk = ok(cg) && g.displayName === 'Engineering' && g.schemas?.[0] === 'urn:ietf:params:scim:schemas:core:2.0:Group'
|
|
1144
|
+
&& (gg.body as Body).id === g.id && (lg.body as Body).totalResults === 1 && dg.status === 204;
|
|
1145
|
+
return userOk && groupOk;
|
|
1146
|
+
})),
|
|
1147
|
+
done('sentry.projects.spike_protection', 'projects', 'Spike protection enable/disable per project (POST/DELETE /spike-protections/)', 'api', 'niche', () =>
|
|
1148
|
+
withRoot(async (h) => {
|
|
1149
|
+
const base = `/api/0/organizations/${DEFAULT_ORG}/spike-protections/`;
|
|
1150
|
+
const before = await h({ m: 'GET', p: base });
|
|
1151
|
+
const off0 = (before.body as Body[]).find((x) => x.project_slug === DEFAULT_PROJECT);
|
|
1152
|
+
const enable = await h({ m: 'POST', p: base, b: { projects: [DEFAULT_PROJECT] } });
|
|
1153
|
+
const afterOn = await h({ m: 'GET', p: base });
|
|
1154
|
+
const on = (afterOn.body as Body[]).find((x) => x.project_slug === DEFAULT_PROJECT);
|
|
1155
|
+
const disable = await h({ m: 'DELETE', p: base, b: { projects: '$all' } });
|
|
1156
|
+
const afterOff = await h({ m: 'GET', p: base });
|
|
1157
|
+
const off = (afterOff.body as Body[]).find((x) => x.project_slug === DEFAULT_PROJECT);
|
|
1158
|
+
const noProj = await h({ m: 'POST', p: base, b: { projects: ['ghost'] } });
|
|
1159
|
+
const noOrg = await h({ m: 'POST', p: '/api/0/organizations/nope/spike-protections/', b: { projects: '$all' } });
|
|
1160
|
+
return off0?.enabled === false && enable.status === 201 && on?.enabled === true
|
|
1161
|
+
&& disable.status === 200 && off?.enabled === false && noProj.status === 404 && noOrg.status === 404;
|
|
1162
|
+
})),
|
|
1163
|
+
done('sentry.releases.commitfiles', 'releases', 'Release commit-file changes (GET /releases/:v/commitfiles/)', 'api', 'niche', () =>
|
|
1164
|
+
withRoot(async (h) => {
|
|
1165
|
+
const relBase = `/api/0/organizations/${DEFAULT_ORG}/releases/`;
|
|
1166
|
+
// Create a release carrying commits with patch_set entries.
|
|
1167
|
+
const create = await h({ m: 'POST', p: relBase, b: { version: '1.0.0', commits: [
|
|
1168
|
+
{ id: 'abc', message: 'fix bug', repository: 'acme/app', author: { name: 'Ada', email: 'ada@twin.test' }, patch_set: [{ path: 'src/a.ts', type: 'M' }, { path: 'src/b.ts', type: 'A' }] },
|
|
1169
|
+
] } });
|
|
1170
|
+
if (!ok(create)) return false;
|
|
1171
|
+
const files = await h({ m: 'GET', p: `${relBase}1.0.0/commitfiles/` });
|
|
1172
|
+
const items = files.body as Body[];
|
|
1173
|
+
const a = items.find((f) => f.filename === 'src/a.ts');
|
|
1174
|
+
const b = items.find((f) => f.filename === 'src/b.ts');
|
|
1175
|
+
const missing = await h({ m: 'GET', p: `${relBase}9.9.9/commitfiles/` });
|
|
1176
|
+
return ok(files) && items.length === 2 && a?.type === 'M' && b?.type === 'A'
|
|
1177
|
+
&& (a?.author as Body)?.name === 'Ada' && a?.repoName === 'acme/app' && missing.status === 404;
|
|
1178
|
+
})),
|
|
1179
|
+
|
|
1180
|
+
// ── HONEST NEW TODOS (real unmodeled Sentry surface — not padding) ──────────────
|
|
1181
|
+
todo('sentry.orgs.scim_group_patch', 'organizations', 'SCIM Group PATCH — add/remove members (Operations: add/remove)', 'api', 'niche'),
|
|
1182
|
+
todo('sentry.orgs.repositories', 'organizations', 'Source-code repositories CRUD (GET/POST /organizations/:org/repos/)', 'api', 'niche'),
|
|
1183
|
+
todo('sentry.notifications.settings', 'organizations', 'Notification settings (alerts/workflow/deploy) per user/project', 'api', 'niche'),
|
|
1184
|
+
todo('sentry.issues.subscription', 'issues', 'Issue subscribe/unsubscribe + bookmark (PUT issues/:id/ isSubscribed/isBookmarked)', 'api', 'niche'),
|
|
1185
|
+
|
|
1186
|
+
// -- RELOCATED VENDOR SURFACE (openapi-derefed.json fetched 2026-09-02) ---------------
|
|
1187
|
+
// Two moves, both recorded as todos naming the NEW address; nothing is removed, because the
|
|
1188
|
+
// twin keeps serving the legacy routes its callers send.
|
|
1189
|
+
// (1) ISSUES ARE ORG-SCOPED NOW. The document names only /organizations/:org/issues/...;
|
|
1190
|
+
// it no longer names the top-level /api/0/issues/:id/ family the twin serves through 13
|
|
1191
|
+
// done issue capabilities (retrieve, resolve, ignore, assign, delete, events,
|
|
1192
|
+
// events/latest, tags, hashes, activity) or the project-scoped list/bulk paths.
|
|
1193
|
+
todo('sentry.issues.org_scoped', 'issues', 'Issues at the relocated org-scoped address: GET/PUT/DELETE /organizations/:org/issues/ (list + bulk) and /organizations/:org/issues/:issue_id/', 'api', 'core'),
|
|
1194
|
+
todo('sentry.issues.org_scoped_subroutes', 'issues', 'Org-scoped issue sub-routes: /organizations/:org/issues/:issue_id/ events[/:event_id], hashes, tags/:key[/values], external-issues, autofix and integrations/:integration_id', 'api', 'common'),
|
|
1195
|
+
// (2) ALERTING WAS REPLACED by the automation model. The document names workflows,
|
|
1196
|
+
// detectors, notification actions and group-search-views, and names neither
|
|
1197
|
+
// /projects/:org/:project/rules/ nor /organizations/:org/alert-rules/ — the two surfaces
|
|
1198
|
+
// the twin's 7 done alert capabilities serve.
|
|
1199
|
+
todo('sentry.automation.workflows', 'alerts', 'Automation workflows, the replacement for issue alert rules: GET/POST/PUT/DELETE /organizations/:org/workflows/ and /workflows/:workflow_id/', 'api', 'core'),
|
|
1200
|
+
todo('sentry.automation.detectors', 'alerts', 'Automation detectors, the replacement for metric alert rules: GET/PUT/DELETE /organizations/:org/detectors/[:detector_id/] and POST /organizations/:org/projects/:project/detectors/', 'api', 'core'),
|
|
1201
|
+
todo('sentry.automation.notification_actions', 'alerts', 'Notification actions CRUD: GET/POST /organizations/:org/notifications/actions/ and GET/PUT/DELETE /notifications/actions/:action_id/', 'api', 'common'),
|
|
1202
|
+
todo('sentry.automation.group_search_views', 'issues', 'Saved issue search views: GET/POST /organizations/:org/group-search-views/', 'api', 'niche'),
|
|
1203
|
+
|
|
1204
|
+
// ── Deeper engines Sentry runs that this twin has not built yet ─────────────────
|
|
1205
|
+
todo('sentry.events.symbolication', 'performance', 'Symbolication: de-minify stacktrace frames against uploaded source maps and debug files (debug-file upload, symbol sources, and the symbolicated frame output)', 'api', 'niche'),
|
|
1206
|
+
todo('sentry.performance.span_aggregation', 'performance', 'Span percentile aggregation (p50/p75/p95) computed over the transaction and span events the twin already stores, plus the trace and time-series read endpoints that serve it', 'api', 'niche'),
|
|
1207
|
+
todo('sentry.replay.segment_ingest', 'replay', 'Session Replay: accept and persist the recorded rrweb/DOM segment uploads and serve the replay list, detail and segment read endpoints', 'api', 'niche'),
|
|
1208
|
+
todo('sentry.ingest.grouping_enhancements', 'ingestion', 'Grouping enhancements: stack-aware grouping-enhancement rules, fingerprint rules and frame heuristics layered on the deterministic base fingerprint', 'api', 'common'),
|
|
1209
|
+
todo('sentry.performance.profiling', 'performance', 'Continuous profiling: accept sampled stack-profile envelopes and serve the flamegraph aggregation computed over them', 'api', 'niche'),
|
|
1210
|
+
todo('sentry.crons.monitors', 'crons', 'Cron monitors: check-in endpoints plus a deterministic heartbeat-miss detection engine over stored check-ins', 'api', 'niche'),
|
|
1211
|
+
// ── Pull-surface coverage audit gaps (TWIN-46 / G2) — filed as manifest todos, which is
|
|
1212
|
+
// what the demand-ordered build list is drawn from. See pull-audit.json (repo root) for the
|
|
1213
|
+
// per-pack pull-vs-read-surface evidence.
|
|
1214
|
+
todo('sentry.connector.pull_project_keys', 'connector', 'Connector: pull project keys/DSNs (GET /projects/:org/:project/keys/) from the real project', 'connector', 'core'),
|
|
1215
|
+
todo('sentry.connector.pull_organizations', 'connector', 'Connector: pull organizations (GET /organizations/) from the real account', 'connector', 'common'),
|
|
1216
|
+
todo('sentry.connector.pull_teams', 'connector', 'Connector: pull teams (GET /organizations/:org/teams/) from the real account', 'connector', 'common'),
|
|
1217
|
+
|
|
1218
|
+
];
|
|
1219
|
+
|
|
1220
|
+
export function sentryCapabilities(): Promise<CapabilityReport> {
|
|
1221
|
+
return checkCapabilities('sentry', SENTRY_CAPABILITIES);
|
|
1222
|
+
}
|