Unverified Commit d0ab983f authored by Lemon-miaow's avatar Lemon-miaow
Browse files

test(velocity): 测试桩按真实 wake 的顺序从服的状态推导回复,运行中的服对谁都 202 就绪,403 和 start_failed...

test(velocity): 测试桩按真实 wake 的顺序从服的状态推导回复,运行中的服对谁都 202 就绪,403 和 start_failed 不再由用例手配,停着的服一律报 fallback
parent 2d82e8cc
Loading
Loading
Loading
Loading
+3 −1
Changes for plugins/test.sh: 3 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -43,7 +43,9 @@
#      fake proxy and a stub felis-api — a refresh registers, moves and drops
#      backends and lets go of a renamed subdomain, the login gate and host routing
#      admit only linked players, each wake refusal reaches the player as its own
#      message, felis:control acts only for the connection's player and holds its
#      message (the stub answers a wake from the server's state in felis-api's own
#      order, so it cannot hand the router an answer the real API never gives),
#      felis:control acts only for the connection's player and holds its
#      frame budget. They ride the module's verified dependency set, which is why
#      they live in Gradle rather than in the javac mains above.
#
+24 −8
Changes for plugins/velocity/test/best/lolicon/felis/velocity/ControlChannelTest.java: 24 added lines, 8 removed lines.
Original line number Diff line number Diff line
@@ -216,20 +216,32 @@ public final class ControlChannelTest {
    }

    private static void wakeAndTransfer() {
        // A WakeRequest parks the player; when the backend is ready the lobby hears
        // TransferReady just before the proxy moves them.
        // A WakeRequest for a stopped server parks the player; when the backend is ready
        // the lobby hears TransferReady just before the proxy moves them.
        Fakes.FakePlayer p = player(true);
        p.current = lobby;
        message(p.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.wakeRequest("forged-name", "beta")));
        Fakes.await("beta woken", () -> api.count("POST " + SERVERS + "beta/wake") == 1);
        message(p.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.wakeRequest("forged-name", "gamma")));
        Fakes.await("gamma woken", () -> api.count("POST " + SERVERS + "gamma/wake") == 1);
        Fakes.await("waker queued", () -> router.waitingCount() == 2);
        api.ready.put("beta", true);
        api.ready.put("gamma", true);
        router.tick();
        List<ControlFrame> r = frames(p);
        assertEq("one frame to the lobby", 1, r.size());
        assertEq("transfer ready", ControlFrame.TRANSFER_READY + " " + p.name + " beta",
        assertEq("transfer ready", ControlFrame.TRANSFER_READY + " " + p.name + " gamma",
                r.get(0).type() + " " + r.get(0).player() + " " + r.get(0).server());
        assertEq("then moved", List.of("beta"), List.copyOf(p.connects));
        assertEq("then moved", List.of("gamma"), List.copyOf(p.connects));

        // For a running one it is a join: TransferReady and the move at once, no wake.
        Fakes.FakePlayer j = player(true);
        j.current = lobby;
        message(j.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.wakeRequest(j.name, "beta")));
        Fakes.await("running server joined", () -> j.connects.size() == 1);
        List<ControlFrame> jr = frames(j);
        assertEq("running server: one frame to the lobby", 1, jr.size());
        assertEq("running server: transfer ready", ControlFrame.TRANSFER_READY + " " + j.name + " beta",
                jr.get(0).type() + " " + jr.get(0).player() + " " + jr.get(0).server());
        assertEq("running server: moved", List.of("beta"), List.copyOf(j.connects));
        assertEq("running server: never woken", 0, api.count("POST " + SERVERS + "beta/wake"));
    }

    private static void budget() {
@@ -295,8 +307,12 @@ public final class ControlChannelTest {

    // Shaped like the operator's status: up is direct at addr; down is the fallback,
    // whose name ("login") sits in the address field.
    // view is a server as GET /servers lists it, and the stub API's status, menu and
    // wake answer for the same state: up at addr, or down on the "login" fallback.
    private static ServerView view(String name, boolean ready, String addr) {
        return new ServerView(name, name, ready ? "Running" : "Stopped", ready, "ownerOnly",
        api.ready.put(name, ready);
        api.address.put(name, addr);
        return new ServerView(name, name, ready ? "Running" : "Stopped", ready, "public",
                "Running", ready ? "direct" : "fallback", ready ? addr : "login", 0, 20);
    }

+73 −12
Changes for plugins/velocity/test/best/lolicon/felis/velocity/Fakes.java: 73 added lines, 12 removed lines.
Original line number Diff line number Diff line
@@ -33,6 +33,8 @@ import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BooleanSupplier;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Fakes are the test doubles the routing tests run the real proxy classes against:
@@ -432,13 +434,18 @@ final class Fakes {

    /**
     * Api is a stub felis-api internal face: link status from {@link #linked}, server
     * status from {@link #ready}, wake answering 202 unless {@link #wakeError} holds an
     * answer for the server, and join-events recorded. {@link #linkDown} makes the
     * link-status route answer 500.
     * status, menu and wake from one server state ({@link #ready}, {@link #phase},
     * {@link #desired}, {@link #gaveUp}, {@link #policy}, {@link #owner}), and
     * join-events recorded. {@link #linkDown} makes the link-status route answer 500.
     *
     * <p>Status replies carry the endpoint the way the operator writes it: a server that
     * is up reports {@code direct} and its {@link #address}; one that is not reports
     * {@code fallback} with the fallback server's NAME, "login", in the address field.
     *
     * <p>The wake follows handleInternalWake step for step, so a test cannot give the
     * router an answer the real API never gives: an up server is 202 ready for anyone,
     * 403 and 409 start_failed come from the state, and {@link #wakeError} only holds
     * what the state cannot say (a cooldown, the running cap, maintenance, a fault).
     */
    static final class Api implements AutoCloseable {
        final Set<UUID> linked = ConcurrentHashMap.newKeySet();
@@ -455,9 +462,19 @@ final class Fakes {
        final Map<String, String> desired = new ConcurrentHashMap<>();
        final Map<String, Integer> restarts = new ConcurrentHashMap<>();
        final Set<String> gaveUp = ConcurrentHashMap.newKeySet();
        /**
         * policy is a server's autostartPolicy, public unless set, and owner the player
         * who owns it. Only public or the owner passes the wake's gate: ownerOnly, and an
         * allowlist the stub keeps empty, refuse everyone else.
         */
        final Map<String, String> policy = new ConcurrentHashMap<>();
        final Map<String, UUID> owner = new ConcurrentHashMap<>();
        /** statusDown makes the status route answer 500. */
        volatile boolean statusDown;
        /** wakeError maps a server to "status code" (e.g. "403 forbidden"). */
        /**
         * wakeError maps a server to "status code" (e.g. "429 cooldown"), answered where
         * the real wake reaches its cooldown: after the gate and the start_failed check.
         */
        final Map<String, String> wakeError = new ConcurrentHashMap<>();
        volatile int joinStatus = 204;
        /** claimable names the servers the menu route reports as claimable. */
@@ -511,16 +528,13 @@ final class Fakes {
                        reply(ex, 200, status(name, ready.getOrDefault(name, false)));
                        return;
                    case "POST wake":
                        if (!error(ex, wakeError.get(name))) {
                            // The real wake reply is this subset of the view: no endpoint.
                            reply(ex, 202, "{\"name\":\"" + name + "\",\"desiredState\":\"Running\","
                                    + "\"phase\":\"Stopped\",\"ready\":false}");
                        }
                        wake(ex, name, bodies.get(method + " " + path));
                        return;
                    case "GET menu":
                        if (!error(ex, menuError.get(name))) {
                            reply(ex, 200, "{\"name\":\"" + name + "\",\"phase\":\"Stopped\",\"ready\":false,"
                                    + "\"playersOnline\":3,\"playersMax\":20,\"claimable\":" + claimable.contains(name) + "}");
                            reply(ex, 200, "{\"name\":\"" + name + "\",\"phase\":\"" + phaseOf(name)
                                    + "\",\"ready\":" + ready.getOrDefault(name, false)
                                    + ",\"playersOnline\":3,\"playersMax\":20,\"claimable\":" + claimable.contains(name) + "}");
                        }
                        return;
                    case "POST claim":
@@ -548,6 +562,52 @@ final class Fakes {
            return true;
        }

        // wake answers the way handleInternalWake does, in its order: a server that is up
        // and meant to stay up is 202 ready with no gate, nothing flipped; then the
        // autostartPolicy gate on the body's mc_uuid; then 409 start_failed for a start
        // whose retries are spent; then the cooldown and the rest of wakeError; then the
        // flip to Running and a 202 with the phase as it stands.
        private void wake(HttpExchange ex, String name, String body) throws IOException {
            boolean up = ready.getOrDefault(name, false);
            String want = desired.getOrDefault(name, "Running");
            if (up && "Running".equals(want)) {
                reply(ex, 202, wakeReply(name, want, true));
                return;
            }
            UUID waker = mcUuid(body);
            if (!"public".equals(policy.getOrDefault(name, "public"))
                    && (waker == null || !waker.equals(owner.get(name)))) {
                error(ex, "403 forbidden");
                return;
            }
            if (gaveUp.contains(name) && "Running".equals(want)) {
                error(ex, "409 start_failed");
                return;
            }
            if (error(ex, wakeError.get(name))) {
                return;
            }
            desired.put(name, "Running");
            reply(ex, 202, wakeReply(name, "Running", up));
        }

        // The real wake reply is this subset of the view: no endpoint.
        private String wakeReply(String name, String desiredState, boolean up) {
            return "{\"name\":\"" + name + "\",\"desiredState\":\"" + desiredState + "\",\"phase\":\""
                    + phaseOf(name) + "\",\"ready\":" + up + "}";
        }

        private static final Pattern MC_UUID = Pattern.compile("\"mc_uuid\"\\s*:\\s*\"([^\"]+)\"");

        private static UUID mcUuid(String body) {
            Matcher m = MC_UUID.matcher(body == null ? "" : body);
            return m.find() ? UUID.fromString(m.group(1)) : null;
        }

        private String phaseOf(String name) {
            return ready.getOrDefault(name, false) ? "Running" : phase.getOrDefault(name, "Stopped");
        }

        private String status(String name, boolean up) {
            String addr = address.get(name);
            String endpoint = up
@@ -556,7 +616,8 @@ final class Fakes {
            // Like the real ServerInfo, autoRestarts and startGaveUp are left out at 0/false.
            int restarts = this.restarts.getOrDefault(name, 0);
            return "{\"name\":\"" + name + "\",\"subdomain\":\"" + name + "\",\"phase\":\""
                    + (up ? "Running" : phase.getOrDefault(name, "Stopped")) + "\",\"ready\":" + up
                    + phaseOf(name) + "\",\"ready\":" + up
                    + ",\"autostartPolicy\":\"" + policy.getOrDefault(name, "public") + "\""
                    + ",\"desiredState\":\"" + desired.getOrDefault(name, "Running") + "\""
                    + (restarts == 0 ? "" : ",\"autoRestarts\":" + restarts)
                    + (gaveUp.contains(name) ? ",\"startGaveUp\":true" : "")
+1 −1
Changes for plugins/velocity/test/best/lolicon/felis/velocity/LegacyForwardingTest.java: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -162,7 +162,7 @@ public final class LegacyForwardingTest {
    }

    private static ServerView view(String name, boolean legacy) {
        return new ServerView(name, name, "Running", true, null, "Running", "ClusterIP", "10.43.0.9:25565", 0, 20, legacy);
        return new ServerView(name, name, "Running", true, null, "Running", "direct", "10.43.0.9:25565", 0, 20, legacy);
    }

    private static void assertEq(String what, Object want, Object got) {
+7 −7
Changes for plugins/velocity/test/best/lolicon/felis/velocity/ServerListSourceTest.java: 7 added lines, 7 removed lines.
Original line number Diff line number Diff line
@@ -31,9 +31,9 @@ public final class ServerListSourceTest {
    private static volatile List<ServerView> answer;

    private static final ServerView LOGIN = new ServerView("login", null, "Running", true,
            null, "Running", "ClusterIP", "10.43.0.17:25565", 0, 0);
            null, "Running", "direct", "10.43.0.17:25565", 0, 0);
    private static final ServerView SURVIVAL = new ServerView("survival", "Survival", "Stopped", false,
            "Anyone", "Stopped", "ClusterIP", "10.43.9.2:25565", 3, 20);
            "public", "Stopped", "fallback", "login", 3, 20);

    public static void main(String[] args) throws Exception {
        Path dir = Files.createTempDirectory("felis-server-list");
@@ -84,10 +84,10 @@ public final class ServerListSourceTest {
        assertEq("survival subdomain", "Survival", s.subdomain());
        assertEq("survival phase", "Stopped", s.phase());
        assertEq("survival ready", false, s.ready());
        assertEq("survival policy", "Anyone", s.autostartPolicy());
        assertEq("survival policy", "public", s.autostartPolicy());
        assertEq("survival desired", "Stopped", s.desiredState());
        assertEq("survival mode", "ClusterIP", s.endpointMode());
        assertEq("survival address", "10.43.9.2:25565", s.endpointAddress());
        assertEq("survival mode", "fallback", s.endpointMode());
        assertEq("survival address", "login", s.endpointAddress());
        assertEq("survival online", 3, s.playersOnline());
        assertEq("survival max", 20, s.playersMax());
    }
@@ -154,7 +154,7 @@ public final class ServerListSourceTest {
    private static void theLegacyForwardingMarkIsSaved(Path dir) throws Exception {
        Path file = fresh(dir);
        ServerView legacy = new ServerView("legacy18", "old", "Running", true,
                null, "Running", "ClusterIP", "10.43.7.1:25565", 0, 20, true);
                null, "Running", "direct", "10.43.7.1:25565", 0, 20, true);
        answer = List.of(legacy, SURVIVAL);
        new ServerListSource(ServerListSourceTest::fetch, file).next();
        answer = null;
@@ -167,7 +167,7 @@ public final class ServerListSourceTest {
    // The exact shape GET /api/v1/servers answers with, extra fields included.
    private static void theApiEnvelopeParses() {
        List<ServerView> v = ServerView.listFromJson("{\"servers\":[{\"name\":\"lobby\",\"subdomain\":\"\","
                + "\"phase\":\"Running\",\"ready\":true,\"desiredState\":\"Running\",\"endpointMode\":\"ClusterIP\","
                + "\"phase\":\"Running\",\"ready\":true,\"desiredState\":\"Running\",\"endpointMode\":\"direct\","
                + "\"endpointAddress\":\"10.43.1.5:25565\",\"playersOnline\":1,\"playersMax\":100,"
                + "\"owner\":\"x\"},\"junk\",{\"name\":\"legacy18\",\"legacyForwarding\":true}]}");
        assertEq("non-object entries skipped", 2, v.size());
Loading