akeso-check 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/README.md +63 -0
- package/SKILL.md +17 -0
- package/bin/akeso-check.mjs +192 -0
- package/examples/report-broken.html +72 -0
- package/examples/report-fixed.html +67 -0
- package/examples/report-reelpals-broken.html +68 -0
- package/examples/report-reelpals-fixed.html +70 -0
- package/fixtures/broken-app/.env.local +5 -0
- package/fixtures/broken-app/app/api/stripe/webhook/route.ts +18 -0
- package/fixtures/broken-app/lib/access.ts +8 -0
- package/fixtures/broken-app/lib/handler.mjs +38 -0
- package/fixtures/broken-app/package.json +4 -0
- package/fixtures/broken-app/server.mjs +34 -0
- package/fixtures/fixed-app/.env.local +4 -0
- package/fixtures/fixed-app/app/api/stripe/webhook/route.ts +23 -0
- package/fixtures/fixed-app/lib/handler.mjs +80 -0
- package/fixtures/fixed-app/package.json +4 -0
- package/fixtures/fixed-app/server.mjs +34 -0
- package/package.json +14 -0
- package/src/adapter.mjs +128 -0
- package/src/detect.mjs +255 -0
- package/src/lifecycle.mjs +147 -0
- package/src/probe.mjs +167 -0
- package/src/report.mjs +127 -0
- package/src/sandbox.mjs +191 -0
- package/src/snapshot.mjs +97 -0
- package/src/stripe-events.mjs +190 -0
- package/tests/adapter.test.mjs +73 -0
- package/tests/lifecycle-acceptance.test.mjs +79 -0
- package/tests/probe.test.mjs +84 -0
- package/tests/snapshot.test.mjs +61 -0
package/src/snapshot.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/* The live snapshot: compare who Stripe says is paying against who the app
|
|
2
|
+
* says is entitled, and price the disagreement.
|
|
3
|
+
*
|
|
4
|
+
* This produces the sales line — "3 canceled users still have Pro, $87 a
|
|
5
|
+
* month" — from the founder's own data, on the founder's own machine. Akeso
|
|
6
|
+
* never holds a key: the Stripe key and database connection come from the
|
|
7
|
+
* project's env, are used read-only, and are never stored or transmitted.
|
|
8
|
+
*
|
|
9
|
+
* The comparison core is pure and fully tested. The two fetchers at the bottom
|
|
10
|
+
* talk to the real world and can only be proven against a real account — that
|
|
11
|
+
* limit is stated here and in the report, not discovered later.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/* Which Stripe subscription statuses mean "this customer is paying and should
|
|
15
|
+
have access" under the default policy. past_due sits in the grace period:
|
|
16
|
+
still entitled, flagged separately so the founder sees it. */
|
|
17
|
+
const PAYING = new Set(["active", "trialing", "past_due"]);
|
|
18
|
+
|
|
19
|
+
/* stripeSide: [{ account, status, priceMonthly, subscriptionId }]
|
|
20
|
+
appSide: [{ account, billingEntitled }]
|
|
21
|
+
Returns every disagreement, priced. */
|
|
22
|
+
export function compareEntitlements(stripeSide, appSide) {
|
|
23
|
+
const app = new Map(appSide.map((row) => [String(row.account), Boolean(row.billingEntitled)]));
|
|
24
|
+
const seenInStripe = new Set();
|
|
25
|
+
|
|
26
|
+
const payingButLockedOut = [];
|
|
27
|
+
const canceledButEntitled = [];
|
|
28
|
+
|
|
29
|
+
for (const sub of stripeSide) {
|
|
30
|
+
const account = String(sub.account);
|
|
31
|
+
seenInStripe.add(account);
|
|
32
|
+
const paying = PAYING.has(sub.status);
|
|
33
|
+
const entitled = app.get(account);
|
|
34
|
+
|
|
35
|
+
if (entitled === undefined) continue; /* unmatched accounts are reported separately, never guessed at */
|
|
36
|
+
|
|
37
|
+
if (paying && !entitled) {
|
|
38
|
+
payingButLockedOut.push({ account, status: sub.status, priceMonthly: sub.priceMonthly ?? null, subscriptionId: sub.subscriptionId });
|
|
39
|
+
} else if (!paying && entitled) {
|
|
40
|
+
canceledButEntitled.push({ account, status: sub.status, priceMonthly: sub.priceMonthly ?? null, subscriptionId: sub.subscriptionId });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* Entitled in the app with no Stripe subscription at all. Could be a leak,
|
|
45
|
+
could be complimentary access — the Check reports, it does not accuse. */
|
|
46
|
+
const entitledWithNoSubscription = [...app.entries()]
|
|
47
|
+
.filter(([account, entitled]) => entitled && !seenInStripe.has(account))
|
|
48
|
+
.map(([account]) => ({ account }));
|
|
49
|
+
|
|
50
|
+
const monthlyExposure = canceledButEntitled
|
|
51
|
+
.reduce((sum, row) => sum + (row.priceMonthly || 0), 0);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
payingButLockedOut, /* the customer-hurting direction: paid, locked out */
|
|
55
|
+
canceledButEntitled, /* the money-leaking direction */
|
|
56
|
+
entitledWithNoSubscription,
|
|
57
|
+
monthlyExposure, /* list-price dollars leaking per month, only from rows with a known price */
|
|
58
|
+
counts: {
|
|
59
|
+
stripeSubscriptions: stripeSide.length,
|
|
60
|
+
appAccounts: appSide.length,
|
|
61
|
+
matched: [...seenInStripe].filter((account) => app.has(account)).length,
|
|
62
|
+
},
|
|
63
|
+
clean: payingButLockedOut.length === 0 && canceledButEntitled.length === 0,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* ---- Real-world fetchers. Only proven against a real Stripe account. ---- */
|
|
68
|
+
|
|
69
|
+
/* Read-only: lists subscriptions with their price. Works with a restricted or
|
|
70
|
+
test key; refuses nothing here because GETs cannot change anything. */
|
|
71
|
+
export async function fetchStripeSubscriptions(stripeKey, { accountField = "client_reference_id" } = {}) {
|
|
72
|
+
const rows = [];
|
|
73
|
+
let startingAfter = null;
|
|
74
|
+
for (let page = 0; page < 20; page += 1) {
|
|
75
|
+
const params = new URLSearchParams({ status: "all", limit: "100", "expand[]": "data.items.data.price" });
|
|
76
|
+
if (startingAfter) params.set("starting_after", startingAfter);
|
|
77
|
+
const response = await fetch(`https://api.stripe.com/v1/subscriptions?${params}`, {
|
|
78
|
+
headers: { Authorization: `Bearer ${stripeKey}` },
|
|
79
|
+
});
|
|
80
|
+
if (!response.ok) throw new Error(`Stripe answered ${response.status}: ${(await response.text()).slice(0, 200)}`);
|
|
81
|
+
const body = await response.json();
|
|
82
|
+
for (const sub of body.data) {
|
|
83
|
+
const price = sub.items?.data?.[0]?.price;
|
|
84
|
+
rows.push({
|
|
85
|
+
subscriptionId: sub.id,
|
|
86
|
+
account: sub.metadata?.[accountField] || sub.metadata?.account || sub.customer,
|
|
87
|
+
status: sub.status,
|
|
88
|
+
priceMonthly: price?.recurring?.interval === "month" && typeof price.unit_amount === "number"
|
|
89
|
+
? price.unit_amount / 100
|
|
90
|
+
: null,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
if (!body.has_more) break;
|
|
94
|
+
startingAfter = body.data.at(-1)?.id;
|
|
95
|
+
}
|
|
96
|
+
return rows;
|
|
97
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { createHmac, randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/* Builds and signs the lifecycle events the Check delivers to the app.
|
|
4
|
+
*
|
|
5
|
+
* These are real Stripe-shaped events with real Stripe signatures — the HMAC
|
|
6
|
+
* scheme Stripe documents, computed with the app's own webhook secret, which
|
|
7
|
+
* the Check reads locally and never transmits. The app under test cannot tell
|
|
8
|
+
* these from Stripe's own deliveries unless it re-fetches objects from the
|
|
9
|
+
* Stripe API. Apps that do re-fetch need the sandbox driver (their own test
|
|
10
|
+
* key + the Stripe CLI); that limit is stated in the report, never papered over.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export function signPayload(rawBody, webhookSecret, timestamp = Math.floor(Date.now() / 1000)) {
|
|
14
|
+
/* Stripe's SDKs use the signing secret VERBATIM as the HMAC key — the
|
|
15
|
+
whsec_ prefix is part of the key, not packaging. Stripping it made every
|
|
16
|
+
real stripe-node app reject our deliveries with 400 (proven against a
|
|
17
|
+
deployed app, Aug 30 2026). */
|
|
18
|
+
const signature = createHmac("sha256", webhookSecret).update(`${timestamp}.${rawBody}`).digest("hex");
|
|
19
|
+
return `t=${timestamp},v1=${signature}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let counter = 0;
|
|
23
|
+
function eventId() {
|
|
24
|
+
counter += 1;
|
|
25
|
+
return `evt_akeso_${Date.now()}_${counter}_${randomUUID().slice(0, 8)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* One event, Stripe-shaped. `created` is controllable because out-of-order
|
|
29
|
+
delivery is one of the scenarios. */
|
|
30
|
+
export function makeEvent({ type, account, created, id, object = {} }) {
|
|
31
|
+
return {
|
|
32
|
+
id: id || eventId(),
|
|
33
|
+
object: "event",
|
|
34
|
+
api_version: "2026-08-27.basil",
|
|
35
|
+
created: created ?? Math.floor(Date.now() / 1000),
|
|
36
|
+
type,
|
|
37
|
+
livemode: false,
|
|
38
|
+
data: {
|
|
39
|
+
object: {
|
|
40
|
+
id: object.id || `${type.startsWith("charge") ? "ch" : type.startsWith("invoice") ? "in" : type.startsWith("checkout") ? "cs" : "sub"}_akeso_${account}`,
|
|
41
|
+
customer: `cus_akeso_${account}`,
|
|
42
|
+
client_reference_id: account,
|
|
43
|
+
metadata: { account },
|
|
44
|
+
...object,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* The ten lifecycle scenarios from the master doc, each on its own account so
|
|
51
|
+
one scenario's state cannot bleed into another. `expect` is the billing
|
|
52
|
+
entitlement the app must report after the last event; null means reported,
|
|
53
|
+
not graded (refunds follow the app's own policy). `critical` marks the
|
|
54
|
+
failures that force an F: canceled customers keeping access. */
|
|
55
|
+
export function scenarios({ accountFor } = {}) {
|
|
56
|
+
/* accountFor maps a scenario's default account ("s1".."s10") onto a real
|
|
57
|
+
account id — needed on deployed apps where rows for made-up accounts
|
|
58
|
+
don't exist and grading their absence would blame the app for our
|
|
59
|
+
harness. Without it, defaults stand. */
|
|
60
|
+
const acct = accountFor || ((id) => id);
|
|
61
|
+
const t = Math.floor(Date.now() / 1000);
|
|
62
|
+
const list = [
|
|
63
|
+
{
|
|
64
|
+
id: "checkout-grants",
|
|
65
|
+
name: "New payment unlocks access",
|
|
66
|
+
account: acct("s1"),
|
|
67
|
+
events: [
|
|
68
|
+
makeEvent({ type: "checkout.session.completed", account: acct("s1"), created: t }),
|
|
69
|
+
],
|
|
70
|
+
expect: true,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "trial-converts",
|
|
74
|
+
name: "Trial ends, subscription becomes active",
|
|
75
|
+
account: acct("s2"),
|
|
76
|
+
events: [
|
|
77
|
+
makeEvent({ type: "customer.subscription.created", account: acct("s2"), created: t, object: { status: "trialing" } }),
|
|
78
|
+
makeEvent({ type: "customer.subscription.updated", account: acct("s2"), created: t + 10, object: { status: "active" } }),
|
|
79
|
+
],
|
|
80
|
+
expect: true,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "renewal-succeeds",
|
|
84
|
+
name: "Monthly renewal payment keeps access",
|
|
85
|
+
account: acct("s3"),
|
|
86
|
+
events: [
|
|
87
|
+
makeEvent({ type: "checkout.session.completed", account: acct("s3"), created: t }),
|
|
88
|
+
makeEvent({ type: "invoice.paid", account: acct("s3"), created: t + 10 }),
|
|
89
|
+
],
|
|
90
|
+
expect: true,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: "payment-fails",
|
|
94
|
+
name: "Card fails, retries exhaust: access ends",
|
|
95
|
+
account: acct("s4"),
|
|
96
|
+
events: [
|
|
97
|
+
makeEvent({ type: "checkout.session.completed", account: acct("s4"), created: t }),
|
|
98
|
+
makeEvent({ type: "invoice.payment_failed", account: acct("s4"), created: t + 10 }),
|
|
99
|
+
makeEvent({ type: "customer.subscription.updated", account: acct("s4"), created: t + 20, object: { status: "past_due" } }),
|
|
100
|
+
makeEvent({ type: "customer.subscription.updated", account: acct("s4"), created: t + 30, object: { status: "unpaid" } }),
|
|
101
|
+
],
|
|
102
|
+
expect: false,
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: "cancel-at-period-end",
|
|
106
|
+
name: "Customer cancels; period ends: access ends",
|
|
107
|
+
account: acct("s5"),
|
|
108
|
+
events: [
|
|
109
|
+
makeEvent({ type: "checkout.session.completed", account: acct("s5"), created: t }),
|
|
110
|
+
makeEvent({ type: "customer.subscription.updated", account: acct("s5"), created: t + 10, object: { status: "active", cancel_at_period_end: true } }),
|
|
111
|
+
makeEvent({ type: "customer.subscription.deleted", account: acct("s5"), created: t + 20, object: { status: "canceled" } }),
|
|
112
|
+
],
|
|
113
|
+
expect: false,
|
|
114
|
+
critical: true,
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: "immediate-cancel",
|
|
118
|
+
name: "Immediate cancellation removes access",
|
|
119
|
+
account: acct("s6"),
|
|
120
|
+
events: [
|
|
121
|
+
makeEvent({ type: "checkout.session.completed", account: acct("s6"), created: t }),
|
|
122
|
+
makeEvent({ type: "customer.subscription.deleted", account: acct("s6"), created: t + 10, object: { status: "canceled" } }),
|
|
123
|
+
],
|
|
124
|
+
expect: false,
|
|
125
|
+
critical: true,
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
id: "reactivation",
|
|
129
|
+
name: "Customer un-cancels before the period ends",
|
|
130
|
+
account: acct("s7"),
|
|
131
|
+
events: [
|
|
132
|
+
makeEvent({ type: "checkout.session.completed", account: acct("s7"), created: t }),
|
|
133
|
+
makeEvent({ type: "customer.subscription.updated", account: acct("s7"), created: t + 10, object: { status: "active", cancel_at_period_end: true } }),
|
|
134
|
+
makeEvent({ type: "customer.subscription.updated", account: acct("s7"), created: t + 20, object: { status: "active", cancel_at_period_end: false } }),
|
|
135
|
+
],
|
|
136
|
+
expect: true,
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: "refund",
|
|
140
|
+
name: "Latest charge refunded (follows the app's own policy)",
|
|
141
|
+
account: acct("s8"),
|
|
142
|
+
events: [
|
|
143
|
+
makeEvent({ type: "checkout.session.completed", account: acct("s8"), created: t }),
|
|
144
|
+
makeEvent({ type: "charge.refunded", account: acct("s8"), created: t + 10 }),
|
|
145
|
+
],
|
|
146
|
+
expect: null,
|
|
147
|
+
},
|
|
148
|
+
(() => {
|
|
149
|
+
const dup = makeEvent({ type: "customer.subscription.deleted", account: acct("s9"), created: t + 10, object: { status: "canceled" } });
|
|
150
|
+
return {
|
|
151
|
+
id: "duplicate-delivery",
|
|
152
|
+
name: "The same event delivered twice",
|
|
153
|
+
account: acct("s9"),
|
|
154
|
+
events: [
|
|
155
|
+
makeEvent({ type: "checkout.session.completed", account: acct("s9"), created: t }),
|
|
156
|
+
dup,
|
|
157
|
+
{ ...dup }, /* identical id: must not error or flip state back */
|
|
158
|
+
],
|
|
159
|
+
expect: false,
|
|
160
|
+
};
|
|
161
|
+
})(),
|
|
162
|
+
{
|
|
163
|
+
id: "out-of-order",
|
|
164
|
+
name: "An old 'still active' event arrives after cancellation",
|
|
165
|
+
account: acct("s10"),
|
|
166
|
+
events: [
|
|
167
|
+
makeEvent({ type: "checkout.session.completed", account: acct("s10"), created: t }),
|
|
168
|
+
makeEvent({ type: "customer.subscription.deleted", account: acct("s10"), created: t + 20, object: { status: "canceled" } }),
|
|
169
|
+
makeEvent({ type: "customer.subscription.updated", account: acct("s10"), created: t + 10, object: { status: "active" } }),
|
|
170
|
+
],
|
|
171
|
+
expect: false,
|
|
172
|
+
},
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
/* Re-time: every scenario gets its own 10-second slice of the recent past,
|
|
176
|
+
ordered like the scenarios themselves. On a shared real account, an app
|
|
177
|
+
with a correct out-of-order guard keeps a per-account high-water mark —
|
|
178
|
+
if scenarios overlapped in event time, scenario 5's events would be
|
|
179
|
+
"older" than scenario 4's mark and silently ignored, and we would grade
|
|
180
|
+
the app on our own collision. resetCreated sits just before each slice so
|
|
181
|
+
a reset delivery lands inside the guard, not behind it. (Runs less than
|
|
182
|
+
~2 minutes apart can still collide with their own previous marks; the
|
|
183
|
+
not_provable guard reports that honestly instead of passing vacuously.) */
|
|
184
|
+
list.forEach((scenario, i) => {
|
|
185
|
+
const base = t - 120 + i * 10;
|
|
186
|
+
for (const event of scenario.events) event.created = base + Math.round((event.created - t) / 10);
|
|
187
|
+
scenario.resetCreated = base - 2;
|
|
188
|
+
});
|
|
189
|
+
return list;
|
|
190
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { mkdtemp, writeFile, readFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { renderAdapter, renderReferenceAdapter } from "../src/adapter.mjs";
|
|
7
|
+
|
|
8
|
+
/* The contract itself gets proven here, against the runnable reference
|
|
9
|
+
implementation — so the rules exist as executed behaviour, not just as
|
|
10
|
+
comments in generated code a founder might delete. */
|
|
11
|
+
|
|
12
|
+
async function loadReference(initialStore) {
|
|
13
|
+
const dir = await mkdtemp(path.join(tmpdir(), "akeso-adapter-"));
|
|
14
|
+
const dbFile = path.join(dir, "store.json");
|
|
15
|
+
await writeFile(dbFile, JSON.stringify(initialStore));
|
|
16
|
+
const moduleFile = path.join(dir, "adapter.mjs");
|
|
17
|
+
await writeFile(moduleFile, renderReferenceAdapter());
|
|
18
|
+
process.env.AKESO_REF_DB = dbFile;
|
|
19
|
+
const adapter = await import(moduleFile);
|
|
20
|
+
return { adapter, dbFile };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test("the generated TS adapter carries the six fields and both functions", () => {
|
|
24
|
+
const source = renderAdapter({ tableName: "profiles", entitledColumn: "is_pro" });
|
|
25
|
+
for (const field of ["billingEntitled", "manualComplimentaryAccess", "manualBillingOverride",
|
|
26
|
+
"administrativeBlock", "securityOrAbuseBlock", "finalAccessDecision"]) {
|
|
27
|
+
assert.ok(source.includes(field), `missing ${field}`);
|
|
28
|
+
}
|
|
29
|
+
assert.match(source, /getBillingEntitlement/);
|
|
30
|
+
assert.match(source, /restoreBillingEntitlement/);
|
|
31
|
+
assert.match(source, /NEVER overridden/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("restore applies, verifies by re-read, and is honest in its receipt", async () => {
|
|
35
|
+
const { adapter } = await loadReference({ a1: { billing_entitled: false } });
|
|
36
|
+
const out = await adapter.restoreBillingEntitlement(
|
|
37
|
+
"a1", { billingEntitled: false }, { billingEntitled: true }, "1", "idem-1", "stripe_says_active");
|
|
38
|
+
assert.equal(out.result, "applied");
|
|
39
|
+
assert.equal(out.verified, true);
|
|
40
|
+
assert.equal(out.before.billingEntitled, false);
|
|
41
|
+
assert.equal(out.after.billingEntitled, true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("a blocked account is never restored, whatever Stripe says", async () => {
|
|
45
|
+
const { adapter } = await loadReference({ banned: { billing_entitled: false, abuse_block: true } });
|
|
46
|
+
const out = await adapter.restoreBillingEntitlement(
|
|
47
|
+
"banned", { billingEntitled: false }, { billingEntitled: true }, "1", "idem-2", "stripe_says_active");
|
|
48
|
+
assert.equal(out.result, "unsupported");
|
|
49
|
+
assert.match(out.reason, /block/);
|
|
50
|
+
const after = await adapter.getBillingEntitlement("banned");
|
|
51
|
+
assert.equal(after.billingEntitled, false, "the write must not have happened");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("a restore racing a newer change loses loudly, not wins silently", async () => {
|
|
55
|
+
const { adapter } = await loadReference({ r1: { billing_entitled: true } });
|
|
56
|
+
/* prepared believing entitled=false; reality moved on */
|
|
57
|
+
const out = await adapter.restoreBillingEntitlement(
|
|
58
|
+
"r1", { billingEntitled: false }, { billingEntitled: true }, "1", "idem-3", "reconcile");
|
|
59
|
+
assert.equal(out.result, "no_op" /* already at target */);
|
|
60
|
+
|
|
61
|
+
const conflicting = await adapter.restoreBillingEntitlement(
|
|
62
|
+
"r1", { billingEntitled: false }, { billingEntitled: false }, "1", "idem-4", "reconcile");
|
|
63
|
+
assert.equal(conflicting.result, "conflict");
|
|
64
|
+
assert.match(conflicting.reason, /changed since/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a rule-version mismatch refuses before touching anything", async () => {
|
|
68
|
+
const { adapter, dbFile } = await loadReference({ v1: { billing_entitled: false } });
|
|
69
|
+
const out = await adapter.restoreBillingEntitlement(
|
|
70
|
+
"v1", { billingEntitled: false }, { billingEntitled: true }, "999", "idem-5", "stale_plan");
|
|
71
|
+
assert.equal(out.result, "conflict");
|
|
72
|
+
assert.equal(JSON.parse(await readFile(dbFile, "utf8")).v1.billing_entitled, false);
|
|
73
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { runLifecycle } from "../src/lifecycle.mjs";
|
|
6
|
+
|
|
7
|
+
/* THE acceptance test from the master doc: the Check must catch the broken app
|
|
8
|
+
(F, with the cancel scenario failing) and clear the fixed one (A). If either
|
|
9
|
+
half fails, the Check is not ready — a checker that misses the bug it was
|
|
10
|
+
built for, or that alarms on a healthy app, is worse than no checker. */
|
|
11
|
+
|
|
12
|
+
async function withFixture(name, port, run) {
|
|
13
|
+
const server = spawn(process.execPath, ["server.mjs"], {
|
|
14
|
+
cwd: path.resolve("fixtures", name),
|
|
15
|
+
env: { ...process.env, PORT: String(port) },
|
|
16
|
+
stdio: "ignore",
|
|
17
|
+
});
|
|
18
|
+
try {
|
|
19
|
+
for (let i = 0; i < 50; i += 1) {
|
|
20
|
+
try { await fetch(`http://localhost:${port}/__akeso_probe?account=warmup`); break; }
|
|
21
|
+
catch { await new Promise((r) => setTimeout(r, 100)); }
|
|
22
|
+
}
|
|
23
|
+
return await run({
|
|
24
|
+
webhookUrl: `http://localhost:${port}/api/stripe/webhook`,
|
|
25
|
+
probeUrl: `http://localhost:${port}/__akeso_probe`,
|
|
26
|
+
webhookSecret: "whsec_fixturefixturefixture5678",
|
|
27
|
+
});
|
|
28
|
+
} finally {
|
|
29
|
+
server.kill();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test("the broken app grades F, and for the right reason", async () => {
|
|
34
|
+
const outcome = await withFixture("broken-app", 4101, runLifecycle);
|
|
35
|
+
assert.equal(outcome.grade.letter, "F");
|
|
36
|
+
const cancel = outcome.results.find((r) => r.id === "cancel-at-period-end");
|
|
37
|
+
assert.equal(cancel.outcome, "fail", "the money-leaking scenario must be the one that fails");
|
|
38
|
+
assert.equal(cancel.observed, true, "canceled customer still shows entitled — that is the bug");
|
|
39
|
+
assert.equal(outcome.results.find((r) => r.id === "checkout-grants").outcome, "pass");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("the fixed app grades A — no alarms on a healthy app", async () => {
|
|
43
|
+
const outcome = await withFixture("fixed-app", 4102, runLifecycle);
|
|
44
|
+
assert.equal(outcome.grade.letter, "A", JSON.stringify(outcome.results.filter(r => r.outcome !== "pass"), null, 2));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("shared real account: grants after the first are not provable, never vacuous passes", async () => {
|
|
48
|
+
/* Deployed apps have no rows for made-up accounts, so every scenario maps
|
|
49
|
+
onto one real account. On a grant-only broken app that account stays
|
|
50
|
+
entitled forever — later grant scenarios must refuse to claim a pass. */
|
|
51
|
+
const outcome = await withFixture("broken-app", 4103, (opts) =>
|
|
52
|
+
runLifecycle({ ...opts, accountFor: () => "shared-real-account" }));
|
|
53
|
+
assert.equal(outcome.grade.letter, "F");
|
|
54
|
+
assert.equal(outcome.results.find((r) => r.id === "checkout-grants").outcome, "pass");
|
|
55
|
+
assert.equal(outcome.results.find((r) => r.id === "trial-converts").outcome, "not_provable");
|
|
56
|
+
assert.equal(outcome.results.find((r) => r.id === "reactivation").outcome, "not_provable");
|
|
57
|
+
assert.equal(outcome.results.find((r) => r.id === "cancel-at-period-end").outcome, "fail");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("shared account + reset on the FIXED app: one real account proves everything", async () => {
|
|
61
|
+
/* The real-world mode: a deployed app has one usable account. Resetting it
|
|
62
|
+
between scenarios (with a cancellation the app understands) must make
|
|
63
|
+
every scenario provable — grade A, nothing vacuous, nothing skipped. */
|
|
64
|
+
const account = `shared-${Date.now()}`; /* fixture state persists on disk across runs */
|
|
65
|
+
const outcome = await withFixture("fixed-app", 4104, (opts) =>
|
|
66
|
+
runLifecycle({ ...opts, accountFor: () => account, resetBeforeEach: true }));
|
|
67
|
+
assert.equal(outcome.grade.letter, "A", JSON.stringify(outcome.results.filter(r => r.outcome !== "pass"), null, 2));
|
|
68
|
+
assert.equal(outcome.results.filter((r) => r.outcome === "not_provable").length, 0);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("a dead server is our failure, never the app's grade", async () => {
|
|
72
|
+
const outcome = await runLifecycle({
|
|
73
|
+
webhookUrl: "http://localhost:59999/api/stripe/webhook",
|
|
74
|
+
probeUrl: "http://localhost:59999/__akeso_probe",
|
|
75
|
+
webhookSecret: "whsec_nothing",
|
|
76
|
+
});
|
|
77
|
+
assert.equal(outcome.grade.letter, "?");
|
|
78
|
+
assert.ok(outcome.results.every((r) => r.outcome === "could_not_test"));
|
|
79
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { chooseProbeTarget, findAccessExports, installProbe, removeProbe } from "../src/probe.mjs";
|
|
6
|
+
import { detect } from "../src/detect.mjs";
|
|
7
|
+
|
|
8
|
+
test("recognises the shapes access functions actually take", () => {
|
|
9
|
+
assert.deepEqual(
|
|
10
|
+
findAccessExports(`export async function isPro(userId: string) { return true; }`),
|
|
11
|
+
[{ name: "isPro", paramCount: 1, firstParam: "userId: string" }],
|
|
12
|
+
);
|
|
13
|
+
assert.deepEqual(
|
|
14
|
+
findAccessExports(`export const hasAccess = async (id) => db.check(id);`),
|
|
15
|
+
[{ name: "hasAccess", paramCount: 1, firstParam: "id" }],
|
|
16
|
+
);
|
|
17
|
+
/* an explicit entitlement function outranks a generic isPro downstream */
|
|
18
|
+
const both = findAccessExports(`
|
|
19
|
+
export function isPro(u) { return true; }
|
|
20
|
+
export function getBillingEntitlement(accountId) { return {}; }
|
|
21
|
+
`);
|
|
22
|
+
assert.equal(both.length, 2);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("refuses to wire a function it would have to guess at", async () => {
|
|
26
|
+
const root = path.resolve("fixtures");
|
|
27
|
+
await writeFile(path.join(root, "_tmp_multi.mjs"),
|
|
28
|
+
"export async function hasAccess(userId, feature, ctx) { return true; }");
|
|
29
|
+
const target = await chooseProbeTarget(root, [{ file: "_tmp_multi.mjs", score: 5, clientSideOnly: false }]);
|
|
30
|
+
assert.equal(target.chosen, null);
|
|
31
|
+
assert.match(target.reason, /guess/);
|
|
32
|
+
const { rm } = await import("node:fs/promises");
|
|
33
|
+
await rm(path.join(root, "_tmp_multi.mjs"));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("a client-side gate is never offered as the server's answer", async () => {
|
|
37
|
+
const target = await chooseProbeTarget(path.resolve("fixtures/broken-app"), [
|
|
38
|
+
{ file: "lib/handler.mjs", score: 4, clientSideOnly: true },
|
|
39
|
+
]);
|
|
40
|
+
assert.equal(target.chosen, null);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("install wires the real function, the probe executes, removal cleans up", async () => {
|
|
44
|
+
const root = path.resolve("fixtures/broken-app");
|
|
45
|
+
const detection = await detect(root);
|
|
46
|
+
/* force the runnable target and a runnable template */
|
|
47
|
+
detection.framework = { framework: "node-other" };
|
|
48
|
+
detection.accessDecisionSites = [{ file: "lib/handler.mjs", score: 9, clientSideOnly: false }];
|
|
49
|
+
|
|
50
|
+
const install = await installProbe(root, detection);
|
|
51
|
+
try {
|
|
52
|
+
assert.equal(install.wired, true, install.reason);
|
|
53
|
+
assert.equal(install.target.name, "isPro");
|
|
54
|
+
|
|
55
|
+
const written = await readFile(install.routeFile, "utf8");
|
|
56
|
+
assert.match(written, /AKESO PROBE/);
|
|
57
|
+
assert.match(written, /from "\.\.\/\.\.\/\.\.\/lib\/handler\.mjs"/);
|
|
58
|
+
|
|
59
|
+
/* the generated route must actually run and give the app's own answer */
|
|
60
|
+
await writeFile(path.join(root, "data", "profiles.json"),
|
|
61
|
+
JSON.stringify({ probed: { is_pro: true }, other: { is_pro: false } }));
|
|
62
|
+
const routeModule = await import(`${install.routeFile}?v=${Date.now()}`);
|
|
63
|
+
const yes = await (await routeModule.GET(new Request("http://x/api/__akeso_probe?account=probed"))).json();
|
|
64
|
+
const no = await (await routeModule.GET(new Request("http://x/api/__akeso_probe?account=other"))).json();
|
|
65
|
+
assert.equal(yes.billingEntitled, true);
|
|
66
|
+
assert.equal(no.billingEntitled, false);
|
|
67
|
+
} finally {
|
|
68
|
+
const removal = await removeProbe(install.routeFile);
|
|
69
|
+
assert.equal(removal.removed, true);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("removal refuses a file that lost the Akeso marker", async () => {
|
|
74
|
+
const root = path.resolve("fixtures/broken-app");
|
|
75
|
+
const file = path.join(root, "app", "api", "__akeso_probe", "route.mjs");
|
|
76
|
+
const { mkdir } = await import("node:fs/promises");
|
|
77
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
78
|
+
await writeFile(file, "// the founder kept and edited this file\nexport const theirs = true;\n");
|
|
79
|
+
const removal = await removeProbe(file);
|
|
80
|
+
assert.equal(removal.removed, false);
|
|
81
|
+
assert.match(removal.reason, /marker/);
|
|
82
|
+
const { rm } = await import("node:fs/promises");
|
|
83
|
+
await rm(path.dirname(file), { recursive: true });
|
|
84
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { compareEntitlements } from "../src/snapshot.mjs";
|
|
4
|
+
|
|
5
|
+
test("finds the money leak: canceled in Stripe, entitled in the app", () => {
|
|
6
|
+
const out = compareEntitlements(
|
|
7
|
+
[
|
|
8
|
+
{ account: "a1", status: "canceled", priceMonthly: 29, subscriptionId: "sub_1" },
|
|
9
|
+
{ account: "a2", status: "canceled", priceMonthly: 29, subscriptionId: "sub_2" },
|
|
10
|
+
{ account: "a3", status: "canceled", priceMonthly: 29, subscriptionId: "sub_3" },
|
|
11
|
+
{ account: "a4", status: "active", priceMonthly: 29, subscriptionId: "sub_4" },
|
|
12
|
+
],
|
|
13
|
+
[
|
|
14
|
+
{ account: "a1", billingEntitled: true },
|
|
15
|
+
{ account: "a2", billingEntitled: true },
|
|
16
|
+
{ account: "a3", billingEntitled: true },
|
|
17
|
+
{ account: "a4", billingEntitled: true },
|
|
18
|
+
],
|
|
19
|
+
);
|
|
20
|
+
assert.equal(out.canceledButEntitled.length, 3);
|
|
21
|
+
assert.equal(out.monthlyExposure, 87); /* the sales line: "$87 a month" */
|
|
22
|
+
assert.equal(out.clean, false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("finds the customer-hurting direction: paying but locked out", () => {
|
|
26
|
+
const out = compareEntitlements(
|
|
27
|
+
[{ account: "b1", status: "active", priceMonthly: 49, subscriptionId: "sub_b" }],
|
|
28
|
+
[{ account: "b1", billingEntitled: false }],
|
|
29
|
+
);
|
|
30
|
+
assert.equal(out.payingButLockedOut.length, 1);
|
|
31
|
+
assert.equal(out.canceledButEntitled.length, 0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("past_due is grace, not a leak", () => {
|
|
35
|
+
const out = compareEntitlements(
|
|
36
|
+
[{ account: "c1", status: "past_due", priceMonthly: 19, subscriptionId: "sub_c" }],
|
|
37
|
+
[{ account: "c1", billingEntitled: true }],
|
|
38
|
+
);
|
|
39
|
+
assert.equal(out.clean, true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("reports, never guesses: unmatched accounts are listed separately", () => {
|
|
43
|
+
const out = compareEntitlements(
|
|
44
|
+
[{ account: "known", status: "active", priceMonthly: 9, subscriptionId: "sub_k" }],
|
|
45
|
+
[
|
|
46
|
+
{ account: "known", billingEntitled: true },
|
|
47
|
+
{ account: "mystery", billingEntitled: true }, /* entitled, no subscription anywhere */
|
|
48
|
+
],
|
|
49
|
+
);
|
|
50
|
+
assert.equal(out.clean, true, "a possible comp account is not a confirmed leak");
|
|
51
|
+
assert.deepEqual(out.entitledWithNoSubscription, [{ account: "mystery" }]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("a subscription with no priced row adds nothing to the exposure figure", () => {
|
|
55
|
+
const out = compareEntitlements(
|
|
56
|
+
[{ account: "d1", status: "canceled", priceMonthly: null, subscriptionId: "sub_d" }],
|
|
57
|
+
[{ account: "d1", billingEntitled: true }],
|
|
58
|
+
);
|
|
59
|
+
assert.equal(out.canceledButEntitled.length, 1);
|
|
60
|
+
assert.equal(out.monthlyExposure, 0, "never invent a dollar figure");
|
|
61
|
+
});
|