Skip to content

Commit 1444992

Browse files
committed
add test to ensure the server bin starts with or without a db
1 parent 07c38a1 commit 1444992

File tree

6 files changed

+229
-22
lines changed

6 files changed

+229
-22
lines changed

src/bin/server.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
9797
let mut sig_int = rt.block_on(async { signal(SignalKind::interrupt()) })?;
9898
let mut sig_term = rt.block_on(async { signal(SignalKind::terminate()) })?;
9999

100+
// When the user configures PORT=0 the operative system will allocate a random unused port.
101+
// This fetches that random port and uses it to display the "listening on port" message later.
102+
let actual_port = server.local_addr().port();
103+
100104
let server = server.with_graceful_shutdown(async move {
101105
// Wait for either signal
102106
futures_util::select! {
@@ -109,7 +113,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
109113

110114
let server = rt.spawn(async { server.await.unwrap() });
111115

112-
println!("listening on port {}", port);
116+
// Do not change this line! Removing the line or changing its contents in any way will break
117+
// the test suite :)
118+
println!("listening on port {}", actual_port);
113119

114120
// Creating this file tells heroku to tell nginx that the application is ready
115121
// to receive traffic.

src/tests/all.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ use diesel::prelude::*;
3232
mod account_lock;
3333
mod authentication;
3434
mod badge;
35+
mod server_binary;
3536
mod builders;
3637
mod categories;
3738
mod category;

src/tests/server_binary.rs

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
use crate::builders::CrateBuilder;
2+
use crate::util::{ChaosProxy, FreshSchema};
3+
use anyhow::Error;
4+
use cargo_registry::models::{NewUser, User};
5+
use diesel::prelude::*;
6+
use reqwest::blocking::{Client, Response};
7+
use std::collections::HashMap;
8+
use std::io::{BufRead, BufReader, Read};
9+
use std::process::{Child, Command, Stdio};
10+
use std::sync::{mpsc::Sender, Arc};
11+
use std::time::Duration;
12+
13+
const SERVER_BOOT_TIMEOUT_SECONDS: u64 = 30;
14+
15+
#[test]
16+
fn normal_startup() -> Result<(), Error> {
17+
let server_bin = ServerBin::prepare()?;
18+
initialize_dummy_crate(&server_bin.db()?);
19+
20+
let running_server = server_bin.start()?;
21+
22+
// Ensure the application correctly responds to download requests
23+
let resp = running_server.get("api/v1/crates/FOO/1.0.0/download")?;
24+
assert!(resp.status().is_redirection());
25+
assert!(resp
26+
.headers()
27+
.get("location")
28+
.unwrap()
29+
.to_str()?
30+
.ends_with("/crates/foo/foo-1.0.0.crate"));
31+
32+
Ok(())
33+
}
34+
35+
#[test]
36+
fn startup_without_database() -> Result<(), Error> {
37+
let server_bin = ServerBin::prepare()?;
38+
initialize_dummy_crate(&server_bin.db()?);
39+
40+
// Break the networking *before* starting the binary, to ensure the binary can fully startup
41+
// without a database connection. Most of crates.io should not work when started without a
42+
// database, but unconditional redirects will work.
43+
server_bin.chaosproxy.break_networking();
44+
45+
let running_server = server_bin.start()?;
46+
47+
// Ensure unconditional redirects work.
48+
let resp = running_server.get("api/v1/crates/FOO/1.0.0/download")?;
49+
assert!(resp.status().is_redirection());
50+
assert!(resp
51+
.headers()
52+
.get("location")
53+
.unwrap()
54+
.to_str()?
55+
.ends_with("/crates/FOO/FOO-1.0.0.crate"));
56+
57+
Ok(())
58+
}
59+
60+
fn initialize_dummy_crate(conn: &PgConnection) {
61+
use cargo_registry::schema::users;
62+
63+
let user: User = diesel::insert_into(users::table)
64+
.values(NewUser {
65+
gh_id: 0,
66+
gh_login: "user",
67+
..NewUser::default()
68+
})
69+
.get_result(conn)
70+
.expect("failed to create dummy user");
71+
72+
CrateBuilder::new("foo", user.id)
73+
.version("1.0.0")
74+
.build(conn)
75+
.expect("failed to create dummy crate");
76+
}
77+
78+
struct ServerBin {
79+
chaosproxy: Arc<ChaosProxy>,
80+
db_url: String,
81+
env: HashMap<String, String>,
82+
fresh_schema: FreshSchema,
83+
}
84+
85+
impl ServerBin {
86+
fn prepare() -> Result<Self, Error> {
87+
let mut env = dotenv::vars().collect::<HashMap<_, _>>();
88+
// Bind a random port every time the server is started.
89+
env.insert("PORT".into(), "0".into());
90+
// Avoid creating too many database connections.
91+
env.insert("DB_POOL_SIZE".into(), "2".into());
92+
env.remove("DB_MIN_SIZE");
93+
94+
// Use a proxied fresh schema as the database url.
95+
let fresh_schema = FreshSchema::new(env.get("TEST_DATABASE_URL").unwrap());
96+
let (chaosproxy, db_url) = ChaosProxy::proxy_database_url(fresh_schema.database_url())?;
97+
env.remove("TEST_DATABASE_URL");
98+
env.insert("DATABASE_URL".into(), db_url.clone());
99+
env.insert("READ_ONLY_REPLICA_URL".into(), db_url.clone());
100+
101+
Ok(ServerBin {
102+
chaosproxy,
103+
db_url,
104+
env,
105+
fresh_schema,
106+
})
107+
}
108+
109+
fn db(&self) -> Result<PgConnection, Error> {
110+
Ok(PgConnection::establish(&self.db_url)?)
111+
}
112+
113+
fn start(self) -> Result<RunningServer, Error> {
114+
let mut process = Command::new(env!("CARGO_BIN_EXE_server"))
115+
.env_clear()
116+
.envs(self.env.into_iter())
117+
.stdout(Stdio::piped())
118+
.stderr(Stdio::piped())
119+
.spawn()?;
120+
121+
let (port_send, port_recv) = std::sync::mpsc::channel();
122+
stream_processor(process.stdout.take().unwrap(), "stdout", Some(port_send));
123+
stream_processor(process.stderr.take().unwrap(), "stderr", None);
124+
125+
// Possible causes for this to fail:
126+
// - the server binary failed to start
127+
// - the server binary requires a database connection now
128+
// - the server binary doesn't print "listening on port {port}" anymore
129+
let port: u16 = port_recv
130+
.recv_timeout(Duration::from_secs(SERVER_BOOT_TIMEOUT_SECONDS))
131+
.map_err(|_| anyhow::anyhow!("the server took too much time to initialize"))?
132+
.parse()?;
133+
134+
let http = Client::builder()
135+
.redirect(reqwest::redirect::Policy::none())
136+
.build()?;
137+
138+
Ok(RunningServer {
139+
process,
140+
port,
141+
http,
142+
_chaosproxy: self.chaosproxy,
143+
_fresh_schema: self.fresh_schema,
144+
})
145+
}
146+
}
147+
148+
struct RunningServer {
149+
process: Child,
150+
port: u16,
151+
http: Client,
152+
153+
// Keep these two items at the bottom in this order to drop everything in the correct order.
154+
_chaosproxy: Arc<ChaosProxy>,
155+
_fresh_schema: FreshSchema,
156+
}
157+
158+
impl RunningServer {
159+
fn get(&self, url: &str) -> Result<Response, Error> {
160+
Ok(self
161+
.http
162+
.get(format!("http://127.0.0.1:{}/{}", self.port, url))
163+
.header("User-Agent", "crates.io test suite")
164+
.send()?)
165+
}
166+
}
167+
168+
impl Drop for RunningServer {
169+
fn drop(&mut self) {
170+
self.process
171+
.kill()
172+
.expect("failed to kill the server binary");
173+
}
174+
}
175+
176+
fn stream_processor<R>(stream: R, kind: &'static str, port_send: Option<Sender<String>>)
177+
where
178+
R: Read + Send + 'static,
179+
{
180+
std::thread::spawn(move || {
181+
let stream = BufReader::new(stream);
182+
for line in stream.lines() {
183+
let line = match line {
184+
Ok(line) => line,
185+
// We receive an EOF when the process terminates
186+
Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => break,
187+
Err(err) => panic!("unexpected error while reading process {}: {}", kind, err),
188+
};
189+
190+
// If we expect the port number to be logged into this stream, look for it and send it
191+
// over the channel as soon as it's found.
192+
if let Some(port) = &port_send {
193+
if let Some(port_str) = line.strip_prefix("listening on port ") {
194+
port.send(port_str.into())
195+
.expect("failed to send the port to the test thread")
196+
}
197+
}
198+
199+
println!("[server {}] {}", kind, line);
200+
}
201+
});
202+
}

src/tests/util.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ mod response;
3939
mod test_app;
4040

4141
pub(crate) use fresh_schema::FreshSchema;
42+
pub(crate) use chaosproxy::ChaosProxy;
4243
pub use response::Response;
4344
pub use test_app::TestApp;
4445

src/tests/util/chaosproxy.rs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use anyhow::Error;
1+
use anyhow::{Context, Error};
22
use std::net::SocketAddr;
33
use std::sync::Arc;
44
use tokio::{
@@ -10,6 +10,7 @@ use tokio::{
1010
runtime::Runtime,
1111
sync::broadcast::Sender,
1212
};
13+
use url::Url;
1314

1415
pub(crate) struct ChaosProxy {
1516
address: SocketAddr,
@@ -51,8 +52,19 @@ impl ChaosProxy {
5152
Ok(instance)
5253
}
5354

54-
pub(crate) fn address(&self) -> SocketAddr {
55-
self.address
55+
pub(crate) fn proxy_database_url(url: &str) -> Result<(Arc<Self>, String), Error> {
56+
let mut db_url = Url::parse(url).context("failed to parse database url")?;
57+
let backend_addr = db_url
58+
.socket_addrs(|| Some(5432))
59+
.context("could not resolve database url")?
60+
.get(0)
61+
.copied()
62+
.ok_or_else(|| anyhow::anyhow!("the database url does not point to any IP"))?;
63+
64+
let instance = ChaosProxy::new(backend_addr).unwrap();
65+
db_url.set_ip_host(instance.address.ip()).unwrap();
66+
db_url.set_port(Some(instance.address.port())).unwrap();
67+
Ok((instance, db_url.into_string()))
5668
}
5769

5870
pub(crate) fn break_networking(&self) {

src/tests/util/test_app.rs

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -197,25 +197,10 @@ impl TestAppBuilder {
197197
// The schema will be cleared up once the app is dropped.
198198
let (db_chaosproxy, fresh_schema) = if !self.config.use_test_database_pool {
199199
let fresh_schema = FreshSchema::new(&self.config.db_primary_config.url);
200-
self.config.db_primary_config.url = fresh_schema.database_url().into();
201-
202-
let mut db_url =
203-
Url::parse(&self.config.db_primary_config.url).expect("invalid db url");
204-
let backend_addr = db_url
205-
.socket_addrs(|| Some(5432))
206-
.expect("could not resolve database url")
207-
.get(0)
208-
.copied()
209-
.expect("the database url does not point to any IP");
210-
211-
let db_chaosproxy = ChaosProxy::new(backend_addr).unwrap();
212-
db_url.set_ip_host(db_chaosproxy.address().ip()).unwrap();
213-
db_url
214-
.set_port(Some(db_chaosproxy.address().port()))
215-
.unwrap();
216-
self.config.db_primary_config.url = db_url.into_string();
200+
let (proxy, url) = ChaosProxy::proxy_database_url(fresh_schema.database_url()).unwrap();
201+
self.config.db_primary_config.url = url;
217202

218-
(Some(db_chaosproxy), Some(fresh_schema))
203+
(Some(proxy), Some(fresh_schema))
219204
} else {
220205
(None, None)
221206
};

0 commit comments

Comments
 (0)