|
| 1 | +import { postgresTest } from "@internal/testcontainers"; |
1 | 2 | import { describe, expect, it } from "vitest"; |
| 3 | +import { FEATURE_FLAG } from "~/v3/featureFlags"; |
| 4 | +import { makeFlag } from "~/v3/featureFlags.server"; |
2 | 5 | import { |
3 | 6 | evaluateGate, |
4 | 7 | type GateDependencies, |
@@ -35,7 +38,7 @@ function makeDeps(toggles: Toggles): { deps: GateDependencies; spies: Spies } { |
35 | 38 | const deps: GateDependencies = { |
36 | 39 | isMollifierEnabled: () => toggles.enabled, |
37 | 40 | isShadowModeOn: () => toggles.shadow, |
38 | | - resolveFlag: async () => toggles.flag, |
| 41 | + resolveOrgFlag: async () => toggles.flag, |
39 | 42 | evaluator: async () => { |
40 | 43 | spies.evaluatorCalls += 1; |
41 | 44 | return toggles.decision; |
@@ -64,7 +67,12 @@ const trippedDecision = { |
64 | 67 |
|
65 | 68 | const passDecision: TripDecision = { divert: false }; |
66 | 69 |
|
67 | | -const inputs: GateInputs = { envId: "e1", orgId: "o1", taskId: "t1" }; |
| 70 | +const inputs: GateInputs = { |
| 71 | + envId: "e1", |
| 72 | + orgId: "o1", |
| 73 | + taskId: "t1", |
| 74 | + orgFeatureFlags: null, |
| 75 | +}; |
68 | 76 |
|
69 | 77 | // Cascade truth table. Every combination of (enabled, shadow, flag, divert) is |
70 | 78 | // enumerated. `evaluatorCalls` is the expected count, not arbitrary: the gate |
@@ -164,3 +172,160 @@ describe("evaluateGate cascade — exhaustive truth table", () => { |
164 | 172 | expect(spies.logMollifiedCalls).toEqual([{ inputs, decision: trippedDecision }]); |
165 | 173 | }); |
166 | 174 | }); |
| 175 | + |
| 176 | +// The gate must opt in single orgs without affecting the others. These tests |
| 177 | +// exercise the *real* `resolveOrgFlag` against a real Postgres testcontainer: |
| 178 | +// we build it via `makeFlag(prisma)` and let the `Organization.featureFlags` |
| 179 | +// blob flow through `flag()`'s overrides path. The global `FeatureFlag` table |
| 180 | +// is empty, so the only signal moving outcomes is the per-org JSON. |
| 181 | +describe("evaluateGate — per-org isolation via Organization.featureFlags", () => { |
| 182 | + function makeIsolationDeps( |
| 183 | + realResolveOrgFlag: GateDependencies["resolveOrgFlag"], |
| 184 | + ): { deps: Partial<GateDependencies>; spies: Spies } { |
| 185 | + const spies: Spies = { |
| 186 | + evaluatorCalls: 0, |
| 187 | + logShadowCalls: [], |
| 188 | + logMollifiedCalls: [], |
| 189 | + recordDecisionCalls: [], |
| 190 | + }; |
| 191 | + // Override lifecycle bits and inject the real DB-backed resolveOrgFlag. |
| 192 | + // Evaluator returns a fixed tripped decision so the outcome is purely a |
| 193 | + // function of the flag resolution (which is what we're isolating on). |
| 194 | + const deps: Partial<GateDependencies> = { |
| 195 | + isMollifierEnabled: () => true, |
| 196 | + isShadowModeOn: () => false, |
| 197 | + resolveOrgFlag: realResolveOrgFlag, |
| 198 | + evaluator: async () => { |
| 199 | + spies.evaluatorCalls += 1; |
| 200 | + return trippedDecision; |
| 201 | + }, |
| 202 | + logShadow: (inputs, decision) => { |
| 203 | + spies.logShadowCalls.push({ inputs, decision }); |
| 204 | + }, |
| 205 | + logMollified: (inputs, decision) => { |
| 206 | + spies.logMollifiedCalls.push({ inputs, decision }); |
| 207 | + }, |
| 208 | + recordDecision: (outcome, reason) => { |
| 209 | + spies.recordDecisionCalls.push({ outcome, reason }); |
| 210 | + }, |
| 211 | + }; |
| 212 | + return { deps, spies }; |
| 213 | + } |
| 214 | + |
| 215 | + // Build the production resolveOrgFlag wired to the test Prisma client. This |
| 216 | + // is exactly the closure `defaultGateDependencies.resolveOrgFlag` runs in |
| 217 | + // prod — the only swap is the Prisma instance. |
| 218 | + function realResolveOrgFlag(prisma: Parameters<typeof makeFlag>[0]) { |
| 219 | + const f = makeFlag(prisma); |
| 220 | + return (inputs: GateInputs) => |
| 221 | + f({ |
| 222 | + key: FEATURE_FLAG.mollifierEnabled, |
| 223 | + defaultValue: false, |
| 224 | + overrides: inputs.orgFeatureFlags ?? {}, |
| 225 | + }); |
| 226 | + } |
| 227 | + |
| 228 | + postgresTest( |
| 229 | + "opts in only the org whose featureFlags has mollifierEnabled=true", |
| 230 | + async ({ prisma }) => { |
| 231 | + const resolve = realResolveOrgFlag(prisma); |
| 232 | + const orgA = { ...inputs, orgId: "org_a", orgFeatureFlags: { mollifierEnabled: true } }; |
| 233 | + const orgB = { ...inputs, orgId: "org_b", orgFeatureFlags: { mollifierEnabled: false } }; |
| 234 | + const orgC = { ...inputs, orgId: "org_c", orgFeatureFlags: null }; |
| 235 | + |
| 236 | + const a = makeIsolationDeps(resolve); |
| 237 | + const b = makeIsolationDeps(resolve); |
| 238 | + const c = makeIsolationDeps(resolve); |
| 239 | + |
| 240 | + const [outcomeA, outcomeB, outcomeC] = await Promise.all([ |
| 241 | + evaluateGate(orgA, a.deps), |
| 242 | + evaluateGate(orgB, b.deps), |
| 243 | + evaluateGate(orgC, c.deps), |
| 244 | + ]); |
| 245 | + |
| 246 | + // Only org A's flag is on → only org A mollifies. Orgs B and C never |
| 247 | + // reach the evaluator because both flag and shadow-mode are off. |
| 248 | + expect(outcomeA.action).toBe("mollify"); |
| 249 | + expect(outcomeB.action).toBe("pass_through"); |
| 250 | + expect(outcomeC.action).toBe("pass_through"); |
| 251 | + |
| 252 | + expect(a.spies.evaluatorCalls).toBe(1); |
| 253 | + expect(b.spies.evaluatorCalls).toBe(0); |
| 254 | + expect(c.spies.evaluatorCalls).toBe(0); |
| 255 | + |
| 256 | + expect(a.spies.logMollifiedCalls).toHaveLength(1); |
| 257 | + expect(b.spies.logMollifiedCalls).toHaveLength(0); |
| 258 | + expect(c.spies.logMollifiedCalls).toHaveLength(0); |
| 259 | + }, |
| 260 | + ); |
| 261 | + |
| 262 | + postgresTest( |
| 263 | + "another org's beta flags must not opt them into mollifier", |
| 264 | + async ({ prisma }) => { |
| 265 | + const resolve = realResolveOrgFlag(prisma); |
| 266 | + // Org A has mollifier on (plus an unrelated beta). |
| 267 | + const orgA = { |
| 268 | + ...inputs, |
| 269 | + orgId: "org_a", |
| 270 | + orgFeatureFlags: { mollifierEnabled: true, hasComputeAccess: true }, |
| 271 | + }; |
| 272 | + // Org B has *other* betas on but mollifier remains off — keys that gate |
| 273 | + // compute/AI/query must not bleed across into the mollifier decision. |
| 274 | + const orgB = { |
| 275 | + ...inputs, |
| 276 | + orgId: "org_b", |
| 277 | + orgFeatureFlags: { hasComputeAccess: true, hasAiAccess: true }, |
| 278 | + }; |
| 279 | + |
| 280 | + const a = makeIsolationDeps(resolve); |
| 281 | + const b = makeIsolationDeps(resolve); |
| 282 | + |
| 283 | + const outcomeA = await evaluateGate(orgA, a.deps); |
| 284 | + const outcomeB = await evaluateGate(orgB, b.deps); |
| 285 | + |
| 286 | + expect(outcomeA.action).toBe("mollify"); |
| 287 | + expect(outcomeB.action).toBe("pass_through"); |
| 288 | + }, |
| 289 | + ); |
| 290 | + |
| 291 | + postgresTest( |
| 292 | + "global FeatureFlag row enables only when an org's overrides don't say otherwise", |
| 293 | + async ({ prisma }) => { |
| 294 | + // Set the global flag on. The repo-wide `flag()` helper checks |
| 295 | + // overrides first, then global, then default. So: |
| 296 | + // - org with explicit `mollifierEnabled: false` → stays off. |
| 297 | + // - org with no override → picks up the global on. |
| 298 | + // - org with explicit `true` → on. |
| 299 | + await prisma.featureFlag.create({ |
| 300 | + data: { key: FEATURE_FLAG.mollifierEnabled, value: true }, |
| 301 | + }); |
| 302 | + const resolve = realResolveOrgFlag(prisma); |
| 303 | + |
| 304 | + const orgOptedOut = { |
| 305 | + ...inputs, |
| 306 | + orgId: "org_opted_out", |
| 307 | + orgFeatureFlags: { mollifierEnabled: false }, |
| 308 | + }; |
| 309 | + const orgInherits = { ...inputs, orgId: "org_inherits", orgFeatureFlags: null }; |
| 310 | + const orgExplicit = { |
| 311 | + ...inputs, |
| 312 | + orgId: "org_explicit", |
| 313 | + orgFeatureFlags: { mollifierEnabled: true }, |
| 314 | + }; |
| 315 | + |
| 316 | + const optedOut = makeIsolationDeps(resolve); |
| 317 | + const inherits = makeIsolationDeps(resolve); |
| 318 | + const explicit = makeIsolationDeps(resolve); |
| 319 | + |
| 320 | + const [outOptedOut, outInherits, outExplicit] = await Promise.all([ |
| 321 | + evaluateGate(orgOptedOut, optedOut.deps), |
| 322 | + evaluateGate(orgInherits, inherits.deps), |
| 323 | + evaluateGate(orgExplicit, explicit.deps), |
| 324 | + ]); |
| 325 | + |
| 326 | + expect(outOptedOut.action).toBe("pass_through"); |
| 327 | + expect(outInherits.action).toBe("mollify"); |
| 328 | + expect(outExplicit.action).toBe("mollify"); |
| 329 | + }, |
| 330 | + ); |
| 331 | +}); |
0 commit comments