PersistentWatcherACLTest.java

/**
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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
 * <p>
 * http://www.apache.org/licenses/LICENSE-2.0
 * <p>
 * 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.
 */

package org.apache.zookeeper.test;

import static org.apache.zookeeper.AddWatchMode.PERSISTENT;
import static org.apache.zookeeper.AddWatchMode.PERSISTENT_RECURSIVE;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.fail;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import org.apache.zookeeper.AddWatchMode;
import org.apache.zookeeper.CreateMode;
import org.apache.zookeeper.KeeperException;
import org.apache.zookeeper.WatchedEvent;
import org.apache.zookeeper.Watcher;
import org.apache.zookeeper.Watcher.Event.EventType;
import org.apache.zookeeper.ZooDefs;
import org.apache.zookeeper.ZooKeeper;
import org.apache.zookeeper.data.ACL;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * This class encodes a set of tests corresponding to a "truth table"
 * of interactions between persistent watchers and znode ACLs:
 *
 * <a href="https://docs.google.com/spreadsheets/d/1eMH2aimrrMc_b6McU8CHm2yCj2X-w30Fy4fCBOHn7NA/edit#gid=0">https://docs.google.com/spreadsheets/d/1eMH2aimrrMc_b6McU8CHm2yCj2X-w30Fy4fCBOHn7NA/edit#gid=0</a>
 */
public class PersistentWatcherACLTest extends ClientBase {
    private static final Logger LOG = LoggerFactory.getLogger(PersistentWatcherACLTest.class);
    /** An ACL denying READ. */
    private static final List<ACL> ACL_NO_READ = Collections.singletonList(new ACL(ZooDefs.Perms.ALL & ~ZooDefs.Perms.READ, ZooDefs.Ids.ANYONE_ID_UNSAFE));
    private BlockingQueue<WatchedEvent> events;
    private Watcher persistentWatcher;

    @Override
    @BeforeEach
    public void setUp() throws Exception {
        super.setUp();

        events = new LinkedBlockingQueue<>();
        persistentWatcher = event -> {
            events.add(event);
            LOG.info("Added event: {}; total: {}", event, events.size());
        };
    }

    /**
     * This Step class, with the Round class below, is used to encode
     * the contents of the truth table.
     *
     * (These should become Records once we target JDK 14+.)
     */
    private static class Step {
        Step(int opCode, String target) {
            this(opCode, target, null, null);
        }
        Step(int opCode, String target, EventType eventType, String eventPath) {
            this.opCode = opCode;
            this.target = target;
            this.eventType = eventType;
            this.eventPath = eventPath;
        }
        /** Action: create, setData or delete */
        final int opCode;
        /** Target path */
        final String target;
        /** Expected event type, {@code null} if no event is expected */
        final EventType eventType;
        /** Expected event path, {@code null} if no event is expected */
        final String eventPath;
    }

    /**
     * This Round class, with the Step class above, is used to encode
     * the contents of the truth table.
     *
     * (These should become Records once we target JDK 14+.)
     */
    private static class Round {
        Round(String summary, Boolean allowA, Boolean allowB, Boolean allowC, String watchTarget, AddWatchMode watchMode, Step[] steps) {
            this.summary = summary;
            this.allowA = allowA;
            this.allowB = allowB;
            this.allowC = allowC;
            this.watchTarget = watchTarget;
            this.watchMode = watchMode;
            this.steps = steps;
        }
        /** Notes/summary */
        final String summary;
        /** Should /a's ACL leave it readable? */
        final Boolean allowA;
        /** Should /a/b's ACL leave it readable? */
        final Boolean allowB;
        /** Should /a/b/c's ACL leave it readable? */
        final Boolean allowC;
        /** Watch path */
        final String watchTarget;
        /** Watch mode */
        final AddWatchMode watchMode;
        /** Actions and expected events */
        final Step[] steps;
    }

    /**
     * A "round" of tests from the table encoded as Java objects.
     *
     * Note that the set of rounds is collected in a {@code ROUNDS}
     * array below, and that this test class includes a {@code main}
     * method which produces a "CSV" rendition of the table, for ease
     * of comparison with the original.
     *
     * @see #ROUNDS
     */
    private static final Round roundNothingAsAIsWatchedButDeniedBIsNotWatched =
        new Round(
            "Nothing as a is watched but denied. b is not watched",
            false, true, null, "/a", PERSISTENT, new Step[] {
                new Step(ZooDefs.OpCode.setData, "/a"),
                new Step(ZooDefs.OpCode.create, "/a/b"),
                new Step(ZooDefs.OpCode.setData, "/a/b"),
                new Step(ZooDefs.OpCode.delete, "/a/b"),
                new Step(ZooDefs.OpCode.delete, "/a"),
            }
        );

    /**
     * @see #roundNothingAsAIsWatchedButDeniedBIsNotWatched
     */
    private static final Round roundNothingAsBothAAndBDenied =
        new Round(
            "Nothing as both a and b denied",
            false, false, null, "/a", PERSISTENT, new Step[] {
                new Step(ZooDefs.OpCode.setData, "/a"),
                new Step(ZooDefs.OpCode.create, "/a/b"),
                new Step(ZooDefs.OpCode.delete, "/a/b"),
                new Step(ZooDefs.OpCode.delete, "/a"),
            }
        );

    /**
     * @see #roundNothingAsAIsWatchedButDeniedBIsNotWatched
     */
    private static final Round roundAChangesInclChildrenAreSeen =
        new Round(
            "a changes, incl children, are seen",
            true, false, null, "/a", PERSISTENT, new Step[] {
                new Step(ZooDefs.OpCode.create, "/a", EventType.NodeCreated, "/a"),
                new Step(ZooDefs.OpCode.setData, "/a", EventType.NodeDataChanged, "/a"),
                new Step(ZooDefs.OpCode.create, "/a/b", EventType.NodeChildrenChanged, "/a"),
                new Step(ZooDefs.OpCode.setData, "/a/b"),
                new Step(ZooDefs.OpCode.delete, "/a/b", EventType.NodeChildrenChanged, "/a"),
                new Step(ZooDefs.OpCode.delete, "/a", EventType.NodeDeleted, "/a"),
            }
        );

    /**
     * @see #roundNothingAsAIsWatchedButDeniedBIsNotWatched
     */
    private static final Round roundNothingForAAsItSDeniedBChangesSeen =
        new Round(
            "Nothing for a as it's denied, b changes allowed/seen",
            false, true, null, "/a", PERSISTENT_RECURSIVE, new Step[] {
                new Step(ZooDefs.OpCode.setData, "/a"),
                new Step(ZooDefs.OpCode.create, "/a/b", EventType.NodeCreated, "/a/b"),
                new Step(ZooDefs.OpCode.setData, "/a/b", EventType.NodeDataChanged, "/a/b"),
                new Step(ZooDefs.OpCode.delete, "/a/b", EventType.NodeDeleted, "/a/b"),
                new Step(ZooDefs.OpCode.delete, "/a"),
            }
        );

    /**
     * @see #roundNothingAsAIsWatchedButDeniedBIsNotWatched
     */
    private static final Round roundNothingBothDenied =
        new Round(
            "Nothing - both denied",
            false, false, null, "/a", PERSISTENT_RECURSIVE, new Step[] {
                new Step(ZooDefs.OpCode.setData, "/a"),
                new Step(ZooDefs.OpCode.create, "/a/b"),
                new Step(ZooDefs.OpCode.setData, "/a/b"),
                new Step(ZooDefs.OpCode.delete, "/a/b"),
                new Step(ZooDefs.OpCode.delete, "/a"),
            }
        );

    /**
     * @see #roundNothingAsAIsWatchedButDeniedBIsNotWatched
     */
    private static final Round roundNothingAllDenied =
        new Round(
            "Nothing - all denied",
            false, false, false, "/a", PERSISTENT_RECURSIVE, new Step[] {
                new Step(ZooDefs.OpCode.create, "/a/b"),
                new Step(ZooDefs.OpCode.setData, "/a/b"),
                new Step(ZooDefs.OpCode.create, "/a/b/c"),
                new Step(ZooDefs.OpCode.setData, "/a/b/c"),
                new Step(ZooDefs.OpCode.delete, "/a/b/c"),
                new Step(ZooDefs.OpCode.delete, "/a/b"),
            }
        );

    /**
     * @see #roundNothingAsAIsWatchedButDeniedBIsNotWatched
     */
    private static final Round roundADeniesSeeAllChangesForBAndCIncludingBChildren =
        new Round(
            "a denies, see all changes for b and c, including b's children",
            false, true, true, "/a", PERSISTENT_RECURSIVE, new Step[] {
                new Step(ZooDefs.OpCode.create, "/a/b", EventType.NodeCreated, "/a/b"),
                new Step(ZooDefs.OpCode.setData, "/a/b", EventType.NodeDataChanged, "/a/b"),
                new Step(ZooDefs.OpCode.create, "/a/b/c", EventType.NodeCreated, "/a/b/c"),
                new Step(ZooDefs.OpCode.setData, "/a/b/c", EventType.NodeDataChanged, "/a/b/c"),
                new Step(ZooDefs.OpCode.delete, "/a/b/c", EventType.NodeDeleted, "/a/b/c"),
                new Step(ZooDefs.OpCode.delete, "/a/b", EventType.NodeDeleted, "/a/b"),
            }
        );

    /**
     * @see #roundNothingAsAIsWatchedButDeniedBIsNotWatched
     */
    private static final Round roundADeniesSeeAllBChangesAndBChildrenNothingForC =
        new Round(
            "a denies, see all b changes and b's children, nothing for c",
            false, true, false, "/a", PERSISTENT_RECURSIVE, new Step[] {
                new Step(ZooDefs.OpCode.create, "/a/b", EventType.NodeCreated, "/a/b"),
                new Step(ZooDefs.OpCode.setData, "/a/b", EventType.NodeDataChanged, "/a/b"),
                new Step(ZooDefs.OpCode.create, "/a/b/c"),
                new Step(ZooDefs.OpCode.setData, "/a/b/c"),
                new Step(ZooDefs.OpCode.delete, "/a/b/c"),
                new Step(ZooDefs.OpCode.delete, "/a/b", EventType.NodeDeleted, "/a/b"),
            }
        );

    /**
     * @see #roundNothingAsAIsWatchedButDeniedBIsNotWatched
     */
    private static final Round roundNothingTheWatchIsOnC =
        new Round(
            "Nothing - the watch is on c",
            false, true, false, "/a/b/c", PERSISTENT_RECURSIVE, new Step[] {
                new Step(ZooDefs.OpCode.create, "/a/b"),
                new Step(ZooDefs.OpCode.setData, "/a/b"),
                new Step(ZooDefs.OpCode.create, "/a/b/c"),
                new Step(ZooDefs.OpCode.setData, "/a/b/c"),
                new Step(ZooDefs.OpCode.delete, "/a/b/c"),
                new Step(ZooDefs.OpCode.delete, "/a/b"),
            }
        );

    /**
     * @see #roundNothingAsAIsWatchedButDeniedBIsNotWatched
     */
    private static final Round roundTheWatchIsOnlyOnCBAndCAllowed =
        new Round(
            "The watch is only on c (b and c allowed)",
            false, true, true, "/a/b/c", PERSISTENT_RECURSIVE, new Step[] {
                new Step(ZooDefs.OpCode.create, "/a/b"),
                new Step(ZooDefs.OpCode.setData, "/a/b"),
                new Step(ZooDefs.OpCode.create, "/a/b/c", EventType.NodeCreated, "/a/b/c"),
                new Step(ZooDefs.OpCode.setData, "/a/b/c", EventType.NodeDataChanged, "/a/b/c"),
                new Step(ZooDefs.OpCode.delete, "/a/b/c", EventType.NodeDeleted, "/a/b/c"),
                new Step(ZooDefs.OpCode.delete, "/a/b"),
            }
        );

    /**
     * Transform the "tristate" {@code allow} property to a concrete
     * ACL which can be passed to the ZooKeeper API.
     *
     * @param allow "tristate" value: {@code null}/don't care, {@code
     * true}, {@code false}
     * @return the ACL
     */
    private static List<ACL> selectAcl(Boolean allow) {
        if (allow == null) {
            return null;
        } else if (!allow) {
            return ACL_NO_READ;
        } else {
            return ZooDefs.Ids.OPEN_ACL_UNSAFE;
        }
    }

    /**
     * Executes one "round" of tests from the Java object encoding of
     * the table.
     *
     * @param round the "round"
     *
     * @see #roundNothingAsAIsWatchedButDeniedBIsNotWatched
     * @see PersistentWatcherACLTest.Round
     * @see PersistentWatcherACLTest.Step
     */
    private void execRound(Round round)
        throws IOException, InterruptedException, KeeperException {
        try (ZooKeeper zk = createClient(new CountdownWatcher(), hostPort)) {
            List<ACL> aclForA = selectAcl(round.allowA);
            List<ACL> aclForB = selectAcl(round.allowB);
            List<ACL> aclForC = selectAcl(round.allowC);

            boolean firstStepCreatesA = round.steps.length > 0
                && round.steps[0].opCode == ZooDefs.OpCode.create
                && round.steps[0].target.equals("/a");

            // Assume /a always exists (except if it's about to be created)
            if (!firstStepCreatesA) {
                zk.create("/a", new byte[0], aclForA, CreateMode.PERSISTENT);
            }

            zk.addWatch(round.watchTarget, persistentWatcher, round.watchMode);

            for (int i = 0; i < round.steps.length; i++) {
                Step step = round.steps[i];

                switch (step.opCode) {
                case ZooDefs.OpCode.create:
                    List<ACL> acl = step.target.endsWith("/c")
                        ? aclForC
                        : step.target.endsWith("/b")
                        ? aclForB
                        : aclForA;
                    zk.create(step.target, new byte[0], acl, CreateMode.PERSISTENT);
                    break;
                case ZooDefs.OpCode.delete:
                    zk.delete(step.target, -1);
                    break;
                case ZooDefs.OpCode.setData:
                    zk.setData(step.target, new byte[0], -1);
                    break;
                default:
                    fail("Unexpected opCode " + step.opCode + " in step " + i);
                    break;
                }

                WatchedEvent actualEvent = events.poll(500, TimeUnit.MILLISECONDS);
                if (step.eventType == null) {
                    assertNull(actualEvent, "Unexpected event " + actualEvent + " at step " + i);
                } else {
                    String m = "In event " + actualEvent + " at step " + i;
                    assertNotNull(actualEvent, m);
                    assertEquals(step.eventType,  actualEvent.getType(), m);
                    assertEquals(step.eventPath, actualEvent.getPath(), m);
                }
            }
        }
    }

    /**
     * A test method, wrapping the definition of a "round."  This
     * should really use JUnit 5's runtime test case generation
     * facilities, but that would prevent backporting this suite to
     * JUnit 4.
     *
     * @see #roundNothingAsAIsWatchedButDeniedBIsNotWatched
     * @see <a href="https://junit.org/junit5/docs/5.0.2/api/org/junit/jupiter/api/DynamicTest.html">JUnit 5 runtime test case generation</a>
     */
    @Test
    public void testNothingAsAIsWatchedButDeniedBIsNotWatched()
        throws IOException, InterruptedException, KeeperException {
        execRound(roundNothingAsAIsWatchedButDeniedBIsNotWatched);
    }

    /**
     * @see #testNothingAsAIsWatchedButDeniedBIsNotWatched
     * @see #roundNothingAsBothAAndBDenied
     */
    @Test
    public void testNothingAsBothAAndBDenied()
        throws IOException, InterruptedException, KeeperException {
        execRound(roundNothingAsBothAAndBDenied);
    }

    /**
     * @see #testNothingAsAIsWatchedButDeniedBIsNotWatched
     * @see #roundAChangesInclChildrenAreSeen
     */
    @Test
    public void testAChangesInclChildrenAreSeen()
        throws IOException, InterruptedException, KeeperException {
        execRound(roundAChangesInclChildrenAreSeen);
    }

    /**
     * @see #testNothingAsAIsWatchedButDeniedBIsNotWatched
     * @see #roundNothingForAAsItSDeniedBChangesSeen
     */
    @Test
    public void testNothingForAAsItSDeniedBChangesSeen()
        throws IOException, InterruptedException, KeeperException {
        execRound(roundNothingForAAsItSDeniedBChangesSeen);
    }

    /**
     * @see #testNothingAsAIsWatchedButDeniedBIsNotWatched
     * @see #roundNothingBothDenied
     */
    @Test
    public void testNothingBothDenied()
        throws IOException, InterruptedException, KeeperException {
        execRound(roundNothingBothDenied);
    }

    /**
     * @see #testNothingAsAIsWatchedButDeniedBIsNotWatched
     * @see #roundNothingAllDenied
     */
    @Test
    public void testNothingAllDenied()
        throws IOException, InterruptedException, KeeperException {
        execRound(roundNothingAllDenied);
    }

    /**
     * @see #testNothingAsAIsWatchedButDeniedBIsNotWatched
     * @see #roundADeniesSeeAllChangesForBAndCIncludingBChildren
     */
    @Test
    public void testADeniesSeeAllChangesForBAndCIncludingBChildren()
        throws IOException, InterruptedException, KeeperException {
        execRound(roundADeniesSeeAllChangesForBAndCIncludingBChildren);
    }

    /**
     * @see #testNothingAsAIsWatchedButDeniedBIsNotWatched
     * @see #roundADeniesSeeAllBChangesAndBChildrenNothingForC
     */
    @Test
    public void testADeniesSeeAllBChangesAndBChildrenNothingForC()
        throws IOException, InterruptedException, KeeperException {
        execRound(roundADeniesSeeAllBChangesAndBChildrenNothingForC);
    }

    /**
     * @see #testNothingAsAIsWatchedButDeniedBIsNotWatched
     * @see #roundNothingTheWatchIsOnC
     */
    @Test
    public void testNothingTheWatchIsOnC()
        throws IOException, InterruptedException, KeeperException {
        execRound(roundNothingTheWatchIsOnC);
    }

    /**
     * @see #testNothingAsAIsWatchedButDeniedBIsNotWatched
     * @see #roundTheWatchIsOnlyOnCBAndCAllowed
     */
    @Test
    public void testTheWatchIsOnlyOnCBAndCAllowed()
        throws IOException, InterruptedException, KeeperException {
        execRound(roundTheWatchIsOnlyOnCBAndCAllowed);
    }

    // The rest of this class is the world's lamest "CSV" encoder.

    /**
     * The set of rounds.  This array includes one entry for each
     * {@code private static final Round round*} member variable
     * defined above.
     *
     * @see #roundNothingAsAIsWatchedButDeniedBIsNotWatched
     */
    private static final Round[] ROUNDS = new Round[] {
        roundNothingAsAIsWatchedButDeniedBIsNotWatched,
        roundNothingAsBothAAndBDenied,
        roundAChangesInclChildrenAreSeen,
        roundNothingForAAsItSDeniedBChangesSeen,
        roundNothingBothDenied,
        roundNothingAllDenied,
        roundADeniesSeeAllChangesForBAndCIncludingBChildren,
        roundADeniesSeeAllBChangesAndBChildrenNothingForC,
        roundNothingTheWatchIsOnC,
        roundTheWatchIsOnlyOnCBAndCAllowed,
    };

    private static String allowString(String prefix, Boolean allow) {
        if (allow == null) {
            return "";
        } else {
            return prefix + (allow ? "allow" : "deny");
        }
    }

    private static String watchModeString(AddWatchMode watchMode) {
        switch (watchMode) {
        case PERSISTENT:
            return "PERSISTENT";
        case PERSISTENT_RECURSIVE:
            return "PRECURSIVE";
        default:
            return "?";
        }
    }

    private static String actionString(int opCode) {
        switch (opCode) {
        case ZooDefs.OpCode.create:
            return "create";
        case ZooDefs.OpCode.delete:
            return "delete";
        case ZooDefs.OpCode.setData:
            return "modify";
        default:
            return "?";
        }
    }

    private static String eventPathString(String eventPath) {
        if (eventPath == null) {
            return "?";
        } else if (eventPath.length() <= 1) {
            return eventPath;
        } else {
            return eventPath.substring(eventPath.lastIndexOf('/') + 1);
        }
    }

    /**
     * Generates a "CSV" rendition of the table in sb.
     *
     * @param sb the target string builder
     */
    private static void genCsv(StringBuilder sb) {
        sb.append("Initial State,")
            .append("Action,")
            .append("NodeCreated,")
            .append("NodeDeleted,")
            .append("NodeDataChanged,")
            .append("NodeChildrenChanged,")
            .append("Notes/summary\n");
        sb.append("Assume /a always exists\n\n");

        for (Round round : ROUNDS) {
            sb.append("\"ACL")
                .append(allowString(": a ", round.allowA))
                .append(allowString(", b ", round.allowB))
                .append(allowString(", c ", round.allowC))
                .append("\"")
                .append(",,,,,,\"")
                .append(round.summary)
                .append("\"\n");
            for (int i = 0; i < round.steps.length; i++) {
                Step step = round.steps[i];

                if (i == 0) {
                    sb.append("\"addWatch(")
                        .append(round.watchTarget)
                        .append(", ")
                        .append(watchModeString(round.watchMode))
                        .append(")\"");
                }

                sb.append(",")
                    .append(actionString(step.opCode))
                    .append(" ")
                    .append(step.target)
                    .append(",");

                if (step.eventType == EventType.NodeCreated) {
                    sb.append("y - ")
                        .append(eventPathString(step.eventPath));
                }

                sb.append(",");

                if (step.eventType == EventType.NodeDeleted) {
                    sb.append("y - ")
                        .append(eventPathString(step.eventPath));
                }

                sb.append(",");

                if (step.eventType == EventType.NodeDataChanged) {
                    sb.append("y - ")
                        .append(eventPathString(step.eventPath));
                }

                sb.append(",");

                if (round.watchMode == PERSISTENT_RECURSIVE) {
                    sb.append("n");
                } else if (step.eventType == EventType.NodeChildrenChanged) {
                    sb.append("y - ")
                        .append(eventPathString(step.eventPath));
                }

                sb.append("\n");
            }

            sb.append("\n");
        }
    }

    /**
     * Generates a "CSV" rendition of the table to standard output.
     *
     * @see #ROUNDS
     */
    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder();
        genCsv(sb);
        System.out.println(sb);
    }
}