feat(web): account page with disconnect + delete-account
GET /account shows the registered display name, the signed-in email, the
user's connected video accounts (status + when), a Connect-YouTube link
when none is connected, and the disconnect / delete controls. Linked from
the header nav.
POST /account/disconnect/{provider}: deletes the OAuth token from the
SecretStore (resolved from the connection's own token_ref, provider-
agnostic) and the connection row. Does NOT delete the account.
POST /account/delete: confirm-before-destroy (a <details> disclosure gates
the destructive submit — works without JS). Captures token refs, calls
store.DeleteUser (cascades all rows), purges every secret, then routes to
/auth/logout to clear the session. Tapir-side only — Dex is left untouched
(decision 2026-06-03).
Account handlers depend on a narrow SecretRemover (Delete) and the extended
Store port; cmd/tapir serve shares one file-backed SecretStore between the
connect flow and account management.
Tests: account page renders connections + name + Connect link; disconnect
removes token (fake records Delete) + row and keeps the account; delete
wipes users/summaries/connections/identities and purges the token, then
redirects to logout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
||||
)
|
||||
|
||||
// fakeSecrets is a SecretRemover that records the refs it was asked to delete, so
|
||||
// account tests can assert the OAuth token cleanup without a real secret file.
|
||||
type fakeSecrets struct {
|
||||
mu sync.Mutex
|
||||
deleted []string
|
||||
}
|
||||
|
||||
func (f *fakeSecrets) Delete(ref string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.deleted = append(f.deleted, ref)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSecrets) deletedRefs() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]string(nil), f.deleted...)
|
||||
}
|
||||
|
||||
// newAccountApp builds the App as the registered stub user with a recording fake
|
||||
// SecretStore, so disconnect/delete can be exercised end-to-end.
|
||||
func newAccountApp(t *testing.T) (*web.App, *fakeSecrets) {
|
||||
t.Helper()
|
||||
s := newStore(t)
|
||||
fs := &fakeSecrets{}
|
||||
return &web.App{
|
||||
Store: s,
|
||||
Identity: s,
|
||||
Auth: web.StubAuth{U: web.User{Subject: stubSubject, Email: "ada@example.com"}},
|
||||
Secrets: fs,
|
||||
}, fs
|
||||
}
|
||||
|
||||
func seedConnection(t *testing.T, app *web.App, provider, account, ref string) {
|
||||
t.Helper()
|
||||
require.NoError(t, app.Store.(*store.Store).UpsertConnection(context.Background(), userID, store.Connection{
|
||||
Provider: provider,
|
||||
ProviderAccount: account,
|
||||
TokenRef: ref,
|
||||
Status: "active",
|
||||
}))
|
||||
}
|
||||
|
||||
func TestAccountPageShowsConnectionAndName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
app, _ := newAccountApp(t)
|
||||
p := rawPool(t)
|
||||
resetDB(t, p)
|
||||
_, err := p.Exec(ctx, `UPDATE users SET display_name = $1 WHERE id = $2`, "Ada", userID)
|
||||
require.NoError(t, err)
|
||||
seedConnection(t, app, "youtube", "ada@channel", web.YouTubeTokenRef(userID))
|
||||
|
||||
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/account", nil))
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
html := body(t, rec)
|
||||
|
||||
require.Contains(t, html, "Ada", "display name shown")
|
||||
require.Contains(t, html, "ada@example.com", "signed-in email shown")
|
||||
require.Contains(t, html, "ada@channel", "connected account shown")
|
||||
require.Contains(t, html, "YouTube")
|
||||
require.Contains(t, html, "/account/disconnect/youtube", "a Disconnect control is present")
|
||||
require.NotContains(t, html, "/oauth/youtube/connect", "no Connect link while already connected")
|
||||
// Confirm-before-destroy: the delete is behind a disclosure, not a bare button.
|
||||
require.Contains(t, html, "/account/delete")
|
||||
require.Contains(t, html, "<details", "delete is gated behind a confirm step")
|
||||
require.Contains(t, html, "cannot be undone")
|
||||
}
|
||||
|
||||
func TestAccountPageShowsConnectLinkWhenNotConnected(t *testing.T) {
|
||||
app, _ := newAccountApp(t)
|
||||
resetDB(t, rawPool(t))
|
||||
|
||||
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/account", nil))
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
html := body(t, rec)
|
||||
|
||||
require.Contains(t, html, "/oauth/youtube/connect", "Connect link offered when not connected")
|
||||
require.Contains(t, html, "Connect YouTube")
|
||||
}
|
||||
|
||||
func TestDisconnectRemovesTokenAndConnectionKeepsAccount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
app, fs := newAccountApp(t)
|
||||
resetDB(t, rawPool(t))
|
||||
ref := web.YouTubeTokenRef(userID)
|
||||
seedConnection(t, app, "youtube", "ada@channel", ref)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/account/disconnect/youtube", nil)
|
||||
rec := do(t, app, req)
|
||||
require.Equal(t, http.StatusSeeOther, rec.Code)
|
||||
require.Equal(t, "/account", rec.Header().Get("Location"))
|
||||
|
||||
// Token purged from the SecretStore.
|
||||
require.Contains(t, fs.deletedRefs(), ref, "the per-user YouTube token must be deleted")
|
||||
|
||||
// Connection row gone...
|
||||
conns, err := app.Store.(*store.Store).ConnectionsForUser(ctx, userID)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, conns, "the connection row must be removed")
|
||||
|
||||
// ...but the account itself survives (disconnect is not delete).
|
||||
name, err := app.Store.(*store.Store).DisplayName(ctx, userID)
|
||||
require.NoError(t, err)
|
||||
_ = name
|
||||
var users int
|
||||
require.NoError(t, rawPool(t).QueryRow(ctx, `SELECT count(*) FROM users WHERE id = $1`, userID).Scan(&users))
|
||||
require.Equal(t, 1, users, "disconnect must not delete the account")
|
||||
}
|
||||
|
||||
func TestDeleteAccountWipesDataAndSecretsAndLogsOut(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
app, fs := newAccountApp(t)
|
||||
p := rawPool(t)
|
||||
resetDB(t, p)
|
||||
ref := web.YouTubeTokenRef(userID)
|
||||
seedConnection(t, app, "youtube", "ada@channel", ref)
|
||||
require.NoError(t, deliver(ctx, app, videoX, "a summary")) // some user data to wipe
|
||||
|
||||
rec := do(t, app, httptest.NewRequest(http.MethodPost, "/account/delete", nil))
|
||||
require.Equal(t, http.StatusSeeOther, rec.Code)
|
||||
require.Equal(t, "/auth/logout", rec.Header().Get("Location"), "delete logs the user out")
|
||||
|
||||
// The user's secrets were removed (the per-user YouTube token).
|
||||
require.Contains(t, fs.deletedRefs(), ref, "delete must purge the user's OAuth tokens")
|
||||
|
||||
// The account and its data are gone.
|
||||
for _, q := range []string{
|
||||
`SELECT count(*) FROM users WHERE id = $1`,
|
||||
`SELECT count(*) FROM summaries WHERE user_id = $1`,
|
||||
`SELECT count(*) FROM video_connections WHERE user_id = $1`,
|
||||
`SELECT count(*) FROM user_identities WHERE user_id = $1`,
|
||||
} {
|
||||
var n int
|
||||
require.NoError(t, p.QueryRow(ctx, q, userID).Scan(&n))
|
||||
require.Equal(t, 0, n, "delete must remove all rows: %s", q)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user