Files
OwnCord/Server/api/invite_handler_test.go
T
J3vbandClaude Fable 5 75d64dd412 refactor(b3-2): auth vertical slice — service.AuthService behind a consumer-owned interface (S-10) + HP-3 draft (#1450)
* docs(b3-1): record PR #1449 = 71d867cb in the status line, step table and evidence block

Pre-squash SHAs completed with the coverage commit a0356ee1 and the three
Codex rounds (head 8614603b).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* refactor(b3-2): auth_deps.go — the consumer-owned AuthService interface

Eight methods beside the handlers that need them: Register, Login,
VerifyTOTP, Logout, DeleteAccount, EnableTOTP, ConfirmTOTP, DisableTOTP —
fewer than the ten *db.DB methods the two handlers call today. The input
and result types they name (Principal, RegisterInput, LoginInput,
AuthResult, TOTPChangeResult) and the AuthBroadcaster the delete path needs
live in service/auth.go. Nothing implements or calls the interface yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* refactor(b3-2): service.AuthService — the auth orchestration, moved verbatim

Register, Login, VerifyTOTP, Logout, DeleteAccount, EnableTOTP, ConfirmTOTP,
DisableTOTP and the RegistrationPolicy gate two characterization rows pin
ahead of the body read. The enumeration guard, the F3 reserve-before-compare,
the audit writes, the best-effort custom-status clear and the 200+warning
partial-success contract move line for line; persistence stays in db behind
Store. Each refusal is a named service.Err* whose Error() is the exact
public message the handler wrote and whose category (ErrUnauthorized and
ErrInvalidInput join the message.go set) the transport maps to a status.
The auth rate multiplier moves to auth/ratescale.go so the route mounts
and the login failure accounting read one value; api keeps its wrappers.
Nothing calls the service yet — the handlers still own their copies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* refactor(b3-2): thin auth handlers — decode, call AuthService, encode

*db.DB leaves every handler signature in auth_handler.go and
totp_handler.go; MountAuthRoutes takes the interface and the
AuthMiddleware the caller builds, and router.go constructs the service
after the hub. Each refusal is encoded by one writeAuthError switch on the
service's error categories. The principal helper in middleware.go hands
the handlers the caller as service.Principal, and userResponse moves next
to the profile handler, so neither auth file names db any more: their two
DBImportAllow rows go in this commit (TestDBImportAllowIsLive proves the
rows could not outlive the import) and the boundary fixture points at
middleware.go instead. The auth-slice limits leave api/constants.go with
the code that reads them; profile_handler.go reads the shared pw_confirm
budget from the service. Test files change only where they mount the
routes (four helper lines + two direct mounts); no assertion or row moves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* docs(b3-2): after-state boundary inventory — api db importers 12 → 10

Regenerated table (49 files; move 28 → 26), the auth slice's after-state
dependency rows, and the honest reading of the plan's "neither db nor
service" target: met for db, not for service — the handlers import service
for the interface's types and Err* categories.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* docs(b3-2): evidence block — pre-squash SHAs, graph deltas, gates, coverage

Characterization green at each SHA in a detached worktree with the frozen
files byte-identical to 71d867cb; nine-method interface vs ten db methods;
api db importers 12 → 10; slice coverage 392/433 = 90.5% → 392/427 = 91.8%;
the five behaviour notes (decode-before-gate corner cases, shared
AuthMiddleware, folded confirmation block, moved limits, moved converter).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* docs(hp-3): scorecard draft and the D4 vertical-slice pattern in server.md

Five questions answered with commands and outputs at fe1d11b8/3f0d24ec;
owner sign-off line left blank. server.md gains D4 — the eight-step
interface/service/handler rule for B3-8 with the awkward step
(gate-before-decode) named — and its D3 deviation note drops the auth
routes. Plans README indexes the scorecard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* docs(b3-2): record PR #1450 in the evidence block and the HP-3 fetch line

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 09:49:05 +02:00

468 lines
16 KiB
Go

package api_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/J3vb/OwnCord/Server/api"
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/permissions"
"github.com/J3vb/OwnCord/Server/service"
"github.com/go-chi/chi/v5"
)
// buildInviteRouter returns a chi router with invite routes and auth middleware.
func buildInviteRouter(database *db.DB, limiter *auth.RateLimiter) http.Handler {
r := chi.NewRouter()
svc := service.New(database, limiter)
api.MountAuthRoutes(r, service.NewAuthService(database, limiter, testTOTPKey, nil), api.AuthMiddleware(database), limiter, nil)
api.MountInviteRoutes(r, database, svc)
return r
}
// loginAndGetToken creates a user with a known password and returns their session token.
func loginAndGetToken(t *testing.T, _ http.Handler, database *db.DB, username string, roleID int) string {
t.Helper()
hash, _ := auth.HashPassword("Password1!")
uid, _ := database.CreateUser(context.Background(), username, hash, roleID)
token, _ := auth.GenerateToken()
_, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1")
return token
}
// ─── POST /api/v1/invites ─────────────────────────────────────────────────────
func TestCreateInvite_Success(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildInviteRouter(database, limiter)
// Admin role (id=2) has MANAGE_INVITES (0x4000000) set.
token := loginAndGetToken(t, router, database, "invitecreator", 2)
rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{
"max_uses": 5,
"expires_in_hours": 48,
})
if rr.Code != http.StatusCreated {
t.Errorf("CreateInvite status = %d, want 201; body = %s", rr.Code, rr.Body.String())
}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
if resp["code"] == nil {
t.Error("CreateInvite response missing code")
}
}
func TestCreateInvite_Unauthorized(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildInviteRouter(database, limiter)
rr := postJSON(t, router, "/api/v1/invites", map[string]any{
"max_uses": 5,
})
if rr.Code != http.StatusUnauthorized {
t.Errorf("CreateInvite no auth status = %d, want 401", rr.Code)
}
}
func TestCreateInvite_MemberForbidden(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildInviteRouter(database, limiter)
// Member role (id=4) does NOT have MANAGE_INVITES.
token := loginAndGetToken(t, router, database, "memberuser", 4)
rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{
"max_uses": 1,
})
if rr.Code != http.StatusForbidden {
t.Errorf("CreateInvite member status = %d, want 403", rr.Code)
}
}
// TestCreateInvite_ChannelAllowOverrideDoesNotGrant pins the scope boundary of
// RequirePermission: it gates on SERVER-WIDE bits, so a per-channel allow must
// never open it. The state is reachable — the admin channel-permission handler
// masks override input with permissions.AllPerms, which includes ManageInvites.
// This kills the plausible-looking "just route RequirePermission through
// Checker.HasChannelPerm" refactor, which would pass naive review because
// GetChannelPermissions returns (0, 0, nil) when no override row exists.
func TestCreateInvite_ChannelAllowOverrideDoesNotGrant(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildInviteRouter(database, limiter)
token := loginAndGetToken(t, router, database, "overrideuser", 4)
if _, err := database.ExecContext(context.Background(),
`INSERT INTO channels (id, name, type) VALUES (1, 'general', 'text')`); err != nil {
t.Fatalf("insert channel: %v", err)
}
if _, err := database.ExecContext(context.Background(),
`INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (1, 4, ?, 0)`,
permissions.ManageInvites,
); err != nil {
t.Fatalf("insert channel override: %v", err)
}
rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{
"max_uses": 1,
})
if rr.Code != http.StatusForbidden {
t.Errorf("CreateInvite with channel allow override status = %d, want 403", rr.Code)
}
}
func TestCreateInvite_Unlimited(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildInviteRouter(database, limiter)
token := loginAndGetToken(t, router, database, "adminuser2", 2)
rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{})
if rr.Code != http.StatusCreated {
t.Errorf("CreateInvite unlimited status = %d, want 201", rr.Code)
}
}
func TestCreateInvite_EmptyBody(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildInviteRouter(database, limiter)
token := loginAndGetToken(t, router, database, "emptyinvitebody", 2)
req := httptest.NewRequest(http.MethodPost, "/api/v1/invites", http.NoBody)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("CreateInvite empty body status = %d, want 201; body = %s", rr.Code, rr.Body.String())
}
var resp map[string]any
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp["max_uses"] != nil {
t.Errorf("max_uses = %v, want nil", resp["max_uses"])
}
if resp["expires_at"] != nil {
t.Errorf("expires_at = %v, want nil", resp["expires_at"])
}
if resp["code"] == nil || resp["code"] == "" {
t.Fatal("expected invite code in response")
}
}
func TestCreateInvite_CreateInviteFailure(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildInviteRouter(database, limiter)
token := loginAndGetToken(t, router, database, "invitecreatefail", 2)
if _, err := database.ExecContext(context.Background(), `DROP TABLE invites`); err != nil {
t.Fatalf("drop invites table: %v", err)
}
rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{})
if rr.Code != http.StatusInternalServerError {
t.Fatalf("CreateInvite create failure status = %d, want 500; body = %s", rr.Code, rr.Body.String())
}
var resp map[string]any
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp["message"] != "an internal error occurred" {
t.Errorf("message = %v, want an internal error occurred", resp["message"])
}
}
func TestCreateInvite_GetInviteFailure(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildInviteRouter(database, limiter)
token := loginAndGetToken(t, router, database, "invitegetfail", 2)
if _, err := database.ExecContext(context.Background(), `
CREATE TRIGGER delete_invite_after_insert
AFTER INSERT ON invites
BEGIN
DELETE FROM invites WHERE code = NEW.code;
END;
`); err != nil {
t.Fatalf("create trigger: %v", err)
}
rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{})
if rr.Code != http.StatusInternalServerError {
t.Fatalf("CreateInvite get failure status = %d, want 500; body = %s", rr.Code, rr.Body.String())
}
var resp map[string]any
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp["message"] != "an internal error occurred" {
t.Errorf("message = %v, want an internal error occurred", resp["message"])
}
if _, err := database.ExecContext(context.Background(), `DROP TRIGGER delete_invite_after_insert`); err != nil {
t.Fatalf("drop trigger: %v", err)
}
}
// ─── GET /api/v1/invites ──────────────────────────────────────────────────────
func TestListInvites_Success(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildInviteRouter(database, limiter)
token := loginAndGetToken(t, router, database, "listuser", 2)
// Create a couple of invites.
postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{"max_uses": 1})
postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{"max_uses": 5})
req := httptest.NewRequest(http.MethodGet, "/api/v1/invites", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("ListInvites status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
var resp []any
_ = json.NewDecoder(rr.Body).Decode(&resp)
if len(resp) < 2 {
t.Errorf("ListInvites returned %d items, want >= 2", len(resp))
}
}
func TestListInvites_Unauthorized(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildInviteRouter(database, limiter)
req := httptest.NewRequest(http.MethodGet, "/api/v1/invites", nil)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("ListInvites no auth status = %d, want 401", rr.Code)
}
}
func TestListInvites_EmptyArray(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildInviteRouter(database, limiter)
token := loginAndGetToken(t, router, database, "emptyinvitelist", 2)
req := httptest.NewRequest(http.MethodGet, "/api/v1/invites", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("ListInvites empty status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
var resp []any
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp) != 0 {
t.Fatalf("ListInvites empty returned %d items, want 0", len(resp))
}
}
// ─── DELETE /api/v1/invites/:code ─────────────────────────────────────────────
func TestRevokeInvite_Success(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildInviteRouter(database, limiter)
token := loginAndGetToken(t, router, database, "revoker", 2)
// Create invite via API.
rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{})
if rr.Code != http.StatusCreated {
t.Fatalf("Create invite for revoke test: status = %d, body = %s", rr.Code, rr.Body.String())
}
var created map[string]any
_ = json.NewDecoder(rr.Body).Decode(&created)
codeVal, ok := created["code"]
if !ok || codeVal == nil {
t.Fatalf("Create invite response missing code field; body parsed as %v", created)
}
code := codeVal.(string)
// Revoke it.
req := httptest.NewRequest(http.MethodDelete, "/api/v1/invites/"+code, nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "127.0.0.1:9999"
rr2 := httptest.NewRecorder()
router.ServeHTTP(rr2, req)
if rr2.Code != http.StatusNoContent {
t.Errorf("RevokeInvite status = %d, want 204; body = %s", rr2.Code, rr2.Body.String())
}
// Verify invite is revoked.
inv, _ := database.GetInvite(context.Background(), code)
if inv == nil || !inv.Revoked {
t.Error("Invite not revoked in database after DELETE")
}
}
func TestRevokeInvite_NotFound(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildInviteRouter(database, limiter)
token := loginAndGetToken(t, router, database, "revoker2", 2)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/invites/doesnotexist", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Errorf("RevokeInvite not found status = %d, want 404", rr.Code)
}
}
func TestRevokeInvite_MemberForbidden(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildInviteRouter(database, limiter)
adminToken := loginAndGetToken(t, router, database, "admin3", 2)
memberToken := loginAndGetToken(t, router, database, "member3", 4)
// Admin creates invite.
rr := postJSONWithToken(t, router, "/api/v1/invites", adminToken, map[string]any{})
var created map[string]any
_ = json.NewDecoder(rr.Body).Decode(&created)
code := created["code"].(string)
// Member tries to revoke.
req := httptest.NewRequest(http.MethodDelete, "/api/v1/invites/"+code, nil)
req.Header.Set("Authorization", "Bearer "+memberToken)
req.RemoteAddr = "127.0.0.1:9999"
rr2 := httptest.NewRecorder()
router.ServeHTTP(rr2, req)
if rr2.Code != http.StatusForbidden {
t.Errorf("RevokeInvite member status = %d, want 403", rr2.Code)
}
}
func TestRevokeInvite_RevokeFailure(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildInviteRouter(database, limiter)
token := loginAndGetToken(t, router, database, "revokefailure", 2)
rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{})
if rr.Code != http.StatusCreated {
t.Fatalf("setup create invite: status = %d, body = %s", rr.Code, rr.Body.String())
}
var created map[string]any
if err := json.NewDecoder(rr.Body).Decode(&created); err != nil {
t.Fatalf("decode create response: %v", err)
}
code := created["code"].(string)
if _, err := database.ExecContext(context.Background(), `
CREATE TRIGGER block_revoke_invite
BEFORE UPDATE OF revoked ON invites
BEGIN
SELECT RAISE(FAIL, 'revoke blocked');
END;
`); err != nil {
t.Fatalf("create trigger: %v", err)
}
req := httptest.NewRequest(http.MethodDelete, "/api/v1/invites/"+code, nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "127.0.0.1:9999"
rr2 := httptest.NewRecorder()
router.ServeHTTP(rr2, req)
if rr2.Code != http.StatusInternalServerError {
t.Fatalf("RevokeInvite failure status = %d, want 500; body = %s", rr2.Code, rr2.Body.String())
}
var resp map[string]any
if err := json.NewDecoder(rr2.Body).Decode(&resp); err != nil {
t.Fatalf("decode revoke failure response: %v", err)
}
if resp["message"] != "an internal error occurred" {
t.Errorf("message = %v, want an internal error occurred", resp["message"])
}
}
// TestListInvites_IncludesRevokedAndActive checks the list endpoint returns
// correct data for both revoked and active invites.
func TestListInvites_IncludesRevokedAndActive(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildInviteRouter(database, limiter)
token := loginAndGetToken(t, router, database, "listall", 2)
// Create and revoke one invite.
rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{})
if rr.Code != http.StatusCreated {
t.Fatalf("Create invite for list test: status = %d, body = %s", rr.Code, rr.Body.String())
}
var created map[string]any
_ = json.NewDecoder(rr.Body).Decode(&created)
code := created["code"].(string)
delReq := httptest.NewRequest(http.MethodDelete, "/api/v1/invites/"+code, nil)
delReq.Header.Set("Authorization", "Bearer "+token)
delReq.RemoteAddr = "127.0.0.1:9999"
httptest.NewRecorder() // discard
router.ServeHTTP(httptest.NewRecorder(), delReq)
// Create one active invite.
postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{})
// List should include both.
req := httptest.NewRequest(http.MethodGet, "/api/v1/invites", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "127.0.0.1:9999"
rr2 := httptest.NewRecorder()
router.ServeHTTP(rr2, req)
if rr2.Code != http.StatusOK {
t.Errorf("ListInvites status = %d, want 200", rr2.Code)
}
}