bug fix
This commit is contained in:
parent
8bc63da653
commit
f3a7aa786b
13 changed files with 125 additions and 128 deletions
|
|
@ -12,7 +12,7 @@ This is a production multi-tenant app under active attack. The two targets are a
|
|||
- **Scope every query by org**, then check visibility (see Reuse below), and pass `org=` on `create()` and `serializer.save()`. RLS is the safety net, not the contract.
|
||||
- **Server-owned fields** (`org`, `created_by`, `profile`, publish flags) are `read_only=True` or popped before save. State transitions validate the source state, not just the target.
|
||||
- Check by name for: org auto-join by email domain, IDOR on detail and nested routes, mass assignment, refresh tokens surviving a privilege change, SSRF, path traversal, stored-markdown XSS, and secrets or another tenant's data in responses and logs.
|
||||
- **Never weaken a protection** (RLS policy, permission class, org filter, validator, CSRF or auth check) to make something work: surface it and ask. Flag vulnerabilities you notice, but never in a public issue, PR or commit message.
|
||||
- **Never weaken a protection** (RLS policy, permission class, org filter, validator, CSRF or auth check) to make something work: surface it and ask. Flag vulnerabilities you notice, but never in a public issue, PR or commit message: they are reported as `SECURITY.md` describes.
|
||||
|
||||
## House rules
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ pnpm run format
|
|||
docker compose up --build # full stack, from the repo root
|
||||
```
|
||||
|
||||
CI (`.github/workflows/tests.yml`) blocks on `makemigrations --check`, `ruff check`, `ruff format --check`, pytest on SQLite and on Postgres, the `postgres_only` tests as a non-superuser role, a wheel install check, `eslint`, `pnpm check`, `pnpm test`, `pnpm build` and `mkdocs --strict`. Prettier only warns. Nothing runs for mobile.
|
||||
CI (`.github/workflows/tests.yml`) blocks on `makemigrations --check`, `ruff check`, `ruff format --check`, pytest on SQLite and on Postgres, the `postgres_only` tests as a non-superuser role, a wheel install check, `eslint`, `pnpm check`, `pnpm test`, `pnpm build` and `mkdocs --strict`. Prettier only warns. Nothing runs for mobile. Markers, the Postgres role and the three CI runs: `docs/contributing/testing.md`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
|
|
@ -94,7 +94,7 @@ Frontend:
|
|||
- **Data loads on the server.** A route's `+page.server.js` calls `#lib/server/v2/<entity>.js`, which calls `apiRequest` from `#lib/api-helpers.js` with the cookies; writes are form actions with `use:enhance` and `fail()`. There are no `+page.js` files, because the JWT is an httpOnly cookie the browser cannot read. Browser code talks to `src/routes/api/*/+server.js` proxies or `?/action`, never to Django, and cannot import `#lib/server/*`.
|
||||
- **A load's returned names are the page's contract.** `server/v2` returns `{ results, totals }` and each load renames them; a mismatch is an SSR 500 while the API answers 200, and `pnpm check` does not catch it. Load the page.
|
||||
- **Forms drop values silently.** A `<select>` with no option matching the stored value submits nothing and clears it on save (use `missingOptions` from `#lib/v2/pickers.js`); an unchecked checkbox submits nothing, so send an explicit `false`.
|
||||
- **`toast()` renders nothing**, because no `<Toaster>` is mounted. Feedback is `fail()` plus `form?.error` or `form?.message`.
|
||||
- **There are no toasts.** No live code calls `toast()` and no `<Toaster>` is mounted; `svelte-sonner` and the notifications store survive only in the unused v1 shell (`components/layout`). Feedback is `fail()` plus `form?.error` or `form?.message`.
|
||||
- Env vars are declared in `src/env.js` and read through `$app/env/public` or `$app/env/private`. In SvelteKit 3 a string starting with `/` is a route ID: link with `resolve()` from `$app/paths`, or `asInternalPath` for a built string.
|
||||
- Vitest runs in Node: it cannot compile `.svelte` or resolve `$app/*` beyond the env stubs, so a testable module imports neither. Tests sit beside the code as `*.test.js`; run them from `frontend/`.
|
||||
- Never log a raw axios error in `hooks.server.js`, since it carries the tokens: use `describeError`. `/help` (the signed-in user's tickets) is not `/help-center` (public) and stays out of `PUBLIC_ROUTES`.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Thank you for helping improve BottleCRM. Contributions of all sizes are welcome,
|
|||
## Before You Start
|
||||
|
||||
- Search the [existing issues](https://github.com/django-crm/Django-CRM/issues) before opening a new one.
|
||||
- Found a vulnerability? Report it privately as [SECURITY.md](SECURITY.md) describes, not in an issue.
|
||||
- For a substantial feature or architectural change, open an issue first so the approach can be discussed before implementation begins.
|
||||
- Keep pull requests focused. Unrelated changes are easier to review when submitted separately.
|
||||
|
||||
|
|
|
|||
21
SECURITY.md
Normal file
21
SECURITY.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Security policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Report it privately through [GitHub's private vulnerability reporting](https://github.com/django-crm/Django-CRM/security/advisories/new). If you cannot use GitHub, email hello@bottlecrm.io with "Security" in the subject line.
|
||||
|
||||
Please do not open a public issue, pull request or discussion about it. Test only against your own self-hosted instance or your own organization, never against other people's data on the hosted service.
|
||||
|
||||
Include what you can:
|
||||
|
||||
- the affected part (API, web app or mobile app) and its version
|
||||
- steps to reproduce, or a proof of concept
|
||||
- the impact: what an attacker gains, and what access they need first
|
||||
|
||||
## Scope
|
||||
|
||||
We most want to hear about anything that lets one user act as another, read or change another organization's data, or gain admin or superadmin rights. Tenant isolation rests on explicit organization filters and on PostgreSQL row-level security, so a bypass of either is in scope.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Fixes ship in the next release of the `django-crm` package and the `ghcr.io/django-crm/bottlecrm-frontend` image. Only the latest release receives security fixes.
|
||||
|
|
@ -47,13 +47,12 @@ def set_rls_context(org_id):
|
|||
|
||||
|
||||
def clear_rls_context():
|
||||
"""Drop the RLS context a task set, mirroring the middleware's reset.
|
||||
"""Drop the session-scoped RLS context set by `set_rls_context`.
|
||||
|
||||
`app.current_org` is set at SESSION scope, so it outlives the statement and
|
||||
the transaction. A task that walks several orgs and then returns leaves the
|
||||
last one's id on the connection, and on a pooled connection that is the next
|
||||
borrower's tenant context. Always paired with `set_rls_context` in a
|
||||
``finally``.
|
||||
Between worker tasks this is a second guard: Celery's Django fixup already
|
||||
closes every connection around each task (test_periodic_tasks_rls.py). It is
|
||||
the only guard where the connection lives on, such as a streamed response
|
||||
that runs after the middleware's reset.
|
||||
"""
|
||||
if connection.vendor != "postgresql":
|
||||
return
|
||||
|
|
|
|||
|
|
@ -22,17 +22,21 @@ step "Run the RLS tests as a policy-bound role").
|
|||
|
||||
import contextlib
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from celery.fixups.django import DjangoWorkerFixup
|
||||
from django.db import connection
|
||||
from django.utils import timezone
|
||||
|
||||
from cases.models import Case, EscalationPolicy, TimeEntry
|
||||
from cases.tasks import auto_stop_stale_timers, scan_for_breached_cases
|
||||
from common.models import Profile, User
|
||||
from common.tasks import set_rls_context
|
||||
from conftest import clear_rls_context, rls_org
|
||||
from crm.celery import app as celery_app
|
||||
from invoices.models import Estimate, Invoice, RecurringInvoice
|
||||
from invoices.tasks import (
|
||||
check_expired_estimates,
|
||||
|
|
@ -266,3 +270,19 @@ def test_check_expired_estimates_expires_in_every_org(org_a, org_b):
|
|||
with rls_org(org):
|
||||
estimate = Estimate.objects.get(pk=estimates[org.id].pk)
|
||||
assert estimate.status == "Expired"
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
@pytest.mark.parametrize("hook", ["on_task_prerun", "on_task_postrun"])
|
||||
def test_no_task_inherits_the_previous_tasks_context(hook):
|
||||
"""Celery's Django fixup closes every connection around each task, which is
|
||||
what keeps a context a task never cleared from reaching the next one.
|
||||
Setting CELERY_DB_REUSE_MAX would quietly turn that off."""
|
||||
fixup = DjangoWorkerFixup(celery_app)
|
||||
worker_task = SimpleNamespace(request=SimpleNamespace(is_eager=False))
|
||||
org = str(uuid4())
|
||||
|
||||
set_rls_context(org)
|
||||
assert _current_org() == org
|
||||
getattr(fixup, hook)(sender=worker_task)
|
||||
assert _current_org() == ""
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||
|
||||
[project]
|
||||
name = "django-crm"
|
||||
version = "1.18.1"
|
||||
version = "1.18.2"
|
||||
# The one-line summary PyPI shows under the project title and in search results,
|
||||
# so it carries the terms people actually search for ("open source CRM",
|
||||
# "self-hosted CRM", "Django CRM") and names the modules rather than the
|
||||
|
|
|
|||
|
|
@ -704,7 +704,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "django-crm"
|
||||
version = "1.18.1"
|
||||
version = "1.18.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "cairocffi" },
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ example on this page uses `django_db` freely alongside the two markers that actu
|
|||
|
||||
## Markers
|
||||
|
||||
`pytest.ini` declares exactly two custom markers:
|
||||
`pytest.ini` declares two custom markers:
|
||||
|
||||
```ini
|
||||
markers =
|
||||
|
|
@ -49,24 +49,10 @@ markers =
|
|||
postgres_only: marks tests that require PostgreSQL
|
||||
```
|
||||
|
||||
`postgres_only` is in active use. Exactly six tests carry it, confirmed by collecting the marker
|
||||
directly (`uv run pytest -m postgres_only --collect-only` → `6/2776 tests collected`), across four
|
||||
files: `common/tests/test_pat_auth.py` (one test), `common/tests/test_pat_model.py` (one),
|
||||
`common/tests/test_rls_pipeline_tables.py` (two), and `invoices/tests/test_portal_rls.py` (two).
|
||||
(`common/tests/test_pat_integration.py` mentions `postgres_only` only in a comment explaining that
|
||||
its own tests deliberately *don't* carry the marker and run on SQLite instead. It contributes zero
|
||||
of the six.) Four of the six assert RLS policy state directly (`pg_policies`,
|
||||
`pg_class.relrowsecurity`): the two in `test_rls_pipeline_tables.py`, plus one each in
|
||||
`test_pat_model.py` and `test_portal_rls.py` confirming `personal_access_token` and
|
||||
`portal_access_token` deliberately carry *no* policy. The other two assert isolation *behaviour*
|
||||
under a real, RLS-enforcing role instead of reading policy metadata:
|
||||
`test_pat_auth.py::test_cross_org_pat_cannot_read_other_orgs_leads` calls the real `/api/leads/`
|
||||
endpoint and checks a foreign org's lead never appears, and
|
||||
`test_portal_rls.py::test_resolution_reads_estimate_that_empty_context_hides` exercises the full
|
||||
portal-token resolution path. `slow` is declared but,
|
||||
as of this writing, applied to nothing (`grep -rn "pytest.mark.slow" --include='*.py' .`, excluding
|
||||
`venv/`, returns no matches in application code). It's reserved for future use, not a marker you'll
|
||||
see filtering anything today.
|
||||
`postgres_only` marks the tests that need a real PostgreSQL: RLS policy state, isolation behaviour
|
||||
under a role that RLS binds, and session-scoped connection state. List them with
|
||||
`uv run pytest -m postgres_only --collect-only -q`. `slow` is declared and currently applied to
|
||||
nothing.
|
||||
|
||||
## The SQLite caveat
|
||||
|
||||
|
|
@ -80,34 +66,36 @@ included, is invisible to this default suite; only the explicit `Model.objects.f
|
|||
application code is being tested. See [Multi-tenancy and RLS](../architecture/multi-tenancy-and-rls.md#the-two-layer-contract)
|
||||
for why that ORM filter has to be correct on its own merits, independent of RLS.
|
||||
|
||||
The `postgres_only`-marked tests exist to close part of that gap, and they self-skip safely rather
|
||||
than fail when there's no PostgreSQL connection: each one checks `connection.vendor != "postgresql"`
|
||||
and calls `pytest.skip(...)` if so. Under the SQLite settings this repository ships, that check is
|
||||
always true, so these six tests always skip. They don't fail, and a green `uv run pytest` run tells
|
||||
you nothing about whether they'd pass. One of them, `test_portal_rls.py`'s module docstring,
|
||||
describes an intended way to actually run them for real, against a non-superuser PostgreSQL role:
|
||||
`RLS_ENFORCE=1`, pointed at a `crm.test_settings_postgres` module. A comment in `backend/.env`
|
||||
(gitignored, `.gitignore:20`, and absent from `.env.example`, so a fresh clone has neither the
|
||||
file nor the reference) names the same two pieces. Neither source is authoritative, because that
|
||||
settings module does not exist anywhere in this checkout as of this writing, so this documented path
|
||||
does not currently work. If you need to prove RLS isolation holds against real data rather than
|
||||
just reading the policy SQL, use `manage.py manage_rls --test` against a real, non-superuser-configured PostgreSQL
|
||||
database instead. See [PostgreSQL and RLS](../self-hosting/postgresql-and-rls.md#verifying-isolation)
|
||||
for exactly what it proves and how to set up that role.
|
||||
The `postgres_only` tests close part of that gap. Under SQLite nearly all of them skip, so a green
|
||||
default run tells you nothing about them. Run them for real with `crm.test_settings_postgres`,
|
||||
which changes only the database and reads the same `DB*` variables as the application:
|
||||
|
||||
```bash
|
||||
DJANGO_SETTINGS_MODULE=crm.test_settings_postgres DBUSER=<role> DBPASSWORD=<password> \
|
||||
uv run pytest --no-cov -m postgres_only
|
||||
```
|
||||
|
||||
**The role must not be a superuser or have `BYPASSRLS`.** Either one bypasses every policy, so the
|
||||
isolation assertions pass whether or not a policy exists. It needs `CREATEDB` so pytest-django can
|
||||
build the test database. See [PostgreSQL and RLS](../self-hosting/postgresql-and-rls.md#verifying-isolation)
|
||||
for setting that role up, and for `manage.py manage_rls --test`, which checks isolation on a real
|
||||
database outside the suite.
|
||||
|
||||
## What CI runs
|
||||
|
||||
`.github/workflows/tests.yml`'s `backend-tests` job runs on the same SQLite settings described
|
||||
above, there's no PostgreSQL service in that job, so `postgres_only` tests skip there too, and
|
||||
CI itself has no path that runs them for real. Two steps matter, in this order:
|
||||
`.github/workflows/tests.yml` runs the backend suite three ways:
|
||||
|
||||
1. **SQLite**, the fast run that produces coverage, after the migration check:
|
||||
```bash
|
||||
uv run python manage.py makemigrations --check --dry-run
|
||||
uv run pytest -m "not postgres_only" --tb=short -v
|
||||
```
|
||||
2. **PostgreSQL as the superuser**: the whole suite minus `postgres_only`, for what SQLite cannot
|
||||
see (declared string lengths, constraints, real type coercion). RLS is inert under a superuser,
|
||||
so this run proves nothing about isolation.
|
||||
3. **PostgreSQL as `crm_ci`**, a `NOSUPERUSER NOBYPASSRLS` role, running only the `postgres_only`
|
||||
tests. This is the run that tests tenant isolation.
|
||||
|
||||
The migration check runs first and fails the build if any model change is missing its migration.
|
||||
See [Pull requests](pull-requests.md#what-ci-runs) for what that means for your workflow. The
|
||||
`-m "not postgres_only"` filter on the test step is redundant with the tests self-skipping, but
|
||||
makes the exclusion explicit in the command itself rather than relying on every future
|
||||
`postgres_only` test remembering its own skip guard.
|
||||
The split exists because the shared fixtures insert rows with no `app.current_org` set, which a
|
||||
policy-bound role refuses. The migration check fails the build when a model change is missing its
|
||||
migration; see [Pull requests](pull-requests.md#what-ci-runs) for what that means for your workflow.
|
||||
|
|
|
|||
|
|
@ -1,62 +1,37 @@
|
|||
# Connect to a self-hosted backend
|
||||
|
||||
Read this page before you run or build the mobile app against anything other than
|
||||
`api.bottlecrm.io`. The short version: the backend URL is baked into the binary at compile time,
|
||||
there is no way to point a build at a different server without editing source and rebuilding, and
|
||||
the two URLs shipped in source today aren't `localhost` or any generic placeholder. They're a
|
||||
specific developer's tunnel and BottleCRM's own production API.
|
||||
`api.bottlecrm.io`. The short version: the backend URL is fixed when the app is compiled, and you
|
||||
choose it with a build flag, not by editing source.
|
||||
|
||||
## The compile-time base URL
|
||||
|
||||
`mobile/lib/config/api_config.dart` (lines 12-19):
|
||||
`mobile/lib/config/api_config.dart` resolves one host for every request:
|
||||
|
||||
```dart
|
||||
/// Development API URL
|
||||
static const String _developmentUrl = 'https://msi-8000.rcdev.in';
|
||||
| Build | No `API_BASE_URL` | With `--dart-define=API_BASE_URL=<url>` |
|
||||
| --- | --- | --- |
|
||||
| Debug (`flutter run`) | `http://localhost:8000` | `<url>`, `http://` allowed |
|
||||
| Release (`flutter build`) | `https://api.bottlecrm.io` | `<url>`, which must start with `https://` |
|
||||
|
||||
/// Production API URL
|
||||
static const String _productionUrl = 'https://api.bottlecrm.io';
|
||||
|
||||
/// Get the current base URL based on build mode
|
||||
static String get baseUrl => kDebugMode ? _developmentUrl : _productionUrl;
|
||||
```
|
||||
|
||||
Both URLs are `static const` Dart strings, and the choice between them is `kDebugMode`. A
|
||||
compile-time flag set by which `flutter run`/`flutter build` mode you use, not anything read at
|
||||
runtime. There is no `--dart-define`, no `String.fromEnvironment` anywhere in `lib/`, and no
|
||||
runtime settings screen: grepping `lib/` for `SharedPreferences` turns up exactly four files, and
|
||||
none of them touch the base URL, `auth_service.dart` (auth tokens/user data), list-view
|
||||
filter/sort state in `screens/tickets/tickets_list_screen.dart` and
|
||||
`screens/deals/deals_list_screen.dart`, and `providers/tickets_provider.dart`, which only defines
|
||||
the JSON-serialization helper (`TicketListFilters.toJson()`) that `tickets_list_screen.dart`
|
||||
persists. It doesn't call the `SharedPreferences` API itself. Whatever `baseUrl` resolves to when
|
||||
the app is *compiled* is what it talks to for the life of that install.
|
||||
|
||||
The practical consequences:
|
||||
|
||||
- **A store-distributed release build can only ever talk to `https://api.bottlecrm.io`.** If
|
||||
you're self-hosting, a generic APK/AAB/IPA built from this source with the constants unchanged
|
||||
will authenticate against and read/write data on BottleCRM's own hosted API, not yours. There is
|
||||
no in-app way to redirect it.
|
||||
- **A debug build (`flutter run`) does not point at `localhost` either.** `_developmentUrl` is
|
||||
`https://msi-8000.rcdev.in`: an individual developer's personal tunnel/host, not a generic
|
||||
loopback address, and not one you control. If you want to run the app against a backend on your
|
||||
own machine, you have to edit this constant regardless of whether you're in debug or release
|
||||
mode.
|
||||
- **This means self-hosting the mobile app is source-edit-and-rebuild, not configure-and-run.**
|
||||
There's no middle ground between "use BottleCRM's hosted API" and "maintain your own fork of
|
||||
this one file." If that's a blocker for your deployment, treat it as a known limitation rather
|
||||
than something you missed a flag for.
|
||||
The choice is compile-time on purpose. A runtime setting would let anything able to write the
|
||||
app's storage redirect every request, and every JWT with it, to a server of its own choosing. So
|
||||
whatever the URL was when the app was built is what it talks to for the life of that install, and a
|
||||
store-distributed build can only ever reach `https://api.bottlecrm.io`.
|
||||
|
||||
## Pointing at your backend
|
||||
|
||||
1. Edit `mobile/lib/config/api_config.dart` and change `_developmentUrl` and/or `_productionUrl`
|
||||
to your backend's URL, which one(s) you need depends on which build modes you'll use. If you
|
||||
don't need a separate dev/prod split, it's simplest to set both to the same value.
|
||||
2. Rebuild. Because these are compile-time constants, don't rely on hot reload to pick up the
|
||||
change reliably. Stop and re-run (`flutter run`) for a debug build, or do a full `flutter build
|
||||
apk --release` / `appbundle --release` / iOS build (see [Release builds](release-builds.md)) for
|
||||
a release one.
|
||||
```bash
|
||||
# Debug build against a backend on your machine (Android emulator)
|
||||
flutter run --dart-define=API_BASE_URL=http://10.0.2.2:8000
|
||||
|
||||
# Release build against your own server
|
||||
flutter build appbundle --release --dart-define=API_BASE_URL=https://crm.example.com
|
||||
```
|
||||
|
||||
A trailing slash is dropped. A release build given a plain `http://` URL refuses to start rather
|
||||
than send tokens unencrypted. Hot reload does not pick up a changed define: stop and re-run, or
|
||||
rebuild (see [Release builds](release-builds.md)). Google Sign-In needs its own client id for your
|
||||
backend, also passed as a define; see below.
|
||||
|
||||
A few things to get right about the URL itself:
|
||||
|
||||
|
|
@ -125,19 +100,13 @@ blank and Google Sign-In stays off. Magic links are the only path in.
|
|||
|
||||
**Android side**: three things, all tied to BottleCRM's own Google Cloud project as shipped:
|
||||
|
||||
1. `mobile/lib/services/auth_service.dart` (lines 70-73) hardcodes a **Web** OAuth client ID as
|
||||
`serverClientId`:
|
||||
```dart
|
||||
await _googleSignIn.initialize(
|
||||
serverClientId:
|
||||
'1072513761792-p59rct7b1c3go7l58e51r3geuqff2tfl.apps.googleusercontent.com',
|
||||
);
|
||||
1. The app signs in with a **Web** OAuth client id as `serverClientId`, the audience your
|
||||
backend's `GOOGLE_CLIENT_ID` verifies against. It defaults to BottleCRM's own, which your
|
||||
backend will reject. Build with a Web client id from **your own** Google Cloud project, the
|
||||
same value as your backend's `GOOGLE_CLIENT_ID`:
|
||||
```bash
|
||||
--dart-define=GOOGLE_SERVER_CLIENT_ID=<your-web-client-id>.apps.googleusercontent.com
|
||||
```
|
||||
The code comment identifies this as "the audience the Django backend's `GOOGLE_CLIENT_ID`
|
||||
verifies against." For your self-hosted backend to accept tokens from your own mobile build,
|
||||
this constant must be a Web client ID from **your own** Google Cloud project, and your
|
||||
backend's `GOOGLE_CLIENT_ID` must be that same value. Reusing BottleCRM's own client ID here
|
||||
will not work against your backend, because your `GOOGLE_CLIENT_ID` won't match it.
|
||||
2. Android sign-in additionally needs an **Android**-type OAuth client (separate from the Web
|
||||
client above) registered in Google Cloud, tied to your app's `applicationId` and the SHA-1
|
||||
fingerprint of whatever certificate signs the build. Get the fingerprint with `cd android &&
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ flutter build appbundle --release --dart-define=API_BASE_URL=https://crm.example
|
|||
|
||||
## Gotchas
|
||||
|
||||
- **The API base URL is compile-time** (`--dart-define=API_BASE_URL`), on purpose: a runtime setting would let anything that can write app storage redirect every JWT. A release build throws unless it is `https://`. Without the define, debug builds use the maintainer's dev server, so pass it. `GOOGLE_SERVER_CLIENT_ID` overrides the same way.
|
||||
- **The API base URL is compile-time** (`--dart-define=API_BASE_URL`), on purpose: a runtime setting would let anything that can write app storage redirect every JWT. A release build throws unless it is `https://`. Without the define, debug builds use `http://localhost:8000`; on an Android emulator pass `http://10.0.2.2:8000` or run `adb reverse tcp:8000 tcp:8000`. `GOOGLE_SERVER_CLIENT_ID` overrides the same way.
|
||||
- **Keep `cupertino_icons`.** Nothing names `CupertinoIcons`, but `SwitchListTile.adaptive` needs the font and the build fails without it.
|
||||
- **Do not set `android.builtInKotlin=true`.** AGP 8.x hardcodes built-in Kotlin to 2.0.0, a downgrade from our 2.4.10 and below Flutter's 2.2.20 floor. The Kotlin Gradle Plugin warning from `file_picker` is hidden by Flutter until AGP 9, not fixed.
|
||||
- **`file_picker` and `flutter_secure_storage` upgrade together**: file_picker below 12 pins `win32 ^5`, and flutter_secure_storage 11 needs `win32 ^6`.
|
||||
|
|
|
|||
|
|
@ -334,7 +334,7 @@ Every endpoint is built from one host, resolved in `lib/config/api_config.dart`:
|
|||
flutter run --dart-define=API_BASE_URL=http://10.0.2.2:8000
|
||||
```
|
||||
|
||||
With no `API_BASE_URL`, the host comes from `kDebugMode`: the development server
|
||||
With no `API_BASE_URL`, the host comes from `kDebugMode`: `http://localhost:8000`
|
||||
in debug, `https://api.bottlecrm.io` in release. See
|
||||
[Environment Configuration](#environment-configuration) above for the release
|
||||
build flags and the plain-HTTP rule.
|
||||
|
|
|
|||
|
|
@ -2,15 +2,12 @@ import 'package:flutter/foundation.dart';
|
|||
|
||||
/// API Configuration for BottleCRM
|
||||
///
|
||||
/// Provides centralized configuration for API endpoints.
|
||||
/// Update [_developmentUrl] with your ngrok URL for development.
|
||||
/// Provides centralized configuration for API endpoints. Point a build at any
|
||||
/// server with `--dart-define=API_BASE_URL=...` rather than editing this file.
|
||||
class ApiConfig {
|
||||
// ==========================================================================
|
||||
// BASE URLS - Update these for your environment
|
||||
// ==========================================================================
|
||||
|
||||
/// Development API URL
|
||||
static const String _developmentUrl = 'https://pc-8004.rcdev.in';
|
||||
/// A debug build with no override talks to a backend on this machine, never
|
||||
/// a remote host: it would receive every sign-in token.
|
||||
static const String _developmentUrl = 'http://localhost:8000';
|
||||
|
||||
/// Production API URL
|
||||
static const String _productionUrl = 'https://api.bottlecrm.io';
|
||||
|
|
|
|||
|
|
@ -10,11 +10,13 @@ import 'package:flutter_test/flutter_test.dart';
|
|||
/// takes the two inputs as arguments for exactly that reason.
|
||||
void main() {
|
||||
group('with no override, which is every build today', () {
|
||||
test('debug and release resolve to different hosts', () {
|
||||
test('debug talks to a local backend, release to the hosted API', () {
|
||||
// A debug build must never send sign-in tokens to a remote host that
|
||||
// nobody running it chose.
|
||||
final debug = ApiConfig.resolveBaseUrl(override: '', isDebug: true);
|
||||
final release = ApiConfig.resolveBaseUrl(override: '', isDebug: false);
|
||||
|
||||
expect(debug, isNot(release));
|
||||
expect(debug, 'http://localhost:8000');
|
||||
expect(release, 'https://api.bottlecrm.io');
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue