feat(api): op-login 游戏内审批先展示目标账号、邮箱与发起来源,须输入账户名确认,velocity 显示审批卡片

This commit is contained in:
Lemon-miaow committed 2026-09-25 17:02:43 +08:00
1 parent 4757353324
commit d93c1b6913
21 files changed
+1088 -176

No files matched your search

@@ -179,30 +179,49 @@ public final class FelisApiClient {
}
/**
* opLoginApprove records an in-game administrator's vouch for a pending op.console
* staff login — the second factor of the spec §B op-login door, supplied from
* Velocity's {@code /felis web op approve <code>}. It POSTs the approver's verified
* online-mode UUID to {@code POST /api/v1/internal/op-login/{id}/approve}; felis-api
* resolves that UUID to a linked account and refuses unless it is {@code role=admin}
* (403 {@code not_admin}), so this is defence in depth over Velocity's own in-game
* guard rather than the sole check. A {@code requestId} naming no live pending
* request is 404 {@code op_login_not_found}. Both arrive as branchable
* {@link LinkException}s; a 200 that does not affirm {@code approved:true} is a
* contract breach, not a refusal.
* opLoginShow reads whose op.console staff sign-in a pending request is — the first
* half of Velocity's {@code /felis web op approve <code>}, shown to the admin before
* they vouch. {@code GET /api/v1/internal/op-login/{id}?approver_uuid=<uuid>}:
* felis-api refuses unless the approver's verified UUID is linked to an admin or
* owner (403 {@code not_admin}), so a player who types the command learns nothing
* about a staff account. An id naming no live pending request is 404 {@code
* op_login_not_found}. Both arrive as branchable {@link LinkException}s.
*
* <p>{@code requestId} is interpolated into the request path. It is
* percent-encoded here, and the Velocity command also validates its charset
* before calling.
*/
public void opLoginApprove(String requestId, UUID approverUuid) throws LinkException {
public OpLoginView opLoginShow(String requestId, UUID approverUuid) throws LinkException {
Objects.requireNonNull(requestId, "requestId");
Objects.requireNonNull(approverUuid, "approverUuid");
String body = "{\"approver_uuid\":\"" + approverUuid + "\"}";
return OpLoginView.fromJson(getObject("/api/v1/internal/op-login/" + segment(requestId)
+ "?approver_uuid=" + approverUuid, 200));
}
/**
* opLoginApprove records an in-game administrator's vouch for a pending op.console
* staff login — the second factor of the spec §B op-login door, supplied from
* Velocity's {@code /felis web op approve <code> <username>}. It POSTs the approver's
* verified online-mode UUID and the account name they typed after reading
* {@link #opLoginShow} to {@code POST /api/v1/internal/op-login/{id}/approve}.
* felis-api refuses unless the UUID is linked to an admin or owner (403 {@code
* not_admin}) and unless the name is the request's account (409 {@code
* op_login_mismatch}, request left pending). An id naming no live pending request is
* 404 {@code op_login_not_found}. All arrive as branchable {@link LinkException}s; a
* 200 that does not affirm {@code approved:true} is a contract breach, not a
* refusal. Returns the approved account's username and email.
*/
public OpLoginView opLoginApprove(String requestId, UUID approverUuid, String username) throws LinkException {
Objects.requireNonNull(requestId, "requestId");
Objects.requireNonNull(approverUuid, "approverUuid");
Objects.requireNonNull(username, "username");
String body = "{\"approver_uuid\":\"" + approverUuid + "\",\"username\":" + Json.quote(username) + "}";
Map<?, ?> res = postObject("/api/v1/internal/op-login/" + segment(requestId) + "/approve", body, 200);
Object approved = res.get("approved");
if (!(approved instanceof Boolean) || !((Boolean) approved)) {
throw new LinkException(200, "bad_response", "approve returned 200 without approved=true");
}
return OpLoginView.fromJson(res);
}
/**
@@ -25,6 +25,32 @@ final class Json {
this.s = s;
}
/**
* quote renders s as a JSON string literal, for the few request bodies that carry
* text a player typed. Quotes, backslashes and control characters are escaped.
*/
static String quote(String s) {
StringBuilder b = new StringBuilder(s.length() + 2).append('"');
for (int k = 0; k < s.length(); k++) {
char c = s.charAt(k);
switch (c) {
case '"':
b.append("\\\"");
break;
case '\\':
b.append("\\\\");
break;
default:
if (c < 0x20) {
b.append(String.format("\\u%04x", (int) c));
} else {
b.append(c);
}
}
}
return b.append('"').toString();
}
/** parse reads a single JSON value from text, rejecting trailing garbage. */
static Object parse(String text) {
Json p = new Json(text);
@@ -0,0 +1,83 @@
package best.lolicon.felis.link;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.Map;
/**
* OpLoginView is a pending op.console staff sign-in as the in-game approver sees it
* ({@code GET /api/v1/internal/op-login/{id}}, spec §B op-login): whose account it is,
* the address the code went to, and when and from where the sign-in was started. The
* approve response reuses it for the account it approved, where only the username
* and email are filled.
*
* <p>{@link #fromJson(Map)} tolerates absent fields like {@link ServerView}: a
* missing string reads as "", an unparseable time as null, so a partial body never
* throws on the proxy's command thread.
*/
public final class OpLoginView {
private final String requestId;
private final String username;
private final String email;
private final String clientIp;
private final String userAgent;
private final Instant createdAt;
public OpLoginView(String requestId, String username, String email,
String clientIp, String userAgent, Instant createdAt) {
this.requestId = requestId;
this.username = username;
this.email = email;
this.clientIp = clientIp;
this.userAgent = userAgent;
this.createdAt = createdAt;
}
/** fromJson builds a view from a parsed show or approve body. */
public static OpLoginView fromJson(Map<?, ?> o) {
Instant created = null;
String at = str(o, "created_at");
if (!at.isEmpty()) {
try {
created = Instant.parse(at);
} catch (DateTimeParseException ignored) {
// Leave it null; the card then omits the age line.
}
}
return new OpLoginView(str(o, "request_id"), str(o, "username"), str(o, "email"),
str(o, "client_ip"), str(o, "user_agent"), created);
}
public String requestId() {
return requestId;
}
/** username is the Felis account the sign-in is for; the approver retypes it. */
public String username() {
return username;
}
public String email() {
return email;
}
/** clientIp is where the sign-in was started; "" when the API did not record one. */
public String clientIp() {
return clientIp;
}
/** userAgent is the browser that started the sign-in; may be "". */
public String userAgent() {
return userAgent;
}
/** createdAt is when the sign-in was started, or null when absent. */
public Instant createdAt() {
return createdAt;
}
private static String str(Map<?, ?> o, String key) {
Object v = o.get(key);
return v instanceof String ? (String) v : "";
}
}
@@ -5,6 +5,7 @@ import com.sun.net.httpserver.HttpServer;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
@@ -28,20 +29,35 @@ public final class FelisApiClientTest {
private static int checks;
private static final List<String> seen = new CopyOnWriteArrayList<>();
private static final List<String> bodies = new CopyOnWriteArrayList<>();
public static void main(String[] args) throws Exception {
HttpServer stub = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
stub.createContext("/", exchange -> {
seen.add(exchange.getRequestMethod() + " " + exchange.getRequestURI().getRawPath());
String query = exchange.getRequestURI().getRawQuery();
seen.add(exchange.getRequestMethod() + " " + exchange.getRequestURI().getRawPath()
+ (query == null ? "" : "?" + query));
bodies.add(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
String path = exchange.getRequestURI().getRawPath();
String body;
int status;
if (path.endsWith("/wake")) {
status = 202;
body = "{\"name\":\"alpha\",\"phase\":\"Starting\",\"ready\":false}";
} else if (path.equals("/api/v1/internal/op-login/mismatch/approve")) {
status = 409;
body = "{\"error\":{\"code\":\"op_login_mismatch\",\"message\":\"that operator login is for a different account\"}}";
} else if (path.equals("/api/v1/internal/op-login/half/approve")) {
status = 200;
body = "{\"username\":\"op\"}";
} else if (path.endsWith("/approve")) {
status = 200;
body = "{\"approved\":true}";
body = "{\"approved\":true,\"username\":\"op\",\"email\":\"[email protected]\"}";
} else if (path.startsWith("/api/v1/internal/op-login/")) {
status = 200;
body = "{\"request_id\":\"0123abcd\",\"username\":\"op\",\"email\":\"[email protected]\","
+ "\"client_ip\":\"203.0.113.9\",\"user_agent\":\"Firefox/140.0\","
+ "\"created_at\":\"2023-11-14T22:13:20Z\",\"expires_at\":\"2023-11-14T22:23:20Z\"}";
} else {
status = 200;
body = "{\"name\":\"alpha\",\"phase\":\"Running\",\"ready\":true,\"claimable\":false}";
@@ -60,6 +76,8 @@ public final class FelisApiClientTest {
wellFormedNamesReachTheirRoute(api);
pathBendingNamesNeverLeaveTheClient(api);
opaqueSegmentsArePercentEncoded(api);
opLoginShowNamesTheAccount(api);
opLoginApproveSendsTheTypedName(api);
} finally {
stub.stop(0);
}
@@ -113,8 +131,53 @@ public final class FelisApiClientTest {
expectRefused("dotdot", () -> FelisApiClient.segment(".."));
seen.clear();
api.opLoginApprove("0123abcd", UUID.fromString("00000000-0000-0000-0000-000000000003"));
assertEq("approve route", List.of("POST /api/v1/internal/op-login/0123abcd/approve"), seen);
api.opLoginApprove("0123abcd", UUID.fromString("00000000-0000-0000-0000-000000000003"), "op");
api.opLoginShow("a/b", UUID.fromString("00000000-0000-0000-0000-000000000003"));
assertEq("op-login routes", List.of(
"POST /api/v1/internal/op-login/0123abcd/approve",
"GET /api/v1/internal/op-login/a%2Fb?approver_uuid=00000000-0000-0000-0000-000000000003"), seen);
}
// The in-game card is built from this view, so every field the admin reads has to
// come through.
private static void opLoginShowNamesTheAccount(FelisApiClient api) throws LinkException {
seen.clear();
OpLoginView v = api.opLoginShow("0123abcd", UUID.fromString("00000000-0000-0000-0000-000000000004"));
assertEq("show route", List.of(
"GET /api/v1/internal/op-login/0123abcd?approver_uuid=00000000-0000-0000-0000-000000000004"), seen);
assertEq("show request_id", "0123abcd", v.requestId());
assertEq("show username", "op", v.username());
assertEq("show email", "[email protected]", v.email());
assertEq("show client_ip", "203.0.113.9", v.clientIp());
assertEq("show user_agent", "Firefox/140.0", v.userAgent());
assertEq("show created_at", Instant.parse("2023-11-14T22:13:20Z"), v.createdAt());
}
// The typed name is player input, so it has to arrive as one JSON string however
// it is spelled; the approved account comes back for the confirmation line.
private static void opLoginApproveSendsTheTypedName(FelisApiClient api) throws LinkException {
UUID approver = UUID.fromString("00000000-0000-0000-0000-000000000005");
bodies.clear();
OpLoginView v = api.opLoginApprove("0123abcd", approver, "o\"p\\");
assertEq("approve body", List.of(
"{\"approver_uuid\":\"00000000-0000-0000-0000-000000000005\",\"username\":\"o\\\"p\\\\\"}"), bodies);
assertEq("approved username", "op", v.username());
assertEq("approved email", "[email protected]", v.email());
try {
api.opLoginApprove("mismatch", approver, "alice");
throw new AssertionError("a 409 approve was not refused");
} catch (LinkException e) {
assertEq("mismatch status", 409, e.statusCode());
assertEq("mismatch code", "op_login_mismatch", e.errorCode());
}
try {
api.opLoginApprove("half", approver, "op");
throw new AssertionError("a 200 without approved=true was accepted");
} catch (LinkException e) {
assertEq("half status", 200, e.statusCode());
assertEq("half code", "bad_response", e.errorCode());
}
}
// ---- harness ----
+13 -2
View File
@@ -11,8 +11,10 @@
# felis-api request path, only the lobby and the login gate may drive
# felis:control (and each only with its own frames), the link-status outage
# fallback fails closed outside its window, /invite prompts cannot double-fire
# or outlive their TTL, and the invite card really is a green/red clickable
# prompt. InviteCardTest needs the adventure jars the velocity plugin compiles
# or outlive their TTL, the invite card really is a green/red clickable
# prompt, and the op-login approval card names the account and leaves its
# name for the admin to type. InviteCardTest and OpApprovalCardTest need the
# adventure jars the velocity plugin compiles
# against; they are fetched from Maven Central below, pinned by version and
# checked by digest (a test run against silently-substituted bytes is not a
# test of what we ship).
@@ -97,6 +99,15 @@ javac -cp "$adventure_api:$adventure_key:$examination_api" -d "$work/card-classe
java -cp "$work/card-classes:$adventure_api:$adventure_key:$examination_api" \
best.lolicon.felis.velocity.InviteCardTest
echo "==> OpApprovalCardTest (/felis web op approve card, velocity)"
mkdir -p "$work/opcard-classes"
javac -cp "$adventure_api:$adventure_key:$examination_api" -d "$work/opcard-classes" \
plugins/shared/src/main/java/best/lolicon/felis/link/*.java \
plugins/velocity/src/main/java/best/lolicon/felis/velocity/OpApprovalCard.java \
plugins/velocity/test/best/lolicon/felis/velocity/OpApprovalCardTest.java
java -cp "$work/opcard-classes:$adventure_api:$adventure_key:$examination_api" \
best.lolicon.felis.velocity.OpApprovalCardTest
# --- 2. production compile gates ------------------------------------------------
for module in velocity paper; do
@@ -4,6 +4,7 @@ import best.lolicon.felis.link.FelisApiClient;
import best.lolicon.felis.link.LinkClient;
import best.lolicon.felis.link.LinkCode;
import best.lolicon.felis.link.LinkException;
import best.lolicon.felis.link.OpLoginView;
import best.lolicon.felis.link.ServerView;
import com.google.inject.Inject;
@@ -28,6 +29,7 @@ import org.slf4j.Logger;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
@@ -291,7 +293,8 @@ public final class FelisVelocityPlugin {
// /felis claim take ownership of the server I'm on
// /felis migrate open a migration of my servers to another account
// /felis web where the web consoles live
// /felis web op approve <code> vouch for a pending op.console staff login (§B)
// /felis web op approve <code> show whose op.console staff login a code is (§B)
// /felis web op approve <code> <account> vouch for it, naming the account
//
// Two guards run before any subcommand that acts or reveals operational state:
//
@@ -357,9 +360,16 @@ public final class FelisVelocityPlugin {
.then(BrigadierCommand.literalArgumentBuilder("approve")
.then(BrigadierCommand.requiredArgumentBuilder("code", StringArgumentType.word())
.executes(ctx -> {
doOpApprove(ctx.getSource(), StringArgumentType.getString(ctx, "code"));
doOpApprove(ctx.getSource(), StringArgumentType.getString(ctx, "code"), null);
return Command.SINGLE_SUCCESS;
})))))
})
.then(BrigadierCommand.requiredArgumentBuilder("account", StringArgumentType.word())
.executes(ctx -> {
doOpApprove(ctx.getSource(),
StringArgumentType.getString(ctx, "code"),
StringArgumentType.getString(ctx, "account"));
return Command.SINGLE_SUCCESS;
}))))))
.build();
CommandMeta meta = commands.metaBuilder("felis").plugin(this).build();
commands.register(meta, new BrigadierCommand(node));
@@ -447,7 +457,7 @@ public final class FelisVelocityPlugin {
helpLine(source, "/felis web",
zh ? "网页控制台地址" : "where the web consoles live");
helpLine(source, "/felis web op approve <code>",
zh ? "批准待处理的管理员登录" : "approve a pending operator sign-in");
zh ? "查看并批准待处理的管理员登录" : "review and approve a pending operator sign-in");
}
private void sendServerList(CommandSource source) {
@@ -641,8 +651,8 @@ public final class FelisVelocityPlugin {
source.sendMessage(field(zh ? "管理员" : "operators", "https://" + adminHost));
}
source.sendMessage(Component.text(
zh ? " 管理员:/felis web op approve <code> 用于为待处理登录作担保"
: " operators: /felis web op approve <code> vouches for a pending sign-in",
zh ? " 管理员:/felis web op approve <code> 查看并批准待处理的登录"
: " operators: /felis web op approve <code> reviews a pending sign-in",
NamedTextColor.GRAY));
}
@@ -661,12 +671,18 @@ public final class FelisVelocityPlugin {
NamedTextColor.GRAY));
source.sendMessage(Component.text(" /felis web op approve <code>", NamedTextColor.WHITE));
source.sendMessage(Component.text(
zh ? "即可为其担保——你必须是已绑定并在线的管理员。"
: "to vouch for it — you must be an online, linked administrator.",
zh ? "查看这是谁的登录,确认后输入其账户名即可担保——你必须是已绑定并在线的管理员。"
: "to see whose sign-in it is, then confirm with their account name to vouch for it"
+ " — you must be an online, linked administrator.",
NamedTextColor.GRAY));
}
private void doOpApprove(CommandSource source, String codeArg) {
// doOpApprove is both halves of the in-game vouch. Without an account name it only
// shows whose sign-in the code belongs to (OpApprovalCard); with one it approves,
// and felis-api refuses unless the name is that request's account. The admin
// therefore always reads the account before vouching, and a code someone else
// relayed cannot be approved blind.
private void doOpApprove(CommandSource source, String codeArg, String accountArg) {
Player player = requirePlayer(source);
if (player == null || !ensureOutOfLimbo(player)) {
return;
@@ -688,17 +704,35 @@ public final class FelisVelocityPlugin {
}
UUID approver = player.getUniqueId();
String who = player.getUsername();
if (accountArg == null) {
async(() -> {
try {
OpLoginView req = apiClient.opLoginShow(code, approver);
long age = req.createdAt() == null ? -1
: Math.max(0, Duration.between(req.createdAt(), Instant.now()).getSeconds());
for (Component line : OpApprovalCard.lines(req, code, zh, age)) {
player.sendMessage(line);
}
} catch (LinkException e) {
player.sendMessage(Component.text(opApproveError(e, code, zh), NamedTextColor.RED));
}
});
return;
}
String account = accountArg.trim();
player.sendMessage(Component.text(
zh ? "正在批准管理员登录……" : "Approving operator sign-in…", NamedTextColor.GRAY));
async(() -> {
try {
apiClient.opLoginApprove(code, approver);
OpLoginView done = apiClient.opLoginApprove(code, approver, account);
player.sendMessage(Component.text(
zh ? "已批准——对方现在可以完成登录了。"
: "Approved — the operator can finish signing in now.", NamedTextColor.GREEN));
logger.info("Felis: op-login {} approved in-game by {} ({})", code, who, approver);
zh ? "已批准 " + done.username() + "(" + done.email() + ")的登录——对方现在可以完成登录了。"
: "Approved " + done.username() + " (" + done.email()
+ ") — they can finish signing in now.", NamedTextColor.GREEN));
logger.info("Felis: op-login {} for {} approved in-game by {} ({})",
code, done.username(), who, approver);
} catch (LinkException e) {
player.sendMessage(Component.text(opApproveError(e, zh), NamedTextColor.RED));
player.sendMessage(Component.text(opApproveError(e, code, zh), NamedTextColor.RED));
}
});
}
@@ -1061,10 +1095,11 @@ public final class FelisVelocityPlugin {
}
}
// opApproveError maps the internal approve refusals to player-safe text. A 403 is
// the API's own admin re-check (defence in depth over the in-game gate); a 404
// means no live pending request carries that code.
private static String opApproveError(LinkException e, boolean zh) {
// opApproveError maps the internal show/approve refusals to player-safe text. A 403
// is the API's own admin re-check (defence in depth over the in-game gate); a 404
// means no live pending request carries that code; a 409 means the typed account
// is not the one the request is for.
private static String opApproveError(LinkException e, String code, boolean zh) {
switch (e.statusCode()) {
case 403:
return zh ? "只有已绑定的管理员才能批准管理员登录。"
@@ -1072,6 +1107,10 @@ public final class FelisVelocityPlugin {
case 404:
return zh ? "没有携带该码的待处理管理员登录(可能已过期)。"
: "No pending operator sign-in with that code (it may have expired).";
case 409:
return zh ? "该登录属于另一个账户,未批准。运行 /felis web op approve " + code + " 查看是谁的登录。"
: "That sign-in is for a different account, so it was not approved. Run /felis web op approve "
+ code + " to see whose it is.";
case 0:
return zh ? "Felis 暂时不可用——请稍后再试。"
: "Felis is temporarily unavailable — please try again.";
@@ -0,0 +1,89 @@
package best.lolicon.felis.velocity;
import best.lolicon.felis.link.OpLoginView;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import java.util.ArrayList;
import java.util.List;
/**
* OpApprovalCard builds what an admin sees after {@code /felis web op approve <code>}:
* whose op.console sign-in the code belongs to (account, address, where and when it
* was started), a warning to approve only a sign-in they know about, and how to
* confirm. Confirming means typing the account name, because a code relayed by
* someone else ("please approve abc123") is exactly the case this card exists to
* catch; the button fills the command up to the name and leaves the name to the
* admin.
*
* <p>A pure function of (request, code, language, age), like {@link InviteCard}, so
* {@code OpApprovalCardTest} can check it without a proxy.
*/
final class OpApprovalCard {
static final String APPROVE_COMMAND = "/felis web op approve";
/** A user agent longer than this is cut, so one line of chat stays one line. */
static final int USER_AGENT_MAX = 80;
private OpApprovalCard() {
}
/**
* lines renders the card in the approver's language. ageSeconds is how long ago the
* sign-in was started, or a negative value when the API did not say.
*/
static List<Component> lines(OpLoginView req, String code, boolean zh, long ageSeconds) {
List<Component> out = new ArrayList<>();
out.add(Component.text(zh ? "待你批准的管理员登录" : "Operator sign-in waiting for your approval",
NamedTextColor.AQUA));
out.add(field(zh ? "账户" : "account", req.username() + " (" + req.email() + ")"));
String from = req.clientIp().isEmpty() ? (zh ? "未知" : "unknown") : req.clientIp();
if (!req.userAgent().isEmpty()) {
from += " · " + shorten(req.userAgent());
}
out.add(field(zh ? "来源" : "from", from));
if (ageSeconds >= 0) {
out.add(field(zh ? "发起" : "started", age(ageSeconds, zh)));
}
out.add(Component.text(
zh ? " 只在你确认此人正在登录时批准。有人私下发给你批准码时尤其要核对账户。"
: " Approve only if you know this person is signing in right now — "
+ "above all when someone else sent you the code.",
NamedTextColor.YELLOW));
String prefill = APPROVE_COMMAND + " " + code + " ";
Component button = Component.text(zh ? "[ 批准… ]" : "[ Approve… ]", NamedTextColor.GREEN)
.clickEvent(ClickEvent.suggestCommand(prefill))
.hoverEvent(HoverEvent.showText(Component.text(
zh ? "点击后在末尾输入上面的账户名,再按回车"
: "Click, then type the account name shown above and press Enter")));
out.add(Component.text(" ").append(button).append(Component.text(
zh ? " 或输入 " + APPROVE_COMMAND + " " + code + " <账户名>"
: " or type " + APPROVE_COMMAND + " " + code + " <account name>",
NamedTextColor.GRAY)));
return out;
}
static String shorten(String userAgent) {
if (userAgent.length() <= USER_AGENT_MAX) {
return userAgent;
}
return userAgent.substring(0, USER_AGENT_MAX - 1) + "…";
}
static String age(long seconds, boolean zh) {
if (seconds < 60) {
return zh ? "刚刚" : "just now";
}
long minutes = seconds / 60;
return zh ? minutes + " 分钟前" : minutes + " min ago";
}
private static Component field(String key, String value) {
return Component.text(" " + key + ": ", NamedTextColor.GRAY)
.append(Component.text(value, NamedTextColor.WHITE));
}
}
@@ -0,0 +1,149 @@
package best.lolicon.felis.velocity;
import best.lolicon.felis.link.OpLoginView;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.TextComponent;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
/**
* OpApprovalCardTest checks what an admin reads before vouching for an op.console
* sign-in: the account, its address, where and when the sign-in started, and a
* confirm button that leaves the account name for the admin to type. The name is the
* point — a button that filled it in would let a relayed code be approved without
* reading the card.
*
* <p>Hermetic and framework-free like {@link InviteCardTest}; it needs the shared link
* sources (for {@link OpLoginView}) and the Kyori jars. Run: {@code javac -cp
* <adventure-api.jar>:<adventure-key.jar>:<examination-api.jar> -d <out>
* shared/src/main/java/best/lolicon/felis/link/*.java
* velocity/src/main/java/best/lolicon/felis/velocity/OpApprovalCard.java
* velocity/test/best/lolicon/felis/velocity/OpApprovalCardTest.java && java -cp
* <out>:<adventure-api.jar>:<adventure-key.jar>:<examination-api.jar>
* best.lolicon.felis.velocity.OpApprovalCardTest}.
*/
public final class OpApprovalCardTest {
private static int checks;
private static final OpLoginView REQ = new OpLoginView("abc123", "alice", "[email protected]",
"203.0.113.9", "Mozilla/5.0 Firefox/140.0", Instant.parse("2023-11-14T22:13:20Z"));
public static void main(String[] args) {
cardNamesTheAccountAndOrigin();
englishCardReadsTheSame();
buttonLeavesTheNameToTheAdmin();
missingOriginStillReads();
longUserAgentIsCut();
ageTurnsIntoMinutesAtSixty();
System.out.println("OpApprovalCardTest OK (" + checks + " checks)");
}
private static void cardNamesTheAccountAndOrigin() {
List<Component> card = OpApprovalCard.lines(REQ, "abc123", true, 150);
assertEq("zh card", List.of(
"待你批准的管理员登录",
" 账户: alice ([email protected])",
" 来源: 203.0.113.9 · Mozilla/5.0 Firefox/140.0",
" 发起: 2 分钟前",
" 只在你确认此人正在登录时批准。有人私下发给你批准码时尤其要核对账户。",
" [ 批准… ] 或输入 /felis web op approve abc123 <账户名>"), text(card));
assertEq("headline colour", NamedTextColor.AQUA, card.get(0).color());
assertEq("warning colour", NamedTextColor.YELLOW, card.get(4).color());
}
private static void englishCardReadsTheSame() {
assertEq("en card", List.of(
"Operator sign-in waiting for your approval",
" account: alice ([email protected])",
" from: 203.0.113.9 · Mozilla/5.0 Firefox/140.0",
" started: just now",
" Approve only if you know this person is signing in right now — above all when someone else sent you the code.",
" [ Approve… ] or type /felis web op approve abc123 <account name>"),
text(OpApprovalCard.lines(REQ, "abc123", false, 59)));
}
// The button only fills the command up to the name: clicking it must not approve,
// and must not type the name for the admin.
private static void buttonLeavesTheNameToTheAdmin() {
List<Component> card = OpApprovalCard.lines(REQ, "abc123", false, 0);
Component button = card.get(card.size() - 1).children().get(0);
assertEq("button colour", NamedTextColor.GREEN, button.color());
// Action and text are compared as plain values: ClickEvent#toString needs
// examination-string, which is not on this test's classpath.
ClickEvent click = button.clickEvent();
assertEq("button only fills the chat box", ClickEvent.Action.SUGGEST_COMMAND, click.action());
assertEq("button fills the command up to the name", "/felis web op approve abc123 ",
((ClickEvent.Payload.Text) click.payload()).value());
assertEq("button hover", "Click, then type the account name shown above and press Enter",
flat(button.hoverEvent().value()));
}
private static void missingOriginStillReads() {
OpLoginView bare = new OpLoginView("abc123", "alice", "[email protected]", "", "", null);
assertEq("bare card", List.of(
"Operator sign-in waiting for your approval",
" account: alice ([email protected])",
" from: unknown",
" Approve only if you know this person is signing in right now — above all when someone else sent you the code.",
" [ Approve… ] or type /felis web op approve abc123 <account name>"),
text(OpApprovalCard.lines(bare, "abc123", false, -1)));
}
private static void longUserAgentIsCut() {
String ua = "x".repeat(200);
OpLoginView req = new OpLoginView("abc123", "alice", "[email protected]", "203.0.113.9", ua, null);
assertEq("from line", " from: 203.0.113.9 · " + "x".repeat(79) + "…",
text(OpApprovalCard.lines(req, "abc123", false, -1)).get(2));
assertEq("short agent kept", "Firefox/140.0", OpApprovalCard.shorten("Firefox/140.0"));
assertEq("80 chars kept", "y".repeat(80), OpApprovalCard.shorten("y".repeat(80)));
}
private static void ageTurnsIntoMinutesAtSixty() {
assertEq("59s", "just now", OpApprovalCard.age(59, false));
assertEq("60s", "1 min ago", OpApprovalCard.age(60, false));
assertEq("599s", "9 分钟前", OpApprovalCard.age(599, true));
}
// ---- harness ----
private static List<String> text(List<Component> card) {
List<String> out = new ArrayList<>();
for (Component c : card) {
out.add(flat(c));
}
return out;
}
/** flat joins a component's text with its children's, depth first. */
private static String flat(Object value) {
if (!(value instanceof Component)) {
return String.valueOf(value);
}
Component c = (Component) value;
StringBuilder sb = new StringBuilder(c instanceof TextComponent ? ((TextComponent) c).content() : "");
for (Component child : c.children()) {
sb.append(flat(child));
}
return sb.toString();
}
private static void assertEq(String what, Object want, Object got) {
if (want instanceof TextColor && got instanceof TextColor) {
if (((TextColor) want).value() != ((TextColor) got).value()) {
throw new AssertionError(what + " = " + got + ", want " + want);
}
checks++;
return;
}
if (want == null ? got != null : !want.equals(got)) {
throw new AssertionError(what + " = " + got + ", want " + want);
}
checks++;
}
}