Skip to main content

olai_stack_runtime/neon/
fake.rs

1//! [`FakeNeon`]: an in-memory [`NeonControl`] for tests (feature `testing`).
2
3use std::collections::BTreeMap;
4use std::path::Path;
5use std::sync::{Arc, Mutex, MutexGuard};
6
7use async_trait::async_trait;
8
9use super::cli::{ApiBranches, first_json};
10use super::{
11    Branch, Connection, CreateBranch, Function, NeonControl, NeonError, Storage, StorageCredential,
12};
13use crate::secrets::SecretValue;
14
15/// A call [`FakeNeon`] was asked to make.
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub enum NeonCall {
18    /// [`create_branch`](NeonControl::create_branch) named this.
19    CreateBranch(String),
20    /// [`delete_branch`](NeonControl::delete_branch) of this id.
21    DeleteBranch(String),
22    /// [`reset_branch`](NeonControl::reset_branch) of this id.
23    ResetBranch(String),
24    /// [`create_database`](NeonControl::create_database): branch id, name.
25    CreateDatabase(String, String),
26    /// [`create_bucket`](NeonControl::create_bucket): branch id, name.
27    CreateBucket(String, String),
28    /// [`create_credential`](NeonControl::create_credential): branch id, token id.
29    CreateCredential(String, String),
30    /// [`revoke_credential`](NeonControl::revoke_credential): branch id, token id.
31    RevokeCredential(String, String),
32}
33
34#[derive(Clone, Default)]
35struct FakeBranch {
36    branch: Branch,
37    functions: Vec<Function>,
38    databases: Vec<String>,
39    buckets: Vec<String>,
40    /// Token id → secret.
41    credentials: BTreeMap<String, String>,
42}
43
44#[derive(Default)]
45struct Inner {
46    project: String,
47    branches: Vec<FakeBranch>,
48    calls: Vec<NeonCall>,
49    signed_in: bool,
50    next: u32,
51}
52
53/// One Neon project in memory, behaving as the spike found the real one does (`docs/neon.md`):
54/// a new branch copies its parent's functions, buckets and credentials at creation, a reset
55/// leaves functions alone, and annotations are kept. Every change is recorded
56/// ([`calls`](Self::calls)).
57#[derive(Clone)]
58pub struct FakeNeon {
59    inner: Arc<Mutex<Inner>>,
60}
61
62impl FakeNeon {
63    /// The project `project` with one default branch, `main` (database `neondb`, storage
64    /// enabled), signed in.
65    pub fn new(project: &str) -> Self {
66        let main = FakeBranch {
67            branch: Branch {
68                id: "br-main".into(),
69                name: "main".into(),
70                parent_id: None,
71                expires_at: None,
72                default: true,
73                annotations: BTreeMap::new(),
74            },
75            databases: vec!["neondb".into()],
76            ..FakeBranch::default()
77        };
78        FakeNeon {
79            inner: Arc::new(Mutex::new(Inner {
80                project: project.to_owned(),
81                branches: vec![main],
82                signed_in: true,
83                ..Inner::default()
84            })),
85        }
86    }
87
88    /// The project as the spike captured it in `dir` (`tests/fixtures/neon/<version>/`): its
89    /// branches with their annotations (`api-branches.json`, `branches-list.json`) and the
90    /// functions on the default branch (`functions-list.json`).
91    pub fn from_fixtures(project: &str, dir: &Path) -> Result<Self, String> {
92        let read = |name: &str| {
93            std::fs::read_to_string(dir.join(name)).map_err(|e| format!("{name}: {e}"))
94        };
95        let listed: Vec<Branch> = first_json(&read("branches-list.json")?)?;
96        let annotated: ApiBranches = first_json(&read("api-branches.json")?)?;
97        let annotated = annotated.into_branches();
98        let functions: Vec<Function> = first_json(&read("functions-list.json")?)?;
99        let fake = FakeNeon::new(project);
100        {
101            let mut inner = fake.lock();
102            inner.branches = listed
103                .into_iter()
104                .map(|b| {
105                    let annotations = annotated
106                        .iter()
107                        .find(|a| a.id == b.id)
108                        .map(|a| a.annotations.clone())
109                        .unwrap_or_default();
110                    FakeBranch {
111                        functions: if b.default {
112                            functions.clone()
113                        } else {
114                            Vec::new()
115                        },
116                        databases: vec!["neondb".into()],
117                        branch: Branch { annotations, ..b },
118                        ..FakeBranch::default()
119                    }
120                })
121                .collect();
122        }
123        Ok(fake)
124    }
125
126    fn lock(&self) -> MutexGuard<'_, Inner> {
127        self.inner.lock().unwrap_or_else(|e| e.into_inner())
128    }
129
130    /// Deploy the function `slug` on the branch `name_or_id`.
131    pub fn deploy_function(&self, name_or_id: &str, slug: &str) {
132        let mut inner = self.lock();
133        if let Some(b) = inner.branches.iter_mut().find(|b| b.branch.is(name_or_id)) {
134            let url = format!("https://{}-{slug}.compute.fake.neon.tech/", b.branch.id);
135            b.functions.retain(|f| f.slug != slug);
136            b.functions.push(Function {
137                slug: slug.to_owned(),
138                invocation_url: url,
139            });
140        }
141    }
142
143    /// Sign the CLI out (or back in).
144    pub fn set_signed_in(&self, signed_in: bool) {
145        self.lock().signed_in = signed_in;
146    }
147
148    /// Every change asked of it, in order.
149    pub fn calls(&self) -> Vec<NeonCall> {
150        self.lock().calls.clone()
151    }
152
153    /// The credentials on the branch `name_or_id`, by token id.
154    pub fn credentials(&self, name_or_id: &str) -> Vec<String> {
155        self.lock()
156            .branches
157            .iter()
158            .find(|b| b.branch.is(name_or_id))
159            .map(|b| b.credentials.keys().cloned().collect())
160            .unwrap_or_default()
161    }
162
163    fn with_branch<T>(
164        &self,
165        project: &str,
166        name_or_id: &str,
167        f: impl FnOnce(&mut FakeBranch, &mut Vec<NeonCall>) -> T,
168    ) -> Result<T, NeonError> {
169        let mut inner = self.lock();
170        check(&inner, project)?;
171        let Inner {
172            branches, calls, ..
173        } = &mut *inner;
174        let b = branches
175            .iter_mut()
176            .find(|b| b.branch.is(name_or_id))
177            .ok_or_else(|| NeonError::NoSuchBranch(name_or_id.to_owned()))?;
178        Ok(f(b, calls))
179    }
180}
181
182fn check(inner: &Inner, project: &str) -> Result<(), NeonError> {
183    if !inner.signed_in {
184        return Err(NeonError::NotAuthenticated("signed out".into()));
185    }
186    if inner.project != project {
187        return Err(NeonError::Failed {
188            command: format!("--project-id {project}"),
189            stderr: "project not found".into(),
190        });
191    }
192    Ok(())
193}
194
195#[async_trait]
196impl NeonControl for FakeNeon {
197    async fn auth_status(&self) -> Result<String, NeonError> {
198        if self.lock().signed_in {
199            Ok("fake-user".into())
200        } else {
201            Err(NeonError::NotAuthenticated("signed out".into()))
202        }
203    }
204
205    async fn branches(&self, project: &str) -> Result<Vec<Branch>, NeonError> {
206        let inner = self.lock();
207        check(&inner, project)?;
208        Ok(inner.branches.iter().map(|b| b.branch.clone()).collect())
209    }
210
211    async fn create_branch(&self, project: &str, spec: &CreateBranch) -> Result<Branch, NeonError> {
212        let mut inner = self.lock();
213        check(&inner, project)?;
214        let parent = match &spec.parent {
215            Some(p) => inner.branches.iter().find(|b| b.branch.is(p)),
216            None => inner.branches.iter().find(|b| b.branch.default),
217        }
218        .cloned()
219        .ok_or_else(|| NeonError::NoSuchBranch(spec.parent.clone().unwrap_or_default()))?;
220        if inner.branches.iter().any(|b| b.branch.name == spec.name) {
221            return Err(NeonError::Failed {
222                command: "branches create".into(),
223                stderr: format!("branch {} already exists", spec.name),
224            });
225        }
226        inner.next += 1;
227        let id = format!("br-fake-{}", inner.next);
228        // A child gets a copy of the parent's functions (each under its own URL), buckets and
229        // credentials, as the spike found.
230        let child = FakeBranch {
231            branch: Branch {
232                id: id.clone(),
233                name: spec.name.clone(),
234                parent_id: Some(parent.branch.id.clone()),
235                expires_at: spec.expires_at.clone(),
236                default: false,
237                annotations: spec.annotations.clone(),
238            },
239            functions: parent
240                .functions
241                .iter()
242                .map(|f| Function {
243                    slug: f.slug.clone(),
244                    invocation_url: format!("https://{id}-{}.compute.fake.neon.tech/", f.slug),
245                })
246                .collect(),
247            databases: parent.databases.clone(),
248            buckets: parent.buckets.clone(),
249            credentials: parent.credentials.clone(),
250        };
251        let branch = child.branch.clone();
252        inner.branches.push(child);
253        inner.calls.push(NeonCall::CreateBranch(spec.name.clone()));
254        Ok(branch)
255    }
256
257    async fn delete_branch(&self, project: &str, id: &str) -> Result<(), NeonError> {
258        let mut inner = self.lock();
259        check(&inner, project)?;
260        let before = inner.branches.len();
261        inner.branches.retain(|b| b.branch.id != id);
262        if inner.branches.len() == before {
263            return Err(NeonError::NoSuchBranch(id.to_owned()));
264        }
265        inner.calls.push(NeonCall::DeleteBranch(id.to_owned()));
266        Ok(())
267    }
268
269    async fn reset_branch(&self, project: &str, id: &str) -> Result<(), NeonError> {
270        self.with_branch(project, id, |_, calls| {
271            calls.push(NeonCall::ResetBranch(id.to_owned()))
272        })
273    }
274
275    async fn connection(
276        &self,
277        project: &str,
278        branch: &str,
279        database: Option<&str>,
280        pooled: bool,
281    ) -> Result<Connection, NeonError> {
282        self.with_branch(project, branch, |b, _| {
283            let id = b.branch.id.trim_start_matches("br-");
284            let host = format!(
285                "ep-{id}{}.c-8.eu-central-1.aws.neon.tech",
286                if pooled { "-pooler" } else { "" }
287            );
288            let database = database.unwrap_or("neondb").to_owned();
289            let password = format!("fake-password-{id}");
290            let options = "sslmode=require&channel_binding=require".to_owned();
291            let userinfo = ["neondb_owner", password.as_str()].join(":");
292            Connection {
293                url: SecretValue::new(format!(
294                    "postgresql://{userinfo}@{host}/{database}?{options}"
295                )),
296                host,
297                role: "neondb_owner".into(),
298                password: SecretValue::new(password),
299                database,
300                options,
301            }
302        })
303    }
304
305    async fn databases(&self, project: &str, branch: &str) -> Result<Vec<String>, NeonError> {
306        self.with_branch(project, branch, |b, _| b.databases.clone())
307    }
308
309    async fn create_database(
310        &self,
311        project: &str,
312        branch: &str,
313        name: &str,
314    ) -> Result<(), NeonError> {
315        self.with_branch(project, branch, |b, calls| {
316            b.databases.push(name.to_owned());
317            calls.push(NeonCall::CreateDatabase(
318                b.branch.id.clone(),
319                name.to_owned(),
320            ));
321        })
322    }
323
324    async fn functions(&self, project: &str, branch: &str) -> Result<Vec<Function>, NeonError> {
325        self.with_branch(project, branch, |b, _| b.functions.clone())
326    }
327
328    async fn storage(&self, project: &str, branch: &str) -> Result<Storage, NeonError> {
329        self.with_branch(project, branch, |b, _| Storage {
330            enabled: true,
331            s3_endpoint: format!(
332                "https://{}.storage.c-8.eu-central-1.aws.neon.tech",
333                b.branch.id
334            ),
335            region: "eu-central-1".into(),
336            force_path_style: true,
337        })
338    }
339
340    async fn buckets(&self, project: &str, branch: &str) -> Result<Vec<String>, NeonError> {
341        self.with_branch(project, branch, |b, _| b.buckets.clone())
342    }
343
344    async fn create_bucket(
345        &self,
346        project: &str,
347        branch: &str,
348        name: &str,
349    ) -> Result<(), NeonError> {
350        self.with_branch(project, branch, |b, calls| {
351            b.buckets.push(name.to_owned());
352            calls.push(NeonCall::CreateBucket(b.branch.id.clone(), name.to_owned()));
353        })
354    }
355
356    async fn create_credential(
357        &self,
358        project: &str,
359        branch: &str,
360        name: &str,
361    ) -> Result<StorageCredential, NeonError> {
362        let n = {
363            let mut inner = self.lock();
364            inner.next += 1;
365            inner.next
366        };
367        self.with_branch(project, branch, |b, calls| {
368            let token_id = format!("nak_fake_{n:032}");
369            let secret = format!("fake-s3-secret-{name}-{n}");
370            b.credentials.insert(token_id.clone(), secret.clone());
371            calls.push(NeonCall::CreateCredential(
372                b.branch.id.clone(),
373                token_id.clone(),
374            ));
375            StorageCredential {
376                token_id,
377                secret_access_key: SecretValue::new(secret),
378            }
379        })
380    }
381
382    async fn revoke_credential(
383        &self,
384        project: &str,
385        branch: &str,
386        token_id: &str,
387    ) -> Result<(), NeonError> {
388        self.with_branch(project, branch, |b, calls| {
389            b.credentials.remove(token_id);
390            calls.push(NeonCall::RevokeCredential(
391                b.branch.id.clone(),
392                token_id.to_owned(),
393            ));
394        })
395    }
396}