feat(auth): first account on a new server needs the setup token from the server log (M462 #4982)
test-go / test (push) Successful in 2m3s
test-web / test (push) Successful in 1m14s
test-go / integration (push) Successful in 4m38s
release / Build signed APK (releases and dev) (push) Successful in 5m36s
release / Build + push container image (push) Successful in 1m24s
release / Verify release artifacts (tag releases only) (push) Skipped
test-go / test (push) Successful in 2m3s
test-web / test (push) Successful in 1m14s
test-go / integration (push) Successful in 4m38s
release / Build signed APK (releases and dev) (push) Successful in 5m36s
release / Build + push container image (push) Successful in 1m24s
release / Verify release artifacts (tag releases only) (push) Skipped
While no accounts exist, the server mints a random setup token at boot and logs it. Registering the first account (which becomes admin) must carry it, so whoever reaches a freshly exposed instance first cannot claim it. The register page asks GET /api/auth/setup-status and shows a "Setup token" field in place of the invite field while setup is pending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,13 @@ import (
|
|||||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
||||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService) {
|
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService) {
|
||||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||||
|
setupToken, err := auth.NewSetupToken()
|
||||||
|
if err != nil {
|
||||||
|
// crypto/rand failing means the platform can't make secrets at all;
|
||||||
|
// sessions would be minted from the same source. Nothing to degrade to.
|
||||||
|
panic("api: mint setup token: " + err.Error())
|
||||||
|
}
|
||||||
|
logSetupTokenIfNeeded(pool, logger, setupToken)
|
||||||
h := &handlers{
|
h := &handlers{
|
||||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
||||||
recSettings: recSettings,
|
recSettings: recSettings,
|
||||||
@@ -60,6 +67,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
fingerprintSettings: fpSettings,
|
fingerprintSettings: fpSettings,
|
||||||
librarySize: recommendation.NewLibrarySize(nil),
|
librarySize: recommendation.NewLibrarySize(nil),
|
||||||
loginGuard: auth.NewLoginGuard(),
|
loginGuard: auth.NewLoginGuard(),
|
||||||
|
setupToken: setupToken,
|
||||||
|
requireSetupToken: true,
|
||||||
registerLimit: auth.NewAttemptLimiter(registerPerAddressMax, time.Hour),
|
registerLimit: auth.NewAttemptLimiter(registerPerAddressMax, time.Hour),
|
||||||
forgotAddressLimit: auth.NewAttemptLimiter(forgotPerAddressMax, time.Hour),
|
forgotAddressLimit: auth.NewAttemptLimiter(forgotPerAddressMax, time.Hour),
|
||||||
forgotEmailLimit: auth.NewAttemptLimiter(forgotPerEmailMax, time.Hour),
|
forgotEmailLimit: auth.NewAttemptLimiter(forgotPerEmailMax, time.Hour),
|
||||||
@@ -69,6 +78,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
r.Route("/api", func(api chi.Router) {
|
r.Route("/api", func(api chi.Router) {
|
||||||
api.Post("/auth/login", h.handleLogin)
|
api.Post("/auth/login", h.handleLogin)
|
||||||
api.Post("/auth/register", h.handleRegister)
|
api.Post("/auth/register", h.handleRegister)
|
||||||
|
api.Get("/auth/setup-status", h.handleSetupStatus)
|
||||||
api.Post("/auth/forgot-password", h.handleForgotPassword)
|
api.Post("/auth/forgot-password", h.handleForgotPassword)
|
||||||
api.Post("/auth/reset-password", h.handleResetPassword)
|
api.Post("/auth/reset-password", h.handleResetPassword)
|
||||||
|
|
||||||
@@ -319,6 +329,12 @@ type handlers struct {
|
|||||||
// instance the scanner and the fingerprint workers read, so a save from the
|
// instance the scanner and the fingerprint workers read, so a save from the
|
||||||
// admin card reaches them without a restart. Nil serves the defaults.
|
// admin card reaches them without a restart. Nil serves the defaults.
|
||||||
fingerprintSettings *library.FingerprintSettingsService
|
fingerprintSettings *library.FingerprintSettingsService
|
||||||
|
// setupToken must accompany the first registration while no users exist
|
||||||
|
// (see auth.SetupToken). requireSetupToken is set by Mount, the only
|
||||||
|
// production constructor; tests that build handlers directly leave it
|
||||||
|
// off unless they are testing it.
|
||||||
|
setupToken *auth.SetupToken
|
||||||
|
requireSetupToken bool
|
||||||
// loginGuard throttles failed logins per account and per address, and
|
// loginGuard throttles failed logins per account and per address, and
|
||||||
// the limiters below cap the other unauthenticated auth routes. All are
|
// the limiters below cap the other unauthenticated auth routes. All are
|
||||||
// nil-safe, so tests that build handlers directly run unthrottled.
|
// nil-safe, so tests that build handlers directly run unthrottled.
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"time"
|
"time"
|
||||||
@@ -10,6 +12,7 @@ import (
|
|||||||
"github.com/jackc/pgerrcode"
|
"github.com/jackc/pgerrcode"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
@@ -29,6 +32,9 @@ type registerReq struct {
|
|||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
InviteToken string `json:"invite_token"`
|
InviteToken string `json:"invite_token"`
|
||||||
|
// SetupToken is required only for the very first account; see
|
||||||
|
// auth.SetupToken.
|
||||||
|
SetupToken string `json:"setup_token"`
|
||||||
DisplayName *string `json:"display_name"`
|
DisplayName *string `json:"display_name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +94,19 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The first account becomes admin, so it must prove it can read the
|
||||||
|
// server log. Checked before the invite logic, which the empty-users
|
||||||
|
// state skips.
|
||||||
|
if userCount == 0 && h.requireSetupToken && !h.setupToken.Matches(req.SetupToken) {
|
||||||
|
// Repeat the token in the log at the moment someone needs it: the
|
||||||
|
// boot line may have scrolled away, or users may have been deleted
|
||||||
|
// since boot.
|
||||||
|
h.logger.Warn("register: first-admin registration needs the setup token",
|
||||||
|
"setup_token", h.setupToken.Value())
|
||||||
|
writeErr(w, apierror.Forbidden("setup_token_invalid", "the setup token from the server log is required to create the first account"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Validate invite (skipped on empty-users state; skipped in 'open' mode).
|
// Validate invite (skipped on empty-users state; skipped in 'open' mode).
|
||||||
usedInviteToken := ""
|
usedInviteToken := ""
|
||||||
if userCount > 0 {
|
if userCount > 0 {
|
||||||
@@ -232,3 +251,31 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleSetupStatus implements GET /api/auth/setup-status. It tells the
|
||||||
|
// register screen whether to ask for the setup token, i.e. whether no
|
||||||
|
// account exists yet. Public, since it is needed before anyone can sign in;
|
||||||
|
// "this server has no users" is not worth hiding from someone who could
|
||||||
|
// simply try to register.
|
||||||
|
func (h *handlers) handleSetupStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n, err := dbq.New(h.pool).CountUsers(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "setup status: count users failed", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"setup_required": n == 0})
|
||||||
|
}
|
||||||
|
|
||||||
|
// logSetupTokenIfNeeded writes the setup token to the log at boot when the
|
||||||
|
// instance has no accounts yet, with the instruction for using it.
|
||||||
|
func logSetupTokenIfNeeded(pool *pgxpool.Pool, logger *slog.Logger, token *auth.SetupToken) {
|
||||||
|
if pool == nil || logger == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := dbq.New(pool).CountUsers(context.Background())
|
||||||
|
if err != nil || n > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Warn("no accounts yet: open the web app, choose Create account, and enter this setup token to become the admin",
|
||||||
|
"setup_token", token.Value())
|
||||||
|
}
|
||||||
|
|||||||
@@ -273,3 +273,75 @@ func TestRegister_FirstAdminRace(t *testing.T) {
|
|||||||
// not "exactly one" (which would require serializable isolation).
|
// not "exactly one" (which would require serializable isolation).
|
||||||
t.Logf("admin count after race = %d (>=1 is the invariant)", adminCount)
|
t.Logf("admin count after race = %d (>=1 is the invariant)", adminCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// With the gate on (as Mount sets it), the first account needs the setup
|
||||||
|
// token from the log; a missing or wrong one is refused and creates nothing.
|
||||||
|
func TestRegister_FirstUserNeedsSetupToken(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
resetUsers(t, h)
|
||||||
|
token, err := auth.NewSetupToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mint: %v", err)
|
||||||
|
}
|
||||||
|
h.setupToken = token
|
||||||
|
h.requireSetupToken = true
|
||||||
|
|
||||||
|
register := func(body string) int {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader([]byte(body)))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.handleRegister(rec, req)
|
||||||
|
return rec.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
if code := register(`{"username":"squatter","password":"abcd1234"}`); code != http.StatusForbidden {
|
||||||
|
t.Errorf("no token: status = %d, want 403", code)
|
||||||
|
}
|
||||||
|
if code := register(`{"username":"squatter","password":"abcd1234","setup_token":"wrong"}`); code != http.StatusForbidden {
|
||||||
|
t.Errorf("wrong token: status = %d, want 403", code)
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
if err := h.pool.QueryRow(context.Background(), `SELECT count(*) FROM users`).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatalf("a refused registration created %d account(s)", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if code := register(`{"username":"operator","password":"abcd1234","setup_token":"` + token.Value() + `"}`); code != http.StatusOK {
|
||||||
|
t.Fatalf("right token: status = %d, want 200", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once an account exists the token plays no part: the second user is
|
||||||
|
// governed by registration mode, not the setup token.
|
||||||
|
if _, err := h.pool.Exec(context.Background(),
|
||||||
|
`UPDATE registration_settings SET mode = 'open' WHERE id = true`); err != nil {
|
||||||
|
t.Fatalf("open mode: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = h.pool.Exec(context.Background(),
|
||||||
|
`UPDATE registration_settings SET mode = 'invite_only' WHERE id = true`)
|
||||||
|
})
|
||||||
|
if code := register(`{"username":"second","password":"abcd1234"}`); code != http.StatusOK {
|
||||||
|
t.Errorf("second user without token: status = %d, want 200", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupToken_MatchesOnlyItself(t *testing.T) {
|
||||||
|
tok, err := auth.NewSetupToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mint: %v", err)
|
||||||
|
}
|
||||||
|
if !tok.Matches(tok.Value()) {
|
||||||
|
t.Error("token does not match itself")
|
||||||
|
}
|
||||||
|
if tok.Matches("") || tok.Matches(tok.Value()+"x") {
|
||||||
|
t.Error("token matched something else")
|
||||||
|
}
|
||||||
|
var none *auth.SetupToken
|
||||||
|
if none.Matches("") || none.Matches("anything") {
|
||||||
|
t.Error("a nil token must fail closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetupToken guards the first-admin registration. Until the first account
|
||||||
|
// exists, register makes whoever calls it the admin, so a fresh instance on a
|
||||||
|
// public address belonged to whoever found it first. Now that call also has
|
||||||
|
// to carry this token, which is generated at startup and written only to the
|
||||||
|
// server log: proof that the caller can read the operator's logs.
|
||||||
|
//
|
||||||
|
// The token lives in memory. A restart mints a new one and logs it again,
|
||||||
|
// which is the behaviour wanted: an old token from a log line someone else
|
||||||
|
// saw stops working.
|
||||||
|
type SetupToken struct {
|
||||||
|
value string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSetupToken mints a 128-bit token.
|
||||||
|
func NewSetupToken() (*SetupToken, error) {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &SetupToken{value: hex.EncodeToString(b)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value returns the token for logging.
|
||||||
|
func (t *SetupToken) Value() string {
|
||||||
|
if t == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matches reports whether supplied is the token, in constant time. A nil
|
||||||
|
// token never matches anything, so a missing token fails closed.
|
||||||
|
func (t *SetupToken) Matches(supplied string) bool {
|
||||||
|
if t == nil || t.value == "" || supplied == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return subtle.ConstantTimeCompare([]byte(t.value), []byte(supplied)) == 1
|
||||||
|
}
|
||||||
@@ -47,10 +47,19 @@ export async function login(username: string, password: string): Promise<void> {
|
|||||||
void sendTimezoneIfStale();
|
void sendTimezoneIfStale();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the server has no accounts yet, in which case the first
|
||||||
|
* registration must carry the setup token printed in the server log.
|
||||||
|
*/
|
||||||
|
export async function getSetupStatus(): Promise<{ setup_required: boolean }> {
|
||||||
|
return api.get<{ setup_required: boolean }>('/api/auth/setup-status');
|
||||||
|
}
|
||||||
|
|
||||||
export async function register(opts: {
|
export async function register(opts: {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
inviteToken?: string;
|
inviteToken?: string;
|
||||||
|
setupToken?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const body: Record<string, string> = {
|
const body: Record<string, string> = {
|
||||||
@@ -58,6 +67,7 @@ export async function register(opts: {
|
|||||||
password: opts.password,
|
password: opts.password,
|
||||||
};
|
};
|
||||||
if (opts.inviteToken) body.invite_token = opts.inviteToken;
|
if (opts.inviteToken) body.invite_token = opts.inviteToken;
|
||||||
|
if (opts.setupToken) body.setup_token = opts.setupToken;
|
||||||
if (opts.displayName) body.display_name = opts.displayName;
|
if (opts.displayName) body.display_name = opts.displayName;
|
||||||
const res = await api.post<LoginResponse>('/api/auth/register', body);
|
const res = await api.post<LoginResponse>('/api/auth/register', body);
|
||||||
setUser(res.user);
|
setUser(res.user);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { pageTitle } from '$lib/branding';
|
import { pageTitle } from '$lib/branding';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { register } from '$lib/auth/store.svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { getSetupStatus, register } from '$lib/auth/store.svelte';
|
||||||
import { errCode } from '$lib/api/errors';
|
import { errCode } from '$lib/api/errors';
|
||||||
import { rateLimitMessage } from '$lib/api/client';
|
import { rateLimitMessage } from '$lib/api/client';
|
||||||
|
|
||||||
@@ -9,6 +10,19 @@
|
|||||||
let password = $state('');
|
let password = $state('');
|
||||||
let confirmPassword = $state('');
|
let confirmPassword = $state('');
|
||||||
let inviteToken = $state('');
|
let inviteToken = $state('');
|
||||||
|
let setupToken = $state('');
|
||||||
|
// True on a brand-new server: the first account becomes admin and must
|
||||||
|
// carry the setup token from the server log.
|
||||||
|
let setupRequired = $state(false);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
setupRequired = (await getSetupStatus()).setup_required;
|
||||||
|
} catch {
|
||||||
|
// Unknown: leave the ordinary form. If a token turns out to be needed,
|
||||||
|
// the server says so and the error below explains where to find it.
|
||||||
|
}
|
||||||
|
});
|
||||||
let displayName = $state('');
|
let displayName = $state('');
|
||||||
let submitting = $state(false);
|
let submitting = $state(false);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
@@ -25,6 +39,8 @@
|
|||||||
return 'That invite token is invalid, expired, or already used.';
|
return 'That invite token is invalid, expired, or already used.';
|
||||||
case 'username_taken':
|
case 'username_taken':
|
||||||
return 'That username is already taken.';
|
return 'That username is already taken.';
|
||||||
|
case 'setup_token_invalid':
|
||||||
|
return "That setup token doesn't match. Copy it from the server log; it changes each time the server restarts.";
|
||||||
default:
|
default:
|
||||||
return 'Registration failed. Please try again.';
|
return 'Registration failed. Please try again.';
|
||||||
}
|
}
|
||||||
@@ -44,6 +60,7 @@
|
|||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
inviteToken: inviteToken || undefined,
|
inviteToken: inviteToken || undefined,
|
||||||
|
setupToken: setupToken.trim() || undefined,
|
||||||
displayName: displayName || undefined,
|
displayName: displayName || undefined,
|
||||||
});
|
});
|
||||||
await goto('/', { replaceState: true });
|
await goto('/', { replaceState: true });
|
||||||
@@ -107,6 +124,23 @@
|
|||||||
bind:value={confirmPassword}
|
bind:value={confirmPassword}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
{#if setupRequired}
|
||||||
|
<label class="block">
|
||||||
|
<span class="mb-1 block text-sm text-text-secondary">Setup token</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
required
|
||||||
|
class="w-full rounded border border-border bg-background px-3 py-2 font-mono outline-none focus:border-accent"
|
||||||
|
bind:value={setupToken}
|
||||||
|
/>
|
||||||
|
<span class="mt-1 block text-xs text-text-secondary">
|
||||||
|
This is a new server, so this account will be its admin. The setup token is printed in the
|
||||||
|
server log (look for "setup_token").
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{:else}
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span class="mb-1 block text-sm text-text-secondary">
|
<span class="mb-1 block text-sm text-text-secondary">
|
||||||
Invite token <span class="text-text-secondary opacity-60">(if required by your server)</span>
|
Invite token <span class="text-text-secondary opacity-60">(if required by your server)</span>
|
||||||
@@ -118,6 +152,7 @@
|
|||||||
bind:value={inviteToken}
|
bind:value={inviteToken}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -11,15 +11,17 @@ vi.mock('$app/navigation', () => ({
|
|||||||
|
|
||||||
vi.mock('$lib/auth/store.svelte', () => ({
|
vi.mock('$lib/auth/store.svelte', () => ({
|
||||||
register: vi.fn(),
|
register: vi.fn(),
|
||||||
|
getSetupStatus: vi.fn().mockResolvedValue({ setup_required: false }),
|
||||||
user: { value: null }
|
user: { value: null }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import RegisterPage from './+page.svelte';
|
import RegisterPage from './+page.svelte';
|
||||||
import { register } from '$lib/auth/store.svelte';
|
import { getSetupStatus, register } from '$lib/auth/store.svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
(getSetupStatus as ReturnType<typeof vi.fn>).mockResolvedValue({ setup_required: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('/register page', () => {
|
describe('/register page', () => {
|
||||||
@@ -115,6 +117,38 @@ describe('/register page', () => {
|
|||||||
await waitFor(() => expect(screen.getByRole('button', { name: /create account/i })).not.toBeDisabled());
|
await waitFor(() => expect(screen.getByRole('button', { name: /create account/i })).not.toBeDisabled());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('on a new server, asks for the setup token instead of an invite and sends it', async () => {
|
||||||
|
(getSetupStatus as ReturnType<typeof vi.fn>).mockResolvedValue({ setup_required: true });
|
||||||
|
(register as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||||
|
render(RegisterPage);
|
||||||
|
const tokenField = await screen.findByLabelText(/setup token/i);
|
||||||
|
expect(screen.queryByLabelText(/invite token/i)).toBeNull();
|
||||||
|
|
||||||
|
await fireEvent.input(screen.getByLabelText(/username/i), { target: { value: 'operator' } });
|
||||||
|
await fireEvent.input(screen.getByLabelText(/^password$/i), { target: { value: 'abcd1234' } });
|
||||||
|
await fireEvent.input(screen.getByLabelText(/confirm password/i), { target: { value: 'abcd1234' } });
|
||||||
|
await fireEvent.input(tokenField, { target: { value: ' 0123abcd ' } });
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: /create account/i }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(register).toHaveBeenCalledWith(expect.objectContaining({ setupToken: '0123abcd' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explains a rejected setup token', async () => {
|
||||||
|
(getSetupStatus as ReturnType<typeof vi.fn>).mockResolvedValue({ setup_required: true });
|
||||||
|
(register as ReturnType<typeof vi.fn>).mockRejectedValue({ code: 'setup_token_invalid' });
|
||||||
|
render(RegisterPage);
|
||||||
|
const tokenField = await screen.findByLabelText(/setup token/i);
|
||||||
|
await fireEvent.input(screen.getByLabelText(/username/i), { target: { value: 'operator' } });
|
||||||
|
await fireEvent.input(screen.getByLabelText(/^password$/i), { target: { value: 'abcd1234' } });
|
||||||
|
await fireEvent.input(screen.getByLabelText(/confirm password/i), { target: { value: 'abcd1234' } });
|
||||||
|
await fireEvent.input(tokenField, { target: { value: 'nope' } });
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: /create account/i }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('alert').textContent).toMatch(/server log/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('login page has link to register', () => {
|
test('login page has link to register', () => {
|
||||||
// The login page symmetrically has a "Register" link - tested here via the
|
// The login page symmetrically has a "Register" link - tested here via the
|
||||||
// register page having a "Sign in" link back to /login.
|
// register page having a "Sign in" link back to /login.
|
||||||
|
|||||||
Reference in New Issue
Block a user