From 7e5ccdd25e377cfa2dd5850ac3c2c1428c40b078 Mon Sep 17 00:00:00 2001 From: Instrumental Date: Fri, 12 Oct 2018 12:55:37 -0500 Subject: CADI ID Translate Issue-ID: AAF-556 Change-Id: Ifd6c42012a90b369b41ad5ae8e724fb5950df958 Signed-off-by: Instrumental --- cadi/core/pom.xml | 2 +- .../main/java/org/onap/aaf/cadi/config/Config.java | 1 + .../org/onap/aaf/cadi/filter/MapBathConverter.java | 170 ++++++++++++++++ .../org/onap/aaf/cadi/taf/basic/BasicHttpTaf.java | 18 ++ .../src/main/java/org/onap/aaf/cadi/util/CSV.java | 190 ++++++++++++++++++ .../aaf/cadi/config/test/JU_MapBathConverter.java | 217 +++++++++++++++++++++ .../java/org/onap/aaf/cadi/util/test/JU_CSV.java | 122 ++++++++++++ 7 files changed, 719 insertions(+), 1 deletion(-) create mode 100644 cadi/core/src/main/java/org/onap/aaf/cadi/filter/MapBathConverter.java create mode 100644 cadi/core/src/main/java/org/onap/aaf/cadi/util/CSV.java create mode 100644 cadi/core/src/test/java/org/onap/aaf/cadi/config/test/JU_MapBathConverter.java create mode 100644 cadi/core/src/test/java/org/onap/aaf/cadi/util/test/JU_CSV.java (limited to 'cadi/core') diff --git a/cadi/core/pom.xml b/cadi/core/pom.xml index 9afde0c8..32b63155 100644 --- a/cadi/core/pom.xml +++ b/cadi/core/pom.xml @@ -16,7 +16,7 @@ org.onap.aaf.authz cadiparent .. - 2.1.3-SNAPSHOT + 2.1.4-SNAPSHOT 4.0.0 diff --git a/cadi/core/src/main/java/org/onap/aaf/cadi/config/Config.java b/cadi/core/src/main/java/org/onap/aaf/cadi/config/Config.java index 088227ed..2479a058 100644 --- a/cadi/core/src/main/java/org/onap/aaf/cadi/config/Config.java +++ b/cadi/core/src/main/java/org/onap/aaf/cadi/config/Config.java @@ -103,6 +103,7 @@ public class Config { public static final String CADI_PROTOCOLS = "cadi_protocols"; public static final String CADI_NOAUTHN = "cadi_noauthn"; public static final String CADI_LOC_LIST = "cadi_loc_list"; + public static final String CADI_BATH_CONVERT = "cadi_bath_convert"; public static final String CADI_USER_CHAIN_TAG = "cadi_user_chain"; public static final String CADI_USER_CHAIN = "USER_CHAIN"; diff --git a/cadi/core/src/main/java/org/onap/aaf/cadi/filter/MapBathConverter.java b/cadi/core/src/main/java/org/onap/aaf/cadi/filter/MapBathConverter.java new file mode 100644 index 00000000..7a138e97 --- /dev/null +++ b/cadi/core/src/main/java/org/onap/aaf/cadi/filter/MapBathConverter.java @@ -0,0 +1,170 @@ +/** + * ============LICENSE_START==================================================== + * org.onap.aaf + * =========================================================================== + * Copyright (c) 2018 AT&T Intellectual Property. All rights reserved. + * =========================================================================== + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ============LICENSE_END==================================================== + * + */ + +package org.onap.aaf.cadi.filter; + +import java.io.IOException; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import javax.xml.ws.Holder; + +import org.onap.aaf.cadi.Access; +import org.onap.aaf.cadi.Access.Level; +import org.onap.aaf.cadi.CadiException; +import org.onap.aaf.cadi.Symm; +import org.onap.aaf.cadi.util.CSV; +import org.onap.aaf.cadi.util.CSV.Visitor; + +/** + * This Filter is designed to help MIGRATE users from systems that don't match the FQI style. + * + * Style 1, where just the ID is translated, i.e. OLD => new@something.onap.org, that is acceptable + * longer term, because it does not store Creds locally. The passwords are in appropriate systems, but + * it's still painful operationally, though it does ease migration. + * + * Style 3, however, which is Direct match of Authorization Header to replacement, is only there + * because some passwords are simply not acceptable for AAF, (too easy, for instance), and it is + * not feasible to break Organization Password rules for a Migration. Therefore, this method + * should not considered something that is in any way a permanent + * + + * + * It goes without saying that any file with the password conversion should be protected by "400", etc. + * + * @author Instrumental (Jonathan) + * + */ +public class MapBathConverter { + private static final String BASIC = "Basic "; + private final Map map; + + /** + * Create with colon separated name value pairs + * Enter the entire "Basic dXNlcjpwYXNz" "Authorization" header, where "dXNlcjpwYXNz" is + * base64 encoded, which can be created with "cadi" tool (in jar) + * + * The replacement should also be an exact replacement of what you want. Recognize that + * this should be TEMPORARY as you are storing credentials outside the users control. + * + * @param value + * @throws IOException + * @throws CadiException + */ + public MapBathConverter(final Access access, final CSV csv) throws IOException, CadiException { + map = new TreeMap<>(); + final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); + final Date now = new Date(); + csv.visit(new Visitor() { + @Override + public void visit(List row) throws CadiException { + if(row.size()<3) { + throw new CadiException("CSV file " + csv + " must have at least 2 Basic Auth columns and an Expiration Date(YYYYMMDD) in each row"); + } + try { + Date date = sdf.parse(row.get(2)); + String oldID = row.get(0); + String newID = row.get(1); + if(date.after(now)) { + if(!oldID.startsWith(BASIC) && newID.startsWith(BASIC)) { + throw new CadiException("CSV file " + csv + ": Uncredentialed ID " + idFromBasic(oldID,null) + + " may not transfer to credentialed ID " + idFromBasic(newID,null)); + } else { + map.put(oldID,newID); + access.printf(Level.INIT, "ID Conversion from %s to %s enabled", + idFromBasic(oldID,null), + idFromBasic(newID,null)); + } + } else { + access.printf(Level.INIT, "ID Conversion from %s to %s has expired.", + idFromBasic(oldID,null), + idFromBasic(newID,null)); + } + } catch (ParseException e) { + throw new CadiException("Cannot Parse Date: " + row.get(2)); + } catch (IOException e) { + throw new CadiException(e); + } + } + }); + } + + private static String idFromBasic(String bath, Holder hpass) throws IOException, CadiException { + if(bath.startsWith(BASIC)) { + String cred = Symm.base64noSplit.decode(bath.substring(6)); + int colon = cred.indexOf(':'); + if(colon<0) { + throw new CadiException("Invalid Authentication Credential for " + cred); + } + if(hpass!=null) { + hpass.value = cred.substring(colon+1); + } + return cred.substring(0, colon); + } else { + return bath; + } + } + + /** + * use to instantiate entries + * + * @return + */ + public Map map() { + return map; + } + + public String convert(Access access, final String bath) { + String rv = map.get(bath); + String cred=null; + Holder hpass=null; + try { + if(rv==null || !rv.startsWith(BASIC)) { + if(bath.startsWith(BASIC)) { + cred = idFromBasic(bath,(hpass=new Holder())); + } + } + + if(cred!=null) { + if(rv==null) { + rv = map.get(cred); + } + // for SAFETY REASONS, we WILL NOT allow a non validated cred to + // pass a password from file. Should be caught from Instation, but... + if(rv!=null) { + if(rv.startsWith(BASIC)) { + return bath; + } else { + rv = BASIC + Symm.base64noSplit.encode(rv+':'+hpass.value); + } + } + } + } catch (IOException | CadiException e) { + access.log(e,"Invalid Authorization"); + } + + return rv; + } +} diff --git a/cadi/core/src/main/java/org/onap/aaf/cadi/taf/basic/BasicHttpTaf.java b/cadi/core/src/main/java/org/onap/aaf/cadi/taf/basic/BasicHttpTaf.java index d5f6b032..3466a8d8 100644 --- a/cadi/core/src/main/java/org/onap/aaf/cadi/taf/basic/BasicHttpTaf.java +++ b/cadi/core/src/main/java/org/onap/aaf/cadi/taf/basic/BasicHttpTaf.java @@ -34,16 +34,20 @@ import org.onap.aaf.cadi.Access.Level; import org.onap.aaf.cadi.BasicCred; import org.onap.aaf.cadi.CachedPrincipal; import org.onap.aaf.cadi.CachedPrincipal.Resp; +import org.onap.aaf.cadi.CadiException; import org.onap.aaf.cadi.CredVal; import org.onap.aaf.cadi.CredVal.Type; import org.onap.aaf.cadi.CredValDomain; import org.onap.aaf.cadi.Taf; +import org.onap.aaf.cadi.config.Config; +import org.onap.aaf.cadi.filter.MapBathConverter; import org.onap.aaf.cadi.principal.BasicPrincipal; import org.onap.aaf.cadi.principal.CachedBasicPrincipal; import org.onap.aaf.cadi.taf.HttpTaf; import org.onap.aaf.cadi.taf.TafResp; import org.onap.aaf.cadi.taf.TafResp.RESP; import org.onap.aaf.cadi.taf.dos.DenialOfServiceTaf; +import org.onap.aaf.cadi.util.CSV; /** * BasicHttpTaf @@ -66,6 +70,7 @@ public class BasicHttpTaf implements HttpTaf { private Map rbacs = new TreeMap<>(); private boolean warn; private long timeToLive; + private MapBathConverter mapIds; public BasicHttpTaf(Access access, CredVal rbac, String realm, long timeToLive, boolean turnOnWarning) { this.access = access; @@ -73,6 +78,16 @@ public class BasicHttpTaf implements HttpTaf { this.rbac = rbac; this.warn = turnOnWarning; this.timeToLive = timeToLive; + String csvFile = access.getProperty(Config.CADI_BATH_CONVERT, null); + if(csvFile==null) { + mapIds=null; + } else { + try { + mapIds = new MapBathConverter(access, new CSV(csvFile)); + } catch (IOException | CadiException e) { + access.log(e,"Bath Map Conversion is not initialzed (non fatal)"); + } + } } public void add(final CredValDomain cvd) { @@ -116,6 +131,9 @@ public class BasicHttpTaf implements HttpTaf { if (warn&&!req.isSecure()) { access.log(Level.WARN,"WARNING! BasicAuth has been used over an insecure channel"); } + if(mapIds != null) { + authz = mapIds.convert(access, authz); + } try { CachedBasicPrincipal ba = new CachedBasicPrincipal(this,authz,realm,timeToLive); if (DenialOfServiceTaf.isDeniedID(ba.getName())!=null) { diff --git a/cadi/core/src/main/java/org/onap/aaf/cadi/util/CSV.java b/cadi/core/src/main/java/org/onap/aaf/cadi/util/CSV.java new file mode 100644 index 00000000..4ae68310 --- /dev/null +++ b/cadi/core/src/main/java/org/onap/aaf/cadi/util/CSV.java @@ -0,0 +1,190 @@ +/** + * ============LICENSE_START==================================================== + * org.onap.aaf + * =========================================================================== + * Copyright (c) 2018 AT&T Intellectual Property. All rights reserved. + * =========================================================================== + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ============LICENSE_END==================================================== + */ + +package org.onap.aaf.cadi.util; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.FileReader; +import java.io.IOException; +import java.io.PrintStream; +import java.util.ArrayList; +import java.util.List; + +import org.onap.aaf.cadi.CadiException; + +/** + * Read CSV file for various purposes + * + * @author Instrumental(Jonathan) + * + */ +public class CSV { + private File csv; + + public CSV(File file) { + csv = file; + } + + public CSV(String csvOfProperties) { + csv = new File(csvOfProperties); + } + + + /** + * Create your code to accept the List row. + * + * Your code may keep the List... CSV does not hold onto it. + * + * @author Instrumental(Jonathan) + * + */ + public interface Visitor { + void visit(List row) throws IOException, CadiException; + } + + public void visit(Visitor visitor) throws IOException, CadiException { + BufferedReader br = new BufferedReader(new FileReader(csv)); + try { + String line; + StringBuilder sb = new StringBuilder(); + while((line = br.readLine())!=null) { + line=line.trim(); + if(!line.startsWith("#") && line.length()>0) { +// System.out.println(line); uncomment to debug + List row = new ArrayList<>(); + boolean quotes=false; + boolean escape=false; + char c; + for(int i=0;i0) { + row.add(sb.toString()); + sb.setLength(0); + } + visitor.visit(row); + } + } + } finally { + br.close(); + } + } + + public Writer writer() throws FileNotFoundException { + return new Writer(); + } + + public class Writer { + private PrintStream ps; + private Writer() throws FileNotFoundException { + ps = new PrintStream(new FileOutputStream(csv)); + } + public void row(Object ... strings) { + if(strings.length>0) { + boolean first = true; + boolean quote; + String s; + for(Object o : strings) { + if(first) { + first = false; + } else { + ps.append(','); + } + s = o.toString(); + quote = s.matches(".*[,|\"].*"); + if(quote) { + ps.append('"'); + ps.print(s.replace("\"", "\"\"") + .replace("'", "''") + .replace("\\", "\\\\")); + ps.append('"'); + } else { + ps.append(s); + } + } + ps.println(); + } + } + + /** + * Note: CSV files do not actually support Comments as a standard, but it is useful + * @param comment + */ + public void comment(String comment) { + ps.print("# "); + ps.println(comment); + } + + public void flush() { + ps.flush(); + } + + public void close() { + ps.close(); + } + } + + public void delete() { + csv.delete(); + } + + public String toString() { + return csv.getAbsolutePath(); + } + +} diff --git a/cadi/core/src/test/java/org/onap/aaf/cadi/config/test/JU_MapBathConverter.java b/cadi/core/src/test/java/org/onap/aaf/cadi/config/test/JU_MapBathConverter.java new file mode 100644 index 00000000..0bfa94cb --- /dev/null +++ b/cadi/core/src/test/java/org/onap/aaf/cadi/config/test/JU_MapBathConverter.java @@ -0,0 +1,217 @@ +/** + * ============LICENSE_START==================================================== + * org.onap.aaf + * =========================================================================== + * Copyright (c) 2018 AT&T Intellectual Property. All rights reserved. + * =========================================================================== + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ============LICENSE_END==================================================== + */ + +package org.onap.aaf.cadi.config.test; + +import java.io.File; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.GregorianCalendar; +import java.util.Iterator; +import java.util.List; + +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.onap.aaf.cadi.Access; +import org.onap.aaf.cadi.CadiException; +import org.onap.aaf.cadi.PropAccess; +import org.onap.aaf.cadi.Symm; +import org.onap.aaf.cadi.filter.MapBathConverter; +import org.onap.aaf.cadi.util.CSV; +import org.onap.aaf.cadi.util.CSV.Visitor; +import org.onap.aaf.cadi.util.CSV.Writer; + +import junit.framework.Assert; + +/** + * Test a simple Migration conversion tool for CADI + * + * @author Instrumental(Jonathan) + * + */ +public class JU_MapBathConverter { + private static final String NEW_USER_SOMETHING_ORG = "NEW_USER@Something.org"; + private static final String OLD_ID = "OLD_ID"; + private static final String SHARED_PASS = "SHARED_PASS"; + private static CSV csv; + private static ArrayList expected; + private static final Access access = new PropAccess(); + private final static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); + + @BeforeClass + public static void createFile() throws IOException { + // Note, you cate a "MapBathConverter" by access to a File. + // We will create that file now. Local is fine. + csv = new CSV("JU_MapBathConverter.csv"); + } + + @BeforeClass + public static void beforeClass() { + expected = new ArrayList<>(); + } + + @Before + public void before() { + expected.clear(); + } + + @Test + public void test() throws IOException, CadiException { + CSV.Writer cw = csv.writer(); + GregorianCalendar gc = new GregorianCalendar(); + gc.add(GregorianCalendar.MONTH, 6); + try { + try { + // CSV can simply be OLD ID and NEW, no passwords + cw.row(exp(OLD_ID), exp(NEW_USER_SOMETHING_ORG),sdf.format(gc.getTime())); + + // Style 1 - Incoming ID/pass, create new cred with NweID and same Pass + cw.row(exp(bath(OLD_ID,SHARED_PASS)), exp(NEW_USER_SOMETHING_ORG),sdf.format(gc.getTime())); + // the response should be Basic with NEW_ID and OLD_PASS + + // Style 2 + cw.row(exp(bath(OLD_ID,"OLD_PASS")), exp(bath(NEW_USER_SOMETHING_ORG,"NEW_PASS")),sdf.format(gc.getTime())); + + } finally { + cw.close(); + } + + final Iterator exp = expected.iterator(); + csv.visit(new Visitor() { + @Override + public void visit(List row) { + int i=0; + for(String s : row) { + switch(i++) { + case 0: + case 1: + Assert.assertEquals(exp.next(), s); + break; + case 2: + System.out.println(s); + break; + default: + Assert.fail("There should only be 3 columns in this test case."); + } + } + } + }); + + MapBathConverter mbc = new MapBathConverter(access, csv); + + // Check no lookup just returns the same + Assert.assertEquals("NonKey", "NonKey"); // if not in map, expect same value + + Iterator exp1 = expected.iterator(); + // there's no passwords in CSV + String old = exp1.next(); + String nw = exp1.next(); + Assert.assertEquals(nw, mbc.convert(access,old)); + + Assert.assertEquals(bath(NEW_USER_SOMETHING_ORG,SHARED_PASS), mbc.convert(access,bath(OLD_ID,SHARED_PASS))); + + // Style 1 (new cred, old password) + old = exp1.next(); + nw = bath(exp1.next(),SHARED_PASS); + Assert.assertEquals(nw, mbc.convert(access,old)); + + // Style 2 + old = exp1.next(); + nw = exp1.next(); + Assert.assertEquals(nw, mbc.convert(access,old)); + + } finally { + csv.delete(); + } + } + + @Test + public void testTooFewColumns() throws IOException, CadiException { + CSV.Writer cw = csv.writer(); + try { + try { + cw.row(exp(bath(OLD_ID,"OLD_PASS")), exp(bath(NEW_USER_SOMETHING_ORG,"NEW_PASS"))); + } finally { + cw.close(); + } + + try { + new MapBathConverter(access, csv); + Assert.fail("file with too few rows should throw exception"); + } catch(CadiException | IOException e) { + Assert.assertTrue("Correctly thrown Exception",true); + } + } finally { + csv.delete(); + } + } + + @Test + public void testNoFile() { + try { + new MapBathConverter(access, new CSV("Bogus")); + Assert.fail("Non Existent File should throw exception"); + } catch(CadiException | IOException e) { + Assert.assertTrue("Correctly thrown Exception",true); + } + } + + @Test + public void testBadRows() throws IOException { + try { + Writer cw = csv.writer(); + try { + cw.row("Single Column"); + } finally { + cw.close(); + } + + try { + new MapBathConverter(access,csv); + Assert.fail("Non Existent File should throw exception"); + } catch(CadiException | IOException e) { + Assert.assertTrue("Correctly thrown Exception",true); + } + } finally { + csv.delete(); + } + + // Check for deletion + Assert.assertFalse(csv.toString() + "should have been deleted",new File(csv.toString()).exists()); + } + + private String bath(String user, String password) throws IOException { + StringBuilder sb = new StringBuilder(user); + sb.append(':'); + sb.append(password); + byte[] encoded = Symm.base64noSplit.encode(sb.toString().getBytes()); + sb.setLength(0); + sb.append("Basic "); + sb.append(new String(encoded)); + return sb.toString(); + } + + private String exp(String s) { + expected.add(s); + return s; + } +} diff --git a/cadi/core/src/test/java/org/onap/aaf/cadi/util/test/JU_CSV.java b/cadi/core/src/test/java/org/onap/aaf/cadi/util/test/JU_CSV.java new file mode 100644 index 00000000..54c48daf --- /dev/null +++ b/cadi/core/src/test/java/org/onap/aaf/cadi/util/test/JU_CSV.java @@ -0,0 +1,122 @@ +/** + * ============LICENSE_START==================================================== + * org.onap.aaf + * =========================================================================== + * Copyright (c) 2018 AT&T Intellectual Property. All rights reserved. + * =========================================================================== + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ============LICENSE_END==================================================== + */ + +package org.onap.aaf.cadi.util.test; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.util.ArrayList; +import java.util.List; + +import javax.xml.ws.Holder; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.onap.aaf.cadi.CadiException; +import org.onap.aaf.cadi.util.CSV; +import org.onap.aaf.cadi.util.CSV.Visitor; +import org.onap.aaf.cadi.util.CSV.Writer; + +public class JU_CSV { + + private String filename; + private File file; + private static ArrayList expected; + + @Before + public void start() { + filename = "Sample.csv"; + file = new File(filename); + } + + @After + public void end() { + if(file!=null) { + file.delete(); + } + } + + @BeforeClass + public static void before() { + expected = new ArrayList<>(); + } + + @Test + public void test() throws IOException, CadiException { + CSV csv = new CSV(file); + // Can't visit for file that doesn't exist + try { + csv.visit(new Visitor() { + @Override + public void visit(List row) { + }}); + } catch(IOException e) { + Assert.assertTrue("CSV correctly created exception",true); + } + + Writer writer = csv.writer(); + try { + writer.row(add("\"hello\"")); + writer.comment("Ignore Comments"); + writer.row(add("dXNlcjpwYXNzd29yZA=="),add("dXNlckBzb21ldGhpbmcub3JnOm90aGVyUGFzc3dvcmQ=")); + writer.row(); // no output + writer.row(add("There is, but one thing to say"), add(" and that is"), add("\"All the best\"")); + } finally { + writer.close(); + } + + PrintStream garbage = new PrintStream(new FileOutputStream(file, true)); + try { + garbage.println("# Ignore empty spaces, etc"); + garbage.println(" "); + garbage.println("# Ignore blank lines"); + garbage.println(); + } finally { + garbage.close(); + } + + + //////////// + // Tests + //////////// + final Holder hi = new Holder<>(0); + csv.visit(new CSV.Visitor() { + @Override + public void visit(List row) { + for(String s: row) { +// System.out.println(hi.value + ") " + s); + Assert.assertEquals(expected.get(hi.value++),s); + } + } + }); + + } + + private String add(String s) { + expected.add(s); + return s; + } + +} -- cgit 1.2.3-korg