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>
154 lines
5.2 KiB
Go
154 lines
5.2 KiB
Go
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)
|
|
}
|
|
}
|