feat: add Creator username password login
CI / test (push) Failing after 15m55s

This commit is contained in:
m
2026-08-16 16:56:38 +03:00
parent 111ad75dea
commit 5fca81531a
15 changed files with 279 additions and 34 deletions
+35 -16
View File
@@ -12,6 +12,7 @@ import (
identitydomain "git.cin.su/m/Assistant/modules/identity/domain"
situationdomain "git.cin.su/m/Assistant/modules/situation/domain"
"git.cin.su/m/Assistant/runtime/health"
"io"
"log"
"net/http"
"os"
@@ -19,12 +20,15 @@ import (
"time"
)
const maxCreatorCredentialsBytes = 4 << 10
func main() {
addr := os.Getenv("LISTEN_ADDR")
if addr == "" {
addr = ":8080"
}
bootstrapToken, sessionKey := os.Getenv("CREATOR_BOOTSTRAP_TOKEN"), []byte(os.Getenv("CREATOR_SESSION_SIGNING_KEY"))
creatorUsername, creatorPassword := os.Getenv("CREATOR_USERNAME"), os.Getenv("CREATOR_PASSWORD")
repository, err := postgres.OpenSituation(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("POSTGRES_USER"), Password: os.Getenv("POSTGRES_PASSWORD")})
if err != nil {
log.Fatal(err)
@@ -63,22 +67,7 @@ func main() {
return situationdomain.NewResolver(bindings).Resolve(runtime)
}
mux.Handle("/healthz", health.Handler{Runtime: "core-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1})
mux.HandleFunc("POST /v1/creator/sessions", func(w http.ResponseWriter, r *http.Request) {
if !identitydomain.BootstrapMatches(bootstrapToken, bearerToken(r)) {
http.Error(w, "creator authentication denied", http.StatusUnauthorized)
return
}
token, session, err := identitydomain.IssueCreatorSession(sessionKey, time.Now(), 8*time.Hour)
if err != nil {
http.Error(w, "creator session unavailable", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(struct {
Token string
Session identity.CreatorSession
}{token, session})
})
mux.HandleFunc("POST /v1/creator/sessions", creatorSessionHandler(bootstrapToken, creatorUsername, creatorPassword, sessionKey, time.Now))
mux.HandleFunc("GET /v1/creator/sessions/current", func(w http.ResponseWriter, r *http.Request) {
session, ok := authenticateCreator(w, r)
if !ok {
@@ -273,6 +262,36 @@ func main() {
}
}
func creatorSessionHandler(bootstrapToken, creatorUsername, creatorPassword string, sessionKey []byte, now func() time.Time) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
authenticated := identitydomain.BootstrapMatches(bootstrapToken, bearerToken(r))
if !authenticated {
var credentials identity.CreatorCredentials
decoder := json.NewDecoder(io.LimitReader(r.Body, maxCreatorCredentialsBytes+1))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&credentials); err != nil || decoder.Decode(&struct{}{}) != io.EOF {
http.Error(w, "creator authentication denied", http.StatusUnauthorized)
return
}
authenticated = identitydomain.CreatorCredentialsMatch(creatorUsername, creatorPassword, credentials)
}
if !authenticated {
http.Error(w, "creator authentication denied", http.StatusUnauthorized)
return
}
token, session, err := identitydomain.IssueCreatorSession(sessionKey, now(), 8*time.Hour)
if err != nil {
http.Error(w, "creator session unavailable", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(struct {
Token string
Session identity.CreatorSession
}{token, session})
}
}
func bearerToken(r *http.Request) string {
value := r.Header.Get("Authorization")
if !strings.HasPrefix(value, "Bearer ") {
+51
View File
@@ -1,8 +1,12 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestBearerTokenRequiresBearerScheme(t *testing.T) {
@@ -16,3 +20,50 @@ func TestBearerTokenRequiresBearerScheme(t *testing.T) {
t.Fatalf("token = %q", token)
}
}
func TestCreatorSessionHandlerAcceptsConfiguredCredentials(t *testing.T) {
handler := creatorSessionHandler("recovery", "m", "password", []byte("01234567890123456789012345678901"), func() time.Time { return time.Unix(1000, 0) })
request := httptest.NewRequest(http.MethodPost, "/v1/creator/sessions", strings.NewReader(`{"username":"m","password":"password"}`))
response := httptest.NewRecorder()
handler(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
var issued struct {
Token string
}
if err := json.NewDecoder(response.Body).Decode(&issued); err != nil || issued.Token == "" {
t.Fatalf("session response = %+v, err=%v", issued, err)
}
}
func TestCreatorSessionHandlerFailsClosed(t *testing.T) {
handler := creatorSessionHandler("recovery", "m", "password", []byte("01234567890123456789012345678901"), time.Now)
tests := []string{
`{"username":"wrong","password":"password"}`,
`{"username":"m","password":"wrong"}`,
`{"username":"","password":""}`,
`{"username":"m","password":"password","actor":"attacker"}`,
`{`,
`{"username":"m","password":"password"}{}`,
}
for _, body := range tests {
request := httptest.NewRequest(http.MethodPost, "/v1/creator/sessions", strings.NewReader(body))
response := httptest.NewRecorder()
handler(response, request)
if response.Code != http.StatusUnauthorized || strings.TrimSpace(response.Body.String()) != "creator authentication denied" {
t.Fatalf("body %q: status=%d response=%q", body, response.Code, response.Body.String())
}
}
}
func TestCreatorSessionHandlerPreservesBootstrapRecovery(t *testing.T) {
handler := creatorSessionHandler("recovery", "", "", []byte("01234567890123456789012345678901"), time.Now)
request := httptest.NewRequest(http.MethodPost, "/v1/creator/sessions", nil)
request.Header.Set("Authorization", "Bearer recovery")
response := httptest.NewRecorder()
handler(response, request)
if response.Code != http.StatusOK {
t.Fatalf("bootstrap recovery status = %d", response.Code)
}
}
+31 -1
View File
@@ -33,6 +33,34 @@ func TestGatewayForwardsOnlyCreatorAllowlist(t *testing.T) {
}
}
func TestGatewayForwardsCreatorCredentialsOnlyInBody(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/creator/sessions" || r.Method != http.MethodPost {
t.Fatalf("unexpected session target %s %s", r.Method, r.URL.Path)
}
if r.Header.Get("Authorization") != "" || r.Header.Get("Content-Type") != "application/json" {
t.Fatalf("unexpected session headers Authorization=%q Content-Type=%q", r.Header.Get("Authorization"), r.Header.Get("Content-Type"))
}
body, _ := io.ReadAll(r.Body)
if string(body) != `{"username":"m","password":"secret"}` {
t.Fatalf("session body = %s", body)
}
_, _ = io.WriteString(w, `{"Token":"bounded"}`)
}))
defer upstream.Close()
handler, err := newHandler(upstream.URL, upstream.URL, upstream.URL, upstream.Client())
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/api/session", strings.NewReader(`{"username":"m","password":"secret"}`))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("session response = %d %s", response.Code, response.Body.String())
}
}
func TestGatewayDeniesInternalAndMutationRoutes(t *testing.T) {
handler, err := newHandler("http://core-runtime:8080", "http://continuity-runtime:8081", "http://commit-runtime:8082", http.DefaultClient)
if err != nil {
@@ -139,7 +167,9 @@ func TestConsoleAndSecurityHeaders(t *testing.T) {
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "Домовой") ||
!strings.Contains(response.Body.String(), "satisfaction-dialog") || !strings.Contains(response.Body.String(), "Подтверждаю переход") {
!strings.Contains(response.Body.String(), "satisfaction-dialog") || !strings.Contains(response.Body.String(), "Подтверждаю переход") ||
!strings.Contains(response.Body.String(), "creator-username") || !strings.Contains(response.Body.String(), "creator-password") ||
strings.Contains(response.Body.String(), "bootstrap-token") {
t.Fatalf("console response = %d", response.Code)
}
if response.Header().Get("Content-Security-Policy") == "" {
@@ -42,6 +42,7 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
.login-form input, .context-form input { width: 100%; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: #0b110e; transition: border-color .18s, background .18s; }
.login-form input:hover, .context-form input:hover { border-color: var(--faint); }
.login-form input { height: 52px; padding: 0 50px 0 14px; font-family: var(--mono); }
.login-form input:not(#creator-password) { padding-right: 14px; margin-bottom: 16px; }
.secret-row { position: relative; }
.secret-row .icon-button { position: absolute; right: 7px; top: 7px; }
.icon-button { width: 38px; height: 38px; display: grid; place-items: center; border: 1px solid var(--line); background: transparent; border-radius: var(--radius); cursor: pointer; transition: background .18s, border-color .18s; }
+13 -7
View File
@@ -99,18 +99,23 @@ function endSession(message = "Сессия завершена.") {
loginForm.addEventListener("submit", async (event) => {
event.preventDefault();
const button = byId("login-button");
const bootstrap = byId("bootstrap-token").value.trim();
const username = byId("creator-username").value.trim();
const password = byId("creator-password").value;
loginError.textContent = "";
button.disabled = true;
button.firstElementChild.textContent = "Проверяю ключ…";
button.firstElementChild.textContent = "Проверяю данные…";
try {
const result = await request("/api/session", {method: "POST", headers: {Authorization: `Bearer ${bootstrap}`}});
const result = await request("/api/session", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({username, password})
});
sessionToken = result.Token;
sessionStorage.setItem("domovoy.creator.session", sessionToken);
byId("bootstrap-token").value = "";
byId("creator-password").value = "";
await showConsole();
} catch (error) {
loginError.textContent = error.message === "creator authentication denied" ? "Ключ не принят." : error.message;
loginError.textContent = error.message === "creator authentication denied" ? "Логин или пароль не принят." : error.message;
} finally {
button.disabled = false;
button.firstElementChild.textContent = "Открыть консоль";
@@ -118,9 +123,10 @@ loginForm.addEventListener("submit", async (event) => {
});
byId("toggle-secret").addEventListener("click", () => {
const input = byId("bootstrap-token");
const input = byId("creator-password");
input.type = input.type === "password" ? "text" : "password";
byId("toggle-secret").setAttribute("aria-label", input.type === "password" ? "Показать ключ" : "Скрыть ключ");
byId("toggle-secret").setAttribute("aria-label", input.type === "password" ? "Показать пароль" : "Скрыть пароль");
byId("toggle-secret").setAttribute("title", input.type === "password" ? "Показать пароль" : "Скрыть пароль");
});
contextForm.addEventListener("submit", (event) => {
+6 -4
View File
@@ -16,12 +16,14 @@
<div class="brand-mark" aria-hidden="true">Д</div>
<p class="eyebrow">защищённый вход · creator</p>
<h1 id="login-title">Домовой</h1>
<p class="login-copy">Личная операторская консоль. Ключ используется только для выпуска ограниченной сессии и не сохраняется браузером.</p>
<p class="login-copy">Личная операторская консоль. Учётные данные используются только для выпуска ограниченной сессии и не сохраняются браузером.</p>
<form id="login-form" class="login-form">
<label for="bootstrap-token">Ключ создателя</label>
<label for="creator-username">Логин</label>
<input id="creator-username" name="username" type="text" autocomplete="username" required autofocus>
<label for="creator-password">Пароль</label>
<div class="secret-row">
<input id="bootstrap-token" name="token" type="password" autocomplete="current-password" required autofocus>
<button class="icon-button" id="toggle-secret" type="button" aria-label="Показать ключ" title="Показать ключ">
<input id="creator-password" name="password" type="password" autocomplete="current-password" required>
<button class="icon-button" id="toggle-secret" type="button" aria-label="Показать пароль" title="Показать пароль">
<span aria-hidden="true">◉</span>
</button>
</div>
+5
View File
@@ -1,5 +1,10 @@
package identity
type CreatorCredentials struct {
Username string `json:"username"`
Password string `json:"password"`
}
type CreatorSession struct {
ActorID ActorID
PrincipalID PrincipalID
+2
View File
@@ -44,6 +44,8 @@ services:
IDENTITY_POSTGRES_PASSWORD: ${IDENTITY_DB_PASSWORD:-assistant_identity_dev_only}
CREATOR_BOOTSTRAP_TOKEN: ${CREATOR_BOOTSTRAP_TOKEN:-creator_dev_only_replace_me}
CREATOR_SESSION_SIGNING_KEY: ${CREATOR_SESSION_SIGNING_KEY:-creator_dev_only_signing_key_32_bytes}
CREATOR_USERNAME: ${CREATOR_USERNAME:-creator}
CREATOR_PASSWORD: ${CREATOR_PASSWORD:-creator_dev_only_replace_me}
WORK_POSTGRES_USER: work_role
WORK_POSTGRES_PASSWORD: ${WORK_DB_PASSWORD:-assistant_work_dev_only}
CAPABILITY_POSTGRES_USER: capability_role
+2
View File
@@ -44,6 +44,8 @@ services:
IDENTITY_POSTGRES_PASSWORD: ${IDENTITY_DB_PASSWORD}
CREATOR_BOOTSTRAP_TOKEN: ${CREATOR_BOOTSTRAP_TOKEN}
CREATOR_SESSION_SIGNING_KEY: ${CREATOR_SESSION_SIGNING_KEY}
CREATOR_USERNAME: ${CREATOR_USERNAME}
CREATOR_PASSWORD: ${CREATOR_PASSWORD}
WORK_POSTGRES_USER: work_role
WORK_POSTGRES_PASSWORD: ${WORK_DB_PASSWORD}
CAPABILITY_POSTGRES_USER: capability_role
+3 -3
View File
@@ -1,5 +1,5 @@
{
"task_file": "dev/tasks/ARCH-000004-implement-diff-scope-guardrail.json",
"task_id": "ARCH-000004",
"task_revision": 1
"task_file": "dev/tasks/DEV-000033-creator-username-password.json",
"task_id": "DEV-000033",
"task_revision": 2
}
@@ -0,0 +1,87 @@
{
"task_id": "DEV-000033",
"task_revision": 2,
"architecture_revision": "ADR-0003 and ADR-0004",
"base_repo_revision": "111ad75deaf20a6822f5440ad232d782479ce79c",
"risk_tier": "E",
"intent": "Add a typed username/password Creator authentication flow for the single Domovoy Creator account while preserving bounded sessions, bootstrap recovery access, and all existing authority boundaries.",
"requirement_ids": ["INV-AUTH-001", "CONSTITUTION-1.3.28"],
"allowed_change_scope": {
"modules": ["identity"],
"paths": [
"contracts/identity/session.go",
"modules/identity/domain/session.go",
"cmd/core-runtime/main.go",
"cmd/core-runtime/main_test.go",
"cmd/creator-web/server_test.go",
"cmd/creator-web/static/index.html",
"cmd/creator-web/static/assets/console.js",
"cmd/creator-web/static/assets/console.css",
"deploy/docker-compose.dev.yml",
"deploy/docker-compose.prod.yml",
"docs/DEPLOYMENT.md",
"tests/creator_auth/**",
"tests/creator_web/**",
"dev/ACTIVE_TASK.json",
"dev/tasks/DEV-000033-creator-username-password.json"
]
},
"forbidden_change_scope": {
"paths": [
"architecture/**",
"ci/**",
".gitea/**",
"security/**",
"database/**",
"modules/authority/**",
"modules/commit/**",
"modules/continuity/**",
"adapters/**"
]
},
"allowed_architecture_delta": {
"new_cross_module_dependencies": 0,
"new_canonical_owners": 0,
"new_privileges": 0
},
"contracts": {
"consume": ["contracts.identity@v1", "Creator bootstrap recovery credential"],
"may_change": ["Creator session issuance request adds typed username/password credentials"]
},
"semantic_owner": "identity",
"canonical_state": ["no new canonical state; the single Creator identity remains domovoy-creator-actor/domovoy-creator-principal"],
"state_transitions": ["valid configured credentials issue the existing eight-hour bounded Creator session"],
"side_effects_and_privileges": ["reads externally injected Creator username and password", "does not grant Authority or accept Actor/Principal from the client"],
"persistence_and_migrations": ["none; credentials remain external runtime configuration"],
"failure_retry_restart": ["missing configuration and invalid credentials fail with the same generic denial", "credentials are not logged or persisted in browser storage", "bootstrap Bearer remains for test and recovery compatibility"],
"assumptions": [
"The user explicitly selected username m and supplied the Creator password",
"The user requested execution, which authorizes this authentication contract change",
"ADR-0004 requires the active pointer update to be reviewed as a Tier E protected-path change"
],
"acceptance_scenarios": [
"valid username/password issues the existing bounded Creator session",
"wrong username, wrong password, empty fields and malformed JSON are denied without credential disclosure",
"the browser sends credentials only in the session request body and stores only the issued session token",
"Actor and Principal remain server-selected constants",
"legacy bootstrap Bearer remains accepted for recovery and remote harnesses",
"production credentials are injected outside the repository",
"desktop and mobile login remain accessible",
"remote login and current-session checks succeed through the deployed gateway"
],
"required_tests": [
"unit",
"adversarial authentication",
"gateway request forwarding",
"architecture preflight",
"exact diff scope",
"go vet",
"responsive browser",
"remote Docker smoke"
],
"terminal_gap_states": [
"BLOCKED_BY_ARCHITECTURE_GAP",
"BLOCKED_BY_SPEC_GAP",
"BLOCKED_BY_CONFLICT"
]
}
+2
View File
@@ -15,3 +15,5 @@ Temporal is intentionally **not** the canonical owner of future obligations. If/
The initial stack definition is `deploy/docker-compose.prod.yml`. Portainer may deploy it from Git after the repository has been pushed.
Credentials/API deployment automation is deliberately not embedded. Use `PORTAINER_API_KEY` from an external secret source or deploy through Portainer UI/API after authenticating.
The single Creator login is configured with external `CREATOR_USERNAME` and `CREATOR_PASSWORD` values. They issue the same bounded Creator session and never select Actor, Principal, Scope or Authority. Keep `CREATOR_BOOTSTRAP_TOKEN` as a separate recovery/automation credential; none of these values belongs in Git, compose defaults for production, image layers or logs.
+10
View File
@@ -47,6 +47,16 @@ func ValidateCreatorSession(secret []byte, token string, now time.Time) (identit
func BootstrapMatches(expected, supplied string) bool {
return expected != "" && supplied != "" && subtle.ConstantTimeCompare([]byte(expected), []byte(supplied)) == 1
}
func CreatorCredentialsMatch(expectedUsername, expectedPassword string, supplied identity.CreatorCredentials) bool {
if expectedUsername == "" || expectedPassword == "" || supplied.Username == "" || supplied.Password == "" {
return false
}
usernameMatches := subtle.ConstantTimeCompare([]byte(expectedUsername), []byte(supplied.Username))
passwordMatches := subtle.ConstantTimeCompare([]byte(expectedPassword), []byte(supplied.Password))
return usernameMatches&passwordMatches == 1
}
func signature(secret []byte, payload string) string {
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(payload))
+18
View File
@@ -2,6 +2,7 @@ package creator_auth_test
import (
"encoding/json"
"git.cin.su/m/Assistant/contracts/identity"
"git.cin.su/m/Assistant/modules/identity/domain"
"strings"
"testing"
@@ -32,6 +33,23 @@ func TestCreatorSessionIsTimeBoundAndDoesNotCarryAuthority(t *testing.T) {
}
}
func TestCreatorUsernameAndPasswordMustBothMatch(t *testing.T) {
if !domain.CreatorCredentialsMatch("m", "password", identity.CreatorCredentials{Username: "m", Password: "password"}) {
t.Fatal("matching Creator credentials rejected")
}
for _, supplied := range []identity.CreatorCredentials{
{},
{Username: "m", Password: "wrong"},
{Username: "wrong", Password: "password"},
{Username: "m"},
{Password: "password"},
} {
if domain.CreatorCredentialsMatch("m", "password", supplied) {
t.Fatalf("invalid Creator credentials accepted: username=%q", supplied.Username)
}
}
}
func TestCreatorBootstrapCredentialMustMatch(t *testing.T) {
if !domain.BootstrapMatches("expected", "expected") {
t.Fatal("matching bootstrap credential rejected")
+13 -3
View File
@@ -52,11 +52,21 @@ $invalid = Invoke-WebRequest -Uri "$BaseURL/api/session/current" -Headers @{Auth
if ($invalid.StatusCode -ne 401) { throw "Invalid Creator session was not denied" }
$environment = docker --context $DockerContext inspect --format '{{range .Config.Env}}{{println .}}{{end}}' $coreContainer
$usernameEntry = $environment | Where-Object { $_ -like "CREATOR_USERNAME=*" } | Select-Object -First 1
$passwordEntry = $environment | Where-Object { $_ -like "CREATOR_PASSWORD=*" } | Select-Object -First 1
if (-not $usernameEntry -or -not $passwordEntry) { throw "Creator username/password is not configured" }
$creatorUsername = $usernameEntry.Substring("CREATOR_USERNAME=".Length)
$creatorPassword = $passwordEntry.Substring("CREATOR_PASSWORD=".Length)
$invalidCredentials = Invoke-WebRequest -Uri "$BaseURL/api/session" -Method Post -ContentType "application/json" -Body (@{username=$creatorUsername;password="invalid"} | ConvertTo-Json -Compress) -SkipHttpErrorCheck
if ($invalidCredentials.StatusCode -ne 401) { throw "Invalid Creator credentials were not denied" }
$issued = Invoke-RestMethod -Uri "$BaseURL/api/session" -Method Post -ContentType "application/json" -Body (@{username=$creatorUsername;password=$creatorPassword} | ConvertTo-Json -Compress)
if (-not $issued.Token) { throw "Creator username/password session was not issued through the gateway" }
$bootstrapEntry = $environment | Where-Object { $_ -like "CREATOR_BOOTSTRAP_TOKEN=*" } | Select-Object -First 1
if (-not $bootstrapEntry) { throw "Creator bootstrap token is not configured" }
$bootstrapToken = $bootstrapEntry.Substring("CREATOR_BOOTSTRAP_TOKEN=".Length)
$issued = Invoke-RestMethod -Uri "$BaseURL/api/session" -Method Post -Headers @{Authorization="Bearer $bootstrapToken"}
if (-not $issued.Token) { throw "Creator session was not issued through the gateway" }
$recoveryIssued = Invoke-RestMethod -Uri "$BaseURL/api/session" -Method Post -Headers @{Authorization="Bearer $bootstrapToken"}
if (-not $recoveryIssued.Token) { throw "Creator recovery session was not issued through the gateway" }
$current = Invoke-RestMethod -Uri "$BaseURL/api/session/current" -Method Get -Headers @{Authorization="Bearer $($issued.Token)"}
if ($current.ActorID -ne "domovoy-creator-actor") { throw "Creator identity was not preserved through the gateway" }
@@ -96,4 +106,4 @@ if ($staleAction.StatusCode -ne 409) { throw "Stale Creator action was not rejec
$arbitraryAction = Invoke-WebRequest -Uri "$BaseURL/api/actions/arbitrary" -Method Post -ContentType "application/json" -Headers @{Authorization="Bearer $($issued.Token)"} -Body '{}' -SkipHttpErrorCheck
if ($arbitraryAction.StatusCode -ne 404) { throw "Arbitrary action route was exposed" }
Write-Output "DEV-000032 Creator satisfaction console gateway: PASS"
Write-Output "DEV-000033 Creator username/password and console gateway: PASS"