panel-redesign: the application form is one POST, under its domain

Stage 1, step 4 (plan § Routes).

Service (internal/app)
  - Settings{Mode, Addresses, AuthIPRestrict, AuthAllowedIPs, Limit} is what
    the form holds besides the login. CreateWithSettings creates the
    application with all of it and removes it again if a later write fails;
    SaveSettings validates the sender rule first and, if a later write
    fails, puts back what it had already changed. The form produces a whole
    application or leaves things as they were.
  - Limit.Mode "" is "use the domain limit"; auto takes its ceiling from a
    recalculation the caller supplies.

Routes and handlers
  - GET, POST /outbound/domains/{id}/applications/new
    GET, POST /outbound/domains/{id}/applications/{aid}
    POST .../{aid}/password, .../{aid}/ratelimit/recalc, .../{aid}/delete
    replace POST /domains/{id}/applications and the six /applications/{aid}/*
    routes. lookupDomainApplication answers 404 when the application is not
    in the domain named by the path - also when the caller may open that
    domain.
  - A generated password is the response to its POST only (no GET path) and
    is sent with Cache-Control: no-store.
  - The login is not renamed by the form; a changed login is a 400.

Old domain page: the three per-application forms (mode, IP restriction,
limit) are one form with one Save; "Remove limit" became the "Use the domain
limit" choice. The e2e client follows (edited, not run here).

Tests: service - create and save in every limit mode, and all-or-nothing on
a refused sender rule, a failed recalculation and a failed sender-map
rebuild; handlers - the form's parsing, one POST creating sender + IPs +
limit, four kinds of refused form creating nothing, save, the no-store
header on both password pages, and five application routes against another
tenant's application addressed through its own domain and through the
caller's.

With @routes removed by hand TestRoutesFollowNavigation now reports only
GET /server/health (step 5).

go test ./...  green except the two Windows-only failures in internal/domain
and internal/logtail.

Roadmap 14/27.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-09 22:02:26 +03:00
co-authored by Claude Opus 5.5
parent bd746c61d7
commit b6feac2200
15 changed files with 792 additions and 157 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ func testLevel2RateLimit(t *testing.T, sc *scenario) {
if err != nil {
t.Fatal(err)
}
if err := sc.panel.setRateLimit(fmt.Sprintf("/applications/%s/ratelimit", appID), 1, 3600); err != nil {
if err := sc.panel.setApplicationLimit(sc.domainID, appID, 1, 3600); err != nil {
t.Fatalf("save application rate limit: %v", err)
}
+8 -5
View File
@@ -172,7 +172,7 @@ func (c *panelClient) dkimRecord(domainID string) (name, value string, err error
// login/password the panel renders inline (security.md — never recoverable
// later, so this is the only place to read it).
func (c *panelClient) addApplication(domainID, login, mode, addresses string) (appLogin, password string, err error) {
resp, body, err := c.postForm("/outbound/domains/"+domainID+"/applications", url.Values{
resp, body, err := c.postForm("/outbound/domains/"+domainID+"/applications/new", url.Values{
"login": {login},
"mode": {mode},
"addresses": {addresses},
@@ -194,14 +194,17 @@ func (c *panelClient) addApplication(domainID, login, mode, addresses string) (a
return appLogin, password, nil
}
// setRateLimit saves a level-2 limit (guide § Rate limiting) on an application
// or domain (/applications/{id}/ratelimit or /outbound/domains/{id}/settings/ratelimit).
func (c *panelClient) setRateLimit(path string, maxMessages, windowSeconds int) error {
// setApplicationLimit saves a manual level-2 limit (guide § Rate limiting) on
// an application. The application form is one POST, so the sender rule goes
// with it: the application stays on the domain wildcard it was created with.
func (c *panelClient) setApplicationLimit(domainID, appID string, maxMessages, windowSeconds int) error {
vals := url.Values{
"mode": {"wildcard"},
"rl_mode": {"manual"},
"max_messages": {fmt.Sprintf("%d", maxMessages)},
"window_seconds": {fmt.Sprintf("%d", windowSeconds)},
}
resp, body, err := c.postForm(path, vals)
resp, body, err := c.postForm("/outbound/domains/"+domainID+"/applications/"+appID, vals)
if err != nil {
return err
}