App 2.4.0: pair with the server by QR code
The Server tab gets "Pair with phone" right under the server URL, which is now pre-filled with https://car.stinnesbeck.com. It opens a dialog with a QR code and the short code, polls the server, and on approval saves this car's own token like Save would — nothing to type in the car. Denied, expired, and pairing switched off on the server are each reported. PairingClient is tested against a stand-in for the server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetAddress;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
/** The car's pairing client against a stand-in that answers like the Go server. */
|
||||
public class PairingClientTest {
|
||||
|
||||
private ServerSocket socket;
|
||||
private Thread thread;
|
||||
private String url;
|
||||
private final Deque<Integer> pollStatuses = new ArrayDeque<>();
|
||||
private volatile int startStatus = 201;
|
||||
private volatile String lastStartBody;
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception {
|
||||
socket = new ServerSocket(0, 10, InetAddress.getLoopbackAddress());
|
||||
url = "http://127.0.0.1:" + socket.getLocalPort();
|
||||
thread = new Thread(() -> {
|
||||
while (!socket.isClosed()) {
|
||||
try (Socket client = socket.accept()) {
|
||||
handle(client);
|
||||
} catch (Exception ignored) {
|
||||
// socket closed in tearDown
|
||||
}
|
||||
}
|
||||
});
|
||||
thread.start();
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown() throws Exception {
|
||||
socket.close();
|
||||
thread.join(2000);
|
||||
}
|
||||
|
||||
/** Minimal HTTP/1.1: one request per connection, body by Content-Length. */
|
||||
private void handle(Socket client) throws Exception {
|
||||
BufferedReader in = new BufferedReader(new InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8));
|
||||
String requestLine = in.readLine();
|
||||
int length = 0;
|
||||
for (String line = in.readLine(); line != null && !line.isEmpty(); line = in.readLine()) {
|
||||
if (line.toLowerCase(java.util.Locale.US).startsWith("content-length:")) {
|
||||
length = Integer.parseInt(line.substring(15).trim());
|
||||
}
|
||||
}
|
||||
char[] body = new char[length];
|
||||
int read = 0;
|
||||
while (read < length) read += in.read(body, read, length - read);
|
||||
String path = requestLine.split(" ")[1];
|
||||
int status;
|
||||
String response;
|
||||
if (path.equals("/v1/pair/start")) {
|
||||
lastStartBody = new String(body);
|
||||
status = startStatus;
|
||||
response = status == 201
|
||||
? "{\"deviceCode\":\"dev-code\",\"userCode\":\"K7F-3QX\",\"verificationUri\":\"https://car.example.com/pair\","
|
||||
+ "\"verificationUriComplete\":\"https://car.example.com/pair?code=K7F-3QX\",\"expiresIn\":600,\"interval\":5}"
|
||||
: "{\"error\":\"pairing is not enabled\"}";
|
||||
} else {
|
||||
Integer next;
|
||||
synchronized (pollStatuses) { next = pollStatuses.poll(); }
|
||||
status = next == null ? 410 : next;
|
||||
response = status == 200 ? "{\"token\":\"car-token-0123456789abcdefghij\",\"deviceId\":\"d1\"}"
|
||||
: status == 202 ? "{\"status\":\"authorization_pending\"}" : "{\"error\":\"x\"}";
|
||||
}
|
||||
byte[] bytes = response.getBytes(StandardCharsets.UTF_8);
|
||||
OutputStream out = client.getOutputStream();
|
||||
out.write(("HTTP/1.1 " + status + " X\r\nContent-Type: application/json\r\nContent-Length: " + bytes.length
|
||||
+ "\r\nConnection: close\r\n\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
out.write(bytes);
|
||||
out.flush();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startSendsTheInstallationAndReadsTheCodes() throws Exception {
|
||||
long before = System.currentTimeMillis();
|
||||
PairingClient.Request request = PairingClient.start(url, "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b");
|
||||
assertTrue(lastStartBody, lastStartBody.contains("6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b"));
|
||||
assertEquals("dev-code", request.deviceCode);
|
||||
assertEquals("K7F-3QX", request.userCode);
|
||||
assertEquals("https://car.example.com/pair?code=K7F-3QX", request.verificationUriComplete);
|
||||
assertEquals(5000L, request.intervalMs);
|
||||
assertTrue(request.expiresAtMs >= before + 600_000L);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pollMapsEveryServerAnswer() throws Exception {
|
||||
synchronized (pollStatuses) {
|
||||
pollStatuses.add(202);
|
||||
pollStatuses.add(200);
|
||||
pollStatuses.add(403);
|
||||
pollStatuses.add(410);
|
||||
}
|
||||
PairingClient.PollResult pending = PairingClient.poll(url, "dev-code");
|
||||
assertEquals(PairingClient.Outcome.PENDING, pending.outcome);
|
||||
assertNull(pending.token);
|
||||
PairingClient.PollResult approved = PairingClient.poll(url, "dev-code");
|
||||
assertEquals(PairingClient.Outcome.APPROVED, approved.outcome);
|
||||
assertEquals("car-token-0123456789abcdefghij", approved.token);
|
||||
assertEquals(PairingClient.Outcome.DENIED, PairingClient.poll(url, "dev-code").outcome);
|
||||
assertEquals(PairingClient.Outcome.EXPIRED, PairingClient.poll(url, "dev-code").outcome);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pairingSwitchedOffOnTheServerIsReportedAsSuch() throws Exception {
|
||||
startStatus = 404;
|
||||
try {
|
||||
PairingClient.start(url, "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b");
|
||||
fail("expected NotEnabledException");
|
||||
} catch (PairingClient.NotEnabledException expected) {
|
||||
// the dialog says pairing is off instead of a generic failure
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user