Skip to content

Commit 81bbe22

Browse files
ADFA-4954 fix(kolibri): validate the node id, and cover the one class that talks to the network
Two findings from reviewing the previous commit. fetchTree interpolated its argument straight into a URL path. No caller reaches it with anything but an already-normalised Channel.rootNodeId(), but the method is public and a value carrying a slash or a query string would have addressed somewhere else entirely. It now normalises first and fails closed, the way ModuleName, AdbShellCommand and ArchiveEntry do at their own boundaries. StudioTreeSource had no test. The previous commit reported 99 passing tests and every one of them was pure logic, so the only class in the slice that touches the network was the only one unverified — while mockwebserver was already a test dependency and HttpOutboundSenderTest already showed the pattern. Adds 11 tests over MockWebServer: the happy path and the request path actually built, id normalisation, six rejected ids that must never become a request, 404, 503, non-JSON, empty body, a 9 MB body against the 8 MB ceiling, a paged child level, and a base URL with no trailing slash. Also readAll now uses try-with-resources; a read() that threw part-way leaked the stream.
1 parent 69b1f34 commit 81bbe22

2 files changed

Lines changed: 185 additions & 14 deletions

File tree

‎controller/app/src/main/java/org/iiab/controller/kolibri/data/StudioTreeSource.java‎

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import android.util.Log;
1313

14+
import org.iiab.controller.kolibri.domain.ChannelId;
1415
import org.iiab.controller.kolibri.domain.TopicNode;
1516
import org.json.JSONObject;
1617

@@ -66,10 +67,17 @@ public StudioTreeSource(String baseUrl) {
6667
* a usable node
6768
*/
6869
public TopicNode fetchTree(String nodeId) {
69-
if (nodeId == null || nodeId.trim().isEmpty()) {
70+
// Validate before the id reaches a URL path, not after. Every caller
71+
// today passes an already-normalised Channel.rootNodeId(), but this
72+
// method is public and a value with a slash or a query string in it
73+
// would build a request to somewhere else entirely. Fail closed, the
74+
// way ModuleName, AdbShellCommand and ArchiveEntry do at their own
75+
// boundaries.
76+
String id = ChannelId.normalise(nodeId);
77+
if (id == null) {
7078
return null;
7179
}
72-
String url = baseUrl + "/api/public/v2/contentnode_tree/" + nodeId.trim();
80+
String url = baseUrl + "/api/public/v2/contentnode_tree/" + id;
7381
try {
7482
String body = httpGet(url);
7583
if (body.isEmpty()) {
@@ -105,19 +113,21 @@ private static String readAll(InputStream is) throws Exception {
105113
if (is == null) {
106114
return "";
107115
}
108-
ByteArrayOutputStream buf = new ByteArrayOutputStream();
109-
byte[] chunk = new byte[8192];
110-
int n;
111-
int total = 0;
112-
while ((n = is.read(chunk)) != -1) {
113-
total += n;
114-
if (total > MAX_BYTES) {
115-
is.close();
116-
throw new Exception("response over " + (MAX_BYTES / (1024 * 1024)) + " MB");
116+
// try-with-resources rather than a close() at each exit: a read() that
117+
// throws part-way used to leak the stream.
118+
try (InputStream in = is) {
119+
ByteArrayOutputStream buf = new ByteArrayOutputStream();
120+
byte[] chunk = new byte[8192];
121+
int n;
122+
int total = 0;
123+
while ((n = in.read(chunk)) != -1) {
124+
total += n;
125+
if (total > MAX_BYTES) {
126+
throw new Exception("response over " + (MAX_BYTES / (1024 * 1024)) + " MB");
127+
}
128+
buf.write(chunk, 0, n);
117129
}
118-
buf.write(chunk, 0, n);
130+
return buf.toString(StandardCharsets.UTF_8.name());
119131
}
120-
is.close();
121-
return buf.toString(StandardCharsets.UTF_8.name());
122132
}
123133
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package org.iiab.controller.kolibri.data;
2+
3+
import static org.junit.Assert.assertEquals;
4+
import static org.junit.Assert.assertFalse;
5+
import static org.junit.Assert.assertNull;
6+
import static org.junit.Assert.assertTrue;
7+
8+
import java.nio.charset.StandardCharsets;
9+
import java.util.Arrays;
10+
11+
import okhttp3.mockwebserver.MockResponse;
12+
import okhttp3.mockwebserver.MockWebServer;
13+
import okhttp3.mockwebserver.RecordedRequest;
14+
15+
import org.iiab.controller.kolibri.domain.TopicNode;
16+
import org.junit.After;
17+
import org.junit.Before;
18+
import org.junit.Test;
19+
20+
/**
21+
* Unit tests for {@link StudioTreeSource} against a local {@link MockWebServer},
22+
* following {@code HttpOutboundSenderTest}.
23+
*
24+
* <p>This is the only class in the Kolibri catalog slice that touches the
25+
* network, so it is the only one whose failure modes cannot be reasoned about
26+
* from pure logic: what it does with a 404, with a body that is not JSON, with a
27+
* response too large to hold on a phone, and — the one that matters for security
28+
* — what it does with a node id that is not a node id.
29+
*
30+
* <p>Runs on a plain JVM: {@code android.util.Log} is stubbed by the module's
31+
* {@code testOptions.unitTests.returnDefaultValues}.
32+
*/
33+
public class StudioTreeSourceTest {
34+
35+
private static final String CH = "c150ea1d69495d37b5b0ac6f017e9bfb";
36+
private static final String NODE = "23a7dc9c73635cd2abbd3e8aab13c3ca";
37+
38+
private MockWebServer server;
39+
private StudioTreeSource source;
40+
41+
@Before
42+
public void setUp() throws Exception {
43+
server = new MockWebServer();
44+
server.start();
45+
// Trailing slash on purpose: the constructor has to strip it, or every
46+
// URL it builds carries a double slash.
47+
source = new StudioTreeSource(server.url("/").toString());
48+
}
49+
50+
@After
51+
public void tearDown() throws Exception {
52+
server.shutdown();
53+
}
54+
55+
private static MockResponse json(String body) {
56+
return new MockResponse().setResponseCode(200)
57+
.setHeader("Content-Type", "application/json").setBody(body);
58+
}
59+
60+
private static String treeJson() {
61+
return "{\"id\":\"" + CH + "\",\"title\":\"3asafeer\",\"kind\":\"topic\","
62+
+ "\"is_leaf\":false,\"lft\":1,\"rght\":360,\"children\":{\"more\":null,"
63+
+ "\"results\":[{\"id\":\"" + NODE + "\",\"title\":\"Beginner\","
64+
+ "\"kind\":\"video\",\"is_leaf\":true,"
65+
+ "\"files\":[{\"file_size\":800,\"preset\":\"high_res_video\"}]}]}}";
66+
}
67+
68+
@Test
69+
public void readsATreeAndHitsTheExpectedPath() throws Exception {
70+
server.enqueue(json(treeJson()));
71+
72+
TopicNode t = source.fetchTree(CH);
73+
assertEquals(CH, t.id());
74+
assertEquals(1, t.children().size());
75+
assertEquals(800L, t.subtreeBytes());
76+
assertEquals(179, t.descendantCount());
77+
78+
RecordedRequest req = server.takeRequest();
79+
assertEquals("/api/public/v2/contentnode_tree/" + CH, req.getPath());
80+
assertEquals("GET", req.getMethod());
81+
assertEquals("application/json", req.getHeader("Accept"));
82+
}
83+
84+
@Test
85+
public void aHyphenatedIdIsNormalisedBeforeItReachesTheUrl() throws Exception {
86+
server.enqueue(json(treeJson()));
87+
source.fetchTree("c150ea1d-6949-5d37-b5b0-ac6f017e9bfb");
88+
assertEquals("/api/public/v2/contentnode_tree/" + CH,
89+
server.takeRequest().getPath());
90+
}
91+
92+
@Test
93+
public void anIdThatIsNotANodeIdNeverBecomesARequest() throws Exception {
94+
// The point of validating before building the URL: a value carrying a
95+
// slash or a query string would address something else entirely.
96+
for (String bad : Arrays.asList("../../admin", "abc?x=1", "bisan-sukod",
97+
"", " ", null)) {
98+
assertNull("expected null for " + bad, source.fetchTree(bad));
99+
}
100+
assertEquals("no request should have been made", 0, server.getRequestCount());
101+
}
102+
103+
@Test
104+
public void aNotFoundIsNullRatherThanAnException() throws Exception {
105+
server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"detail\":\"nope\"}"));
106+
assertNull(source.fetchTree(CH));
107+
}
108+
109+
@Test
110+
public void aServerErrorIsNull() throws Exception {
111+
server.enqueue(new MockResponse().setResponseCode(503));
112+
assertNull(source.fetchTree(CH));
113+
}
114+
115+
@Test
116+
public void aBodyThatIsNotJsonIsNull() throws Exception {
117+
server.enqueue(json("<html>maintenance</html>"));
118+
assertNull(source.fetchTree(CH));
119+
}
120+
121+
@Test
122+
public void anEmptyBodyIsNull() throws Exception {
123+
server.enqueue(json(""));
124+
assertNull(source.fetchTree(CH));
125+
}
126+
127+
@Test
128+
public void jsonWithoutAUsableIdIsNull() throws Exception {
129+
server.enqueue(json("{\"title\":\"no id here\"}"));
130+
assertNull(source.fetchTree(CH));
131+
}
132+
133+
@Test
134+
public void anOversizedResponseIsRefusedInsteadOfFillingTheHeap() throws Exception {
135+
// A phone must not try to hold an unbounded body. 9 MB is over the 8 MB
136+
// ceiling; the read aborts and the caller gets null.
137+
byte[] filler = new byte[9 * 1024 * 1024];
138+
Arrays.fill(filler, (byte) 'x');
139+
server.enqueue(json("{\"pad\":\"" + new String(filler, StandardCharsets.UTF_8) + "\"}"));
140+
assertNull(source.fetchTree(CH));
141+
}
142+
143+
@Test
144+
public void aPagedChildLevelComesBackWithAnUnknownSubtreeSize() throws Exception {
145+
server.enqueue(json("{\"id\":\"" + CH + "\",\"kind\":\"topic\",\"is_leaf\":false,"
146+
+ "\"children\":{\"more\":{\"cursor\":\"abc\"},\"results\":[{\"id\":\""
147+
+ NODE + "\",\"is_leaf\":true,\"files\":[{\"file_size\":10}]}]}}"));
148+
TopicNode t = source.fetchTree(CH);
149+
assertEquals(1, t.children().size());
150+
assertFalse(t.hasSubtreeSize());
151+
}
152+
153+
@Test
154+
public void aBaseUrlWithoutATrailingSlashWorksToo() throws Exception {
155+
String noSlash = server.url("/").toString().replaceAll("/$", "");
156+
server.enqueue(json(treeJson()));
157+
assertTrue(new StudioTreeSource(noSlash).fetchTree(CH) != null);
158+
assertEquals("/api/public/v2/contentnode_tree/" + CH,
159+
server.takeRequest().getPath());
160+
}
161+
}

0 commit comments

Comments
 (0)