@prodverdict/engine 0.0.1
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/dist/config/index.d.ts +3 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +3 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/parse.d.ts +4 -0
- package/dist/config/parse.d.ts.map +1 -0
- package/dist/config/parse.js +40 -0
- package/dist/config/parse.js.map +1 -0
- package/dist/config/schema.d.ts +468 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +66 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/connectors/fixture.d.ts +5 -0
- package/dist/connectors/fixture.d.ts.map +1 -0
- package/dist/connectors/fixture.js +16 -0
- package/dist/connectors/fixture.js.map +1 -0
- package/dist/connectors/index.d.ts +8 -0
- package/dist/connectors/index.d.ts.map +1 -0
- package/dist/connectors/index.js +6 -0
- package/dist/connectors/index.js.map +1 -0
- package/dist/connectors/load-fixtures.d.ts +9 -0
- package/dist/connectors/load-fixtures.d.ts.map +1 -0
- package/dist/connectors/load-fixtures.js +27 -0
- package/dist/connectors/load-fixtures.js.map +1 -0
- package/dist/connectors/postgres-live.d.ts +4 -0
- package/dist/connectors/postgres-live.d.ts.map +1 -0
- package/dist/connectors/postgres-live.js +51 -0
- package/dist/connectors/postgres-live.js.map +1 -0
- package/dist/connectors/sql-identifiers.d.ts +3 -0
- package/dist/connectors/sql-identifiers.d.ts.map +1 -0
- package/dist/connectors/sql-identifiers.js +13 -0
- package/dist/connectors/sql-identifiers.js.map +1 -0
- package/dist/connectors/stripe-live.d.ts +3 -0
- package/dist/connectors/stripe-live.d.ts.map +1 -0
- package/dist/connectors/stripe-live.js +32 -0
- package/dist/connectors/stripe-live.js.map +1 -0
- package/dist/connectors/types.d.ts +23 -0
- package/dist/connectors/types.d.ts.map +1 -0
- package/dist/connectors/types.js +2 -0
- package/dist/connectors/types.js.map +1 -0
- package/dist/evaluators/access.d.ts +9 -0
- package/dist/evaluators/access.d.ts.map +1 -0
- package/dist/evaluators/access.js +132 -0
- package/dist/evaluators/access.js.map +1 -0
- package/dist/evaluators/config.d.ts +22 -0
- package/dist/evaluators/config.d.ts.map +1 -0
- package/dist/evaluators/config.js +191 -0
- package/dist/evaluators/config.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/dist/verdict.d.ts +3 -0
- package/dist/verdict.d.ts.map +1 -0
- package/dist/verdict.js +8 -0
- package/dist/verdict.js.map +1 -0
- package/package.json +39 -0
- package/src/config/index.ts +2 -0
- package/src/config/parse.test.ts +63 -0
- package/src/config/parse.ts +48 -0
- package/src/config/schema.ts +83 -0
- package/src/connectors/fixture.ts +18 -0
- package/src/connectors/index.ts +7 -0
- package/src/connectors/load-fixtures.test.ts +19 -0
- package/src/connectors/load-fixtures.ts +45 -0
- package/src/connectors/postgres-live.ts +61 -0
- package/src/connectors/sql-identifiers.test.ts +14 -0
- package/src/connectors/sql-identifiers.ts +16 -0
- package/src/connectors/stripe-live.ts +38 -0
- package/src/connectors/types.ts +25 -0
- package/src/evaluators/access.test.ts +206 -0
- package/src/evaluators/access.ts +159 -0
- package/src/evaluators/config.test.ts +266 -0
- package/src/evaluators/config.ts +213 -0
- package/src/index.ts +12 -0
- package/src/types.ts +27 -0
- package/src/verdict.test.ts +29 -0
- package/src/verdict.ts +7 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { evaluateAccess } from './access.js';
|
|
3
|
+
import { createFixtureStripeReader, createFixtureDatabaseReader } from '../connectors/fixture.js';
|
|
4
|
+
import type { StripeSubscription } from '../connectors/types.js';
|
|
5
|
+
import type { AppUser } from '../connectors/types.js';
|
|
6
|
+
import type { AccessContractConfig } from '../config/schema.js';
|
|
7
|
+
|
|
8
|
+
const baseCfg: AccessContractConfig = {
|
|
9
|
+
type: 'access',
|
|
10
|
+
source_of_truth: 'stripe',
|
|
11
|
+
database: {
|
|
12
|
+
url_env: 'DATABASE_URL',
|
|
13
|
+
users_table: 'users',
|
|
14
|
+
columns: {
|
|
15
|
+
id: 'id',
|
|
16
|
+
stripe_customer_id: 'stripe_customer_id',
|
|
17
|
+
has_paid_access: 'has_paid_access',
|
|
18
|
+
plan: 'plan',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
stripe: { secret_env: 'STRIPE_SECRET_KEY' },
|
|
22
|
+
plans: { price_pro: 'pro', price_starter: 'starter' },
|
|
23
|
+
severity: 'high',
|
|
24
|
+
fix: 'Sync has_paid_access from webhooks.',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function makeSub(overrides: Partial<StripeSubscription> = {}): StripeSubscription {
|
|
28
|
+
return {
|
|
29
|
+
id: 'sub_1',
|
|
30
|
+
customerId: 'cus_1',
|
|
31
|
+
status: 'active',
|
|
32
|
+
priceIds: ['price_pro'],
|
|
33
|
+
...overrides,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeUser(overrides: Partial<AppUser> = {}): AppUser {
|
|
38
|
+
return {
|
|
39
|
+
id: 'u1',
|
|
40
|
+
stripeCustomerId: 'cus_1',
|
|
41
|
+
hasPaidAccess: true,
|
|
42
|
+
plan: 'pro',
|
|
43
|
+
...overrides,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('evaluateAccess — pass cases', () => {
|
|
48
|
+
it('returns no findings when everything is aligned', async () => {
|
|
49
|
+
const sources = {
|
|
50
|
+
stripe: createFixtureStripeReader([makeSub()]),
|
|
51
|
+
database: createFixtureDatabaseReader([makeUser()]),
|
|
52
|
+
};
|
|
53
|
+
const findings = await evaluateAccess(baseCfg, sources);
|
|
54
|
+
expect(findings).toHaveLength(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returns no findings for canceled sub + no access', async () => {
|
|
58
|
+
const sources = {
|
|
59
|
+
stripe: createFixtureStripeReader([makeSub({ status: 'canceled' })]),
|
|
60
|
+
database: createFixtureDatabaseReader([makeUser({ hasPaidAccess: false, plan: null })]),
|
|
61
|
+
};
|
|
62
|
+
const findings = await evaluateAccess(baseCfg, sources);
|
|
63
|
+
expect(findings).toHaveLength(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('ignores users with no stripe_customer_id', async () => {
|
|
67
|
+
const sources = {
|
|
68
|
+
stripe: createFixtureStripeReader([]),
|
|
69
|
+
database: createFixtureDatabaseReader([makeUser({ stripeCustomerId: null, hasPaidAccess: false })]),
|
|
70
|
+
};
|
|
71
|
+
const findings = await evaluateAccess(baseCfg, sources);
|
|
72
|
+
expect(findings).toHaveLength(0);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('evaluateAccess — revenue leak (active sub, no access)', () => {
|
|
77
|
+
it('produces a high finding when active sub + has_paid_access=false', async () => {
|
|
78
|
+
const sources = {
|
|
79
|
+
stripe: createFixtureStripeReader([makeSub({ status: 'active' })]),
|
|
80
|
+
database: createFixtureDatabaseReader([makeUser({ hasPaidAccess: false })]),
|
|
81
|
+
};
|
|
82
|
+
const findings = await evaluateAccess(baseCfg, sources);
|
|
83
|
+
const high = findings.filter((f) => f.severity === 'high');
|
|
84
|
+
expect(high).toHaveLength(1);
|
|
85
|
+
expect(high[0]!.entity).toBe('user:u1');
|
|
86
|
+
expect(high[0]!.message).toMatch(/Revenue leak/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('triggers for trialing subscriptions too', async () => {
|
|
90
|
+
const sources = {
|
|
91
|
+
stripe: createFixtureStripeReader([makeSub({ status: 'trialing' })]),
|
|
92
|
+
database: createFixtureDatabaseReader([makeUser({ hasPaidAccess: false })]),
|
|
93
|
+
};
|
|
94
|
+
const findings = await evaluateAccess(baseCfg, sources);
|
|
95
|
+
expect(findings.some((f) => f.severity === 'high')).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('evaluateAccess — wrongful access (lapsed sub, still has access)', () => {
|
|
100
|
+
it('produces a high finding for canceled sub + has_paid_access=true', async () => {
|
|
101
|
+
const sources = {
|
|
102
|
+
stripe: createFixtureStripeReader([makeSub({ status: 'canceled' })]),
|
|
103
|
+
database: createFixtureDatabaseReader([makeUser({ hasPaidAccess: true })]),
|
|
104
|
+
};
|
|
105
|
+
const findings = await evaluateAccess(baseCfg, sources);
|
|
106
|
+
const high = findings.filter((f) => f.severity === 'high');
|
|
107
|
+
expect(high).toHaveLength(1);
|
|
108
|
+
expect(high[0]!.message).toMatch(/Wrongful access/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('triggers for unpaid subscriptions', async () => {
|
|
112
|
+
const sources = {
|
|
113
|
+
stripe: createFixtureStripeReader([makeSub({ status: 'unpaid' })]),
|
|
114
|
+
database: createFixtureDatabaseReader([makeUser({ hasPaidAccess: true })]),
|
|
115
|
+
};
|
|
116
|
+
const findings = await evaluateAccess(baseCfg, sources);
|
|
117
|
+
expect(findings.some((f) => f.severity === 'high')).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('triggers for past_due subscriptions', async () => {
|
|
121
|
+
const sources = {
|
|
122
|
+
stripe: createFixtureStripeReader([makeSub({ status: 'past_due' })]),
|
|
123
|
+
database: createFixtureDatabaseReader([makeUser({ hasPaidAccess: true })]),
|
|
124
|
+
};
|
|
125
|
+
const findings = await evaluateAccess(baseCfg, sources);
|
|
126
|
+
expect(findings.some((f) => f.severity === 'high')).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('evaluateAccess — plan drift', () => {
|
|
131
|
+
it('flags unknown price ID (not in plans map)', async () => {
|
|
132
|
+
const sources = {
|
|
133
|
+
stripe: createFixtureStripeReader([makeSub({ priceIds: ['price_unknown'] })]),
|
|
134
|
+
database: createFixtureDatabaseReader([makeUser()]),
|
|
135
|
+
};
|
|
136
|
+
const findings = await evaluateAccess(baseCfg, sources);
|
|
137
|
+
const priceFinding = findings.find((f) => f.entity.startsWith('price:'));
|
|
138
|
+
expect(priceFinding).toBeDefined();
|
|
139
|
+
expect(priceFinding!.severity).toBe('high');
|
|
140
|
+
expect(priceFinding!.entity).toBe('price:price_unknown');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('flags plan mismatch between app and Stripe price', async () => {
|
|
144
|
+
const sources = {
|
|
145
|
+
stripe: createFixtureStripeReader([makeSub({ priceIds: ['price_starter'] })]),
|
|
146
|
+
database: createFixtureDatabaseReader([makeUser({ plan: 'pro' })]),
|
|
147
|
+
};
|
|
148
|
+
const findings = await evaluateAccess(baseCfg, sources);
|
|
149
|
+
const planFinding = findings.find((f) => f.message.includes('plan is'));
|
|
150
|
+
expect(planFinding).toBeDefined();
|
|
151
|
+
expect(planFinding!.severity).toBe('high');
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('evaluateAccess — duplicate customer', () => {
|
|
156
|
+
it('flags when two users share a stripe_customer_id', async () => {
|
|
157
|
+
const sources = {
|
|
158
|
+
stripe: createFixtureStripeReader([makeSub()]),
|
|
159
|
+
database: createFixtureDatabaseReader([
|
|
160
|
+
makeUser({ id: 'u1' }),
|
|
161
|
+
makeUser({ id: 'u2' }),
|
|
162
|
+
]),
|
|
163
|
+
};
|
|
164
|
+
const findings = await evaluateAccess(baseCfg, sources);
|
|
165
|
+
const dup = findings.find((f) => f.entity.startsWith('customer:'));
|
|
166
|
+
expect(dup).toBeDefined();
|
|
167
|
+
expect(dup!.severity).toBe('medium');
|
|
168
|
+
expect(dup!.message).toMatch(/Duplicate/);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('evaluateAccess — orphan references', () => {
|
|
173
|
+
it('flags user with customer ID but no Stripe subscriptions found', async () => {
|
|
174
|
+
const sources = {
|
|
175
|
+
stripe: createFixtureStripeReader([]),
|
|
176
|
+
database: createFixtureDatabaseReader([makeUser()]),
|
|
177
|
+
};
|
|
178
|
+
const findings = await evaluateAccess(baseCfg, sources);
|
|
179
|
+
expect(findings.some((f) => f.message.includes('no Stripe subscriptions'))).toBe(true);
|
|
180
|
+
expect(findings[0]!.severity).toBe('medium');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('low-severity finding for Stripe customer with active sub but no app user', async () => {
|
|
184
|
+
const sources = {
|
|
185
|
+
stripe: createFixtureStripeReader([makeSub({ customerId: 'cus_orphan' })]),
|
|
186
|
+
database: createFixtureDatabaseReader([]),
|
|
187
|
+
};
|
|
188
|
+
const findings = await evaluateAccess(baseCfg, sources);
|
|
189
|
+
const low = findings.find((f) => f.severity === 'low');
|
|
190
|
+
expect(low).toBeDefined();
|
|
191
|
+
expect(low!.entity).toBe('customer:cus_orphan');
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('evaluateAccess — config without plans map', () => {
|
|
196
|
+
it('skips plan checks when plans map is absent', async () => {
|
|
197
|
+
const cfg: AccessContractConfig = { ...baseCfg, plans: undefined };
|
|
198
|
+
const sources = {
|
|
199
|
+
stripe: createFixtureStripeReader([makeSub({ priceIds: ['price_anything'] })]),
|
|
200
|
+
database: createFixtureDatabaseReader([makeUser()]),
|
|
201
|
+
};
|
|
202
|
+
const findings = await evaluateAccess(cfg, sources);
|
|
203
|
+
const planFindings = findings.filter((f) => f.entity.startsWith('price:'));
|
|
204
|
+
expect(planFindings).toHaveLength(0);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { Finding, Severity } from '../types.js';
|
|
2
|
+
import type { AccessContractConfig } from '../config/schema.js';
|
|
3
|
+
import type { StripeReader, DatabaseReader, AppUser, StripeSubscription } from '../connectors/types.js';
|
|
4
|
+
|
|
5
|
+
const LAPSED_STATUSES = new Set(['canceled', 'unpaid', 'past_due', 'incomplete_expired']);
|
|
6
|
+
const ACTIVE_STATUSES = new Set(['active', 'trialing']);
|
|
7
|
+
|
|
8
|
+
export interface AccessDataSources {
|
|
9
|
+
stripe: StripeReader;
|
|
10
|
+
database: DatabaseReader;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function evaluateAccess(
|
|
14
|
+
cfg: AccessContractConfig,
|
|
15
|
+
sources: AccessDataSources,
|
|
16
|
+
): Promise<Finding[]> {
|
|
17
|
+
const [subscriptions, users] = await Promise.all([
|
|
18
|
+
sources.stripe.listSubscriptions(),
|
|
19
|
+
sources.database.listUsers(),
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const findings: Finding[] = [];
|
|
23
|
+
const defaultFix = cfg.fix;
|
|
24
|
+
const severity = cfg.severity;
|
|
25
|
+
|
|
26
|
+
const subsByCustomerId = groupByCustomerId(subscriptions);
|
|
27
|
+
const usersByCustomerId = new Map<string, AppUser[]>();
|
|
28
|
+
|
|
29
|
+
for (const user of users) {
|
|
30
|
+
if (!user.stripeCustomerId) continue;
|
|
31
|
+
const arr = usersByCustomerId.get(user.stripeCustomerId) ?? [];
|
|
32
|
+
arr.push(user);
|
|
33
|
+
usersByCustomerId.set(user.stripeCustomerId, arr);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Check 1: duplicate stripe_customer_id across multiple app users
|
|
37
|
+
for (const [customerId, mapped] of usersByCustomerId) {
|
|
38
|
+
if (mapped.length > 1) {
|
|
39
|
+
const ids = mapped.map((u) => u.id).join(', ');
|
|
40
|
+
findings.push({
|
|
41
|
+
contract: 'access',
|
|
42
|
+
severity: 'medium',
|
|
43
|
+
entity: `customer:${customerId}`,
|
|
44
|
+
message: `stripe_customer_id "${customerId}" is linked to ${mapped.length} users (${ids}). Duplicate mapping.`,
|
|
45
|
+
fix: 'Ensure each Stripe customer maps to exactly one app user.',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Check 2: per-user access state vs Stripe state
|
|
51
|
+
for (const user of users) {
|
|
52
|
+
if (!user.stripeCustomerId) {
|
|
53
|
+
// No Stripe link at all — skip access checks (not necessarily an error)
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const subs = subsByCustomerId.get(user.stripeCustomerId);
|
|
58
|
+
|
|
59
|
+
if (!subs || subs.length === 0) {
|
|
60
|
+
// App has a customer ID but Stripe has no record
|
|
61
|
+
findings.push({
|
|
62
|
+
contract: 'access',
|
|
63
|
+
severity: 'medium',
|
|
64
|
+
entity: `user:${user.id}`,
|
|
65
|
+
message: `User has stripe_customer_id "${user.stripeCustomerId}" but no Stripe subscriptions were found.`,
|
|
66
|
+
fix: 'Verify the stripe_customer_id is correct or remove the stale reference.',
|
|
67
|
+
});
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Use the most relevant subscription (active/trialing preferred; otherwise latest)
|
|
72
|
+
const activeSub = subs.find((s) => ACTIVE_STATUSES.has(s.status));
|
|
73
|
+
const lapsedSub = subs.find((s) => LAPSED_STATUSES.has(s.status));
|
|
74
|
+
const anySub = activeSub ?? lapsedSub ?? subs[0]!;
|
|
75
|
+
|
|
76
|
+
if (activeSub) {
|
|
77
|
+
// Should have access
|
|
78
|
+
if (!user.hasPaidAccess) {
|
|
79
|
+
findings.push({
|
|
80
|
+
contract: 'access',
|
|
81
|
+
severity,
|
|
82
|
+
entity: `user:${user.id}`,
|
|
83
|
+
message:
|
|
84
|
+
`User has an active/trialing Stripe subscription (${activeSub.id}, status: ${activeSub.status}) ` +
|
|
85
|
+
`but has_paid_access is false. Revenue leak — user cannot access paid features.`,
|
|
86
|
+
fix: defaultFix ?? 'Set has_paid_access=true and assign the correct plan on subscription activation.',
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Check plan mapping drift
|
|
91
|
+
if (cfg.plans) {
|
|
92
|
+
for (const priceId of activeSub.priceIds) {
|
|
93
|
+
const expectedPlan = cfg.plans[priceId];
|
|
94
|
+
if (expectedPlan === undefined) {
|
|
95
|
+
findings.push({
|
|
96
|
+
contract: 'access',
|
|
97
|
+
severity,
|
|
98
|
+
entity: `price:${priceId}`,
|
|
99
|
+
message: `Subscription ${activeSub.id} uses price "${priceId}" which is not in the plans map.`,
|
|
100
|
+
fix: `Add "${priceId}" to the plans map in prodverdict.yml, or remove it from Stripe if deprecated.`,
|
|
101
|
+
});
|
|
102
|
+
} else if (user.plan !== null && user.plan !== expectedPlan) {
|
|
103
|
+
findings.push({
|
|
104
|
+
contract: 'access',
|
|
105
|
+
severity,
|
|
106
|
+
entity: `user:${user.id}`,
|
|
107
|
+
message:
|
|
108
|
+
`User plan is "${user.plan}" but active Stripe price "${priceId}" maps to plan "${expectedPlan}".`,
|
|
109
|
+
fix: defaultFix ?? `Update the user's plan to "${expectedPlan}" to match the Stripe price.`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} else if (lapsedSub) {
|
|
115
|
+
// Should not have access
|
|
116
|
+
if (user.hasPaidAccess) {
|
|
117
|
+
findings.push({
|
|
118
|
+
contract: 'access',
|
|
119
|
+
severity,
|
|
120
|
+
entity: `user:${user.id}`,
|
|
121
|
+
message:
|
|
122
|
+
`User has a ${anySub.status} Stripe subscription (${anySub.id}) ` +
|
|
123
|
+
`but has_paid_access is still true. Wrongful access — user is accessing paid features without a valid subscription.`,
|
|
124
|
+
fix: defaultFix ?? 'Set has_paid_access=false and revoke plan access in the cancellation/webhook handler.',
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Check 3: Stripe customers with subscriptions but no matching app user
|
|
131
|
+
for (const [customerId, subs] of subsByCustomerId) {
|
|
132
|
+
if (!usersByCustomerId.has(customerId)) {
|
|
133
|
+
const activeSub = subs.find((s) => ACTIVE_STATUSES.has(s.status));
|
|
134
|
+
if (activeSub) {
|
|
135
|
+
findings.push({
|
|
136
|
+
contract: 'access',
|
|
137
|
+
severity: 'low',
|
|
138
|
+
entity: `customer:${customerId}`,
|
|
139
|
+
message: `Stripe customer "${customerId}" has an active subscription (${activeSub.id}) but no matching app user row.`,
|
|
140
|
+
fix: 'Verify the customer was not deleted from the app, or handle the Stripe subscription cleanup.',
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return findings;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function groupByCustomerId(
|
|
150
|
+
subscriptions: StripeSubscription[],
|
|
151
|
+
): Map<string, StripeSubscription[]> {
|
|
152
|
+
const map = new Map<string, StripeSubscription[]>();
|
|
153
|
+
for (const sub of subscriptions) {
|
|
154
|
+
const arr = map.get(sub.customerId) ?? [];
|
|
155
|
+
arr.push(sub);
|
|
156
|
+
map.set(sub.customerId, arr);
|
|
157
|
+
}
|
|
158
|
+
return map;
|
|
159
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { evaluateConfig, scanEnvReferences, parseEnvFile } from './config.js';
|
|
3
|
+
import type { ConfigContractConfig } from '../config/schema.js';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
function makeConfig(overrides: Partial<ConfigContractConfig> = {}): ConfigContractConfig {
|
|
9
|
+
return {
|
|
10
|
+
type: 'config',
|
|
11
|
+
severity: 'high',
|
|
12
|
+
rules: [],
|
|
13
|
+
scan_references: false,
|
|
14
|
+
env_example_file: '.env.example',
|
|
15
|
+
check_placeholders: true,
|
|
16
|
+
ignore_vars: [],
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('evaluateConfig', () => {
|
|
22
|
+
describe('required rule', () => {
|
|
23
|
+
it('passes when required var is set', async () => {
|
|
24
|
+
const findings = await evaluateConfig(
|
|
25
|
+
makeConfig({ rules: [{ type: 'required', name: 'STRIPE_SECRET_KEY' }] }),
|
|
26
|
+
{ repoRoot: process.cwd(), env: { STRIPE_SECRET_KEY: 'sk_live_abc' } },
|
|
27
|
+
);
|
|
28
|
+
expect(findings).toHaveLength(0);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('fails when required var is missing', async () => {
|
|
32
|
+
const findings = await evaluateConfig(
|
|
33
|
+
makeConfig({ rules: [{ type: 'required', name: 'STRIPE_SECRET_KEY' }] }),
|
|
34
|
+
{ repoRoot: process.cwd(), env: {} },
|
|
35
|
+
);
|
|
36
|
+
expect(findings).toHaveLength(1);
|
|
37
|
+
expect(findings[0]!.severity).toBe('high');
|
|
38
|
+
expect(findings[0]!.entity).toBe('env:STRIPE_SECRET_KEY');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('fails when required var is empty string', async () => {
|
|
42
|
+
const findings = await evaluateConfig(
|
|
43
|
+
makeConfig({ rules: [{ type: 'required', name: 'DATABASE_URL' }] }),
|
|
44
|
+
{ repoRoot: process.cwd(), env: { DATABASE_URL: '' } },
|
|
45
|
+
);
|
|
46
|
+
expect(findings).toHaveLength(1);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('uses per-rule severity override', async () => {
|
|
50
|
+
const findings = await evaluateConfig(
|
|
51
|
+
makeConfig({
|
|
52
|
+
rules: [{ type: 'required', name: 'OPTIONAL_THING', severity: 'low' }],
|
|
53
|
+
}),
|
|
54
|
+
{ repoRoot: process.cwd(), env: {} },
|
|
55
|
+
);
|
|
56
|
+
expect(findings[0]!.severity).toBe('low');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('warns about placeholder values', async () => {
|
|
60
|
+
const findings = await evaluateConfig(
|
|
61
|
+
makeConfig({ rules: [{ type: 'required', name: 'API_KEY' }], check_placeholders: true }),
|
|
62
|
+
{ repoRoot: process.cwd(), env: { API_KEY: 'your_key_here' } },
|
|
63
|
+
);
|
|
64
|
+
expect(findings).toHaveLength(1);
|
|
65
|
+
expect(findings[0]!.severity).toBe('medium');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('does not warn about placeholders when check_placeholders is false', async () => {
|
|
69
|
+
const findings = await evaluateConfig(
|
|
70
|
+
makeConfig({ rules: [{ type: 'required', name: 'API_KEY' }], check_placeholders: false }),
|
|
71
|
+
{ repoRoot: process.cwd(), env: { API_KEY: 'your_key_here' } },
|
|
72
|
+
);
|
|
73
|
+
expect(findings).toHaveLength(0);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('not_default rule', () => {
|
|
78
|
+
it('passes when value is not in forbidden list', async () => {
|
|
79
|
+
const findings = await evaluateConfig(
|
|
80
|
+
makeConfig({ rules: [{ type: 'not_default', name: 'DEBUG', forbidden_values: ['true', '1'] }] }),
|
|
81
|
+
{ repoRoot: process.cwd(), env: { DEBUG: 'false' } },
|
|
82
|
+
);
|
|
83
|
+
expect(findings).toHaveLength(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('fails when value matches a forbidden value', async () => {
|
|
87
|
+
const findings = await evaluateConfig(
|
|
88
|
+
makeConfig({ rules: [{ type: 'not_default', name: 'DEBUG', forbidden_values: ['true', '1'] }] }),
|
|
89
|
+
{ repoRoot: process.cwd(), env: { DEBUG: 'true' } },
|
|
90
|
+
);
|
|
91
|
+
expect(findings).toHaveLength(1);
|
|
92
|
+
expect(findings[0]!.entity).toBe('env:DEBUG');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('is case-insensitive', async () => {
|
|
96
|
+
const findings = await evaluateConfig(
|
|
97
|
+
makeConfig({ rules: [{ type: 'not_default', name: 'LOG_LEVEL', forbidden_values: ['debug'] }] }),
|
|
98
|
+
{ repoRoot: process.cwd(), env: { LOG_LEVEL: 'DEBUG' } },
|
|
99
|
+
);
|
|
100
|
+
expect(findings).toHaveLength(1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('skips check when var is unset', async () => {
|
|
104
|
+
const findings = await evaluateConfig(
|
|
105
|
+
makeConfig({ rules: [{ type: 'not_default', name: 'DEBUG', forbidden_values: ['true'] }] }),
|
|
106
|
+
{ repoRoot: process.cwd(), env: {} },
|
|
107
|
+
);
|
|
108
|
+
expect(findings).toHaveLength(0);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('scan_references', () => {
|
|
113
|
+
let tmpDir: string;
|
|
114
|
+
|
|
115
|
+
beforeEach(() => {
|
|
116
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pv-config-test-'));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
afterEach(() => {
|
|
120
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('finds env vars referenced in source but not in .env.example', async () => {
|
|
124
|
+
fs.writeFileSync(path.join(tmpDir, 'index.ts'), 'const key = process.env.MISSING_VAR;');
|
|
125
|
+
fs.writeFileSync(path.join(tmpDir, '.env.example'), '# no vars\n');
|
|
126
|
+
|
|
127
|
+
const findings = await evaluateConfig(
|
|
128
|
+
makeConfig({ scan_references: true }),
|
|
129
|
+
{ repoRoot: tmpDir, env: {} },
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const envFinding = findings.find((f) => f.entity === 'env:MISSING_VAR');
|
|
133
|
+
expect(envFinding).toBeDefined();
|
|
134
|
+
expect(envFinding!.severity).toBe('low');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('does not flag vars declared in .env.example', async () => {
|
|
138
|
+
fs.writeFileSync(path.join(tmpDir, 'index.ts'), 'const key = process.env.DECLARED_VAR;');
|
|
139
|
+
fs.writeFileSync(path.join(tmpDir, '.env.example'), 'DECLARED_VAR=example_value\n');
|
|
140
|
+
|
|
141
|
+
const findings = await evaluateConfig(
|
|
142
|
+
makeConfig({ scan_references: true }),
|
|
143
|
+
{ repoRoot: tmpDir, env: {} },
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
expect(findings.filter((f) => f.entity === 'env:DECLARED_VAR')).toHaveLength(0);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('does not flag always-available vars like NODE_ENV', async () => {
|
|
150
|
+
fs.writeFileSync(path.join(tmpDir, 'index.ts'), 'if (process.env.NODE_ENV === "production") {}');
|
|
151
|
+
fs.writeFileSync(path.join(tmpDir, '.env.example'), '');
|
|
152
|
+
|
|
153
|
+
const findings = await evaluateConfig(
|
|
154
|
+
makeConfig({ scan_references: true }),
|
|
155
|
+
{ repoRoot: tmpDir, env: {} },
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
expect(findings.filter((f) => f.entity === 'env:NODE_ENV')).toHaveLength(0);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('respects ignore_vars', async () => {
|
|
162
|
+
fs.writeFileSync(path.join(tmpDir, 'index.ts'), 'const x = process.env.INTERNAL_VAR;');
|
|
163
|
+
fs.writeFileSync(path.join(tmpDir, '.env.example'), '');
|
|
164
|
+
|
|
165
|
+
const findings = await evaluateConfig(
|
|
166
|
+
makeConfig({ scan_references: true, ignore_vars: ['INTERNAL_VAR'] }),
|
|
167
|
+
{ repoRoot: tmpDir, env: {} },
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
expect(findings.filter((f) => f.entity === 'env:INTERNAL_VAR')).toHaveLength(0);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('scanEnvReferences', () => {
|
|
176
|
+
let tmpDir: string;
|
|
177
|
+
|
|
178
|
+
beforeEach(() => {
|
|
179
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pv-scan-test-'));
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
afterEach(() => {
|
|
183
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('finds process.env.X references', () => {
|
|
187
|
+
fs.writeFileSync(path.join(tmpDir, 'app.ts'), 'const key = process.env.STRIPE_KEY;');
|
|
188
|
+
const refs = scanEnvReferences(tmpDir);
|
|
189
|
+
expect(refs.has('STRIPE_KEY')).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('finds import.meta.env.X references', () => {
|
|
193
|
+
fs.writeFileSync(path.join(tmpDir, 'app.ts'), 'const base = import.meta.env.VITE_API_URL;');
|
|
194
|
+
const refs = scanEnvReferences(tmpDir);
|
|
195
|
+
expect(refs.has('VITE_API_URL')).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('finds process.env["X"] references', () => {
|
|
199
|
+
fs.writeFileSync(path.join(tmpDir, 'app.ts'), "const x = process.env['DATABASE_URL'];");
|
|
200
|
+
const refs = scanEnvReferences(tmpDir);
|
|
201
|
+
expect(refs.has('DATABASE_URL')).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('skips node_modules', () => {
|
|
205
|
+
const nmDir = path.join(tmpDir, 'node_modules', 'pkg');
|
|
206
|
+
fs.mkdirSync(nmDir, { recursive: true });
|
|
207
|
+
fs.writeFileSync(path.join(nmDir, 'index.js'), 'process.env.SHOULD_SKIP;');
|
|
208
|
+
const refs = scanEnvReferences(tmpDir);
|
|
209
|
+
expect(refs.has('SHOULD_SKIP')).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('skips dist/', () => {
|
|
213
|
+
const distDir = path.join(tmpDir, 'dist');
|
|
214
|
+
fs.mkdirSync(distDir, { recursive: true });
|
|
215
|
+
fs.writeFileSync(path.join(distDir, 'index.js'), 'process.env.DIST_VAR;');
|
|
216
|
+
const refs = scanEnvReferences(tmpDir);
|
|
217
|
+
expect(refs.has('DIST_VAR')).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('skips test/spec source files', () => {
|
|
221
|
+
fs.writeFileSync(path.join(tmpDir, 'app.ts'), 'const key = process.env.PROD_VAR;');
|
|
222
|
+
fs.writeFileSync(path.join(tmpDir, 'app.test.ts'), 'const x = process.env.TEST_FIXTURE_VAR;');
|
|
223
|
+
fs.writeFileSync(path.join(tmpDir, 'app.spec.tsx'), 'const y = process.env.SPEC_VAR;');
|
|
224
|
+
const refs = scanEnvReferences(tmpDir);
|
|
225
|
+
expect(refs.has('PROD_VAR')).toBe(true);
|
|
226
|
+
expect(refs.has('TEST_FIXTURE_VAR')).toBe(false);
|
|
227
|
+
expect(refs.has('SPEC_VAR')).toBe(false);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe('parseEnvFile', () => {
|
|
232
|
+
let tmpDir: string;
|
|
233
|
+
|
|
234
|
+
beforeEach(() => {
|
|
235
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pv-parse-env-'));
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
afterEach(() => {
|
|
239
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('parses KEY=VALUE pairs', () => {
|
|
243
|
+
const file = path.join(tmpDir, '.env');
|
|
244
|
+
fs.writeFileSync(file, 'FOO=bar\nBAZ=qux\n');
|
|
245
|
+
const result = parseEnvFile(file);
|
|
246
|
+
expect(result.get('FOO')).toBe('bar');
|
|
247
|
+
expect(result.get('BAZ')).toBe('qux');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('strips quotes', () => {
|
|
251
|
+
const file = path.join(tmpDir, '.env');
|
|
252
|
+
fs.writeFileSync(file, 'SECRET="my_secret"\n');
|
|
253
|
+
expect(parseEnvFile(file).get('SECRET')).toBe('my_secret');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('skips comment lines', () => {
|
|
257
|
+
const file = path.join(tmpDir, '.env');
|
|
258
|
+
fs.writeFileSync(file, '# comment\nKEY=val\n');
|
|
259
|
+
const result = parseEnvFile(file);
|
|
260
|
+
expect(result.size).toBe(1);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('returns empty map for missing file', () => {
|
|
264
|
+
expect(parseEnvFile('/nonexistent/.env').size).toBe(0);
|
|
265
|
+
});
|
|
266
|
+
});
|