Files
Kevin L'Hereec da71d33450
Build & Deploy / build (push) Failing after 5s
Build & Deploy / docker (push) Skipped
feat: add public read-only sharing for computers
Adds the ability for an owner to share a computer build via a public,
unauthenticated link, inspired by a feature request from the original
pcbuilder repo (no such feature actually existed there — implemented
from scratch here).

- New columns on computers: share_token (unique, nullable), share_enabled,
  share_show_sell_price, share_show_cost
- computer_share_service.ts: generates a non-guessable share token
  (crypto.randomBytes, base64url), enables/disables sharing, and supports
  rotating the token to invalidate previously distributed links
- ComputerShareController (authenticated, owner-scoped like the rest of
  the app): PUT /computers/:id/share to update settings, POST
  /computers/:id/share/rotate to rotate the token
- PublicComputerController + GET /share/:token (outside any auth/guest
  group, reachable anonymously): resolves only computers with
  share_enabled = true, 404 otherwise
- ComputerPublicTransformer: dedicated read-only projection that never
  leaks userId, and only includes sellPrice/cost when the owner opted in
  via share_show_sell_price / share_show_cost — everything else (title,
  scores, state, components, pictures) is always visible on a shared link
- ComputerShareModal.vue: owner-facing UI (toggle sharing, choose which
  prices to expose, copy the public link, revoke/rotate it) wired into
  computers/show.vue via a new 'Share' button
- share/show.vue: public page rendered with EmptyLayout (no authenticated
  header/sidebar)
- Boolean columns use a  cast since MySQL returns tinyint(1),
  not native booleans, through Lucid
- 6 new functional tests covering: enabling sharing, price visibility
  toggles, disabling sharing revokes the link, cross-user protection on
  the share settings endpoint (404), token rotation invalidating the old
  link, and anonymous access to a shared computer
2026-07-23 11:43:38 +02:00

171 lines
5.4 KiB
TypeScript

import { test } from '@japa/runner'
import User from '#models/user'
import Computer from '#models/computer'
import ComputerState from '#models/computer_state'
async function createUser(email: string) {
return User.create({
name: 'Test User',
email,
password: 'secret@123A',
})
}
async function createState(name = 'ON_GOING') {
return ComputerState.firstOrCreate({ name }, { name })
}
async function createComputer(userId: number, stateId: number) {
return Computer.create({
title: 'Shared PC',
sellPrice: 999,
gpuScore: 5,
cpuScore: 5,
globalScore: 5,
stateId,
userId,
})
}
test.group('Computer sharing', () => {
test('owner can enable sharing and the public page becomes reachable', async ({
client,
assert,
}) => {
const owner = await createUser('share-owner-a@example.com')
const state = await createState()
const computer = await createComputer(owner.id, state.id)
const enableResponse = await client
.put(`/computers/${computer.id}/share`)
.loginAs(owner)
.withCsrfToken()
.redirects(0)
.json({ enabled: true, showSellPrice: true, showCost: false })
enableResponse.assertStatus(302)
await computer.refresh()
assert.isTrue(computer.shareEnabled)
assert.isString(computer.shareToken)
const publicResponse = await client
.get(`/share/${computer.shareToken}`)
.header('X-Inertia', 'true')
.header('X-Inertia-Version', '1')
.redirects(0)
publicResponse.assertStatus(200)
publicResponse.assertBodyContains({ props: { computer: { title: 'Shared PC' } } })
})
test('sell price and cost are hidden unless explicitly enabled', async ({ client }) => {
const owner = await createUser('share-owner-b@example.com')
const state = await createState()
const computer = await createComputer(owner.id, state.id)
await client
.put(`/computers/${computer.id}/share`)
.loginAs(owner)
.withCsrfToken()
.redirects(0)
.json({ enabled: true, showSellPrice: false, showCost: false })
await computer.refresh()
const publicResponse = await client
.get(`/share/${computer.shareToken}`)
.header('X-Inertia', 'true')
.header('X-Inertia-Version', '1')
.redirects(0)
publicResponse.assertStatus(200)
publicResponse.assertBodyContains({ props: { computer: { sellPrice: null, cost: null } } })
})
test('disabling sharing makes the public link unreachable', async ({ client }) => {
const owner = await createUser('share-owner-c@example.com')
const state = await createState()
const computer = await createComputer(owner.id, state.id)
await client
.put(`/computers/${computer.id}/share`)
.loginAs(owner)
.withCsrfToken()
.redirects(0)
.json({ enabled: true, showSellPrice: false, showCost: false })
await computer.refresh()
const token = computer.shareToken
await client
.put(`/computers/${computer.id}/share`)
.loginAs(owner)
.withCsrfToken()
.redirects(0)
.json({ enabled: false, showSellPrice: false, showCost: false })
const publicResponse = await client.get(`/share/${token}`).redirects(0)
publicResponse.assertStatus(404)
})
test('a user cannot enable sharing on another user computer', async ({ client }) => {
const owner = await createUser('share-owner-d@example.com')
const attacker = await createUser('share-attacker-d@example.com')
const state = await createState()
const computer = await createComputer(owner.id, state.id)
const response = await client
.put(`/computers/${computer.id}/share`)
.loginAs(attacker)
.withCsrfToken()
.redirects(0)
.json({ enabled: true, showSellPrice: true, showCost: true })
response.assertStatus(404)
})
test('rotating the share token invalidates the previous link', async ({ client, assert }) => {
const owner = await createUser('share-owner-e@example.com')
const state = await createState()
const computer = await createComputer(owner.id, state.id)
await client
.put(`/computers/${computer.id}/share`)
.loginAs(owner)
.withCsrfToken()
.redirects(0)
.json({ enabled: true, showSellPrice: false, showCost: false })
await computer.refresh()
const oldToken = computer.shareToken
await client
.post(`/computers/${computer.id}/share/rotate`)
.loginAs(owner)
.withCsrfToken()
.redirects(0)
await computer.refresh()
assert.notEqual(computer.shareToken, oldToken)
const oldLinkResponse = await client.get(`/share/${oldToken}`).redirects(0)
oldLinkResponse.assertStatus(404)
const newLinkResponse = await client.get(`/share/${computer.shareToken}`).redirects(0)
newLinkResponse.assertStatus(200)
})
test('an unauthenticated visitor can view a shared computer', async ({ client }) => {
const owner = await createUser('share-owner-f@example.com')
const state = await createState()
const computer = await createComputer(owner.id, state.id)
await client
.put(`/computers/${computer.id}/share`)
.loginAs(owner)
.withCsrfToken()
.redirects(0)
.json({ enabled: true, showSellPrice: true, showCost: true })
await computer.refresh()
// No .loginAs() here: simulates a fully anonymous visitor.
const response = await client.get(`/share/${computer.shareToken}`).redirects(0)
response.assertStatus(200)
})
})