Unverified Commit a29571de authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

feat(api): reclaim squatted usernames for Mojang-priority players (spec §B3)

When the configured third-party Yggdrasil and the official Mojang service
issue the same username under different UUIDs, the non-genuine squatter is
displaced in favour of the real Mojang owner (正版优先). This adds the
Go-verifiable data layer of that flow on the internal (velocity) face.

- migration 0006: username_blacklist (barred squatter UUIDs) and
  player_data_holds (the displaced account's 30-day data stash), both keyed
  by mc_uuid so the genuine Mojang player — identical username, different
  UUID — is never caught by the bar.
- POST /api/v1/internal/player/reclaim bars the squatter UUID and stashes
  its data in one transaction (all-or-nothing). It is idempotent on a
  retried callback and returns the hold's effective expiry — the first
  reclaim's window, never a fresh now()+30d — so the rejected player is told
  the truth about how long their data is kept.
- GET /api/v1/internal/player/blacklist/{mc_uuid} is the login-gate check
  velocity calls to reject a barred squatter before admitting them.

Scope: velocity collision-routing, the limbo prompt, the authlib
dual-backend and the data-inherit flow are code-only (Java plus a QR-bound
device session a row cannot express) and are not part of this slice. Unit
tests cover the handlers and the in-memory repo contract; the Postgres SQL
path is exercised by integration only.
parent 1f8b9bb5
Loading
Loading
Loading
Loading
+74 −0
Changes for docs/openapi.yaml: 74 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -668,6 +668,80 @@ paths:
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v1/internal/player/reclaim:
    post:
      tags: [account-internal]
      operationId: reclaimUsername
      summary: Record a Mojang-priority username reclaim — bar the squatter UUID and stash its data (spec §B3).
      description: >
        Internal-only. Velocity records a username-collision reclaim: the
        non-genuine squatter UUID is barred and its world/player data stashed for
        a 30-day window so a new account can inherit it. Idempotent — a repeat
        reclaim of an already-barred UUID is a no-op. The bar is keyed by UUID,
        never the contested name, so the genuine Mojang player always passes.
      x-felis-face: [internal]
      x-felis-tier: service
      security: [{ serviceToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [squatter_uuid, username]
              properties:
                squatter_uuid: { type: string, format: uuid }
                username: { type: string }
                data_ref:
                  type: string
                  description: >
                    Optional opaque handle to the data already archived for the
                    hold (server-side only, never returned). Archival may be
                    deferred, in which case this is omitted.
      responses:
        '200':
          description: Reclaim recorded.
          content:
            application/json:
              schema:
                type: object
                required: [blacklisted, username, hold_expires_at]
                properties:
                  blacklisted: { type: boolean, const: true }
                  username: { type: string }
                  hold_expires_at: { type: string, format: date-time }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v1/internal/player/blacklist/{mc_uuid}:
    get:
      tags: [account-internal]
      operationId: checkUsernameBlacklist
      summary: Report whether an in-game UUID was barred by a prior reclaim (spec §B3).
      description: >
        Internal-only. The velocity login gate calls it to reject a barred
        squatter before letting them in; the genuine Mojang UUID — same username,
        different UUID — is never on the list and always passes.
      x-felis-face: [internal]
      x-felis-tier: service
      security: [{ serviceToken: [] }]
      parameters:
        - { name: mc_uuid, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Blacklist status.
          content:
            application/json:
              schema:
                type: object
                required: [blacklisted]
                properties:
                  blacklisted: { type: boolean }
        '401':
          $ref: '#/components/responses/Unauthorized'

  # ----------------------------------------------------- external: servers ---
  /api/v1/servers/{name}/wake:
    post:
+7 −0
Changes for internal/api/api.go: 7 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -172,6 +172,13 @@ func (a *API) internalAPIRoutes() []apiRoute {
		// verified UUID. Internal-only — the code is born from an online-mode UUID the
		// web never holds (account_link_codes has no user_id column).
		{Method: "POST", Pattern: "/api/v1/internal/account/link/code", h: a.handleCreateLinkCode},
		// Username-collision reclaim (spec §B3): velocity records a Mojang-priority
		// reclaim (bar the squatter UUID + stash its data for 30 days) and gates the
		// limbo login by checking whether a connecting UUID was barred. Internal-only —
		// velocity holds a service token, and the bar is keyed by UUID so the genuine
		// Mojang player (same name, different UUID) always passes.
		{Method: "POST", Pattern: "/api/v1/internal/player/reclaim", h: a.handleReclaimUsername},
		{Method: "GET", Pattern: "/api/v1/internal/player/blacklist/{mc_uuid}", h: a.handleCheckBlacklist},
	}
}

+37 −0
Changes for internal/api/api_test.go: 37 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -54,6 +54,24 @@ type fakeRepo struct {
	// player email OTPs (spec §B2). Keyed by row id; the verify path scans for the
	// newest live (user, purpose) just as the PG query does.
	otps map[string]*fakeEmailOTP
	// username-collision reclaim (spec §B3). blacklist mirrors username_blacklist
	// (mc_uuid -> barred), holds mirrors player_data_holds keyed by the held
	// (squatter) mc_uuid — both keyed by UUID, matching the PG UNIQUE(mc_uuid)
	// idempotency. They are written together by ReclaimUsername so the fake encodes
	// the same all-or-nothing contract the PG transaction enforces.
	blacklist map[string]bool
	holds     map[string]fakeDataHold
}

// fakeDataHold mirrors a player_data_holds row at the granularity the verifiable
// (write-only) layer exercises: which name/data was stashed for the squatter UUID
// and when the 30-day window ends. reclaimed_by_user_id/reclaimed_at have no fake
// fields — the inherit flow that would set them is CODE-ONLY (deferred).
type fakeDataHold struct {
	id        string
	username  string
	dataRef   string
	expiresAt time.Time
}

// fakeEmailOTP mirrors an email_otps row: only the code hash is held (never the
@@ -107,6 +125,8 @@ func newFakeRepo() *fakeRepo {
		sessions:       map[string]*fakeSession{},
		settings:       map[string][]byte{},
		otps:           map[string]*fakeEmailOTP{},
		blacklist:      map[string]bool{},
		holds:          map[string]fakeDataHold{},
	}
}

@@ -210,6 +230,23 @@ func (f *fakeRepo) UserByMCUUID(_ context.Context, uuid string) (string, error)
	}
	return "", ErrNotFound
}

// ReclaimUsername mirrors PGRepo.ReclaimUsername: it bars the squatter UUID and
// stashes the data hold together (the all-or-nothing PG transaction), keyed by
// mc_uuid so a repeat reclaim of an already-barred UUID is an idempotent no-op
// (ON CONFLICT (mc_uuid) DO NOTHING on both tables) — the first reclaim wins and
// a duplicate neither errors nor overwrites the stored hold.
func (f *fakeRepo) ReclaimUsername(_ context.Context, id, squatterUUID, username, dataRef string, expiresAt time.Time) (time.Time, error) {
	if h, ok := f.holds[squatterUUID]; ok { // already stashed — idempotent no-op; keep & report the first window
		return h.expiresAt, nil
	}
	f.blacklist[squatterUUID] = true
	f.holds[squatterUUID] = fakeDataHold{id: id, username: username, dataRef: dataRef, expiresAt: expiresAt}
	return expiresAt, nil
}
func (f *fakeRepo) IsUsernameBlacklisted(_ context.Context, mcUUID string) (bool, error) {
	return f.blacklist[mcUUID], nil
}
func (f *fakeRepo) ClaimServer(_ context.Context, n, u string) (bool, error) {
	ok, present := f.claimOK[n]
	if !present {
+125 −0
Changes for internal/api/handlers_player_reclaim.go: 125 added lines, 0 removed lines.
Original line number Diff line number Diff line
package api

import (
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"net/http"
	"time"
)

// Username-collision reclaim (spec §B3, internal face). The configured
// third-party Yggdrasil and the official Mojang service can mint the SAME
// username under DIFFERENT UUIDs. Velocity detects the collision in a limbo login
// server; when the connecting player is NOT the genuine Mojang owner, Mojang
// takes priority (正版优先): the squatter is rejected and barred, and its data is
// stashed for a 30-day window so a new account can inherit it.
//
// These two endpoints are the Go-verifiable data layer of that flow, both
// internal-face (velocity holds a service token, never a web Principal):
//
//	POST /api/v1/internal/player/reclaim          — record a reclaim (bar + stash)
//	GET  /api/v1/internal/player/blacklist/{uuid} — the login gate's bar check
//
// Velocity collision-routing, the limbo prompt, the authlib dual-backend, and the
// data-inherit flow are CODE-ONLY (Java + a QR-bound device session a Postgres
// row cannot express) and are not represented here. The block is keyed by UUID,
// never by the contested name, so the genuine Mojang player — same username,
// different UUID — is never caught.

const (
	// reclaimHoldTTL is the 30-day window a reclaimed account's data is stashed for
	// before it may be purged (spec §B3 "您的数据将会被暂存 30 天"). The hold's
	// expires_at is the API clock + this, so one authoritative clock drives expiry.
	reclaimHoldTTL = 30 * 24 * time.Hour
)

// newHoldID returns an opaque random row id (128 bits, hex) for a
// player_data_holds row, mirroring newOTPID.
func newHoldID() (string, error) {
	var b [16]byte
	if _, err := rand.Read(b[:]); err != nil {
		return "", err
	}
	return hex.EncodeToString(b[:]), nil
}

// reclaimRequest is the velocity reclaim callback body (spec §B3): the verified
// online-mode UUID of the squatter being displaced, the contested username (for
// display/audit), and an optional opaque handle to the data already archived for
// the hold. data_ref is optional — archival may be deferred — but a squatter_uuid
// and username are always required.
type reclaimRequest struct {
	SquatterUUID string `json:"squatter_uuid"`
	Username     string `json:"username"`
	DataRef      string `json:"data_ref"`
}

// handleReclaimUsername records a Mojang-priority username reclaim (spec §B3,
// internal face). In one transaction it bars the squatter UUID and stashes its
// data as a 30-day hold (Repo.ReclaimUsername). It is idempotent: a repeat
// reclaim of an already-barred UUID is a no-op that still answers 200, so a
// retried velocity callback is harmless. It returns the hold's expiry so velocity
// can tell the rejected player how long their data is kept.
func (a *API) handleReclaimUsername(w http.ResponseWriter, r *http.Request) {
	var req reclaimRequest
	if err := decodeJSON(w, r, &req); err != nil {
		writeError(w, r, err)
		return
	}
	if req.SquatterUUID == "" {
		writeError(w, r, newError(http.StatusBadRequest, "bad_request", "squatter_uuid is required"))
		return
	}
	if req.Username == "" {
		writeError(w, r, newError(http.StatusBadRequest, "bad_request", "username is required"))
		return
	}
	id, err := newHoldID()
	if err != nil {
		writeError(w, r, err)
		return
	}
	proposedExpiry := a.now().Add(reclaimHoldTTL)
	// ReclaimUsername returns the EFFECTIVE persisted expiry, which differs from the
	// proposed one on an idempotent retry (the hold keeps its first window). We echo
	// the persisted value so a re-firing velocity callback never tells the player a
	// 30-day window that the stored hold does not actually have.
	heldUntil, err := a.Repo.ReclaimUsername(r.Context(), id, req.SquatterUUID, req.Username, req.DataRef, proposedExpiry)
	if err != nil {
		writeError(w, r, err)
		return
	}
	// A reclaim bars a player and stashes their world — a security-significant
	// accountability event. The squatter UUID and contested name go in the audit
	// payload (the flat columns model a server op, not this), keyed by Source
	// internal since velocity, not a human, drives it.
	payload, _ := json.Marshal(map[string]string{"username": req.Username, "squatter_uuid": req.SquatterUUID})
	_ = a.Repo.Audit(r.Context(), AuditEntry{
		Actor: "velocity", Source: "internal", Action: "player.reclaim",
		RequestID: requestIDFromContext(r.Context()), Payload: payload,
	})
	writeJSON(w, http.StatusOK, map[string]any{
		"blacklisted":     true,
		"username":        req.Username,
		"hold_expires_at": heldUntil.UTC(),
	})
}

// handleCheckBlacklist reports whether an in-game UUID was barred by a prior
// reclaim (spec §B3, internal face). The velocity login gate calls it to reject a
// squatter before letting them in; the genuine Mojang UUID (same name, different
// UUID) is never on the list, so it always passes.
func (a *API) handleCheckBlacklist(w http.ResponseWriter, r *http.Request) {
	mcUUID := r.PathValue("mc_uuid")
	if mcUUID == "" {
		writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required"))
		return
	}
	blacklisted, err := a.Repo.IsUsernameBlacklisted(r.Context(), mcUUID)
	if err != nil {
		writeError(w, r, err)
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"blacklisted": blacklisted})
}
+239 −0
Changes for internal/api/handlers_player_reclaim_test.go: 239 added lines, 0 removed lines.
Original line number Diff line number Diff line
package api

import (
	"encoding/json"
	"net/http"
	"testing"
	"time"
)

// Username-collision reclaim (spec §B3) is Mojang-priority: when a non-genuine
// player squats a name the official service also issues, velocity bars the
// squatter's UUID and stashes its data for 30 days. These tests pin the
// Go-verifiable data layer of that flow — the bar, the stash, idempotency, and
// the one safety property that must never regress: the block is keyed by UUID, so
// the genuine Mojang player (same name, DIFFERENT UUID) is never caught.

// TestReclaimBarsSquatterAndStashes is the happy path: a reclaim bars the
// squatter UUID, the subsequent gate check for that UUID reports barred, and the
// data hold lands with the API-clock 30-day expiry.
func TestReclaimBarsSquatterAndStashes(t *testing.T) {
	const squatter = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
	repo := newFakeRepo()
	api := newTestAPI(repo, newFakeCluster())
	ih := api.InternalHandler()

	body := `{"squatter_uuid":"` + squatter + `","username":"Notch","data_ref":"s3://holds/notch"}`
	w := do(ih, "POST", "/api/v1/internal/player/reclaim", body, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("reclaim: code = %d, want 200 (%s)", w.Code, w.Body.String())
	}
	b := acctBody(t, w)
	if b["blacklisted"] != true || b["username"] != "Notch" {
		t.Fatalf("reclaim body = %v, want blacklisted:true username:Notch", b)
	}

	// The squatter UUID is now barred — what the velocity login gate checks.
	if !repo.blacklist[squatter] {
		t.Fatal("squatter UUID was not barred")
	}
	// The data is stashed for inherit with the authoritative-clock 30-day window.
	hold, ok := repo.holds[squatter]
	if !ok {
		t.Fatal("no data hold was stashed for the squatter")
	}
	if hold.username != "Notch" || hold.dataRef != "s3://holds/notch" {
		t.Errorf("hold = %+v, want username:Notch dataRef:s3://holds/notch", hold)
	}
	if want := api.now().Add(reclaimHoldTTL); !hold.expiresAt.Equal(want) {
		t.Errorf("hold.expiresAt = %v, want %v", hold.expiresAt, want)
	}

	// The gate check for the barred UUID reports it.
	w = do(ih, "GET", "/api/v1/internal/player/blacklist/"+squatter, "", nil)
	if w.Code != http.StatusOK {
		t.Fatalf("blacklist check: code = %d (%s)", w.Code, w.Body.String())
	}
	if acctBody(t, w)["blacklisted"] != true {
		t.Fatalf("blacklist check body = %s, want blacklisted:true", w.Body.String())
	}
}

// TestReclaimNeverCatchesGenuineMojangPlayer is the load-bearing safety property:
// the block is keyed by UUID, never by the contested name. A genuine Mojang
// player shares the username but carries a different UUID, so the gate must let
// them through even after the squatter is barred. If this ever regresses,正版优先
// becomes正版连不上 — the exact outcome the design forbids.
func TestReclaimNeverCatchesGenuineMojangPlayer(t *testing.T) {
	const (
		squatter = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
		genuine  = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" // same name, real owner
	)
	repo := newFakeRepo()
	api := newTestAPI(repo, newFakeCluster())
	ih := api.InternalHandler()

	body := `{"squatter_uuid":"` + squatter + `","username":"Notch"}`
	if w := do(ih, "POST", "/api/v1/internal/player/reclaim", body, nil); w.Code != http.StatusOK {
		t.Fatalf("reclaim: code = %d (%s)", w.Code, w.Body.String())
	}

	// The genuine owner — identical username, different UUID — is NOT barred.
	w := do(ih, "GET", "/api/v1/internal/player/blacklist/"+genuine, "", nil)
	if w.Code != http.StatusOK {
		t.Fatalf("genuine check: code = %d (%s)", w.Code, w.Body.String())
	}
	if got := acctBody(t, w)["blacklisted"]; got != false {
		t.Fatalf("genuine Mojang player blacklisted = %v, want false", got)
	}
}

// TestReclaimIsIdempotent proves a retried velocity callback is harmless: a repeat
// reclaim of an already-barred UUID still answers 200 and does not disturb the
// original hold (matching the ON CONFLICT DO NOTHING in both inserts).
func TestReclaimIsIdempotent(t *testing.T) {
	const squatter = "cccccccc-cccc-cccc-cccc-cccccccccccc"
	repo := newFakeRepo()
	api := newTestAPI(repo, newFakeCluster())
	ih := api.InternalHandler()

	first := `{"squatter_uuid":"` + squatter + `","username":"Herobrine","data_ref":"ref-1"}`
	if w := do(ih, "POST", "/api/v1/internal/player/reclaim", first, nil); w.Code != http.StatusOK {
		t.Fatalf("first reclaim: code = %d (%s)", w.Code, w.Body.String())
	}
	original := repo.holds[squatter]

	// A retry with a different data_ref must not overwrite the original stash.
	retry := `{"squatter_uuid":"` + squatter + `","username":"Herobrine","data_ref":"ref-2"}`
	if w := do(ih, "POST", "/api/v1/internal/player/reclaim", retry, nil); w.Code != http.StatusOK {
		t.Fatalf("retry reclaim: code = %d (%s)", w.Code, w.Body.String())
	}
	if got := repo.holds[squatter]; got != original {
		t.Errorf("idempotent retry altered the hold: got %+v, want %+v", got, original)
	}
}

// TestReclaimRetryReportsOriginalWindow is the regression for the response-honesty
// fix: the squatter reconnects days later, velocity re-fires the reclaim, and the
// hold keeps its FIRST 30-day window (ON CONFLICT preserves it). The response must
// report that persisted window, NOT a fresh now()+30d — otherwise velocity tells
// the player a kept-until date the stored hold does not actually have. A frozen
// clock cannot catch this, so the clock is advanced between the two reclaims.
func TestReclaimRetryReportsOriginalWindow(t *testing.T) {
	const squatter = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"
	repo := newFakeRepo()
	api := newTestAPI(repo, newFakeCluster())
	clock := time.Unix(1_700_000_000, 0)
	api.Now = func() time.Time { return clock }
	ih := api.InternalHandler()

	body := `{"squatter_uuid":"` + squatter + `","username":"Notch"}`
	w := do(ih, "POST", "/api/v1/internal/player/reclaim", body, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("first reclaim: code = %d (%s)", w.Code, w.Body.String())
	}
	first := acctBody(t, w)["hold_expires_at"]

	// Three days pass; velocity re-fires the reclaim on the squatter's next attempt.
	clock = clock.Add(72 * time.Hour)
	w = do(ih, "POST", "/api/v1/internal/player/reclaim", body, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("retry reclaim: code = %d (%s)", w.Code, w.Body.String())
	}
	second := acctBody(t, w)["hold_expires_at"]

	if first != second {
		t.Fatalf("retry hold_expires_at = %v, want the original %v — the response must reflect the persisted hold, not a fresh clock", second, first)
	}
	// And it must equal the first reclaim's window, not the retry-time clock.
	if want := time.Unix(1_700_000_000, 0).Add(reclaimHoldTTL).UTC().Format(time.RFC3339Nano); second != want {
		t.Errorf("hold_expires_at = %v, want %v (first reclaim's window)", second, want)
	}
}

// TestReclaimAudited proves a reclaim writes a security-significant accountability
// row: a player is barred and their world stashed, so the event lands in
// audit_logs as player.reclaim from velocity over the internal source, with the
// contested name and squatter UUID in the structured payload.
func TestReclaimAudited(t *testing.T) {
	const squatter = "dddddddd-dddd-dddd-dddd-dddddddddddd"
	repo := newFakeRepo()
	api := newTestAPI(repo, newFakeCluster())
	ih := api.InternalHandler()

	body := `{"squatter_uuid":"` + squatter + `","username":"Steve"}`
	if w := do(ih, "POST", "/api/v1/internal/player/reclaim", body, nil); w.Code != http.StatusOK {
		t.Fatalf("reclaim: code = %d (%s)", w.Code, w.Body.String())
	}
	if len(repo.audits) != 1 {
		t.Fatalf("audits = %d, want 1", len(repo.audits))
	}
	a := repo.audits[0]
	if a.Action != "player.reclaim" || a.Actor != "velocity" || a.Source != "internal" {
		t.Fatalf("audit = %+v, want player.reclaim/velocity/internal", a)
	}
	var p map[string]string
	if err := json.Unmarshal(a.Payload, &p); err != nil {
		t.Fatalf("audit payload not JSON: %v (%s)", err, a.Payload)
	}
	if p["username"] != "Steve" || p["squatter_uuid"] != squatter {
		t.Errorf("audit payload = %v, want username:Steve squatter_uuid:%s", p, squatter)
	}
}

// TestReclaimValidation is the input matrix: a reclaim with no UUID or no username
// is a 400, strict decoding rejects unknown fields, and a rejected request neither
// bars anyone nor stashes anything.
func TestReclaimValidation(t *testing.T) {
	cases := []struct {
		name string
		body string
	}{
		{"missing squatter_uuid", `{"username":"Notch"}`},
		{"empty squatter_uuid", `{"squatter_uuid":"","username":"Notch"}`},
		{"missing username", `{"squatter_uuid":"aaaa"}`},
		{"empty username", `{"squatter_uuid":"aaaa","username":""}`},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			repo := newFakeRepo()
			api := newTestAPI(repo, newFakeCluster())
			w := do(api.InternalHandler(), "POST", "/api/v1/internal/player/reclaim", tc.body, nil)
			if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
				t.Fatalf("code = %d body %s, want 400 bad_request", w.Code, w.Body.String())
			}
			if len(repo.blacklist) != 0 || len(repo.holds) != 0 {
				t.Fatal("a rejected reclaim must not bar or stash anything")
			}
		})
	}

	t.Run("unknown field rejected", func(t *testing.T) {
		repo := newFakeRepo()
		api := newTestAPI(repo, newFakeCluster())
		body := `{"squatter_uuid":"aaaa","username":"Notch","reason":"smuggled"}`
		w := do(api.InternalHandler(), "POST", "/api/v1/internal/player/reclaim", body, nil)
		if w.Code != http.StatusBadRequest {
			t.Fatalf("strict decode must reject unknown field, code = %d (%s)", w.Code, w.Body.String())
		}
	})
}

// TestReclaimFaceSeparation enforces that the reclaim and gate-check endpoints are
// internal-only: velocity holds a service token, a web Principal never reaches
// them. Crossing onto the external face must 404, not silently work — a logged-in
// user could otherwise bar an arbitrary UUID.
func TestReclaimFaceSeparation(t *testing.T) {
	user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
	api := newTestAPI(newFakeRepo(), newFakeCluster())
	api.External = staticExternal{p: user}
	eh := api.ExternalHandler()

	body := `{"squatter_uuid":"aaaa","username":"Notch"}`
	if w := do(eh, "POST", "/api/v1/internal/player/reclaim", body, nil); w.Code != http.StatusNotFound {
		t.Errorf("reclaim on external face: code = %d, want 404", w.Code)
	}
	if w := do(eh, "GET", "/api/v1/internal/player/blacklist/aaaa", "", nil); w.Code != http.StatusNotFound {
		t.Errorf("blacklist check on external face: code = %d, want 404", w.Code)
	}
}
Loading