feat(applications): publish encrypted private drafts (#148)

This commit is contained in:
nessar committed 2026-09-04 13:46:00 +02:00
1 parent 59d6e3d6e1
commit 06a8037ad9
62 files changed
+3163 -17

No files matched your search

+11 -8
View File
@@ -92,9 +92,9 @@ flowchart TD
Backend -->|Lecture / Écriture / Compression WebP| Storage
```
### Chiffrement prévu des candidatures
### Chiffrement des candidatures
Le mécanisme de chiffrement de bout en bout prévu par [l'ADR-0034](docs/adr/0034-encrypt-candidate-applications-outside-the-server.md) protège les données et justificatifs de candidature avant leur envoi au serveur :
Le mécanisme de chiffrement de bout en bout défini par [l'ADR-0034](docs/adr/0034-encrypt-candidate-applications-outside-the-server.md) protège les données et justificatifs de candidature avant leur envoi au serveur :
```mermaid
sequenceDiagram
@@ -113,14 +113,17 @@ sequenceDiagram
Note over S,C: Création et saisie d'un dossier
C->>S: Ouvre le lien public du logement par un GET sans effet
S-->>C: Charge l'application et la notice
C->>S: Demande automatiquement la création par POST
S-->>C: Fournit le brouillon, sa capacité d'accès et la clé publique
C->>C: Génère la clé du dossier et place capacité + clé après #
C->>C: Chiffre les champs et les PDF/JPEG/PNG par AES-256-GCM
S-->>C: Fournit la notice et la clé publique du bailleur
C->>C: Génère la capacité et la clé du dossier
C->>C: Chiffre la clé du dossier par RSA-OAEP 3072 / SHA-256
C->>S: Envoie seulement les chiffrés et utilise la capacité d'accès
C->>S: POST avec la capacité en en-tête et la clé de dossier chiffrée
S->>S: Ne conserve qu'une empreinte de la capacité
S-->>C: Fournit l'identifiant du brouillon vide
C->>C: Remplace l'URL par identifiant + capacité + clé après #
Note over S,C: Saisie ultérieure du dossier
C->>C: Chiffre les champs et les PDF/JPEG/PNG par AES-256-GCM
C->>S: Envoie seulement les chiffrés et utilise la capacité d'accès
Note over B,S: Lecture d'un dossier soumis
B->>S: Demande le dossier avec sa session OIDC
@@ -26,15 +26,17 @@ public class SecurityConfiguration {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, SecurityProperties properties,
OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService) throws Exception {
http.authorizeHttpRequests(authorize -> authorize.requestMatchers("/api/user").permitAll()
.requestMatchers("/api/admin/**").hasAuthority(properties.adminRole()).requestMatchers("/api/**")
.authenticated().anyRequest().permitAll())
http.authorizeHttpRequests(
authorize -> authorize.requestMatchers("/api/user", "/api/public/**", "/api/candidate/**").permitAll()
.requestMatchers("/api/admin/**").hasAuthority(properties.adminRole())
.requestMatchers("/api/**").authenticated().anyRequest().permitAll())
.exceptionHandling(exceptions -> exceptions.defaultAuthenticationEntryPointFor(
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
PathPatternRequestMatcher.pathPattern("/api/**")))
.oauth2Login(oauth2 -> oauth2.userInfoEndpoint(userInfo -> userInfo.oidcUserService(oidcUserService))
.defaultSuccessUrl("/", true))
.logout(logout -> logout.logoutSuccessUrl("/")).csrf(csrf -> csrf.spa());
.logout(logout -> logout.logoutSuccessUrl("/"))
.csrf(csrf -> csrf.ignoringRequestMatchers("/api/public/**", "/api/candidate/**").spa());
return http.build();
}
}
@@ -0,0 +1,8 @@
package fr.nessar.lmnporganizer.rental.adapter.in.config;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("lmnp.applications.creation-rate-limit")
public record ApplicationCreationRateLimitProperties(int maximumAttempts, Duration window) {
}
@@ -39,10 +39,15 @@ import fr.nessar.lmnporganizer.rental.application.rent.RentDueQueryService;
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentHistoryService;
import fr.nessar.lmnporganizer.rental.application.rent.RentRevisionService;
import fr.nessar.lmnporganizer.rental.application.rent.ReverseRentPaymentService;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationCreationRateLimiter;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationLinkService;
import fr.nessar.lmnporganizer.rental.application.tenant.DeleteTenantService;
import fr.nessar.lmnporganizer.rental.application.tenant.TenantAttachmentService;
import fr.nessar.lmnporganizer.rental.application.tenant.TenantDirectoryService;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentRepository;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultRepository;
import fr.nessar.lmnporganizer.rental.domain.application.PublicApplicationLinkRepository;
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplicationRepository;
import fr.nessar.lmnporganizer.rental.domain.calendar.ManualEventRepository;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportRepository;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryRepository;
@@ -69,7 +74,7 @@ import org.springframework.context.annotation.Profile;
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties({E2eClockProperties.class, RentalAttachmentProperties.class,
RentalStorageProperties.class,})
RentalStorageProperties.class, ApplicationCreationRateLimitProperties.class})
public class RentalApplicationConfiguration {
private static final ZoneId PROPERTY_CIVIL_TIME_ZONE = ZoneId.of("Europe/Paris");
@@ -153,6 +158,21 @@ public class RentalApplicationConfiguration {
return new TenantAttachmentService(tenants, storage, identifiers, properties.maxSize().toBytes());
}
@Bean
ApplicationCreationRateLimiter applicationCreationRateLimiter(ApplicationCreationRateLimitProperties properties,
Clock rentalClock) {
return new ApplicationCreationRateLimiter(properties.maximumAttempts(), properties.window(), rentalClock);
}
@Bean
ApplicationLinkService applicationLinkService(ApartmentRepository apartments, LandlordRepository landlords,
ApplicationVaultRepository vaults, PublicApplicationLinkRepository links,
RentalApplicationRepository applications, TransactionRunner transactions, IdentifierGenerator identifiers,
Clock rentalClock, ApplicationCreationRateLimiter rateLimiter) {
return new ApplicationLinkService(apartments, landlords, vaults, links, applications, transactions, identifiers,
rentalClock, rateLimiter);
}
@Bean
LeaseDraftService leaseDraftService(ApartmentRepository apartments, TenantRepository tenants,
LeaseRepository leases, TransactionRunner transactions, IdentifierGenerator identifiers,
@@ -0,0 +1,25 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.application;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@Component
public class ApplicationSecurityHeadersFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
if (request.getRequestURI().startsWith("/api/application-links")
|| request.getRequestURI().startsWith("/api/public/application-links")
|| request.getRequestURI().startsWith("/api/candidate/applications")
|| request.getRequestURI().startsWith("/api/rental-applications")) {
response.setHeader("Cache-Control", "no-store");
response.setHeader("Referrer-Policy", "no-referrer");
}
filterChain.doFilter(request, response);
}
}
@@ -0,0 +1,27 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.application;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationLinkService;
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplicationId;
import org.springframework.http.HttpHeaders;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/candidate/applications")
public class CandidateApplicationController {
private final ApplicationLinkService links;
public CandidateApplicationController(ApplicationLinkService links) {
this.links = links;
}
@GetMapping("/{applicationId}")
RentalApplicationApi.CandidateApplicationResponse resume(@PathVariable String applicationId,
@RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authorization) {
return RentalApplicationApi.CandidateApplicationResponse
.from(links.resume(new RentalApplicationId(applicationId), authorization));
}
}
@@ -0,0 +1,45 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.application;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationLinkService;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import jakarta.validation.Valid;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/application-links/apartments")
public class LandlordApplicationLinkController {
private final ApplicationLinkService links;
private final AuthenticatedLandlord authenticatedLandlord;
public LandlordApplicationLinkController(ApplicationLinkService links,
AuthenticatedLandlord authenticatedLandlord) {
this.links = links;
this.authenticatedLandlord = authenticatedLandlord;
}
@GetMapping("/{apartmentId}")
RentalApplicationApi.LinkStatusResponse status(@AuthenticationPrincipal OidcUser principal,
@PathVariable String apartmentId) {
return RentalApplicationApi.LinkStatusResponse
.from(links.status(authenticatedLandlord.id(principal), new ApartmentId(apartmentId)));
}
@PostMapping("/{apartmentId}")
RentalApplicationApi.LinkStatusResponse publish(@AuthenticationPrincipal OidcUser principal,
@PathVariable String apartmentId,
@Valid @RequestBody(required = false) RentalApplicationApi.PublishRequest request) {
ApplicationLinkService.VaultActivation activation = request == null || request.activation() == null
? null
: request.activation().toDomain();
return RentalApplicationApi.LinkStatusResponse
.from(links.publish(authenticatedLandlord.id(principal), new ApartmentId(apartmentId), activation));
}
}
@@ -0,0 +1,40 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.application;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationLinkService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/public/application-links")
public class PublicApplicationController {
private final ApplicationLinkService links;
public PublicApplicationController(ApplicationLinkService links) {
this.links = links;
}
@GetMapping("/{token}")
RentalApplicationApi.PublicLinkResponse preview(@PathVariable String token) {
return RentalApplicationApi.PublicLinkResponse.from(links.preview(token));
}
@PostMapping("/{token}/applications")
ResponseEntity<RentalApplicationApi.CreatedDraftResponse> create(@PathVariable String token,
@RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authorization,
@Valid @RequestBody RentalApplicationApi.CreateDraftRequest request, HttpServletRequest servletRequest) {
var application = links.createDraft(token, authorization, request.encryptedApplicationKey().toDomain(),
servletRequest.getRemoteAddr());
return ResponseEntity.status(HttpStatus.CREATED)
.body(RentalApplicationApi.CreatedDraftResponse.from(application));
}
}
@@ -0,0 +1,81 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.application;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationLinkService;
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplication;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.Instant;
final class RentalApplicationApi {
private RentalApplicationApi() {
}
record PublishRequest(@Valid VaultActivationRequest activation) {
}
record VaultActivationRequest(@NotBlank @Size(max = 1400) String publicKey,
@NotBlank @Size(max = 1400) String credentialId, @NotBlank @Size(max = 100) String label,
@NotBlank @Size(max = 64) String prfSalt, @NotBlank @Size(max = 64) String hkdfSalt,
@NotBlank @Size(max = 32) String nonce, @NotBlank @Size(max = 12000) String encryptedPrivateKey,
@Min(1) @Max(1) int cryptoVersion) {
ApplicationLinkService.VaultActivation toDomain() {
return new ApplicationLinkService.VaultActivation(publicKey, credentialId, label, prfSalt, hkdfSalt, nonce,
encryptedPrivateKey, cryptoVersion);
}
}
record LinkStatusResponse(boolean vaultActive, int passkeyCount, ActiveLinkResponse activeLink) {
static LinkStatusResponse from(ApplicationLinkService.LinkStatus status) {
return new LinkStatusResponse(status.vaultActive(), status.passkeyCount(),
status.activeLink() == null
? null
: new ActiveLinkResponse(status.activeLink().token(),
"/candidatures/postuler/" + status.activeLink().token(),
status.activeLink().createdAt()));
}
}
record ActiveLinkResponse(String token, String path, Instant createdAt) {
}
record PublicLinkResponse(String token, Instant createdAt, String landlordPublicKey, String landlordName,
String landlordEmail, String apartmentName) {
static PublicLinkResponse from(ApplicationLinkService.PublicLinkView view) {
return new PublicLinkResponse(view.token(), view.createdAt(), view.landlordPublicKey(), view.landlordName(),
view.landlordEmail(), view.apartmentName());
}
}
record CreateDraftRequest(@NotNull @Valid EncryptedApplicationKeyRequest encryptedApplicationKey) {
}
record EncryptedApplicationKeyRequest(@Min(1) @Max(1) int cryptoVersion,
@NotBlank @Size(max = 600) String ciphertext) {
ApplicationLinkService.EncryptedApplicationKey toDomain() {
return new ApplicationLinkService.EncryptedApplicationKey(cryptoVersion, ciphertext);
}
}
record CreatedDraftResponse(String id, String state, int revision) {
static CreatedDraftResponse from(RentalApplication application) {
return new CreatedDraftResponse(application.id().value(), application.state().name(),
application.revision());
}
}
record CandidateApplicationResponse(String id, String apartmentId, String state, int revision, Instant createdAt,
Instant lastActivityAt, String encryptedApplicationKey, int cryptoVersion, String landlordName,
String landlordEmail, String apartmentName) {
static CandidateApplicationResponse from(ApplicationLinkService.CandidateApplicationView view) {
RentalApplication application = view.application();
return new CandidateApplicationResponse(application.id().value(), application.apartmentId().value(),
application.state().name(), application.revision(), application.createdAt(),
application.lastActivityAt(), application.encryptedApplicationKey(), application.cryptoVersion(),
view.landlordName(), view.landlordEmail(), view.apartmentName());
}
}
}
@@ -1,6 +1,8 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.common;
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentConflict;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationCreationRateLimited;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultRequired;
import fr.nessar.lmnporganizer.rental.application.apartment.AcquisitionFactsCorrectionNotAllowed;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentTooLarge;
@@ -69,6 +71,18 @@ public class RentalApiExceptionHandler {
.body(new CodedApiError("tenant_contract_profile_incomplete", exception.getMessage()));
}
@ExceptionHandler(ApplicationVaultRequired.class)
ResponseEntity<CodedApiError> applicationVaultRequired(ApplicationVaultRequired exception) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new CodedApiError("application_vault_required", exception.getMessage()));
}
@ExceptionHandler(ApplicationCreationRateLimited.class)
ResponseEntity<CodedApiError> applicationCreationRateLimited(ApplicationCreationRateLimited exception) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(new CodedApiError("application_creation_rate_limited", exception.getMessage()));
}
@ExceptionHandler(FurnitureInventoryPrivateAreasMissing.class)
ResponseEntity<FurnitureInventoryPrerequisiteError> furnitureInventoryPrivateAreasMissing(
FurnitureInventoryPrivateAreasMissing exception) {
@@ -0,0 +1,81 @@
package fr.nessar.lmnporganizer.rental.adapter.out.mongodb.application;
import fr.nessar.lmnporganizer.rental.adapter.out.mongodb.LandlordScopedMongoWriter;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationPasskey;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVault;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultRepository;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.stereotype.Repository;
@Repository
public class MongoApplicationVaultRepository implements ApplicationVaultRepository {
private final MongoTemplate mongoTemplate;
public MongoApplicationVaultRepository(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}
@Override
public Optional<ApplicationVault> find(LandlordId landlordId) {
return Optional.ofNullable(mongoTemplate.findById(landlordId.value(), ApplicationVaultDocument.class))
.map(MongoApplicationVaultRepository::toDomain);
}
@Override
public ApplicationVault save(ApplicationVault vault) {
ApplicationVaultDocument document = toDocument(vault);
return toDomain(LandlordScopedMongoWriter.saveLandlord(mongoTemplate, document, document.landlordId(),
vault.version(), document::withVersion));
}
private static ApplicationVaultDocument toDocument(ApplicationVault vault) {
return new ApplicationVaultDocument(vault.landlordId().value(), vault.publicKey(),
vault.passkeys().stream().map(MongoApplicationVaultRepository::toDocument).toList(),
persistedVersion(vault.version()));
}
private static ApplicationPasskeyDocument toDocument(ApplicationPasskey passkey) {
return new ApplicationPasskeyDocument(passkey.credentialId(), passkey.label(), passkey.prfSalt(),
passkey.hkdfSalt(), passkey.nonce(), passkey.encryptedPrivateKey(), passkey.cryptoVersion(),
passkey.createdAt(), passkey.lastUsedAt());
}
private static ApplicationVault toDomain(ApplicationVaultDocument document) {
return ApplicationVault.reconstitute(new LandlordId(document.landlordId()), document.publicKey(),
document.passkeys().stream().map(MongoApplicationVaultRepository::toDomain).toList(),
domainVersion(document.version()));
}
private static ApplicationPasskey toDomain(ApplicationPasskeyDocument document) {
return new ApplicationPasskey(document.credentialId(), document.label(), document.prfSalt(),
document.hkdfSalt(), document.nonce(), document.encryptedPrivateKey(), document.cryptoVersion(),
document.createdAt(), document.lastUsedAt());
}
private static Long persistedVersion(long version) {
return version < 0 ? null : version;
}
private static long domainVersion(Long version) {
return version == null ? -1 : version;
}
}
@Document("applicationVaults")
record ApplicationVaultDocument(@Id String landlordId, String publicKey, List<ApplicationPasskeyDocument> passkeys,
@Version Long version) {
ApplicationVaultDocument withVersion(long updatedVersion) {
return new ApplicationVaultDocument(landlordId, publicKey, passkeys, updatedVersion);
}
}
record ApplicationPasskeyDocument(String credentialId, String label, String prfSalt, String hkdfSalt, String nonce,
String encryptedPrivateKey, int cryptoVersion, Instant createdAt, Instant lastUsedAt) {
}
@@ -0,0 +1,68 @@
package fr.nessar.lmnporganizer.rental.adapter.out.mongodb.application;
import fr.nessar.lmnporganizer.rental.adapter.out.mongodb.LandlordScopedMongoWriter;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.application.PublicApplicationLink;
import fr.nessar.lmnporganizer.rental.domain.application.PublicApplicationLinkRepository;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import java.time.Instant;
import java.util.Optional;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.stereotype.Repository;
@Repository
public class MongoPublicApplicationLinkRepository implements PublicApplicationLinkRepository {
private final MongoTemplate mongoTemplate;
public MongoPublicApplicationLinkRepository(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}
@Override
public Optional<PublicApplicationLink> find(ApartmentId apartmentId) {
return Optional.ofNullable(mongoTemplate.findById(apartmentId.value(), PublicApplicationLinkDocument.class))
.map(MongoPublicApplicationLinkRepository::toDomain);
}
@Override
public Optional<PublicApplicationLink> findActiveByToken(String token) {
Query query = Query.query(Criteria.where("token").is(token).and("closedAt").is(null));
return Optional.ofNullable(mongoTemplate.findOne(query, PublicApplicationLinkDocument.class))
.map(MongoPublicApplicationLinkRepository::toDomain);
}
@Override
public PublicApplicationLink save(PublicApplicationLink link) {
PublicApplicationLinkDocument document = toDocument(link);
return toDomain(LandlordScopedMongoWriter.save(mongoTemplate, document, document.apartmentId(),
document.landlordId(), link.version(), document::withVersion));
}
private static PublicApplicationLinkDocument toDocument(PublicApplicationLink link) {
return new PublicApplicationLinkDocument(link.apartmentId().value(), link.landlordId().value(), link.token(),
link.createdAt(), link.closedAt(), link.creationCount(), link.lastCreatedAt(),
link.version() < 0 ? null : link.version());
}
private static PublicApplicationLink toDomain(PublicApplicationLinkDocument document) {
return PublicApplicationLink.reconstitute(new ApartmentId(document.apartmentId()),
new LandlordId(document.landlordId()), document.token(), document.createdAt(), document.closedAt(),
document.creationCount(), document.lastCreatedAt(),
document.version() == null ? -1 : document.version());
}
}
@Document("publicApplicationLinks")
record PublicApplicationLinkDocument(@Id String apartmentId, String landlordId, @Indexed(unique = true) String token,
Instant createdAt, Instant closedAt, long creationCount, Instant lastCreatedAt, @Version Long version) {
PublicApplicationLinkDocument withVersion(long updatedVersion) {
return new PublicApplicationLinkDocument(apartmentId, landlordId, token, createdAt, closedAt, creationCount,
lastCreatedAt, updatedVersion);
}
}
@@ -0,0 +1,63 @@
package fr.nessar.lmnporganizer.rental.adapter.out.mongodb.application;
import fr.nessar.lmnporganizer.rental.adapter.out.mongodb.LandlordScopedMongoWriter;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplication;
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplicationId;
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplicationRepository;
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplicationState;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import java.time.Instant;
import java.util.Optional;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.stereotype.Repository;
@Repository
public class MongoRentalApplicationRepository implements RentalApplicationRepository {
private final MongoTemplate mongoTemplate;
public MongoRentalApplicationRepository(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}
@Override
public Optional<RentalApplication> find(RentalApplicationId id) {
return Optional.ofNullable(mongoTemplate.findById(id.value(), RentalApplicationDocument.class))
.map(MongoRentalApplicationRepository::toDomain);
}
@Override
public RentalApplication save(RentalApplication application) {
RentalApplicationDocument document = toDocument(application);
return toDomain(LandlordScopedMongoWriter.save(mongoTemplate, document, document.id(), document.landlordId(),
application.version(), document::withVersion));
}
private static RentalApplicationDocument toDocument(RentalApplication application) {
return new RentalApplicationDocument(application.id().value(), application.apartmentId().value(),
application.landlordId().value(), application.state(), application.revision(), application.createdAt(),
application.lastActivityAt(), application.capabilityHash(), application.encryptedApplicationKey(),
application.cryptoVersion(), application.version() < 0 ? null : application.version());
}
private static RentalApplication toDomain(RentalApplicationDocument document) {
return RentalApplication.reconstitute(new RentalApplicationId(document.id()),
new ApartmentId(document.apartmentId()), new LandlordId(document.landlordId()), document.state(),
document.revision(), document.createdAt(), document.lastActivityAt(), document.capabilityHash(),
document.encryptedApplicationKey(), document.cryptoVersion(),
document.version() == null ? -1 : document.version());
}
}
@Document("rentalApplications")
record RentalApplicationDocument(@Id String id, String apartmentId, String landlordId, RentalApplicationState state,
int revision, Instant createdAt, Instant lastActivityAt, String capabilityHash, String encryptedApplicationKey,
int cryptoVersion, @Version Long version) {
RentalApplicationDocument withVersion(long updatedVersion) {
return new RentalApplicationDocument(id, apartmentId, landlordId, state, revision, createdAt, lastActivityAt,
capabilityHash, encryptedApplicationKey, cryptoVersion, updatedVersion);
}
}
@@ -0,0 +1,7 @@
package fr.nessar.lmnporganizer.rental.application.rentalapplication;
public final class ApplicationCreationRateLimited extends RuntimeException {
public ApplicationCreationRateLimited() {
super("Trop de candidatures ont été créées depuis ce réseau. Réessayez plus tard.");
}
}
@@ -0,0 +1,46 @@
package fr.nessar.lmnporganizer.rental.application.rentalapplication;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.ConcurrentHashMap;
public final class ApplicationCreationRateLimiter {
private final int maximumAttempts;
private final Duration window;
private final Clock clock;
private final ConcurrentHashMap<Key, Counter> attempts = new ConcurrentHashMap<>();
public ApplicationCreationRateLimiter(int maximumAttempts, Duration window, Clock clock) {
if (maximumAttempts < 1 || window == null || window.isZero() || window.isNegative()) {
throw new IllegalArgumentException("Application creation rate limit must be positive.");
}
this.maximumAttempts = maximumAttempts;
this.window = window;
this.clock = clock;
}
public boolean acquire(String linkToken, String remoteAddress) {
Instant now = Instant.now(clock);
attempts.entrySet().removeIf(entry -> !now.isBefore(entry.getValue().startedAt().plus(window)));
Counter counter = attempts.compute(new Key(linkToken, remoteAddress),
(key, current) -> current == null || !now.isBefore(current.startedAt().plus(window))
? new Counter(now, 1)
: current.increment());
return counter.count() <= maximumAttempts;
}
private record Key(String linkToken, String remoteAddress) {
private Key {
if (linkToken == null || linkToken.isBlank() || remoteAddress == null || remoteAddress.isBlank()) {
throw new IllegalArgumentException("Rate-limit key is required.");
}
}
}
private record Counter(Instant startedAt, int count) {
Counter increment() {
return new Counter(startedAt, count + 1);
}
}
}
@@ -0,0 +1,180 @@
package fr.nessar.lmnporganizer.rental.application.rentalapplication;
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.apartment.Apartment;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentRepository;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationPasskey;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVault;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultRepository;
import fr.nessar.lmnporganizer.rental.domain.application.PublicApplicationLink;
import fr.nessar.lmnporganizer.rental.domain.application.PublicApplicationLinkRepository;
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplication;
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplicationId;
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplicationRepository;
import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordRepository;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Instant;
import java.util.Base64;
public final class ApplicationLinkService {
private static final String CAPABILITY_SCHEME = "Application ";
private final ApartmentRepository apartments;
private final LandlordRepository landlords;
private final ApplicationVaultRepository vaults;
private final PublicApplicationLinkRepository links;
private final RentalApplicationRepository applications;
private final TransactionRunner transactions;
private final IdentifierGenerator identifiers;
private final Clock clock;
private final ApplicationCreationRateLimiter rateLimiter;
public ApplicationLinkService(ApartmentRepository apartments, LandlordRepository landlords,
ApplicationVaultRepository vaults, PublicApplicationLinkRepository links,
RentalApplicationRepository applications, TransactionRunner transactions, IdentifierGenerator identifiers,
Clock clock, ApplicationCreationRateLimiter rateLimiter) {
this.apartments = apartments;
this.landlords = landlords;
this.vaults = vaults;
this.links = links;
this.applications = applications;
this.transactions = transactions;
this.identifiers = identifiers;
this.clock = clock;
this.rateLimiter = rateLimiter;
}
public LinkStatus status(LandlordId landlordId, ApartmentId apartmentId) {
apartment(landlordId, apartmentId);
ApplicationVault vault = vaults.find(landlordId).orElse(null);
return new LinkStatus(vault != null, vault == null ? 0 : vault.passkeys().size(),
links.find(apartmentId).filter(PublicApplicationLink::active).orElse(null));
}
public LinkStatus publish(LandlordId landlordId, ApartmentId apartmentId, VaultActivation activation) {
return transactions.run(() -> {
apartment(landlordId, apartmentId);
ApplicationVault vault = vaults.find(landlordId).orElseGet(() -> activate(landlordId, activation));
PublicApplicationLink active = links.find(apartmentId).filter(PublicApplicationLink::active).orElse(null);
if (active != null) {
return new LinkStatus(true, vault.passkeys().size(), active);
}
PublicApplicationLink published = PublicApplicationLink.publish(apartmentId, landlordId, identifiers.next(),
Instant.now(clock));
links.save(published);
return new LinkStatus(true, vault.passkeys().size(), published);
});
}
public PublicLinkView preview(String token) {
PublicApplicationLink link = activeLink(token);
ApplicationVault vault = vaults.find(link.landlordId())
.orElseThrow(() -> new ResourceNotFound("Application link"));
Landlord landlord = landlords.find(link.landlordId()).orElseThrow(() -> new ResourceNotFound("Landlord"));
Apartment apartment = apartment(link.landlordId(), link.apartmentId());
return view(link, vault, landlord, apartment);
}
public RentalApplication createDraft(String token, String authorization, EncryptedApplicationKey encryptedKey,
String remoteAddress) {
activeLink(token);
if (!rateLimiter.acquire(token, remoteAddress)) {
throw new ApplicationCreationRateLimited();
}
String capabilityHash = capabilityHash(authorization);
return transactions.run(() -> {
PublicApplicationLink link = activeLink(token);
vaults.find(link.landlordId()).orElseThrow(() -> new ResourceNotFound("Application link"));
if (encryptedKey == null) {
throw new IllegalArgumentException("Encrypted application key is required.");
}
RentalApplication draft = RentalApplication.draft(new RentalApplicationId(identifiers.next()),
link.apartmentId(), link.landlordId(), Instant.now(clock), capabilityHash,
encryptedKey.ciphertext(), encryptedKey.cryptoVersion());
link.recordDraftCreation(Instant.now(clock));
links.save(link);
return applications.save(draft);
});
}
public CandidateApplicationView resume(RentalApplicationId id, String authorization) {
RentalApplication application = applications.find(id).orElseThrow(() -> new ResourceNotFound("Application"));
if (!application.authorizes(capabilityHash(authorization))) {
throw new ResourceNotFound("Application");
}
Landlord landlord = landlords.find(application.landlordId())
.orElseThrow(() -> new ResourceNotFound("Landlord"));
Apartment apartment = apartment(application.landlordId(), application.apartmentId());
return new CandidateApplicationView(application, landlord.firstName() + " " + landlord.lastName(),
landlord.email(), apartment.name());
}
private ApplicationVault activate(LandlordId landlordId, VaultActivation activation) {
if (activation == null) {
throw new ApplicationVaultRequired();
}
ApplicationPasskey passkey = new ApplicationPasskey(activation.credentialId(), activation.label(),
activation.prfSalt(), activation.hkdfSalt(), activation.nonce(), activation.encryptedPrivateKey(),
activation.cryptoVersion(), Instant.now(clock), null);
return vaults.save(ApplicationVault.activate(landlordId, activation.publicKey(), passkey));
}
private PublicApplicationLink activeLink(String token) {
if (token == null || token.isBlank()) {
throw new ResourceNotFound("Application link");
}
return links.findActiveByToken(token).orElseThrow(() -> new ResourceNotFound("Application link"));
}
private Apartment apartment(LandlordId landlordId, ApartmentId apartmentId) {
return apartments.find(landlordId, apartmentId).orElseThrow(() -> new ResourceNotFound("Apartment"));
}
private static PublicLinkView view(PublicApplicationLink link, ApplicationVault vault, Landlord landlord,
Apartment apartment) {
return new PublicLinkView(link.token(), link.createdAt(), vault.publicKey(),
landlord.firstName() + " " + landlord.lastName(), landlord.email(), apartment.name());
}
private static String capabilityHash(String authorization) {
if (authorization == null || !authorization.startsWith(CAPABILITY_SCHEME)) {
throw new ResourceNotFound("Application");
}
String capability = authorization.substring(CAPABILITY_SCHEME.length());
try {
byte[] decoded = Base64.getUrlDecoder().decode(capability);
if (decoded.length != 32) {
throw new ResourceNotFound("Application");
}
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(MessageDigest.getInstance("SHA-256").digest(decoded));
} catch (IllegalArgumentException | NoSuchAlgorithmException failure) {
throw new ResourceNotFound("Application");
}
}
public record LinkStatus(boolean vaultActive, int passkeyCount, PublicApplicationLink activeLink) {
}
public record VaultActivation(String publicKey, String credentialId, String label, String prfSalt, String hkdfSalt,
String nonce, String encryptedPrivateKey, int cryptoVersion) {
}
public record EncryptedApplicationKey(int cryptoVersion, String ciphertext) {
}
public record PublicLinkView(String token, Instant createdAt, String landlordPublicKey, String landlordName,
String landlordEmail, String apartmentName) {
}
public record CandidateApplicationView(RentalApplication application, String landlordName, String landlordEmail,
String apartmentName) {
}
}
@@ -0,0 +1,7 @@
package fr.nessar.lmnporganizer.rental.application.rentalapplication;
public final class ApplicationVaultRequired extends RuntimeException {
public ApplicationVaultRequired() {
super("Activez le coffre de candidatures avant de publier ce lien.");
}
}
@@ -0,0 +1,31 @@
package fr.nessar.lmnporganizer.rental.domain.application;
import java.util.Base64;
final class ApplicationCryptoValue {
private ApplicationCryptoValue() {
}
static String encoded(String value, String field, int minimumBytes, int maximumBytes) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " is required.");
}
int length;
try {
length = Base64.getUrlDecoder().decode(value).length;
} catch (IllegalArgumentException failure) {
throw new IllegalArgumentException(field + " must use base64url encoding.", failure);
}
if (length < minimumBytes || length > maximumBytes) {
throw new IllegalArgumentException(field + " has an invalid length.");
}
return value;
}
static int version(int version) {
if (version != 1) {
throw new IllegalArgumentException("Unsupported application cryptographic format.");
}
return version;
}
}
@@ -0,0 +1,22 @@
package fr.nessar.lmnporganizer.rental.domain.application;
import java.time.Instant;
public record ApplicationPasskey(String credentialId, String label, String prfSalt, String hkdfSalt, String nonce,
String encryptedPrivateKey, int cryptoVersion, Instant createdAt, Instant lastUsedAt) {
public ApplicationPasskey {
credentialId = ApplicationCryptoValue.encoded(credentialId, "Credential ID", 1, 1024);
if (label == null || label.isBlank() || label.length() > 100) {
throw new IllegalArgumentException("Passkey label is required and limited to 100 characters.");
}
label = label.trim();
prfSalt = ApplicationCryptoValue.encoded(prfSalt, "PRF salt", 32, 32);
hkdfSalt = ApplicationCryptoValue.encoded(hkdfSalt, "HKDF salt", 32, 32);
nonce = ApplicationCryptoValue.encoded(nonce, "Private-key envelope nonce", 12, 12);
encryptedPrivateKey = ApplicationCryptoValue.encoded(encryptedPrivateKey, "Encrypted private key", 512, 8192);
cryptoVersion = ApplicationCryptoValue.version(cryptoVersion);
if (createdAt == null) {
throw new IllegalArgumentException("Passkey creation time is required.");
}
}
}
@@ -0,0 +1,53 @@
package fr.nessar.lmnporganizer.rental.domain.application;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import java.util.List;
import java.util.Objects;
public final class ApplicationVault {
private final LandlordId landlordId;
private final String publicKey;
private final List<ApplicationPasskey> passkeys;
private final long version;
private ApplicationVault(LandlordId landlordId, String publicKey, List<ApplicationPasskey> passkeys, long version) {
this.landlordId = Objects.requireNonNull(landlordId, "Landlord ID is required.");
this.publicKey = ApplicationCryptoValue.encoded(publicKey, "Landlord public key", 384, 1024);
this.passkeys = List.copyOf(Objects.requireNonNull(passkeys, "Application passkeys are required."));
if (this.passkeys.isEmpty()) {
throw new IllegalArgumentException("An application vault requires a passkey.");
}
if (this.passkeys.stream().map(ApplicationPasskey::credentialId).distinct().count() != this.passkeys.size()) {
throw new IllegalArgumentException("Application passkey credential IDs must be unique.");
}
if (version < -1) {
throw new IllegalArgumentException("Application vault version cannot be less than -1.");
}
this.version = version;
}
public static ApplicationVault activate(LandlordId landlordId, String publicKey, ApplicationPasskey passkey) {
return new ApplicationVault(landlordId, publicKey, List.of(passkey), -1);
}
public static ApplicationVault reconstitute(LandlordId landlordId, String publicKey,
List<ApplicationPasskey> passkeys, long version) {
return new ApplicationVault(landlordId, publicKey, passkeys, version);
}
public LandlordId landlordId() {
return landlordId;
}
public String publicKey() {
return publicKey;
}
public List<ApplicationPasskey> passkeys() {
return passkeys;
}
public long version() {
return version;
}
}
@@ -0,0 +1,10 @@
package fr.nessar.lmnporganizer.rental.domain.application;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import java.util.Optional;
public interface ApplicationVaultRepository {
Optional<ApplicationVault> find(LandlordId landlordId);
ApplicationVault save(ApplicationVault vault);
}
@@ -0,0 +1,90 @@
package fr.nessar.lmnporganizer.rental.domain.application;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import java.time.Instant;
import java.util.Objects;
public final class PublicApplicationLink {
private final ApartmentId apartmentId;
private final LandlordId landlordId;
private final String token;
private final Instant createdAt;
private final Instant closedAt;
private final long version;
private long creationCount;
private Instant lastCreatedAt;
private PublicApplicationLink(ApartmentId apartmentId, LandlordId landlordId, String token, Instant createdAt,
Instant closedAt, long creationCount, Instant lastCreatedAt, long version) {
this.apartmentId = Objects.requireNonNull(apartmentId, "Apartment ID is required.");
this.landlordId = Objects.requireNonNull(landlordId, "Landlord ID is required.");
if (token == null || token.isBlank()) {
throw new IllegalArgumentException("Public application token is required.");
}
this.token = token;
this.createdAt = Objects.requireNonNull(createdAt, "Public application link creation time is required.");
this.closedAt = closedAt;
if (creationCount < 0 || version < -1) {
throw new IllegalArgumentException("Public application link counters are invalid.");
}
this.creationCount = creationCount;
this.lastCreatedAt = lastCreatedAt;
this.version = version;
}
public static PublicApplicationLink publish(ApartmentId apartmentId, LandlordId landlordId, String token,
Instant createdAt) {
return new PublicApplicationLink(apartmentId, landlordId, token, createdAt, null, 0, null, -1);
}
public static PublicApplicationLink reconstitute(ApartmentId apartmentId, LandlordId landlordId, String token,
Instant createdAt, Instant closedAt, long creationCount, Instant lastCreatedAt, long version) {
return new PublicApplicationLink(apartmentId, landlordId, token, createdAt, closedAt, creationCount,
lastCreatedAt, version);
}
public void recordDraftCreation(Instant createdAt) {
if (!active()) {
throw new IllegalStateException("Public application link is closed.");
}
creationCount++;
lastCreatedAt = Objects.requireNonNull(createdAt);
}
public boolean active() {
return closedAt == null;
}
public ApartmentId apartmentId() {
return apartmentId;
}
public LandlordId landlordId() {
return landlordId;
}
public String token() {
return token;
}
public Instant createdAt() {
return createdAt;
}
public Instant closedAt() {
return closedAt;
}
public long creationCount() {
return creationCount;
}
public Instant lastCreatedAt() {
return lastCreatedAt;
}
public long version() {
return version;
}
}
@@ -0,0 +1,12 @@
package fr.nessar.lmnporganizer.rental.domain.application;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import java.util.Optional;
public interface PublicApplicationLinkRepository {
Optional<PublicApplicationLink> find(ApartmentId apartmentId);
Optional<PublicApplicationLink> findActiveByToken(String token);
PublicApplicationLink save(PublicApplicationLink link);
}
@@ -0,0 +1,104 @@
package fr.nessar.lmnporganizer.rental.domain.application;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Objects;
public final class RentalApplication {
private final RentalApplicationId id;
private final ApartmentId apartmentId;
private final LandlordId landlordId;
private final RentalApplicationState state;
private final int revision;
private final Instant createdAt;
private final Instant lastActivityAt;
private final String capabilityHash;
private final String encryptedApplicationKey;
private final int cryptoVersion;
private final long version;
private RentalApplication(RentalApplicationId id, ApartmentId apartmentId, LandlordId landlordId,
RentalApplicationState state, int revision, Instant createdAt, Instant lastActivityAt,
String capabilityHash, String encryptedApplicationKey, int cryptoVersion, long version) {
this.id = Objects.requireNonNull(id, "Rental application ID is required.");
this.apartmentId = Objects.requireNonNull(apartmentId, "Apartment ID is required.");
this.landlordId = Objects.requireNonNull(landlordId, "Landlord ID is required.");
this.state = Objects.requireNonNull(state, "Rental application state is required.");
if (revision < 0 || version < -1) {
throw new IllegalArgumentException("Rental application revisions are invalid.");
}
this.revision = revision;
this.createdAt = Objects.requireNonNull(createdAt, "Rental application creation time is required.");
this.lastActivityAt = Objects.requireNonNull(lastActivityAt, "Rental application activity time is required.");
this.capabilityHash = ApplicationCryptoValue.encoded(capabilityHash, "Capability hash", 32, 32);
this.encryptedApplicationKey = ApplicationCryptoValue.encoded(encryptedApplicationKey,
"Encrypted application key", 384, 384);
this.cryptoVersion = ApplicationCryptoValue.version(cryptoVersion);
this.version = version;
}
public static RentalApplication draft(RentalApplicationId id, ApartmentId apartmentId, LandlordId landlordId,
Instant createdAt, String capabilityHash, String encryptedApplicationKey, int cryptoVersion) {
return new RentalApplication(id, apartmentId, landlordId, RentalApplicationState.DRAFT, 0, createdAt, createdAt,
capabilityHash, encryptedApplicationKey, cryptoVersion, -1);
}
public static RentalApplication reconstitute(RentalApplicationId id, ApartmentId apartmentId, LandlordId landlordId,
RentalApplicationState state, int revision, Instant createdAt, Instant lastActivityAt,
String capabilityHash, String encryptedApplicationKey, int cryptoVersion, long version) {
return new RentalApplication(id, apartmentId, landlordId, state, revision, createdAt, lastActivityAt,
capabilityHash, encryptedApplicationKey, cryptoVersion, version);
}
public boolean authorizes(String presentedHash) {
return presentedHash != null && MessageDigest.isEqual(capabilityHash.getBytes(StandardCharsets.US_ASCII),
presentedHash.getBytes(StandardCharsets.US_ASCII));
}
public RentalApplicationId id() {
return id;
}
public ApartmentId apartmentId() {
return apartmentId;
}
public LandlordId landlordId() {
return landlordId;
}
public RentalApplicationState state() {
return state;
}
public int revision() {
return revision;
}
public Instant createdAt() {
return createdAt;
}
public Instant lastActivityAt() {
return lastActivityAt;
}
public String capabilityHash() {
return capabilityHash;
}
public String encryptedApplicationKey() {
return encryptedApplicationKey;
}
public int cryptoVersion() {
return cryptoVersion;
}
public long version() {
return version;
}
}
@@ -0,0 +1,10 @@
package fr.nessar.lmnporganizer.rental.domain.application;
public record RentalApplicationId(String value) {
public RentalApplicationId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Rental application ID is required.");
}
value = value.trim();
}
}
@@ -0,0 +1,9 @@
package fr.nessar.lmnporganizer.rental.domain.application;
import java.util.Optional;
public interface RentalApplicationRepository {
Optional<RentalApplication> find(RentalApplicationId id);
RentalApplication save(RentalApplication application);
}
@@ -0,0 +1,5 @@
package fr.nessar.lmnporganizer.rental.domain.application;
public enum RentalApplicationState {
DRAFT, SUBMITTED, SELECTED, REJECTED, WITHDRAWN, EXPIRED
}
@@ -15,6 +15,8 @@ lmnp.condition-photos.timeout=${LMNP_CONDITION_PHOTO_TIMEOUT:20s}
lmnp.insee-irl.base-uri=${INSEE_IRL_BASE_URI:https://bdm.insee.fr/series/sdmx/data/SERIES_BDM/}
lmnp.insee-irl.request-timeout=${INSEE_IRL_REQUEST_TIMEOUT:5s}
lmnp.e2e.clock.fixed-instant=${E2E_FIXED_INSTANT:2027-09-02T12:00:00Z}
lmnp.applications.creation-rate-limit.maximum-attempts=${APPLICATION_CREATION_RATE_LIMIT:10}
lmnp.applications.creation-rate-limit.window=${APPLICATION_CREATION_RATE_WINDOW:1h}
# OAuth2 Client registration
spring.security.oauth2.client.registration.oidc.client-id=${OIDC_CLIENT_ID:lmnp-client}
spring.security.oauth2.client.registration.oidc.client-secret=${OIDC_CLIENT_SECRET}
@@ -0,0 +1,148 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.application;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import fr.nessar.lmnporganizer.LmnpOrganizerApplication;
import fr.nessar.lmnporganizer.identity.adapter.in.security.SecurityConfiguration;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.RentalApiExceptionHandler;
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationCreationRateLimited;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationLinkService;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.application.PublicApplicationLink;
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplication;
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplicationId;
import fr.nessar.lmnporganizer.rental.domain.landlord.ExternalIdentity;
import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import java.time.Instant;
import java.util.Arrays;
import java.util.Base64;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest(controllers = {LandlordApplicationLinkController.class, PublicApplicationController.class,
CandidateApplicationController.class})
@ContextConfiguration(classes = LmnpOrganizerApplication.class)
@Import({AuthenticatedLandlord.class, RentalApiExceptionHandler.class, SecurityConfiguration.class,
ApplicationSecurityHeadersFilter.class})
class ApplicationWebTest {
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
private static final ApartmentId APARTMENT_ID = new ApartmentId("apartment-1");
private static final Instant NOW = Instant.parse("2026-09-04T10:00:00Z");
@Autowired
MockMvc mockMvc;
@MockitoBean
ApplicationLinkService links;
@MockitoBean
LandlordAccountService accounts;
@BeforeEach
void identifiesTheAuthenticatedLandlord() {
when(accounts.identify(any()))
.thenReturn(Landlord.register(LANDLORD_ID, new ExternalIdentity("https://issuer.example", "subject-1"),
"Camille", "Martin", "camille@example.com"));
}
@Test
void publicPreviewIsAnonymousEffectlessAndNeverCacheable() throws Exception {
when(links.preview("public-token")).thenReturn(new ApplicationLinkService.PublicLinkView("public-token", NOW,
encoded(550, 1), "Camille Martin", "camille@example.com", "Studio"));
mockMvc.perform(get("/api/public/application-links/public-token")).andExpect(status().isOk())
.andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store"))
.andExpect(header().string("Referrer-Policy", "no-referrer"))
.andExpect(jsonPath("$.apartmentName").value("Studio"));
verify(links).preview("public-token");
verifyNoMoreInteractions(links);
}
@Test
void anonymousClientCreatesADraftWithoutCsrfAndPassesTheCapabilityOnlyAsAuthorization() throws Exception {
RentalApplication draft = RentalApplication.draft(new RentalApplicationId("application-1"), APARTMENT_ID,
LANDLORD_ID, NOW, encoded(32, 2), encoded(384, 3), 1);
when(links.createDraft(eq("public-token"), eq("Application candidate-capability"), any(), any()))
.thenReturn(draft);
mockMvc.perform(post("/api/public/application-links/public-token/applications")
.header(HttpHeaders.AUTHORIZATION, "Application candidate-capability")
.contentType(MediaType.APPLICATION_JSON).content("""
{"encryptedApplicationKey":{"cryptoVersion":1,"ciphertext":"encrypted-key"}}
""")).andExpect(status().isCreated())
.andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store"))
.andExpect(jsonPath("$.id").value("application-1")).andExpect(jsonPath("$.state").value("DRAFT"));
verify(links).createDraft(eq("public-token"), eq("Application candidate-capability"), any(), any());
}
@Test
void candidateCapabilityRouteIsAnonymousAndReturnsNoDistinguishingFailure() throws Exception {
when(links.resume(new RentalApplicationId("unknown"), "Application invalid"))
.thenThrow(new ResourceNotFound("Application"));
mockMvc.perform(
get("/api/candidate/applications/unknown").header(HttpHeaders.AUTHORIZATION, "Application invalid"))
.andExpect(status().isNotFound()).andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store"))
.andExpect(jsonPath("$.code").value("resource_not_found"));
}
@Test
void landlordLinkRouteRequiresOidcAndUsesTheAuthenticatedPortfolioOwner() throws Exception {
when(links.status(LANDLORD_ID, APARTMENT_ID)).thenReturn(new ApplicationLinkService.LinkStatus(true, 1,
PublicApplicationLink.publish(APARTMENT_ID, LANDLORD_ID, "public-token", NOW)));
mockMvc.perform(get("/api/application-links/apartments/apartment-1")).andExpect(status().isUnauthorized());
mockMvc.perform(get("/api/application-links/apartments/apartment-1")
.with(oidcLogin().idToken(token -> token.issuer("https://issuer.example").claim("sub", "subject-1"))))
.andExpect(status().isOk()).andExpect(jsonPath("$.activeLink.token").value("public-token"));
verify(links).status(LANDLORD_ID, APARTMENT_ID);
}
@Test
void creationRateLimitUsesTheStableApiErrorContract() throws Exception {
when(links.createDraft(any(), any(), any(), any())).thenThrow(new ApplicationCreationRateLimited());
mockMvc.perform(post("/api/public/application-links/public-token/applications")
.header(HttpHeaders.AUTHORIZATION, "Application candidate-capability")
.contentType(MediaType.APPLICATION_JSON).content("""
{"encryptedApplicationKey":{"cryptoVersion":1,"ciphertext":"encrypted-key"}}
""")).andExpect(status().isTooManyRequests())
.andExpect(jsonPath("$.code").value("application_creation_rate_limited"));
}
@Test
void landlordPublicationStillRequiresCsrf() throws Exception {
mockMvc.perform(post("/api/application-links/apartments/apartment-1")
.with(oidcLogin().idToken(token -> token.issuer("https://issuer.example"))))
.andExpect(status().isForbidden());
}
private static String encoded(int length, int value) {
byte[] bytes = new byte[length];
Arrays.fill(bytes, (byte) value);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
}
@@ -0,0 +1,26 @@
package fr.nessar.lmnporganizer.rental.application.rentalapplication;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import org.junit.jupiter.api.Test;
class ApplicationCreationRateLimiterTest {
@Test
void forgetsTheAddressWhenTheWindowExpires() {
Instant now = Instant.parse("2026-09-04T10:00:00Z");
Clock clock = mock(Clock.class);
when(clock.instant()).thenReturn(now, now, now.plus(Duration.ofHours(1)));
ApplicationCreationRateLimiter limiter = new ApplicationCreationRateLimiter(1, Duration.ofHours(1), clock);
assertTrue(limiter.acquire("link", "203.0.113.1"));
assertFalse(limiter.acquire("link", "203.0.113.1"));
assertTrue(limiter.acquire("link", "203.0.113.1"));
}
}
@@ -0,0 +1,237 @@
package fr.nessar.lmnporganizer.rental.application.rentalapplication;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.apartment.Apartment;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentRepository;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationPasskey;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVault;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultRepository;
import fr.nessar.lmnporganizer.rental.domain.application.PublicApplicationLink;
import fr.nessar.lmnporganizer.rental.domain.application.PublicApplicationLinkRepository;
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplication;
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplicationId;
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplicationRepository;
import fr.nessar.lmnporganizer.rental.domain.landlord.ExternalIdentity;
import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordRepository;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.ArrayDeque;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
class ApplicationLinkServiceTest {
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
private static final ApartmentId APARTMENT_ID = new ApartmentId("apartment-1");
private static final Instant NOW = Instant.parse("2026-09-04T10:00:00Z");
private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);
private static final String CAPABILITY = encoded(32, 7);
@Test
void firstPublicationAtomicallyActivatesTheVaultAndCreatesOneLink() {
Fixture fixture = fixture("public-token");
when(fixture.vaults.find(LANDLORD_ID)).thenReturn(Optional.empty());
when(fixture.links.find(APARTMENT_ID)).thenReturn(Optional.empty());
ApplicationLinkService.LinkStatus published = fixture.service.publish(LANDLORD_ID, APARTMENT_ID, activation());
assertEquals("public-token", published.activeLink().token());
assertEquals(true, published.vaultActive());
verify(fixture.vaults).save(any(ApplicationVault.class));
verify(fixture.links).save(any(PublicApplicationLink.class));
}
@Test
void publicationIsLimitedToTheAuthenticatedLandlordsApartment() {
Fixture fixture = fixture("unused");
assertThrows(ResourceNotFound.class,
() -> fixture.service.publish(new LandlordId("landlord-2"), APARTMENT_ID, activation()));
verifyNoInteractions(fixture.vaults, fixture.links);
}
@Test
void publicationRequiresActivationOnlyWhenTheLandlordHasNoVault() {
Fixture missing = fixture("unused");
when(missing.vaults.find(LANDLORD_ID)).thenReturn(Optional.empty());
when(missing.links.find(APARTMENT_ID)).thenReturn(Optional.empty());
assertThrows(ApplicationVaultRequired.class, () -> missing.service.publish(LANDLORD_ID, APARTMENT_ID, null));
verify(missing.links, never()).save(any());
Fixture existing = fixture("public-token");
when(existing.vaults.find(LANDLORD_ID)).thenReturn(Optional.of(vault()));
when(existing.links.find(APARTMENT_ID)).thenReturn(Optional.empty());
assertEquals("public-token", existing.service.publish(LANDLORD_ID, APARTMENT_ID, null).activeLink().token());
verify(existing.vaults, never()).save(any());
}
@Test
void publicPreviewIsReadOnlyAndCreationStoresOnlyACapabilityHash() {
Fixture fixture = fixture("application-1");
PublicApplicationLink link = link();
when(fixture.links.findActiveByToken("public-token")).thenReturn(Optional.of(link));
when(fixture.vaults.find(LANDLORD_ID)).thenReturn(Optional.of(vault()));
ApplicationLinkService.PublicLinkView preview = fixture.service.preview("public-token");
assertEquals("Studio", preview.apartmentName());
verify(fixture.applications, never()).save(any());
verify(fixture.links, never()).save(any());
RentalApplication created = fixture.service.createDraft("public-token", "Application " + CAPABILITY,
new ApplicationLinkService.EncryptedApplicationKey(1, encoded(384, 11)), "203.0.113.4");
ArgumentCaptor<RentalApplication> saved = ArgumentCaptor.forClass(RentalApplication.class);
verify(fixture.applications).save(saved.capture());
assertEquals(new RentalApplicationId("application-1"), created.id());
assertNotEquals(CAPABILITY, saved.getValue().capabilityHash());
assertEquals(created.id(),
fixture.service.resume(created.id(), "Application " + CAPABILITY).application().id());
assertThrows(ResourceNotFound.class,
() -> fixture.service.resume(created.id(), "Application " + encoded(32, 8)));
}
@Test
void anEmptyDraftIsNotReadWhileTheLandlordConsultsTheLink() {
Fixture fixture = fixture("unused");
when(fixture.vaults.find(LANDLORD_ID)).thenReturn(Optional.of(vault()));
when(fixture.links.find(APARTMENT_ID)).thenReturn(Optional.of(link()));
fixture.service.status(LANDLORD_ID, APARTMENT_ID);
verifyNoInteractions(fixture.applications);
}
@Test
void creationIsLimitedByPublicLinkAndRemoteAddress() {
Fixture fixture = fixture("application-1", 1);
when(fixture.links.findActiveByToken("public-token")).thenReturn(Optional.of(link()));
when(fixture.vaults.find(LANDLORD_ID)).thenReturn(Optional.of(vault()));
fixture.service.createDraft("public-token", "Application " + CAPABILITY,
new ApplicationLinkService.EncryptedApplicationKey(1, encoded(384, 11)), "203.0.113.4");
assertThrows(ApplicationCreationRateLimited.class,
() -> fixture.service.createDraft("public-token", "Application " + CAPABILITY,
new ApplicationLinkService.EncryptedApplicationKey(1, encoded(384, 11)), "203.0.113.4"));
}
@Test
void unknownLinksDoNotConsumeRateLimitCapacity() {
Fixture fixture = fixture("application-1", 1);
when(fixture.links.findActiveByToken("public-token")).thenReturn(Optional.empty());
assertThrows(ResourceNotFound.class,
() -> fixture.service.createDraft("public-token", "Application " + CAPABILITY,
new ApplicationLinkService.EncryptedApplicationKey(1, encoded(384, 11)), "203.0.113.4"));
when(fixture.links.findActiveByToken("public-token")).thenReturn(Optional.of(link()));
when(fixture.vaults.find(LANDLORD_ID)).thenReturn(Optional.of(vault()));
fixture.service.createDraft("public-token", "Application " + CAPABILITY,
new ApplicationLinkService.EncryptedApplicationKey(1, encoded(384, 11)), "203.0.113.4");
}
private static Fixture fixture(String identifier) {
return fixture(identifier, 5);
}
private static Fixture fixture(String identifier, int limit) {
ApartmentRepository apartments = mock(ApartmentRepository.class);
LandlordRepository landlords = mock(LandlordRepository.class);
ApplicationVaultRepository vaults = mock(ApplicationVaultRepository.class);
PublicApplicationLinkRepository links = mock(PublicApplicationLinkRepository.class);
RentalApplicationRepository applications = mock(RentalApplicationRepository.class);
when(apartments.find(LANDLORD_ID, APARTMENT_ID)).thenReturn(Optional.of(apartment()));
when(landlords.find(LANDLORD_ID)).thenReturn(Optional.of(landlord()));
when(vaults.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(links.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
AtomicReference<RentalApplication> storedApplication = new AtomicReference<>();
when(applications.save(any())).thenAnswer(invocation -> {
RentalApplication saved = invocation.getArgument(0);
storedApplication.set(saved);
return saved;
});
when(applications.find(any())).thenAnswer(invocation -> {
RentalApplication stored = storedApplication.get();
RentalApplicationId requested = invocation.getArgument(0);
return stored != null && stored.id().equals(requested) ? Optional.of(stored) : Optional.empty();
});
IdentifierGenerator identifiers = queued(identifier);
TransactionRunner transactions = new TransactionRunner() {
@Override
public <T> T run(Supplier<T> operation) {
return operation.get();
}
};
ApplicationCreationRateLimiter limiter = new ApplicationCreationRateLimiter(limit, Duration.ofHours(1), CLOCK);
ApplicationLinkService service = new ApplicationLinkService(apartments, landlords, vaults, links, applications,
transactions, identifiers, CLOCK, limiter);
return new Fixture(vaults, links, applications, service);
}
private static IdentifierGenerator queued(String... values) {
ArrayDeque<String> queue = new ArrayDeque<>(List.of(values));
return queue::remove;
}
private static ApplicationLinkService.VaultActivation activation() {
return new ApplicationLinkService.VaultActivation(encoded(550, 1), encoded(32, 6), "Passkey principale",
encoded(32, 2), encoded(32, 3), encoded(12, 4), encoded(2500, 5), 1);
}
private static ApplicationVault vault() {
ApplicationPasskey passkey = new ApplicationPasskey(encoded(32, 6), "Passkey principale", encoded(32, 2),
encoded(32, 3), encoded(12, 4), encoded(2500, 5), 1, NOW, null);
return ApplicationVault.activate(LANDLORD_ID, encoded(550, 1), passkey);
}
private static PublicApplicationLink link() {
return PublicApplicationLink.publish(APARTMENT_ID, LANDLORD_ID, "public-token", NOW);
}
private static Apartment apartment() {
return new Apartment(APARTMENT_ID, LANDLORD_ID, "Studio", "2 rue B", new BigDecimal("120000"),
LocalDate.of(2024, 6, 15), LocalDate.of(2026, 8, 11), "", ApartmentLegalProfile.empty(), List.of());
}
private static Landlord landlord() {
Landlord landlord = Landlord.register(LANDLORD_ID, new ExternalIdentity("issuer", "subject"), "Camille",
"Martin", "camille@example.com");
landlord.updateProfile("Camille", "Martin", "camille@example.com", "06 12 34 56 78", "1 rue A");
return landlord;
}
private static String encoded(int length, int value) {
byte[] bytes = new byte[length];
Arrays.fill(bytes, (byte) value);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
private record Fixture(ApplicationVaultRepository vaults, PublicApplicationLinkRepository links,
RentalApplicationRepository applications, ApplicationLinkService service) {
}
}
+9 -1
View File
@@ -12,13 +12,21 @@ server {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
add_header Referrer-Policy "no-referrer" always;
}
location ^~ /candidatures/ {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-store" always;
add_header Referrer-Policy "no-referrer" always;
}
location /api {
proxy_pass http://backend:8080;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
}
+27 -1
View File
@@ -1,4 +1,30 @@
@if (
@if (publicExperience()) {
<div class="public-shell">
<header class="public-header">
<app-brand-mark class="brand-mark" />
<div>
<strong>LMNP Organizer</strong>
<span>Espace candidat sécurisé</span>
</div>
</header>
<main class="public-main">
@if (routeLoading()) {
<mat-progress-bar
class="route-progress"
mode="indeterminate"
aria-label="Chargement de la page"
/>
}
@if (routeLoadError()) {
<div class="route-load-error" role="alert">
<span>{{ routeLoadError() }}</span>
<button mat-button type="button" (click)="retryRouteLoad()">Réessayer</button>
</div>
}
<router-outlet />
</main>
</div>
} @else if (
session.status() === 'checking' ||
session.status() === 'signing-out' ||
(session.isLoggedIn() && profileCheckStatus() === 'checking')
+14
View File
@@ -2,6 +2,20 @@ import { Routes } from '@angular/router';
import { administratorGuard } from './services/administrator.guard';
export const routes: Routes = [
{
path: 'candidatures/postuler/:token',
loadComponent: () =>
import('./components/public-application-page/public-application-page').then(
({ PublicApplicationPage }) => PublicApplicationPage,
),
},
{
path: 'candidatures/reprendre',
loadComponent: () =>
import('./components/candidate-application-page/candidate-application-page').then(
({ CandidateApplicationPage }) => CandidateApplicationPage,
),
},
{
path: 'dashboard',
loadComponent: () =>
+33
View File
@@ -51,6 +51,39 @@
background: var(--canvas);
}
.public-shell {
min-height: 100vh;
padding: tokens.space(4);
background: var(--canvas);
}
.public-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: tokens.space(3);
width: min(100%, 48rem);
margin-inline: auto;
padding-block: tokens.space(2) tokens.space(6);
> div {
display: grid;
min-width: 0;
}
span {
color: var(--text-muted);
font-size: 0.8rem;
}
}
.public-main {
container-type: inline-size;
width: min(100%, 48rem);
margin-inline: auto;
padding-block-end: tokens.space(8);
}
.workspace-main {
min-width: 0;
min-height: 100vh;
+11
View File
@@ -41,6 +41,7 @@ describe('App', () => {
const dialog = { open: vi.fn() };
beforeEach(() => {
window.history.replaceState({}, '', '/');
status.set('checking');
clearListeners.clear();
session.isLoggedIn.set(false);
@@ -74,6 +75,16 @@ describe('App', () => {
});
});
it('renders candidate routes without starting the OIDC session bootstrap', () => {
window.history.replaceState({}, '', '/candidatures/reprendre#application=one');
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
expect(session.refresh).not.toHaveBeenCalled();
expect(fixture.nativeElement.querySelector('.public-shell')).not.toBeNull();
expect(fixture.nativeElement.querySelector('app-sign-in-page')).toBeNull();
});
it('shows a neutral session loader before authentication resolves', () => {
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
+12 -1
View File
@@ -80,12 +80,19 @@ export class App implements OnInit {
readonly profileCheckStatus = signal<'idle' | 'checking' | 'checked' | 'error'>('idle');
readonly routeLoading = signal(false);
readonly routeLoadError = signal('');
readonly publicExperience = signal(isApplicationPath(globalThis.location.pathname));
constructor() {
this.session.onClear(() => this.profileCompletionDialog?.close());
this.router.events.pipe(takeUntilDestroyed()).subscribe((event) => {
if (event instanceof NavigationStart) {
const wasPublic = this.publicExperience();
const isPublic = isApplicationPath(event.url);
this.publicExperience.set(isPublic);
if (wasPublic && !isPublic && this.session.status() === 'checking') {
this.session.refresh();
}
this.routeLoadError.set('');
} else if (event instanceof RouteConfigLoadStart) {
this.loadingRoutes.add(event.route);
@@ -124,7 +131,7 @@ export class App implements OnInit {
}
ngOnInit() {
this.session.refresh();
if (!this.publicExperience()) this.session.refresh();
}
login() {
@@ -167,3 +174,7 @@ export class App implements OnInit {
.catch(() => this.profileCheckStatus.set('error'));
}
}
function isApplicationPath(url: string): boolean {
return url === '/candidatures' || url.startsWith('/candidatures/');
}
@@ -44,6 +44,7 @@
Créer le plan d’amortissement
</button>
<app-apartment-document-manager [apartmentId]="apartmentId" />
<app-application-link-manager [apartmentId]="apartmentId" />
}
}
</section>
@@ -143,7 +143,14 @@ describe('ApartmentsPage', () => {
});
});
afterEach(() => http.verify());
afterEach(() => {
http.match((request) =>
request.url.startsWith('/api/application-links/apartments/'),
).forEach((request) =>
request.flush({ vaultActive: false, passkeyCount: 0, activeLink: null }),
);
http.verify();
});
it('shows apartment-shaped placeholders instead of a false empty state on first load', () => {
const fixture = TestBed.createComponent(ApartmentsPage);
@@ -15,6 +15,7 @@ import { ApartmentList } from '../apartment-list/apartment-list';
import { ApartmentDocumentManager } from '../apartment-document-manager/apartment-document-manager';
import { ConfirmationDialog } from '../confirmation-dialog/confirmation-dialog';
import { ApartmentAcquisitionCorrection } from '../apartment-acquisition-correction/apartment-acquisition-correction';
import { ApplicationLinkManager } from '../application-link-manager/application-link-manager';
@Component({
selector: 'app-apartments-page',
@@ -24,6 +25,7 @@ import { ApartmentAcquisitionCorrection } from '../apartment-acquisition-correct
ApartmentDocumentManager,
ApartmentForm,
ApartmentList,
ApplicationLinkManager,
],
templateUrl: './apartments-page.html',
})
@@ -0,0 +1,55 @@
<section class="application-link-manager" aria-labelledby="application-link-title">
<header>
<div>
<p class="eyebrow">Candidatures locatives</p>
<h2 id="application-link-title">Lien public de candidature</h2>
</div>
</header>
@if (loading()) {
<div class="local-state" role="status" aria-live="polite">
<mat-spinner diameter="28" />
<span>Chargement du lien…</span>
</div>
} @else if (status()?.activeLink; as link) {
<p>Partagez ce lien avec les candidats. Chaque ouverture crée un dossier privé distinct.</p>
<a class="application-url" [href]="link.path" target="_blank" rel="noopener noreferrer">
{{ link.path }}
</a>
@if (status()!.passkeyCount < 2) {
<p class="passkey-warning" role="status">
Ajoutez bientôt une seconde Passkey depuis les réglages pour éviter une perte
d’accès.
</p>
}
} @else {
<p>
Publiez un lien pour recevoir des dossiers chiffrés. La première publication active
votre coffre avec une Passkey compatible.
</p>
<button
mat-flat-button
type="button"
class="primary-action"
[disabled]="publishing()"
(click)="publish()"
>
@if (publishing()) {
<mat-spinner diameter="18" />
}
{{ publishing() ? 'Publication…' : 'Publier le lien' }}
</button>
}
@if (error()) {
<div class="alert-error" role="alert">
<p>{{ error() }}</p>
@if (!status()) {
<button mat-button type="button" (click)="load()">Réessayer</button>
}
</div>
}
@if (compatibilityHelp()) {
<p class="compatibility-help" role="alert">{{ compatibilityHelp() }}</p>
}
</section>
@@ -0,0 +1,61 @@
@use '../../../tokens' as tokens;
:host {
display: block;
}
.application-link-manager {
@include tokens.card;
display: grid;
gap: tokens.space(4);
margin-block-start: tokens.space(6);
padding: tokens.space(5);
container-type: inline-size;
h2,
p {
margin: 0;
}
> p {
color: var(--text-muted);
line-height: 1.55;
}
.primary-action {
justify-self: start;
}
}
.local-state {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: tokens.space(3);
min-height: 3rem;
color: var(--text-muted);
}
.application-url {
min-width: 0;
color: var(--accent-strong);
font-weight: 700;
overflow-wrap: anywhere;
}
.passkey-warning,
.compatibility-help {
padding: tokens.space(3);
border-radius: tokens.radius(md);
background: var(--warning-soft);
color: var(--warning);
line-height: 1.5;
}
.alert-error {
margin: 0;
p {
margin-block-end: tokens.space(2);
}
}
@@ -0,0 +1,91 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { of } from 'rxjs';
import { vi } from 'vitest';
import {
ApplicationPasskeyError,
ApplicationPasskeyService,
} from '../../services/application-passkey.service';
import { RentalApplicationService } from '../../services/rental-application.service';
import { ApplicationLinkManager } from './application-link-manager';
describe('ApplicationLinkManager', () => {
const inactive = { vaultActive: false, passkeyCount: 0, activeLink: null };
const active = {
vaultActive: true,
passkeyCount: 1,
activeLink: {
token: 'public-token',
path: '/candidatures/postuler/public-token',
createdAt: '2026-09-04T10:00:00Z',
},
};
const applications = {
getLinkStatus: vi.fn(() => of(inactive)),
publishLink: vi.fn(() => of(active)),
};
const passkeys = { enroll: vi.fn() };
const dialog = { open: vi.fn() };
let fixture: ComponentFixture<ApplicationLinkManager>;
beforeEach(() => {
vi.clearAllMocks();
applications.getLinkStatus.mockReturnValue(of(inactive));
applications.publishLink.mockReturnValue(of(active));
dialog.open.mockReturnValue({ afterClosed: () => of(false) });
TestBed.configureTestingModule({
providers: [
{ provide: RentalApplicationService, useValue: applications },
{ provide: ApplicationPasskeyService, useValue: passkeys },
{ provide: MatDialog, useValue: dialog },
],
});
fixture = TestBed.createComponent(ApplicationLinkManager);
fixture.componentRef.setInput('apartmentId', 'apt-1');
fixture.detectChanges();
});
it('does not create a vault or link when the explanation is cancelled', () => {
button('Publier le lien').click();
expect(dialog.open).toHaveBeenCalledOnce();
expect(passkeys.enroll).not.toHaveBeenCalled();
expect(applications.publishLink).not.toHaveBeenCalled();
});
it('fails closed with compatibility help when PRF is unavailable', async () => {
dialog.open.mockReturnValue({ afterClosed: () => of(true) });
passkeys.enroll.mockRejectedValue(new ApplicationPasskeyError('prf_unsupported'));
button('Publier le lien').click();
await fixture.whenStable();
fixture.detectChanges();
expect(applications.publishLink).not.toHaveBeenCalled();
expect(fixture.nativeElement.textContent).toContain('fonction PRF requise');
});
it('publishes directly when the landlord already has a vault', () => {
applications.getLinkStatus.mockReturnValue(
of({ ...inactive, vaultActive: true, passkeyCount: 1 }),
);
fixture.destroy();
fixture = TestBed.createComponent(ApplicationLinkManager);
fixture.componentRef.setInput('apartmentId', 'apt-1');
fixture.detectChanges();
button('Publier le lien').click();
fixture.detectChanges();
expect(dialog.open).not.toHaveBeenCalled();
expect(passkeys.enroll).not.toHaveBeenCalled();
expect(applications.publishLink).toHaveBeenCalledWith('apt-1', undefined);
expect(fixture.nativeElement.textContent).toContain('/candidatures/postuler/public-token');
});
function button(label: string): HTMLButtonElement {
return [...fixture.nativeElement.querySelectorAll('button')].find((candidate) =>
candidate.textContent?.includes(label),
) as HTMLButtonElement;
}
});
@@ -0,0 +1,120 @@
import { Component, DestroyRef, inject, input, OnInit, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import {
ApplicationPasskeyError,
ApplicationPasskeyService,
} from '../../services/application-passkey.service';
import {
ApplicationLinkStatus,
RentalApplicationService,
} from '../../services/rental-application.service';
import { ConfirmationDialog } from '../confirmation-dialog/confirmation-dialog';
@Component({
selector: 'app-application-link-manager',
imports: [MatButtonModule, MatProgressSpinnerModule],
templateUrl: './application-link-manager.html',
styleUrl: './application-link-manager.scss',
})
export class ApplicationLinkManager implements OnInit {
readonly apartmentId = input.required<string>();
readonly status = signal<ApplicationLinkStatus | null>(null);
readonly loading = signal(true);
readonly publishing = signal(false);
readonly error = signal('');
readonly compatibilityHelp = signal('');
private readonly applications = inject(RentalApplicationService);
private readonly passkeys = inject(ApplicationPasskeyService);
private readonly dialog = inject(MatDialog);
private readonly destroyRef = inject(DestroyRef);
ngOnInit(): void {
this.load();
}
load(): void {
this.loading.set(true);
this.error.set('');
this.applications
.getLinkStatus(this.apartmentId())
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (status) => {
this.status.set(status);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
this.error.set('Impossible de charger le lien de candidature.');
},
});
}
publish(): void {
if (this.publishing()) return;
this.error.set('');
this.compatibilityHelp.set('');
if (this.status()?.vaultActive) {
this.sendPublication();
return;
}
this.dialog
.open(ConfirmationDialog, {
data: {
title: 'Protéger les candidatures avec une Passkey ?',
message:
'Les candidatures contiendront des données sensibles. Votre navigateur va créer un coffre chiffré et protéger sa clé privée avec une Passkey. Cette Passkey sera nécessaire pour lire les dossiers : sans elle, les données seront irrécupérables. Aucune clé de secours, aucun PIN et aucune copie serveur ne seront créés.',
confirmLabel: 'Créer la Passkey et publier',
},
role: 'alertdialog',
width: 'min(36rem, calc(100vw - 2rem))',
maxWidth: 'calc(100vw - 2rem)',
})
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((confirmed) => {
if (!confirmed) return;
this.publishing.set(true);
void this.passkeys
.enroll('Passkey principale')
.then((activation) => this.sendPublication(activation))
.catch((failure: unknown) => this.handlePasskeyFailure(failure));
});
}
private sendPublication(
activation?: Parameters<RentalApplicationService['publishLink']>[1],
): void {
this.publishing.set(true);
this.applications
.publishLink(this.apartmentId(), activation)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (status) => {
this.status.set(status);
this.publishing.set(false);
},
error: () => {
this.publishing.set(false);
this.error.set('Le lien de candidature n’a pas pu être publié. Réessayez.');
},
});
}
private handlePasskeyFailure(failure: unknown): void {
this.publishing.set(false);
if (failure instanceof ApplicationPasskeyError && failure.code === 'cancelled') {
this.compatibilityHelp.set(
'Création annulée : aucun coffre ni lien n’a été créé. Vous pouvez réessayer quand vous êtes prêt.',
);
return;
}
this.compatibilityHelp.set(
'Cet appareil n’a pas fourni la fonction PRF requise par le chiffrement. Essayez un navigateur récent et une Passkey compatible ; les autres fonctions restent disponibles.',
);
}
}
@@ -0,0 +1,63 @@
<section class="privacy-notice" aria-labelledby="application-privacy-title">
<header>
<span class="material-icons" aria-hidden="true">encrypted</span>
<div>
<p class="eyebrow">Vos données restent confidentielles</p>
<h2 id="application-privacy-title">Notice d’information</h2>
</div>
</header>
<p class="summary">
Votre dossier est chiffré dans ce navigateur pour {{ landlordName() }}. Gardez votre lien
privé&nbsp;: il est indispensable pour reprendre votre candidature et ne peut pas être
récupéré.
</p>
<details>
<summary>Lire la notice complète</summary>
<div class="notice-content">
<h3>Responsable et finalités</h3>
<p>
{{ landlordName() }} est responsable du traitement de votre candidature locative
@if (landlordEmail()) {
et peut être contacté à
<a [href]="'mailto:' + landlordEmail()">{{ landlordEmail() }}</a>
}
. Vos informations servent uniquement à étudier le dossier, apprécier sa
solvabilité, échanger avec vous et, le cas échéant, préparer la location. Ce
traitement relève des mesures précontractuelles demandées par le candidat&nbsp;;
aucun consentement obligatoire n’est demandé.
</p>
<h3>Données, justificatifs et destinataires</h3>
<p>
Le dossier peut contenir votre identité et vos coordonnées, votre situation
personnelle, professionnelle et financière, votre recherche de logement, vos
garanties ainsi que les justificatifs que vous choisissez de transmettre. Ils sont
destinés au seul bailleur. Le service héberge leur version chiffrée et des
métadonnées techniques minimales, sans disposer de la clé permettant de lire le
contenu.
</p>
<h3>Durée, sécurité et limites</h3>
<p>
Une candidature ouverte expire après 90 jours sans modification. Les candidatures
sélectionnées, rejetées ou retirées sont supprimées au plus tard 90 jours après ce
statut. Le chiffrement réduit l’exposition du contenu stocké mais ne remplace ni
l’autorisation ni la suppression. Le service ne réalise pas d’analyse antivirus des
fichiers. Révoquer un lien bloque les accès futurs au serveur, sans effacer les
copies déjà enregistrées par son détenteur.
</p>
<h3>Votre lien privé et vos droits</h3>
<p>
Le fragment de votre lien contient les secrets d’accès et de chiffrement. Il n’est
pas envoyé au serveur&nbsp;: ne le partagez pas et conservez-le jusqu’à la fin de
vos démarches. Ni le bailleur ni le service ne peuvent reconstruire un lien perdu.
Pour demander l’accès, la rectification, l’effacement, la limitation ou l’opposition
lorsque celle-ci s’applique, contactez le bailleur à l’adresse indiquée ci-dessus.
Vous pouvez aussi saisir la CNIL.
</p>
</div>
</details>
</section>
@@ -0,0 +1,77 @@
@use '../../../tokens' as tokens;
:host {
display: block;
}
.privacy-notice {
@include tokens.card(lg, surface-soft);
container-type: inline-size;
padding: tokens.space(5);
overflow-wrap: anywhere;
}
header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: tokens.space(3);
.material-icons {
display: grid;
width: 2.75rem;
min-height: 2.75rem;
place-items: center;
border-radius: tokens.radius(md);
background: var(--surface-accent);
color: var(--accent);
}
.eyebrow,
h2 {
margin: 0;
}
h2 {
font-size: 1.25rem;
}
}
.summary {
margin: tokens.space(4) 0;
color: var(--text-muted);
line-height: 1.6;
}
details {
border-block-start: 1px solid var(--border);
padding-block-start: tokens.space(3);
}
summary {
min-height: 2.75rem;
cursor: pointer;
color: var(--accent-strong);
font-weight: 700;
}
.notice-content {
display: grid;
gap: tokens.space(2);
padding-block-start: tokens.space(3);
h3 {
margin: tokens.space(2) 0 0;
font-size: 1rem;
}
p {
margin: 0;
color: var(--text-muted);
line-height: 1.65;
}
a {
color: var(--accent-strong);
}
}
@@ -0,0 +1,11 @@
import { Component, input } from '@angular/core';
@Component({
selector: 'app-application-privacy-notice',
templateUrl: './application-privacy-notice.html',
styleUrl: './application-privacy-notice.scss',
})
export class ApplicationPrivacyNotice {
readonly landlordName = input('le bailleur');
readonly landlordEmail = input('');
}
@@ -0,0 +1,32 @@
<article class="application-page">
<header>
<p class="eyebrow">Votre lien privé</p>
<h1>{{ application()?.apartmentName || 'Candidature locative' }}</h1>
<p>Conservez l’adresse complète de cette page pour reprendre votre dossier.</p>
</header>
@if (loading()) {
<section class="application-state" role="status" aria-live="polite">
<mat-spinner diameter="34" />
<strong>Ouverture sécurisée du dossier…</strong>
</section>
} @else if (error()) {
<section class="application-state error-state" role="alert">
<strong>Candidature indisponible</strong>
<p>{{ error() }}</p>
</section>
} @else {
<section class="application-state ready-state" role="status">
<span class="material-icons" aria-hidden="true">key</span>
<div>
<strong>Votre dossier privé est prêt</strong>
<p>Vous pourrez renseigner et envoyer son contenu depuis cette page.</p>
</div>
</section>
}
<app-application-privacy-notice
[landlordName]="application()?.landlordName || 'le bailleur'"
[landlordEmail]="application()?.landlordEmail || ''"
/>
</article>
@@ -0,0 +1,48 @@
@use '../../../tokens' as tokens;
:host {
display: block;
}
.application-page {
display: grid;
gap: tokens.space(6);
header h1 {
margin-block-end: tokens.space(3);
font-size: clamp(2rem, 8cqi, 3.25rem);
letter-spacing: -0.045em;
line-height: 1.05;
}
header > p:last-child,
.application-state p {
margin: 0;
color: var(--text-muted);
line-height: 1.6;
}
}
.application-state {
@include tokens.card;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: tokens.space(4);
min-height: 7rem;
padding: tokens.space(5);
.material-icons {
color: var(--accent);
font-size: 2rem;
}
}
.error-state {
align-items: flex-start;
flex-direction: column;
}
.ready-state {
border-color: var(--accent);
}
@@ -0,0 +1,64 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';
import { vi } from 'vitest';
import { ApplicationCryptoService } from '../../services/application-crypto.service';
import { RentalApplicationService } from '../../services/rental-application.service';
import { CandidateApplicationPage } from './candidate-application-page';
describe('CandidateApplicationPage', () => {
const application = {
id: 'application-1',
apartmentId: 'apt-1',
state: 'DRAFT' as const,
revision: 0,
createdAt: '2026-09-04T10:00:00Z',
lastActivityAt: '2026-09-04T10:00:00Z',
encryptedApplicationKey: 'encrypted-key',
cryptoVersion: 1,
landlordName: 'Camille Martin',
landlordEmail: 'camille@example.com',
apartmentName: 'Studio République',
};
const applications = { resume: vi.fn(() => of(application)) };
const crypto = {
decode: vi.fn((value: string) =>
value === 'valid-capability' || value === 'valid-key'
? new Uint8Array(32)
: new Uint8Array(),
),
};
it('sends only the capability to resume a valid private link', () => {
const fixture = create(
'application=application-1&capability=valid-capability&key=valid-key',
);
fixture.detectChanges();
expect(applications.resume).toHaveBeenCalledWith('application-1', 'valid-capability');
expect(fixture.nativeElement.textContent).toContain('Votre dossier privé est prêt');
expect(fixture.nativeElement.textContent).not.toContain('valid-key');
});
it('rejects an altered link before contacting the server', () => {
const fixture = create('application=application-1&capability=invalid&key=valid-key');
fixture.detectChanges();
expect(applications.resume).not.toHaveBeenCalled();
expect(fixture.nativeElement.textContent).toContain(
'lien privé est incomplet ou a été altéré',
);
});
function create(fragment: string) {
vi.clearAllMocks();
TestBed.configureTestingModule({
providers: [
{ provide: RentalApplicationService, useValue: applications },
{ provide: ApplicationCryptoService, useValue: crypto },
{ provide: ActivatedRoute, useValue: { snapshot: { fragment } } },
],
});
return TestBed.createComponent(CandidateApplicationPage);
}
});
@@ -0,0 +1,83 @@
import { Component, DestroyRef, inject, OnDestroy, OnInit, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { ActivatedRoute } from '@angular/router';
import { ApplicationCryptoService } from '../../services/application-crypto.service';
import {
CandidateApplication,
RentalApplicationService,
} from '../../services/rental-application.service';
import { ApplicationPrivacyNotice } from '../application-privacy-notice/application-privacy-notice';
@Component({
selector: 'app-candidate-application-page',
imports: [ApplicationPrivacyNotice, MatProgressSpinnerModule],
templateUrl: './candidate-application-page.html',
styleUrl: './candidate-application-page.scss',
})
export class CandidateApplicationPage implements OnInit, OnDestroy {
readonly application = signal<CandidateApplication | null>(null);
readonly loading = signal(true);
readonly error = signal('');
private readonly fragment = inject(ActivatedRoute).snapshot.fragment ?? '';
private readonly applications = inject(RentalApplicationService);
private readonly crypto = inject(ApplicationCryptoService);
private readonly destroyRef = inject(DestroyRef);
private applicationKey: Uint8Array<ArrayBuffer> | null = null;
ngOnInit(): void {
const secrets = this.readSecrets();
if (!secrets) {
this.loading.set(false);
this.error.set('Ce lien privé est incomplet ou a été altéré.');
return;
}
this.applicationKey = secrets.key;
this.applications
.resume(secrets.applicationId, secrets.capability)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (application) => {
this.application.set(application);
this.loading.set(false);
},
error: () => {
this.clearApplicationKey();
this.loading.set(false);
this.error.set(
'Cette candidature est indisponible. Vérifiez que le lien est complet.',
);
},
});
}
ngOnDestroy(): void {
this.clearApplicationKey();
}
private readSecrets(): {
applicationId: string;
capability: string;
key: Uint8Array<ArrayBuffer>;
} | null {
const values = new URLSearchParams(this.fragment);
const applicationId = values.get('application')?.trim() ?? '';
const capability = values.get('capability') ?? '';
const encodedKey = values.get('key') ?? '';
if (!applicationId || applicationId.length > 200) return null;
try {
const capabilityBytes = this.crypto.decode(capability);
const key = this.crypto.decode(encodedKey);
return capabilityBytes.length === 32 && key.length === 32
? { applicationId, capability, key }
: null;
} catch {
return null;
}
}
private clearApplicationKey(): void {
this.applicationKey?.fill(0);
this.applicationKey = null;
}
}
@@ -0,0 +1,36 @@
<article class="application-page">
<header class="application-heading">
<p class="eyebrow">Candidature locative</p>
<h1>{{ link()?.apartmentName || 'Préparation de votre dossier' }}</h1>
@if (link(); as publicLink) {
<p>Vous transmettez votre dossier directement à {{ publicLink.landlordName }}.</p>
} @else {
<p>Nous vérifions le lien avant de créer votre espace privé.</p>
}
</header>
@if (state() === 'loading' || state() === 'creating') {
<section class="application-state" role="status" aria-live="polite">
<mat-spinner diameter="34" />
<div>
<strong>{{
state() === 'loading' ? 'Vérification du lien…' : 'Création du dossier chiffré…'
}}</strong>
<p>Ne fermez pas cette page.</p>
</div>
</section>
} @else {
<section class="application-state error-state" role="alert">
<strong>Impossible de continuer</strong>
<p>{{ error() }}</p>
<button mat-flat-button type="button" class="primary-action" (click)="start()">
Réessayer
</button>
</section>
}
<app-application-privacy-notice
[landlordName]="link()?.landlordName || 'le bailleur'"
[landlordEmail]="link()?.landlordEmail || ''"
/>
</article>
@@ -0,0 +1,45 @@
@use '../../../tokens' as tokens;
:host {
display: block;
}
.application-page {
display: grid;
gap: tokens.space(6);
}
.application-heading {
h1 {
margin-block-end: tokens.space(3);
font-size: clamp(2rem, 8cqi, 3.25rem);
letter-spacing: -0.045em;
line-height: 1.05;
}
> p:last-child {
margin: 0;
color: var(--text-muted);
line-height: 1.6;
}
}
.application-state {
@include tokens.card;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: tokens.space(4);
min-height: 7rem;
padding: tokens.space(5);
p {
margin: tokens.space(1) 0 0;
color: var(--text-muted);
}
}
.error-state {
align-items: flex-start;
flex-direction: column;
}
@@ -0,0 +1,81 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { ApplicationCryptoService } from '../../services/application-crypto.service';
import { RentalApplicationService } from '../../services/rental-application.service';
import { PublicApplicationPage } from './public-application-page';
describe('PublicApplicationPage', () => {
const link = {
token: 'public-token',
createdAt: '2026-09-04T10:00:00Z',
landlordPublicKey: 'public-key',
landlordName: 'Camille Martin',
landlordEmail: 'camille@example.com',
apartmentName: 'Studio République',
};
const secrets = {
capability: 'candidate-capability',
applicationKey: 'candidate-key',
encryptedApplicationKey: { cryptoVersion: 1 as const, ciphertext: 'encrypted-key' },
};
const applications = {
previewPublicLink: vi.fn(() => of(link)),
createDraft: vi.fn(() => of({ id: 'application-1', state: 'DRAFT' as const, revision: 0 })),
};
const crypto = { createDraftSecrets: vi.fn().mockResolvedValue(secrets) };
const router = { navigate: vi.fn().mockResolvedValue(true) };
let fixture: ComponentFixture<PublicApplicationPage>;
beforeEach(() => {
vi.clearAllMocks();
applications.previewPublicLink.mockReturnValue(of(link));
applications.createDraft.mockReturnValue(
of({ id: 'application-1', state: 'DRAFT' as const, revision: 0 }),
);
crypto.createDraftSecrets.mockResolvedValue(secrets);
TestBed.configureTestingModule({
providers: [
{ provide: RentalApplicationService, useValue: applications },
{ provide: ApplicationCryptoService, useValue: crypto },
{ provide: Router, useValue: router },
{
provide: ActivatedRoute,
useValue: {
snapshot: { paramMap: convertToParamMap({ token: 'public-token' }) },
},
},
],
});
fixture = TestBed.createComponent(PublicApplicationPage);
});
it('creates a distinct draft in the client and replaces the public URL with private secrets', async () => {
fixture.detectChanges();
await vi.waitFor(() => expect(router.navigate).toHaveBeenCalledOnce());
expect(crypto.createDraftSecrets).toHaveBeenCalledWith('public-key');
expect(applications.createDraft).toHaveBeenCalledWith(
'public-token',
'candidate-capability',
secrets.encryptedApplicationKey,
);
const [, options] = router.navigate.mock.calls[0];
const fragment = new URLSearchParams(options.fragment);
expect(fragment.get('application')).toBe('application-1');
expect(fragment.get('capability')).toBe('candidate-capability');
expect(fragment.get('key')).toBe('candidate-key');
expect(options.replaceUrl).toBe(true);
});
it('shows a truthful retryable failure without attempting draft creation when preview fails', () => {
applications.previewPublicLink.mockReturnValue(throwError(() => new Error('Unavailable')));
fixture.detectChanges();
expect(applications.createDraft).not.toHaveBeenCalled();
expect(fixture.nativeElement.textContent).toContain('Impossible de continuer');
expect(fixture.nativeElement.textContent).toContain('Notice d’information');
});
});
@@ -0,0 +1,83 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, DestroyRef, inject, OnInit, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { ActivatedRoute, Router } from '@angular/router';
import { from, map, switchMap, tap } from 'rxjs';
import { ApplicationCryptoService } from '../../services/application-crypto.service';
import {
PublicApplicationLink,
RentalApplicationService,
} from '../../services/rental-application.service';
import { ApplicationPrivacyNotice } from '../application-privacy-notice/application-privacy-notice';
@Component({
selector: 'app-public-application-page',
imports: [ApplicationPrivacyNotice, MatButtonModule, MatProgressSpinnerModule],
templateUrl: './public-application-page.html',
styleUrl: './public-application-page.scss',
})
export class PublicApplicationPage implements OnInit {
readonly link = signal<PublicApplicationLink | null>(null);
readonly state = signal<'loading' | 'creating' | 'error'>('loading');
readonly error = signal('');
private readonly token = inject(ActivatedRoute).snapshot.paramMap.get('token') ?? '';
private readonly applications = inject(RentalApplicationService);
private readonly crypto = inject(ApplicationCryptoService);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
ngOnInit(): void {
this.start();
}
start(): void {
this.state.set('loading');
this.error.set('');
this.applications
.previewPublicLink(this.token)
.pipe(
tap((link) => {
this.link.set(link);
this.state.set('creating');
}),
switchMap((link) =>
from(this.crypto.createDraftSecrets(link.landlordPublicKey)).pipe(
map((secrets) => ({ link, secrets })),
),
),
switchMap(({ secrets }) =>
this.applications
.createDraft(
this.token,
secrets.capability,
secrets.encryptedApplicationKey,
)
.pipe(map((application) => ({ application, secrets }))),
),
takeUntilDestroyed(this.destroyRef),
)
.subscribe({
next: ({ application, secrets }) => {
const fragment = new URLSearchParams({
application: application.id,
capability: secrets.capability,
key: secrets.applicationKey,
}).toString();
void this.router.navigate(['/candidatures/reprendre'], {
fragment,
replaceUrl: true,
});
},
error: (failure: unknown) => {
this.state.set('error');
this.error.set(
failure instanceof HttpErrorResponse && failure.status === 429
? 'Trop de dossiers ont été ouverts depuis ce réseau. Réessayez plus tard.'
: 'Ce lien est indisponible ou le dossier n’a pas pu être préparé.',
);
},
});
}
}
@@ -0,0 +1,78 @@
import { TestBed } from '@angular/core/testing';
import { APPLICATION_CRYPTO, ApplicationCryptoService } from './application-crypto.service';
describe('ApplicationCryptoService', () => {
let service: ApplicationCryptoService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [{ provide: APPLICATION_CRYPTO, useValue: globalThis.crypto }],
});
service = TestBed.inject(ApplicationCryptoService);
});
it('wraps the landlord private key and the application key without making them extractable', async () => {
const prfOutput = service.randomBytes(32);
const activation = await service.createVaultActivation(
prfOutput,
service.encode(service.randomBytes(32)),
'Passkey principale',
service.randomBytes(32),
);
expect(service.decode(activation.hkdfSalt)).toHaveLength(32);
expect(service.decode(activation.nonce)).toHaveLength(12);
expect(service.decode(activation.publicKey).length).toBeGreaterThanOrEqual(384);
expect(service.decode(activation.encryptedPrivateKey).length).toBeGreaterThan(512);
const privateKey = await stage(
'unwrap landlord key',
service.unwrapVaultPrivateKey(activation, prfOutput),
);
const draft = await stage(
'create draft key',
service.createDraftSecrets(activation.publicKey),
);
const clearApplicationKey = await stage(
'decrypt application key',
globalThis.crypto.subtle.decrypt(
{ name: 'RSA-OAEP' },
privateKey,
service.decode(draft.encryptedApplicationKey.ciphertext),
),
);
expect(privateKey.extractable).toBe(false);
expect(service.encode(clearApplicationKey)).toBe(draft.applicationKey);
expect(service.decode(draft.capability)).toHaveLength(32);
});
it('rejects a wrong PRF output and altered private-key envelopes', async () => {
const prfOutput = service.randomBytes(32);
const activation = await service.createVaultActivation(
prfOutput,
service.encode(service.randomBytes(32)),
'Passkey principale',
service.randomBytes(32),
);
await expect(
service.unwrapVaultPrivateKey(activation, service.randomBytes(32)),
).rejects.toThrow();
const altered = service.decode(activation.encryptedPrivateKey);
altered[0] ^= 1;
await expect(
service.unwrapVaultPrivateKey(
{ ...activation, encryptedPrivateKey: service.encode(altered) },
prfOutput,
),
).rejects.toThrow();
});
});
async function stage<T>(name: string, operation: Promise<T>): Promise<T> {
try {
return await operation;
} catch (failure) {
throw new Error(name, { cause: failure });
}
}
@@ -0,0 +1,177 @@
import { inject, Injectable, InjectionToken } from '@angular/core';
export const APPLICATION_CRYPTO = new InjectionToken<Crypto>('APPLICATION_CRYPTO', {
factory: () => globalThis.crypto,
});
export interface ApplicationVaultActivation {
publicKey: string;
credentialId: string;
label: string;
prfSalt: string;
hkdfSalt: string;
nonce: string;
encryptedPrivateKey: string;
cryptoVersion: 1;
}
export interface ApplicationDraftSecrets {
capability: string;
applicationKey: string;
encryptedApplicationKey: { cryptoVersion: 1; ciphertext: string };
}
const encoder = new TextEncoder();
const vaultInfo = encoder.encode('lmnp-organizer:application-vault:v1');
@Injectable({ providedIn: 'root' })
export class ApplicationCryptoService {
private readonly crypto = inject(APPLICATION_CRYPTO);
randomBytes(length: number): Uint8Array<ArrayBuffer> {
return this.crypto.getRandomValues(new Uint8Array(length));
}
encode(bytes: BufferSource): string {
const view = ArrayBuffer.isView(bytes) ? asBytes(bytes) : new Uint8Array(bytes);
let binary = '';
for (const byte of view) binary += String.fromCharCode(byte);
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
}
decode(value: string): Uint8Array<ArrayBuffer> {
const padded = value
.replaceAll('-', '+')
.replaceAll('_', '/')
.padEnd(value.length + ((4 - (value.length % 4)) % 4), '=');
try {
return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
} catch {
throw new Error('Format cryptographique inconnu.');
}
}
async createVaultActivation(
prfOutput: BufferSource,
credentialId: string,
label: string,
prfSalt: Uint8Array<ArrayBuffer>,
): Promise<ApplicationVaultActivation> {
const keyPair = await this.crypto.subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 3072,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256',
},
true,
['encrypt', 'decrypt'],
);
if (!isCryptoKeyPair(keyPair)) throw new Error('Impossible de créer le coffre.');
const [publicKey, privateKeyBytes] = await Promise.all([
this.crypto.subtle.exportKey('spki', keyPair.publicKey),
this.crypto.subtle.exportKey('pkcs8', keyPair.privateKey),
]);
const hkdfSalt = this.randomBytes(32);
const nonce = this.randomBytes(12);
const wrappingKey = await this.wrappingKey(prfOutput, hkdfSalt);
let encryptedPrivateKey: ArrayBuffer;
try {
encryptedPrivateKey = await this.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: nonce, additionalData: vaultInfo, tagLength: 128 },
wrappingKey,
privateKeyBytes,
);
} finally {
new Uint8Array(privateKeyBytes).fill(0);
}
return {
publicKey: this.encode(publicKey),
credentialId,
label: label.trim(),
prfSalt: this.encode(prfSalt),
hkdfSalt: this.encode(hkdfSalt),
nonce: this.encode(nonce),
encryptedPrivateKey: this.encode(encryptedPrivateKey),
cryptoVersion: 1,
};
}
async unwrapVaultPrivateKey(
activation: ApplicationVaultActivation,
prfOutput: BufferSource,
): Promise<CryptoKey> {
if (activation.cryptoVersion !== 1) throw new Error('Format cryptographique inconnu.');
const wrappingKey = await this.wrappingKey(prfOutput, this.decode(activation.hkdfSalt));
const privateKey = await this.crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: this.decode(activation.nonce),
additionalData: vaultInfo,
tagLength: 128,
},
wrappingKey,
this.decode(activation.encryptedPrivateKey),
);
return this.crypto.subtle.importKey(
'pkcs8',
privateKey,
{ name: 'RSA-OAEP', hash: 'SHA-256' },
false,
['decrypt'],
);
}
async createDraftSecrets(landlordPublicKey: string): Promise<ApplicationDraftSecrets> {
const publicKey = await this.crypto.subtle.importKey(
'spki',
this.decode(landlordPublicKey),
{ name: 'RSA-OAEP', hash: 'SHA-256' },
false,
['encrypt'],
);
const applicationKey = this.randomBytes(32);
const capability = this.randomBytes(32);
try {
const encryptedApplicationKey = await this.crypto.subtle.encrypt(
{ name: 'RSA-OAEP' },
publicKey,
applicationKey,
);
return {
capability: this.encode(capability),
applicationKey: this.encode(applicationKey),
encryptedApplicationKey: {
cryptoVersion: 1,
ciphertext: this.encode(encryptedApplicationKey),
},
};
} finally {
applicationKey.fill(0);
capability.fill(0);
}
}
private async wrappingKey(prfOutput: BufferSource, salt: BufferSource): Promise<CryptoKey> {
const material = await this.crypto.subtle.importKey('raw', prfOutput, 'HKDF', false, [
'deriveKey',
]);
return this.crypto.subtle.deriveKey(
{ name: 'HKDF', hash: 'SHA-256', salt, info: vaultInfo },
material,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
);
}
}
function isCryptoKeyPair(value: CryptoKey | CryptoKeyPair): value is CryptoKeyPair {
return 'publicKey' in value && 'privateKey' in value;
}
function asBytes(value: ArrayBufferView<ArrayBuffer>): Uint8Array<ArrayBuffer> {
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
}
@@ -0,0 +1,93 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { ApplicationCryptoService, ApplicationVaultActivation } from './application-crypto.service';
import {
APPLICATION_CREDENTIALS,
ApplicationCredentials,
ApplicationPasskeyError,
ApplicationPasskeyService,
} from './application-passkey.service';
describe('ApplicationPasskeyService', () => {
const activation: ApplicationVaultActivation = {
publicKey: 'public-key',
credentialId: 'credential',
label: 'Passkey principale',
prfSalt: 'prf-salt',
hkdfSalt: 'hkdf-salt',
nonce: 'nonce',
encryptedPrivateKey: 'private-key-envelope',
cryptoVersion: 1,
};
const crypto = {
randomBytes: vi.fn((length: number) => new Uint8Array(length).fill(1)),
encode: vi.fn(() => 'credential'),
createVaultActivation: vi.fn().mockResolvedValue(activation),
};
let credentials: ApplicationCredentials;
beforeEach(() => {
vi.clearAllMocks();
credentials = { create: vi.fn(), get: vi.fn() };
TestBed.configureTestingModule({
providers: [
{ provide: APPLICATION_CREDENTIALS, useFactory: () => credentials },
{ provide: ApplicationCryptoService, useValue: crypto },
],
});
});
it('uses the PRF result returned during passkey creation', async () => {
const prfOutput = new Uint8Array(32).fill(2).buffer;
vi.mocked(credentials.create).mockResolvedValue(credential(prfOutput));
await expect(TestBed.inject(ApplicationPasskeyService).enroll('Principale')).resolves.toBe(
activation,
);
expect(credentials.get).not.toHaveBeenCalled();
expect(crypto.createVaultActivation).toHaveBeenCalledWith(
prfOutput,
'credential',
'Principale',
expect.any(Uint8Array),
);
});
it('completes PRF through an assertion when creation did not return it', async () => {
vi.mocked(credentials.create).mockResolvedValue(credential());
vi.mocked(credentials.get).mockResolvedValue(credential(new Uint8Array(32).fill(3).buffer));
await TestBed.inject(ApplicationPasskeyService).enroll('Principale');
expect(credentials.get).toHaveBeenCalledOnce();
expect(crypto.createVaultActivation).toHaveBeenCalledOnce();
});
it('fails closed when PRF is unavailable and distinguishes user cancellation', async () => {
vi.mocked(credentials.create).mockResolvedValue(credential());
vi.mocked(credentials.get).mockResolvedValue(credential());
await expect(
TestBed.inject(ApplicationPasskeyService).enroll('Principale'),
).rejects.toMatchObject({
code: 'prf_unsupported',
});
expect(crypto.createVaultActivation).not.toHaveBeenCalled();
vi.mocked(credentials.create).mockRejectedValue(
new DOMException('Cancelled', 'NotAllowedError'),
);
await expect(
TestBed.inject(ApplicationPasskeyService).enroll('Principale'),
).rejects.toEqual(new ApplicationPasskeyError('cancelled'));
});
});
function credential(first?: ArrayBuffer): Credential {
return {
id: 'credential',
type: 'public-key',
rawId: new Uint8Array(32).buffer,
getClientExtensionResults: () => ({ prf: { results: first ? { first } : undefined } }),
} as PublicKeyCredential;
}
@@ -0,0 +1,124 @@
import { inject, Injectable, InjectionToken } from '@angular/core';
import { ApplicationCryptoService, ApplicationVaultActivation } from './application-crypto.service';
export interface ApplicationCredentials {
create(options?: CredentialCreationOptions): Promise<Credential | null>;
get(options?: CredentialRequestOptions): Promise<Credential | null>;
}
export const APPLICATION_CREDENTIALS = new InjectionToken<ApplicationCredentials | null>(
'APPLICATION_CREDENTIALS',
{ factory: () => globalThis.navigator?.credentials ?? null },
);
export type ApplicationPasskeyErrorCode = 'cancelled' | 'prf_unsupported' | 'failed';
export class ApplicationPasskeyError extends Error {
constructor(readonly code: ApplicationPasskeyErrorCode) {
super(code);
}
}
@Injectable({ providedIn: 'root' })
export class ApplicationPasskeyService {
private readonly credentials = inject(APPLICATION_CREDENTIALS);
private readonly crypto = inject(ApplicationCryptoService);
async enroll(label: string): Promise<ApplicationVaultActivation> {
if (!this.credentials) throw new ApplicationPasskeyError('prf_unsupported');
const prfSalt = this.crypto.randomBytes(32);
try {
const created = requirePublicKeyCredential(
await this.credentials.create({ publicKey: this.creationOptions(prfSalt) }),
);
const prfOutput =
created.getClientExtensionResults().prf?.results?.first ??
(await this.completePrf(created, prfSalt));
if (!prfOutput) throw new ApplicationPasskeyError('prf_unsupported');
try {
return await this.crypto.createVaultActivation(
prfOutput,
this.crypto.encode(created.rawId),
label,
prfSalt,
);
} finally {
clearBytes(prfOutput);
}
} catch (failure) {
if (failure instanceof ApplicationPasskeyError) throw failure;
if (failure instanceof DOMException && failure.name === 'NotAllowedError') {
throw new ApplicationPasskeyError('cancelled');
}
throw new ApplicationPasskeyError('failed');
}
}
private async completePrf(
created: PrfPublicKeyCredential,
prfSalt: Uint8Array<ArrayBuffer>,
): Promise<BufferSource | undefined> {
if (!this.credentials) return undefined;
const asserted = requirePublicKeyCredential(
await this.credentials.get({
publicKey: {
challenge: this.crypto.randomBytes(32),
allowCredentials: [{ id: created.rawId, type: 'public-key' }],
userVerification: 'required',
extensions: { prf: { eval: { first: prfSalt } } },
},
}),
);
return asserted.getClientExtensionResults().prf?.results?.first;
}
private creationOptions(prfSalt: Uint8Array<ArrayBuffer>): PublicKeyCredentialCreationOptions {
return {
challenge: this.crypto.randomBytes(32),
rp: { name: 'LMNP Organizer' },
user: {
id: this.crypto.randomBytes(32),
name: 'candidatures',
displayName: 'Coffre de candidatures',
},
pubKeyCredParams: [
{ type: 'public-key', alg: -7 },
{ type: 'public-key', alg: -257 },
],
authenticatorSelection: { residentKey: 'required', userVerification: 'required' },
attestation: 'none',
timeout: 120_000,
extensions: { prf: { eval: { first: prfSalt } } },
};
}
}
interface PrfPublicKeyCredential extends Credential {
readonly rawId: ArrayBuffer;
getClientExtensionResults(): AuthenticationExtensionsClientOutputs;
}
function requirePublicKeyCredential(value: Credential | null): PrfPublicKeyCredential {
if (!isPublicKeyCredential(value)) {
throw new ApplicationPasskeyError('failed');
}
return value;
}
function isPublicKeyCredential(value: Credential | null): value is PrfPublicKeyCredential {
return Boolean(
value &&
value.type === 'public-key' &&
'rawId' in value &&
value.rawId instanceof ArrayBuffer &&
'getClientExtensionResults' in value &&
typeof value.getClientExtensionResults === 'function',
);
}
function clearBytes(value: BufferSource): void {
const bytes = ArrayBuffer.isView(value)
? new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
: new Uint8Array(value);
bytes.fill(0);
}
@@ -0,0 +1,46 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { RentalApplicationService } from './rental-application.service';
describe('RentalApplicationService', () => {
let http: HttpTestingController;
let service: RentalApplicationService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
http = TestBed.inject(HttpTestingController);
service = TestBed.inject(RentalApplicationService);
});
afterEach(() => http.verify());
it('keeps public, capability and landlord routes distinct', () => {
service.getLinkStatus('apt-1').subscribe();
http.expectOne('/api/application-links/apartments/apt-1').flush({
vaultActive: false,
passkeyCount: 0,
activeLink: null,
});
service.previewPublicLink('public-token').subscribe();
http.expectOne('/api/public/application-links/public-token').flush({});
service
.createDraft('public-token', 'secret-capability', {
cryptoVersion: 1,
ciphertext: 'encrypted-key',
})
.subscribe();
const create = http.expectOne('/api/public/application-links/public-token/applications');
expect(create.request.headers.get('Authorization')).toBe('Application secret-capability');
create.flush({ id: 'application-1', state: 'DRAFT', revision: 0 });
service.resume('application-1', 'secret-capability').subscribe();
const resume = http.expectOne('/api/candidate/applications/application-1');
expect(resume.request.headers.get('Authorization')).toBe('Application secret-capability');
resume.flush({});
});
});
@@ -0,0 +1,88 @@
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { ApplicationVaultActivation } from './application-crypto.service';
export interface ActiveApplicationLink {
token: string;
path: string;
createdAt: string;
}
export interface ApplicationLinkStatus {
vaultActive: boolean;
passkeyCount: number;
activeLink: ActiveApplicationLink | null;
}
export interface PublicApplicationLink {
token: string;
createdAt: string;
landlordPublicKey: string;
landlordName: string;
landlordEmail: string;
apartmentName: string;
}
export interface CandidateApplication {
id: string;
apartmentId: string;
state: 'DRAFT';
revision: number;
createdAt: string;
lastActivityAt: string;
encryptedApplicationKey: string;
cryptoVersion: number;
landlordName: string;
landlordEmail: string;
apartmentName: string;
}
export interface CreatedApplicationDraft {
id: string;
state: 'DRAFT';
revision: number;
}
@Injectable({ providedIn: 'root' })
export class RentalApplicationService {
private readonly http = inject(HttpClient);
getLinkStatus(apartmentId: string): Observable<ApplicationLinkStatus> {
return this.http.get<ApplicationLinkStatus>(
`/api/application-links/apartments/${apartmentId}`,
);
}
publishLink(
apartmentId: string,
activation?: ApplicationVaultActivation,
): Observable<ApplicationLinkStatus> {
return this.http.post<ApplicationLinkStatus>(
`/api/application-links/apartments/${apartmentId}`,
activation ? { activation } : null,
);
}
previewPublicLink(token: string): Observable<PublicApplicationLink> {
return this.http.get<PublicApplicationLink>(`/api/public/application-links/${token}`);
}
createDraft(
token: string,
capability: string,
encryptedApplicationKey: { cryptoVersion: 1; ciphertext: string },
): Observable<CreatedApplicationDraft> {
return this.http.post<CreatedApplicationDraft>(
`/api/public/application-links/${token}/applications`,
{ encryptedApplicationKey },
{ headers: new HttpHeaders({ Authorization: `Application ${capability}` }) },
);
}
resume(applicationId: string, capability: string): Observable<CandidateApplication> {
return this.http.get<CandidateApplication>(`/api/candidate/applications/${applicationId}`, {
headers: new HttpHeaders({ Authorization: `Application ${capability}` }),
});
}
}
+1
View File
@@ -5,6 +5,7 @@
<title>LmnpFrontend</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<script>
try {
const preference = localStorage.getItem('lmnp-theme');