1515 */
1616package org .labkey .onprc_billing ;
1717
18+ import org .junit .After ;
19+ import org .junit .Assert ;
20+ import org .junit .Before ;
21+ import org .junit .Test ;
1822import org .labkey .api .collections .CaseInsensitiveHashMap ;
1923import org .labkey .api .data .CompareType ;
2024import org .labkey .api .data .Container ;
3135import org .labkey .api .query .FieldKey ;
3236import org .labkey .api .query .Queryable ;
3337import org .labkey .api .security .User ;
38+ import org .labkey .api .util .GUID ;
39+ import org .labkey .api .util .JunitUtil ;
40+ import org .labkey .api .util .TestContext ;
3441
3542import java .util .ArrayList ;
43+ import java .util .Arrays ;
3644import java .util .Collection ;
3745import java .util .Collections ;
46+ import java .util .Date ;
47+ import java .util .HashSet ;
3848import java .util .List ;
3949import java .util .Map ;
50+ import java .util .Set ;
4051
4152/**
4253 * User: bimber
@@ -71,16 +82,28 @@ public static ONPRC_BillingManager get()
7182 return _instance ;
7283 }
7384
74- public List <String > deleteBillingRuns (User user , Collection <String > pks , boolean testOnly )
85+ public List <String > deleteBillingRuns (User user , Container container , Collection <String > pks , boolean testOnly )
7586 {
7687 TableInfo invoiceRuns = ONPRC_BillingSchema .getInstance ().getSchema ().getTable (ONPRC_BillingSchema .TABLE_INVOICE_RUNS );
7788 TableInfo invoicedItems = ONPRC_BillingSchema .getInstance ().getSchema ().getTable (ONPRC_BillingSchema .TABLE_INVOICED_ITEMS );
7889 TableInfo miscCharges = ONPRC_BillingSchema .getInstance ().getSchema ().getTable (ONPRC_BillingSchema .TABLE_MISC_CHARGES );
7990
8091 //create filters
81- SimpleFilter invoiceRunFilter = new SimpleFilter (FieldKey .fromString ("invoiceId" ), pks , CompareType .IN );
92+ SimpleFilter invoiceRunsFilter = createContainerScopedInFilter (container , "objectid" , pks );
93+ Set <String > invoiceRunIds = getInvoiceRunIds (invoiceRuns , invoiceRunsFilter );
94+ if (invoiceRunIds .isEmpty ())
95+ {
96+ List <String > ret = new ArrayList <>();
97+ if (testOnly )
98+ {
99+ ret .add ("0 records from invoiced items" );
100+ ret .add ("0 records from misc charges will be removed from the deleted invoice, which means they will be picked up by the next billing period. They are not deleted." );
101+ }
102+ return ret ;
103+ }
82104
83- SimpleFilter miscChargesFilter = new SimpleFilter (FieldKey .fromString ("invoiceId" ), pks , CompareType .IN );
105+ SimpleFilter invoiceRunFilter = createContainerScopedInFilter (container , "invoiceId" , invoiceRunIds );
106+ SimpleFilter miscChargesFilter = createMiscChargesFilter (invoiceRunIds );
84107
85108 //perform the work
86109 List <String > ret = new ArrayList <>();
@@ -96,7 +119,7 @@ public List<String> deleteBillingRuns(User user, Collection<String> pks, boolean
96119 {
97120 try (DbScope .Transaction transaction = ExperimentService .get ().ensureTransaction ())
98121 {
99- long deleted1 = Table .delete (invoicedItems , invoiceRunFilter );
122+ Table .delete (invoicedItems , invoiceRunFilter );
100123
101124 TableSelector tsMiscCharges2 = new TableSelector (miscCharges , Collections .singleton ("objectid" ), miscChargesFilter , null );
102125 String [] miscChargesIds = tsMiscCharges2 .getArray (String .class );
@@ -107,7 +130,7 @@ public List<String> deleteBillingRuns(User user, Collection<String> pks, boolean
107130 Table .update (user , miscCharges , map , objectid );
108131 }
109132
110- long deleted3 = Table .delete (invoiceRuns , new SimpleFilter ( FieldKey . fromString ( "objectid" ), pks , CompareType . IN ) );
133+ Table .delete (invoiceRuns , invoiceRunsFilter );
111134
112135 transaction .commit ();
113136 }
@@ -116,6 +139,33 @@ public List<String> deleteBillingRuns(User user, Collection<String> pks, boolean
116139 return ret ;
117140 }
118141
142+ private SimpleFilter createContainerScopedInFilter (Container container , String columnName , Collection <String > values )
143+ {
144+ return SimpleFilter .createContainerFilter (container ).addInClause (FieldKey .fromString (columnName ), values );
145+ }
146+
147+ private Set <String > getInvoiceRunIds (TableInfo invoiceRuns , SimpleFilter objectIdFilter )
148+ {
149+ TableSelector tsInvoiceRuns = new TableSelector (invoiceRuns , Collections .singleton ("objectid" ), objectIdFilter , null );
150+ String [] invoiceRunIds = tsInvoiceRuns .getArray (String .class );
151+ return new HashSet <>(Arrays .asList (invoiceRunIds ));
152+ }
153+
154+ private SimpleFilter createMiscChargesFilter (Collection <String > invoiceRunIds )
155+ {
156+ // Intentionally NOT container-scoped. Source miscCharges records can live in different containers
157+ // (the billing/finance container, the configured EHR study container, or other satellite containers that
158+ // feed charges into a billing run), so there is no reliable, complete set of "source" containers to filter on.
159+ //
160+ // This is not a cross-container security issue: invoiceRunIds has already been narrowed by getInvoiceRunIds()
161+ // to the run ids that actually exist in the requesting container (see the container-scoped objectIdFilter).
162+ // A forged or out-of-container run id never reaches this filter, so matching miscCharges solely by
163+ // invoiceId only ever touches charges belonging to runs the caller is already authorized to delete.
164+ SimpleFilter filter = new SimpleFilter ();
165+ filter .addInClause (FieldKey .fromString ("invoiceId" ), invoiceRunIds );
166+ return filter ;
167+ }
168+
119169 public Container getBillingContainer (Container c )
120170 {
121171 Module billing = ModuleLoader .getInstance ().getModule (ONPRC_BillingModule .NAME );
@@ -139,4 +189,145 @@ public Container getSLADataFolder(Container c)
139189 return ContainerManager .getForPath (path );
140190
141191 }
192+
193+ public static class TestCase extends Assert
194+ {
195+ private static final String FOLDER_A = "ONPRCBillingDeleteTestA" ;
196+ private static final String FOLDER_B = "ONPRCBillingDeleteTestB" ;
197+ private static final String FOLDER_SATELLITE = "ONPRCBillingDeleteTestSatellite" ;
198+
199+ private User _user ;
200+ private Container _containerA ;
201+ private Container _containerB ;
202+ private Container _containerSatellite ;
203+ private String _runIdA ;
204+ private String _runIdB ;
205+
206+ @ Before
207+ public void setUp ()
208+ {
209+ _user = TestContext .get ().getUser ();
210+ deleteTestFolders ();
211+
212+ Container junit = JunitUtil .getTestContainer ();
213+ _containerA = createBillingFolder (junit , FOLDER_A );
214+ _containerB = createBillingFolder (junit , FOLDER_B );
215+ _containerSatellite = createBillingFolder (junit , FOLDER_SATELLITE );
216+
217+ _runIdA = insertBillingRun (_containerA );
218+ _runIdB = insertBillingRun (_containerB );
219+ }
220+
221+ @ After
222+ public void tearDown ()
223+ {
224+ deleteTestFolders ();
225+ }
226+
227+ @ Test
228+ public void testDeleteBillingRunsIsContainerScoped ()
229+ {
230+ ONPRC_BillingManager manager = ONPRC_BillingManager .get ();
231+ ONPRC_BillingSchema schema = ONPRC_BillingSchema .getInstance ();
232+
233+ // A testOnly preview issued from container A targeting container B's run must not see container B's rows.
234+ for (String summary : manager .deleteBillingRuns (_user , _containerA , List .of (_runIdB ), true ))
235+ assertTrue ("Preview from another container should count 0 rows, but got: " + summary , summary .startsWith ("0 " ));
236+
237+ // An actual delete issued from container A targeting container B's run must leave container B untouched.
238+ manager .deleteBillingRuns (_user , _containerA , List .of (_runIdB ), false );
239+ assertEquals ("invoiceRuns row in container B should survive a delete issued from container A" , 1 , containerRowCount (getTable (schema , ONPRC_BillingSchema .TABLE_INVOICE_RUNS ), _containerB ));
240+ assertEquals ("invoicedItems row in container B should survive a delete issued from container A" , 1 , containerRowCount (getTable (schema , ONPRC_BillingSchema .TABLE_INVOICED_ITEMS ), _containerB ));
241+ assertEquals ("miscCharges row in container B should still reference its invoice" , 1 , miscChargesWithInvoiceCount (_containerB ));
242+
243+ // Satellite source data: miscCharges can live in a container that is neither the requesting/finance
244+ // container nor the run's own container. The delete is keyed off the authorized run id, so these rows are
245+ // still previewed and detached.
246+ String runIdWithSatelliteMiscCharge = insertBillingRun (_containerA , _containerSatellite );
247+ List <String > satellitePreview = manager .deleteBillingRuns (_user , _containerA , List .of (runIdWithSatelliteMiscCharge ), true );
248+ assertTrue ("Preview should count miscCharges rows in an unrelated source container: " + satellitePreview ,
249+ satellitePreview .stream ().anyMatch (summary -> summary .startsWith ("1 records from misc charges" )));
250+
251+ manager .deleteBillingRuns (_user , _containerA , List .of (runIdWithSatelliteMiscCharge ), false );
252+ assertEquals ("miscCharges row in the satellite source container should be detached from the deleted invoice" , 0 , miscChargesWithInvoiceCount (_containerSatellite ));
253+ assertEquals ("miscCharges row in the satellite source container should not be deleted" , 1 , containerRowCount (getTable (schema , ONPRC_BillingSchema .TABLE_MISC_CHARGES ), _containerSatellite ));
254+
255+ // Positive control: deleting a run from its own container removes its rows.
256+ manager .deleteBillingRuns (_user , _containerA , List .of (_runIdA ), false );
257+ assertEquals ("invoiceRuns row in container A should be deleted" , 0 , containerRowCount (getTable (schema , ONPRC_BillingSchema .TABLE_INVOICE_RUNS ), _containerA ));
258+ assertEquals ("invoicedItems row in container A should be deleted" , 0 , containerRowCount (getTable (schema , ONPRC_BillingSchema .TABLE_INVOICED_ITEMS ), _containerA ));
259+ assertEquals ("miscCharges row in container A should be detached from the deleted invoice" , 0 , miscChargesWithInvoiceCount (_containerA ));
260+ assertEquals ("miscCharges row in container A should not be deleted" , 1 , containerRowCount (getTable (schema , ONPRC_BillingSchema .TABLE_MISC_CHARGES ), _containerA ));
261+ }
262+
263+ private Container createBillingFolder (Container parent , String name )
264+ {
265+ Container c = ContainerManager .createContainer (parent , name , _user );
266+ Set <Module > active = new HashSet <>(c .getActiveModules ());
267+ active .add (ModuleLoader .getInstance ().getModule (ONPRC_BillingModule .NAME ));
268+ c .setActiveModules (active , _user );
269+ return c ;
270+ }
271+
272+ private String insertBillingRun (Container c )
273+ {
274+ return insertBillingRun (c , c );
275+ }
276+
277+ private String insertBillingRun (Container billingContainer , Container miscChargesContainer )
278+ {
279+ ONPRC_BillingSchema schema = ONPRC_BillingSchema .getInstance ();
280+ String runId = GUID .makeGUID ();
281+
282+ Map <String , Object > run = new CaseInsensitiveHashMap <>();
283+ run .put ("objectid" , runId );
284+ run .put ("runDate" , new Date ());
285+ run .put ("billingPeriodStart" , new Date ());
286+ run .put ("billingPeriodEnd" , new Date ());
287+ run .put ("container" , billingContainer .getId ());
288+ Table .insert (_user , getTable (schema , ONPRC_BillingSchema .TABLE_INVOICE_RUNS ), run );
289+
290+ Map <String , Object > invoicedItem = new CaseInsensitiveHashMap <>();
291+ invoicedItem .put ("objectid" , GUID .makeGUID ());
292+ invoicedItem .put ("invoiceId" , runId );
293+ invoicedItem .put ("container" , billingContainer .getId ());
294+ Table .insert (_user , getTable (schema , ONPRC_BillingSchema .TABLE_INVOICED_ITEMS ), invoicedItem );
295+
296+ Map <String , Object > miscCharge = new CaseInsensitiveHashMap <>();
297+ miscCharge .put ("objectid" , GUID .makeGUID ());
298+ miscCharge .put ("invoiceId" , runId );
299+ miscCharge .put ("container" , miscChargesContainer .getId ());
300+ Table .insert (_user , getTable (schema , ONPRC_BillingSchema .TABLE_MISC_CHARGES ), miscCharge );
301+
302+ return runId ;
303+ }
304+
305+ private TableInfo getTable (ONPRC_BillingSchema schema , String tableName )
306+ {
307+ return schema .getSchema ().getTable (tableName );
308+ }
309+
310+ private long containerRowCount (TableInfo table , Container c )
311+ {
312+ return new TableSelector (table , SimpleFilter .createContainerFilter (c ), null ).getRowCount ();
313+ }
314+
315+ private long miscChargesWithInvoiceCount (Container c )
316+ {
317+ SimpleFilter filter = SimpleFilter .createContainerFilter (c );
318+ filter .addCondition (FieldKey .fromParts ("invoiceId" ), null , CompareType .NONBLANK );
319+ return new TableSelector (getTable (ONPRC_BillingSchema .getInstance (), ONPRC_BillingSchema .TABLE_MISC_CHARGES ), filter , null ).getRowCount ();
320+ }
321+
322+ private void deleteTestFolders ()
323+ {
324+ Container junit = JunitUtil .getTestContainer ();
325+ for (String name : List .of (FOLDER_A , FOLDER_B , FOLDER_SATELLITE ))
326+ {
327+ Container c = junit .getChild (name );
328+ if (c != null )
329+ ContainerManager .delete (c , _user );
330+ }
331+ }
332+ }
142333}
0 commit comments